Skip to content

perf(tui): stop walking the whole item store to list or open a thread - #6646

Merged
Hmbown merged 4 commits into
Hmbown:mainfrom
gaord:perf/thread-item-index
Sep 29, 2026
Merged

Hmbown merged 4 commits into
Hmbown:mainfrom
gaord:perf/thread-item-index

Conversation

@gaord

@gaord gaord commented Sep 26, 2026

Copy link
Copy Markdown
Contributor

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 cat streams 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.

  • The thread list reads each row's preview out of the row's own newest turn. item_ids is 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 predates item_ids still goes through that pass, collected for the whole page so the scan runs once rather than once per turn.
  • Opening a thread reads the items directory once per store and answers from that map after. The map is built from that same walk, so it holds exactly 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 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_detail and the fork preparation whose items the transcript rebuild consumes.

Measured on that store, both builds over identical clones:

before after
open a thread 1.33–6.34s 0.025–0.059s
thread list (/v1/threads/summary?limit=100) 1.27s 0.031s

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_index is 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=16MiB exported as scripts/dev-test.sh and 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_config and tui::widgets::tests::ascii_safe_tier_covers_whole_rendered_surfaces fail 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_cap and the two runtime_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 --locked here reports 13436 passed; 5 failed / 13434 passed; 7 failed across 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_store
    • runtime_threads::tests::a_warmed_store_opens_a_thread_without_reading_the_items_directory
    • runtime_threads::tests::item_reads_include_items_a_turn_never_registered
    • runtime_threads::tests::an_item_written_while_the_index_is_being_built_is_published_once

    test 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 except latest_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_directory asserted 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

  • This PR adds a new layer/module/abstraction — it names or deletes the layer it replaces
  • Updated docs or comments as needed
  • Added or updated tests where relevant
  • Verified TUI behavior manually if UI changes — no TUI surface changes here
  • Harvested/co-authored credit uses a GitHub numeric noreply address — no co-authors

@gaord
gaord requested a review from Hmbown as a code owner September 26, 2026 16:13
@gaord
gaord force-pushed the perf/thread-item-index branch from 9c017cf to 083e386 Compare September 26, 2026 16:20
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.
gaord and others added 2 commits September 28, 2026 21:50
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
gaord marked this pull request as draft September 28, 2026 14:06
@gaord
gaord force-pushed the perf/thread-item-index branch from 11062aa to 85b914d Compare September 28, 2026 14:24
@gaord
gaord marked this pull request as ready for review September 28, 2026 14:31

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 4 potential issues.

Devin Review

Comment thread crates/tui/src/runtime_threads.rs
Comment thread crates/tui/src/runtime_threads.rs
Comment thread crates/tui/src/runtime_threads.rs
Comment thread crates/tui/src/runtime_api.rs
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.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ZwqatxgVFxHvovngywnks
@Hmbown
Hmbown merged commit a4e9d88 into Hmbown:main Sep 29, 2026
26 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants