Repository navigation
perf(server): thread list reads no longer scan a long thread's runs and items - #35167
Conversation
The thread shell query finds a thread's active run four times. SQLite served each lookup by walking the ordinal index newest-first and reading every run's status, so a thread with no active run had its whole run history read on every shell read. Read those lookups through the existing (thread_id, status) index instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The thread-shell list finds each thread's last executed run with a correlated subquery ordered by completion time. No index carried that order, so SQLite sorted the thread's whole run history for every thread in the list. On a 200-thread fixture with one million runs the shell query took 214 ms; with the index it takes 2 ms and returns the same rows. Migration 83 adds a partial index on the runs table in the order that lookup reads, with the lookup's own predicate, so the shell query walks the index instead of sorting. The lookup keeps its semantics: same runs, same order. Refs #23000 Refs #9661 Co-Authored-By: Claude Code <noreply@anthropic.com>
Shell snapshots and getThreadShell counted each thread's items by walking the (thread_id, run_id) index, one entry per item. Migration 085 keeps one count per (thread, run) with triggers on turn items, and the shell count queries read it. (cherry picked from commit f8b758d of #27975; the migration moved from 076 to 085)
…writes and rebuilds One plan case now covers every shell lookup that used to read thread history: the active run lookups, the last executed run, and the item counts, which must not read turn items. The item count case writes an item again, moves it between a live run, a rolled back run and no run, rolls a run back and forward, and writes an item before its run exists. These cases come from #34666. The rebuild case checks the shell count after an item rewrite and after a projection rebuild. Co-authored-by: Braulio Oliveira <brauliobo@gmail.com> Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
ApprovabilityVerdict: Approved at Macroscope's review found this PR approvable — Thread-shell reads now use targeted indexes and trigger-maintained item counts instead of scanning long histories. The migrations add derived data without deleting source records or changing product defaults, keeping the exposure limited. No code changes detected at You can add or adjust custom eligibility rules. Learn more. |
Thread transfer impact✅ Thread transfer remains within every enforced ceiling.
Baseline: Scenario and decoded snapshot size10 historical turns, 5 command tools per turn, 878.9 KiB retained MCP result per historical turn, and a 1.05 MiB retained result in the measured turn.
Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed. |
|
No actionable comments were generated in the recent review. Walkthrough
Merge Risk:
|
|
🎉 amazing, can't wait for the next nightly |
Problem
The thread shell read feeds the sidebar and the mobile home list. It runs for every thread in a snapshot, and again for one thread on each shell update, once per subscriber. Three parts of it read a thread's whole history:
So an idle thread with a long history costs more on every update. On a copy of real data with one added thread of 16,500 runs and 66,000 items,
getThreadShellfor that thread takes 56 ms.Change
This replaces four community PRs that each fixed one part. It keeps three of them as their own commits, with their authors.
(thread_id, status)index, so they visit only active runs.Why the trigger design and not #34666
#34666 and #27975 store the same counts. I measured both on the same data, each against
mainin the same session. That session was noisier than the final one below, so compare the two columns, not the absolute values.apply)#34666 reads faster because it also stores whether each run counts. That flag copies run state into a second table, and two event handlers must keep it right. It also adds a read before every turn item write. The trigger design stores one fact, the number of items per (thread, run), which is a pure function of the turn item table. It costs less on the write path and it holds for every writer, including the test rebuild and
migrate-dev-db. The run status rules stay in the read, where they already were.Scope
Server read path and two migrations. No wire, contract or client change: every shell that the benchmark read came back byte for byte the same on
mainand on this branch.Related issue: #23000.
Verification
All timings are warm medians of 15 reads through the real
ProjectionStorecode, on copies of my real database made withvp run migrate-dev-db(1,643 threads, 3,205 runs, 112,649 items). "Long" adds synthetic threads built from real rows: one thread with 16,500 runs, and 40 threads of 500 runs and 25,000 items (1.18M items in total). The machine was busy, so small differences are noise.getThreadShell, longest thread (104 runs)getThreadShell, 16,500-run threadgetThreadShell, 500-run threadFor the 16,500-run thread, the thread row statement goes from 35.3 ms to 0.67 ms and the count statement from 20.8 ms to 8.5 ms.
EXPLAIN QUERY PLAN, run lookups of the thread row statement:Item counts:
Write cost. Events replayed through
ProjectionStore.applyin one transaction on a tmpfs copy, lowest of 9 rounds, main and branch run in turn:That is about +4 us (6%) per turn item update and +10 us (16%) per new turn item. Two earlier sessions on a busier machine measured up to +10 us and +18 us. With each event in its own transaction the difference is inside the noise.
The trigger cost is not the count row. SQLite keeps a statement journal for any statement on a table with triggers, so each turn item write pays for it. A shell update on a thread with 100 runs saves about 0.3 ms per subscriber, so the trade is good when shell reads are about as frequent as item writes, which is the case for an active thread.
Migrations. On the 1.18M-item copy: 84 takes 24 ms and 85 takes 70 ms from tmpfs, and 164 ms and 1.5 s from a cold file on disk. On the real copy from disk: 44 ms and 136 ms.
Stored counts against a recount (
GROUP BY thread_id, run_idover turn items), zero rows differ after each of:apply;migrate-dev-dbpruning a source that already has the table and triggers;Rollback and fork ancestry. With every 7th run rolled back (5,664 runs) and 10 threads forked from a run of another thread,
mainand this branch return the same shells (same SHA-256 for the snapshot and for each long thread).Tests.
ProjectionStore.test.ts, "reads shell runs and item counts without walking a thread's history": fails onmain, passes here.085_TurnItemCounts.test.ts: backfill, repeated upsert, move and delete.ProjectionStore.test.ts, the item count case, andFoundationPersistence.test.ts, the rebuild case: count through rewrites, moves, rollback, a late run and a projection rebuild.vp run check:changedpasses.Not verified.
scripts/migrate-dev-db.test.tshas one case that fails on my machine onmainand here in the same way ("copied pull request links keep their history"). I did not look into it.Still grows with history.
Credit
This PR replaces #34747, #31828, #34666 and #27975. Thank you @joei4cm, @bowd, @brauliobo and @Pyseph.
Co-authored-by: Bogdan bogdan@bowd.io
Co-authored-by: Michael Zhou joei4cm@gmail.com
Co-authored-by: Pyseph 45090858+Pyseph@users.noreply.github.com
Co-authored-by: Braulio Oliveira brauliobo@gmail.com
Made with Claude Fable 5.2 in Claude Code (T3 Code).
🤖 Generated with Claude Code