镜像站点 · 本页由第三方 GitHub 只读镜像提供,非 GitHub 官方站点,不接受任何登录或凭据输入。前往 github.com
Skip to content

perf(server): thread list reads no longer scan a long thread's runs and items - #35167

Merged
t3dotgg merged 5 commits into
mainfrom
takeover/shell-read-cost
Nov 12, 2026
Merged

t3dotgg merged 5 commits into
mainfrom
takeover/shell-read-cost

Conversation

@t3dotgg

@t3dotgg t3dotgg commented Nov 12, 2026

Copy link
Copy Markdown
Member

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:

  • The four active run lookups walk every run of a thread that has no active run.
  • The last executed run lookup sorts every run of the thread.
  • The item counts walk one index entry per turn item.

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, getThreadShell for 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.

  • Active runs (#31828, @bowd). The four lookups read through the existing (thread_id, status) index, so they visit only active runs.
  • Last executed run (#34747, @joei4cm). Migration 84 adds a partial index in the order that lookup reads, so it stops sorting.
  • Item counts (#27975, @Pyseph). Migration 85 adds one count row per (thread, run). Triggers on turn items keep it exact, and the two shell count queries read it. Rolled back and missing runs are still left out at read time, as before.
  • Tests (mine). One plan case covers all three reads. The item count case takes the rewrite, move, rollback and late run cases from #34666 (@brauliobo). The rebuild case checks the count after a projection rebuild.

Why the trigger design and not #34666

#34666 and #27975 store the same counts. I measured both on the same data, each against main in the same session. That session was noisier than the final one below, so compare the two columns, not the absolute values.

#27975 (triggers) #34666 (kept in apply)
Count read, 16,500-run thread (raw SQL) 5.1 ms 1.1 ms
Added cost per turn item update +9 to +14 us +23 to +26 us
Added cost per new turn item +7 to +14 us +26 to +32 us
Added cost per run update 0 +8 to +11 us

#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 main and on this branch.

Related issue: #23000.

Verification

All timings are warm medians of 15 reads through the real ProjectionStore code, on copies of my real database made with vp 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.

Read main this PR
Real data: active shell snapshot (1,643 threads) 130 ms 112 to 124 ms
Real data: getThreadShell, longest thread (104 runs) 1.16 ms 0.86 ms
Long: active shell snapshot (1,684 threads) 302 ms 151 ms
Long: getThreadShell, 16,500-run thread 56.1 ms 9.8 ms
Long: getThreadShell, 500-run thread 3.97 ms 0.96 ms

For 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:

main
SEARCH r USING INDEX orchestration_v2_projection_runs_thread_ordinal_idx (thread_id=?)        x4
SEARCH candidate USING INDEX orchestration_v2_projection_runs_thread_ordinal_idx (thread_id=?)
USE TEMP B-TREE FOR ORDER BY

this PR
SEARCH r USING INDEX orchestration_v2_projection_runs_thread_status_idx (thread_id=? AND status=?)   x4
SEARCH candidate USING INDEX orchestration_v2_projection_runs_last_executed_idx (thread_id=?)

Item counts:

main
SEARCH orchestration_v2_projection_turn_items USING COVERING INDEX orchestration_v2_projection_turn_items_thread_run_idx (thread_id=?)
SEARCH r USING INDEX sqlite_autoindex_orchestration_v2_projection_runs_1 (run_id=?) LEFT-JOIN
USE TEMP B-TREE FOR GROUP BY

this PR
SEARCH counts USING PRIMARY KEY (thread_id=?)
SEARCH r USING INDEX sqlite_autoindex_orchestration_v2_projection_runs_1 (run_id=?) LEFT-JOIN

Write cost. Events replayed through ProjectionStore.apply in one transaction on a tmpfs copy, lowest of 9 rounds, main and branch run in turn:

Event main this PR
Turn item update 64.7 to 65.9 us 68.6 to 69.7 us
New turn item 63.3 to 65.6 us 73.4 to 75.8 us
Run update 34.8 to 36.1 us 35.6 to 37.3 us

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_id over turn items), zero rows differ after each of:

  • the migration backfill, on both copies;
  • 60,000 inserts and 80,000 updates through apply;
  • migrate-dev-db pruning a source that already has the table and triggers;
  • deleting 1,000,000 turn items (6.2 s on main, 7.4 s here).

Rollback and fork ancestry. With every 7th run rolled back (5,664 runs) and 10 threads forked from a run of another thread, main and 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 on main, passes here.
  • 085_TurnItemCounts.test.ts: backfill, repeated upsert, move and delete.
  • ProjectionStore.test.ts, the item count case, and FoundationPersistence.test.ts, the rebuild case: count through rewrites, moves, rollback, a late run and a projection rebuild.
  • vp run check:changed passes.

Not verified.

  • A running server, a cold page cache, and a database larger than 4 GB. The real threads on my machine are short (156 runs at most), so the long thread numbers come from synthetic rows.
  • scripts/migrate-dev-db.test.ts has one case that fails on my machine on main and here in the same way ("copied pull request links keep their history"). I did not look into it.

Still grows with history.

  • The count read is one row per run, with a lookup of that run's status: about 0.5 us per run (8.5 ms at 16,500 runs, 0.17 ms at 500).
  • A thread forked from a run still reads every run of its source thread.

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

bowd and others added 4 commits November 12, 2026 02:33
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>
@github-actions github-actions Bot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:L 100-499 changed lines (additions + deletions). labels Nov 12, 2026
@macroscopeapp

macroscopeapp Bot commented Nov 12, 2026 •

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Approved at e3b6e99

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 6496e65. Prior analysis still applies.

You can add or adjust custom eligibility rules. Learn more.

@github-actions

github-actions Bot commented Nov 12, 2026 •

Copy link
Copy Markdown
Contributor

Thread transfer impact

✅ Thread transfer remains within every enforced ceiling.

Provider Metric Main baseline This PR Impact PR ceiling
Codex Total thread wire 5.0 KiB 5.0 KiB 0 B (0.0%) 6.8 KiB ✅
Codex Thread snapshot wire 3.8 KiB 3.8 KiB 0 B (0.0%) 4.9 KiB ✅
Codex Live turn WebSocket wire 1.2 KiB 1.2 KiB 0 B (0.0%) 2.0 KiB ✅
Codex Live turn WebSocket decoded 21.5 KiB 21.5 KiB 0 B (0.0%) 24.4 KiB ✅
Codex Live turn messages 1 1 0 (0.0%) 8 ✅
Claude Total thread wire 5.0 KiB 5.0 KiB 0 B (0.0%) 6.8 KiB ✅
Claude Thread snapshot wire 3.8 KiB 3.8 KiB 0 B (0.0%) 4.9 KiB ✅
Claude Live turn WebSocket wire 1.2 KiB 1.2 KiB 0 B (0.0%) 2.0 KiB ✅
Claude Live turn WebSocket decoded 21.9 KiB 21.9 KiB 0 B (0.0%) 24.4 KiB ✅
Claude Live turn messages 1 1 0 (0.0%) 8 ✅

Baseline: fc6c7c8 · PR result: 6496e65 · Source CI: success

Scenario and decoded snapshot size

10 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.

  • Codex decoded thread snapshot: 108.5 KiB
  • Claude decoded thread snapshot: 108.8 KiB

Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed.

@coderabbitai

coderabbitai Bot commented Nov 12, 2026

Copy link
Copy Markdown
Contributor

Explain Change →

No actionable comments were generated in the recent review.

Walkthrough

Adds a run lookup index and maintained per-thread, per-run turn-item counts. Shell snapshots use these structures for run lookups and item counts. Tests cover migration behavior, query plans, count changes, and projection rebuilding.

Changes: Shell Queries and Counts

Layer / File(s) Summary
Run index and maintained item counts
apps/server/src/persistence/Migrations/084_LastExecutedRunIndex.ts, apps/server/src/persistence/Migrations/085_TurnItemCounts.ts, apps/server/src/persistence/Migrations/085_TurnItemCounts.test.ts, apps/server/src/persistence/Migrations.ts, apps/server/src/persistence/reconcileV2PreviewMigration.test.ts
Adds and registers the last-executed-run index and the per-thread, per-run item-count table. Triggers maintain counts on inserts, qualifying updates, and deletes. Tests cover backfill, updates, deletes, and migration upgrades.
Shell snapshot query changes
apps/server/src/orchestration-v2/ProjectionStore.ts, docs/internals/performance-regressions.md
Uses the run status index for shell run lookups and reads item counts from the maintained table, excluding counts for rolled-back runs. Updates the performance documentation.
Query plan and count validation
apps/server/src/orchestration-v2/ProjectionStore.test.ts, apps/server/src/orchestration-v2/FoundationPersistence.test.ts
Tests query plans, count behavior for item rewrites and run changes, and item counts before and after projection rebuilding.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Change: Refactor

Possibly related issues

  • pingdotgg/t3code issue 23000 — Addresses shell query costs with a last-executed-run index, indexed run lookups, and maintained per-run item counts. The issue also proposes other indexes that this PR does not add.

Possibly related PRs

Suggested reviewers: juliusmarminge, maria-rcks

Merge Risk: Minimal · up to e3b6e

This change speeds up thread-shell reads using a new index and maintained item counts, with no wire or client changes. No actionable merge-blocking risk was found in the supplied context.

Pre-merge checks: Passed 4

Check name Status Explanation
Title check Passed The title names the main change: thread list reads no longer scan a long thread's runs and items. It follows the conventional commit format, and it matches the objectives (active-run lookups, last-exe…
Description check Passed The description follows the template. It has Problem, Change, Scope, and Verification sections, and it has a conventional-style context for the title. The Problem section explains the cost of reading …
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Full details: Title check

Explanation

The title names the main change: thread list reads no longer scan a long thread's runs and items. It follows the conventional commit format, and it matches the objectives (active-run lookups, last-executed index, and per-(thread, run) item counts that avoid walking history). It is specific and concise.

Full details: Description check

Explanation

The description follows the template. It has Problem, Change, Scope, and Verification sections, and it has a conventional-style context for the title. The Problem section explains the cost of reading full thread history, with a reproduction and measurements. The Change section lists the four component PRs and the tests. The Scope section links issue #23000 and states that it changes only server reads and two migrations. The Verification section gives benchmark tables, EXPLAIN QUERY PLAN output, write-cost measurements, migration timings, and test coverage, and it states what was not verified. The template's agent-model line is present at the end. The description is long, but its content is complete and specific.

Finishing Touches

Generate unit tests (beta)

  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts
Recent review info

Run configuration

Commits

Reviewing files that changed from the base of the PR and between 7d3635e and e3b6e99.

Files selected for processing: 9

  • apps/server/src/orchestration-v2/FoundationPersistence.test.ts
  • apps/server/src/orchestration-v2/ProjectionStore.test.ts
  • apps/server/src/orchestration-v2/ProjectionStore.ts
  • apps/server/src/persistence/Migrations.ts
  • apps/server/src/persistence/Migrations/084_LastExecutedRunIndex.ts
  • apps/server/src/persistence/Migrations/085_TurnItemCounts.test.ts
  • apps/server/src/persistence/Migrations/085_TurnItemCounts.ts
  • apps/server/src/persistence/reconcileV2PreviewMigration.test.ts
  • docs/internals/performance-regressions.md

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 2 remain after this review.

@bowd

bowd commented Nov 12, 2026

Copy link
Copy Markdown
Contributor

🎉 amazing, can't wait for the next nightly

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L 100-499 changed lines (additions + deletions). vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants