perf(tui): stop walking the whole item store to list or open a thread - #6646
Merged
Merged
Conversation
gaord
force-pushed
the
perf/thread-item-index
branch
from
September 26, 2026 16:20
9c017cf to
083e386
Compare
Hmbown
pushed a commit
to gaord/CodeWhale
that referenced
this pull request
Sep 26, 2026
Review follow-up on Hmbown#6646 (by @gaord), on top of their commit. - note_item_in_index pushed the item id into the published map on every save. The runtime saves one item id several times (in progress, then completed or failed), and the Runtime API warms the index at startup, so every turn run after startup returned each such item once per save from get_thread_detail and the fork rebuild. A write that raced the directory read and was noted after publish was duplicated the same way. The published branch now skips an id the turn already lists. - newest_message_text_in_turn failed the whole /v1/threads/summary page when a turn named an item whose file was removed. It now skips that id, as list_items_for_turns_map already does. - CHANGELOG line and contributor credit for Hmbown#6646; tui CHANGELOG and web changelog regenerated. Tests: runtime_threads::tests 247 passed, 0 failed, 2 ignored (adds an_item_saved_again_after_the_index_is_warm_is_listed_once and thread_list_facts_skips_an_item_file_that_is_gone); runtime_api::tests 268 passed, 0 failed. cargo fmt --check OK; clippy -p codewhale-tui --all-targets --all-features with CI flags OK. sync-changelog --check, check-versions --range-audit-advisory, check-contributor-credit v0.10.0, web lib/public-copy.test.ts (6 passed) OK. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
gaord
added a commit
to gaord/CodeWhale
that referenced
this pull request
Sep 26, 2026
CONTRIBUTING.md writes changelog entries on `main` at merge time rather than in a branch, because every PR that carries them re-conflicts with every other PR that does. Hmbown#6646's receipt — both CHANGELOG files and the generated web copy — is removed here, so the PR carries no changelog hunks for the release manager's batched receipt to conflict with. The review follow-up's two fixes stay: a re-saved item id is listed once in a warm index, and a thread list skips an item file that is already gone.
A sidebar click took 1.3s warm and 6.7s cold on a 140-thread store before the conversation appeared, and the whole of it was one read. An item's filename carries only the item id, so the only way to find the items of the turns being opened is to read every item record — 61,441 files, 294MB — and throw away all but the ~272 a median thread holds. Reading harder is not available either: sixteen concurrent `cat` streams of those files finish in 3.6s against 2.1s for one, because the cost is the per-file syscall rather than the bytes. Both reads on that path are now bounded by what the client asked for. The thread list reads each row's preview out of the row's own newest turn. A turn record carries `item_ids` in append order, and a turn's newest message is the last message item it appended — what follows it is a status, reasoning, file-change or tool record — so the walk ends within a handful of files per row: 165 item files for a page of 100, against 58,048 for the store-wide pass it replaces. A turn whose record predates `item_ids` still goes through that pass, collected for the whole page so the scan runs once rather than per turn. Opening a thread reads the items directory once per store and answers from the map after that. The map is built from that same walk, so it holds what the walk held — including the items `attach_item_to_turn` deliberately leaves out of a settled turn's `item_ids`, which the rebuild paths read out of the directory regardless — and every item write keeps it current. A write that lands while the walk runs is recorded and drained into the map when it publishes; a write with no walk running records nothing, because its file is already on disk for the next walk to find. Both readers of one turn's items come off it: `get_thread_detail`, and the fork preparation whose items the transcript rebuild consumes. Measured on that store, the two builds over identical clones: open a thread 1.33-6.34s -> 0.025-0.059s thread list 1.27s -> 0.031s The Runtime API warms the index while it starts, so the first open after a restart does not pay the walk either. That is the one place this raises a startup cost: the store is read once during launch, in the background, where it used to be read once per thread opened. Tests cover the index answering exactly what the walk answered (an item no turn registered, and an item written after the index was built), a warmed store opening a thread without reading the directory, one directory read per store across repeated reads, and a write that races the walk being published exactly once. The last three fail against the pre-change read.
Review follow-up on Hmbown#6646 (by @gaord), on top of that branch's own commit. The changelog hunks the original follow-up carried are not part of this: a PR leaves CHANGELOG.md to the release manager (CONTRIBUTING.md), and web/lib/changelog.generated.ts is untracked upstream (b90c7d3) and derived at test time, so this branch carries no copy of it to drift. - note_item_in_index pushed the item id into the published map on every save. The runtime saves one item id several times (in progress, then completed or failed), and the Runtime API warms the index at startup, so every turn run after startup returned each such item once per save from get_thread_detail and the fork rebuild. A write that raced the directory read and was noted after publish was duplicated the same way. The published branch now skips an id the turn already lists. - newest_message_text_in_turn failed the whole /v1/threads/summary page when a turn named an item whose file was removed. It now skips that id, as list_items_for_turns_map already does. Tests: adds an_item_saved_again_after_the_index_is_warm_is_listed_once and thread_list_facts_skips_an_item_file_that_is_gone. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
gaord
marked this pull request as draft
September 28, 2026 14:06
gaord
force-pushed
the
perf/thread-item-index
branch
from
September 28, 2026 14:24
11062aa to
85b914d
Compare
gaord
marked this pull request as ready for review
September 28, 2026 14:31
Review of the item-index change asked what keeps the thread-list preview honest when it reads a turn's own item ids. The answer was derivable but not written down: message items are always registered on their turn when written, so a settled turn cannot hold a late message its preview missed, and the read-only store's index is a snapshot of one open. Both are now said where the questions arise.
Hmbown
approved these changes
Sep 29, 2026
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014ZwqatxgVFxHvovngywnks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Opening a thread in the GUI is a sidebar click that took 1.3s warm / 6.7s cold on a 140-thread store (61,441 item files, 294MB) before the conversation appeared, and every second of it was one directory read. An item's filename carries only the item id, so the only way to find the items of the turns being opened is to read every item record and throw away all but the ~272 a median thread holds. Reading harder is not available either: sixteen concurrent
catstreams over those 61k files finish in 3.6s against 2.1s for one, so the cost is the per-file syscall, not the bytes it moves.Both reads on that path are now bounded by what the client asked for.
item_idsis append-ordered and a turn's newest message is the last message item it appended (what follows it is a status, reasoning, file-change or tool record), so the walk ends within a handful of files per row: 165 item files for a page of 100, against 58,048 for the store-wide pass it replaces. A turn whose record predatesitem_idsstill goes through that pass, collected for the whole page so the scan runs once rather than once per turn.attach_item_to_turndeliberately leaves out of a settled turn'sitem_ids, which the rebuild paths read out of the directory anyway — and every item write keeps it current. A write that races the walk is recorded and drained into the published map; a write with no walk running records nothing, because its file is already on disk for the next walk to find. Both readers of one turn's items come off it:get_thread_detailand the fork preparation whose items the transcript rebuild consumes.Measured on that store, both builds over identical clones:
/v1/threads/summary?limit=100)One deliberate cost. The Runtime API warms the index while it starts, in the background, so the first open after a restart does not pay the walk either. That is a store read during launch, where the read used to happen once per thread opened. It is off the request path and the second half of
RuntimeThreadStore::ensure_item_indexis what a lazy first read would call, so say the word if you would rather pay it on the first open instead.No-Issue: performance defect found by measuring a local store; nothing on file covers it.
Testing
macOS aarch64, with
RUST_MIN_STACK=16MiBexported asscripts/dev-test.shand CI do.cargo fmt --all -- --check— clean.cargo clippy -p codewhale-tui --all-targets --all-features --locked -- -D warnings -A clippy::uninlined_format_args -A clippy::too_many_arguments -A clippy::unnecessary_map_or— clean. That is the CI invocation limited to the crate this touches; a first run caught five lints in the new tests, all fixed.cargo check --workspace --all-features --locked— clean, so nothing outside the crate sees a broken shape.cargo test --workspace --all-features --locked— not run locally. The TUI lib suite alone ends red on this machine for reasons that predate this branch, and I would rather name them than tick the box:tui::app::tests::app_new_detects_missing_api_key_with_default_configandtui::widgets::tests::ascii_safe_tier_covers_whole_rendered_surfacesfail identically on a stashed tree (this machine has an API key configured and renders CJK); proven by stashing this branch's changes and rerunning both.runtime_threads::tests::host_goal_loop_kickoff_arms_one_continuation_and_parks_at_engine_capand the tworuntime_api::tests::compatibility_stream_*tests fail under a saturated parallel run and pass in isolation (test result: ok. 3 passed; 0 failed) — both were failing this way before this branch existed.cargo test -p codewhale-tui --lib --lockedhere reports13436 passed; 5 failed/13434 passed; 7 failedacross two runs, i.e. the set moves with load.The tests this change owns, run alone:
runtime_threads::tests::list_items_for_turns_map_reads_the_items_directory_once_per_storeruntime_threads::tests::a_warmed_store_opens_a_thread_without_reading_the_items_directoryruntime_threads::tests::item_reads_include_items_a_turn_never_registeredruntime_threads::tests::an_item_written_while_the_index_is_being_built_is_published_oncetest result: ok. 4 passed; 0 failed.The regression guard was proved to bite. Restoring the pre-change read (a directory walk inside
item_ids_for_turns) makes three of those tests fail —a later read must come off the index, not the directory: left: 12, right: 0— and they pass again once it is reverted.Output equality with the previous build. Over identical clones, eight old threads'
GET /v1/threads/{id}responses are byte-identical exceptlatest_seq(each engine's own event cursor), and for six more, the item count, set and order match exactly. One early mismatch was traced to the live store being written between the two clones — the item files themselves differ.Also adjusted:
thread_summary_listing_never_walks_the_items_directoryasserted the items directory is read exactly once per page; it now asserts zero item files are read by directory walk, which is the stronger guarantee the preview read gives.Checklist