perf(server): sending, editing and steering queued messages no longer read a long thread's whole history#29521

open7
main
fix/issue-29468
opened6d agobyJJulius Marminge
View on GitHubTwo-way sync enabledPR last imported 2d ago

Problem

Sending a message, editing a queued message, and promoting a queued message to steer took 7-15 s on a long-lived thread. Each command reads the thread's projection before it plans, and those reads walked the thread's whole history: every turn item to filter by type, every node to drop settled leaves, every node again to find owned provider threads, plus a separate pass that grouped every user and assistant item to order messages.

Reproduced on main (c8a601c0df) with a synthetic thread at the reporter's scale (1,867 runs, 16,899 nodes, 23,670 turn items, 76 MB of item payload): one send read about 177 MB from SQLite to return about 0.6 MB, and the query plans match the issue's (USE TEMP B-TREE FOR GROUP BY, turn_items_thread_ordinal_idx (thread_id=?) for a type filter, nodes_thread_run_idx (thread_id=?) for the settled-node and provider-thread reads).

Change

  • Message ordering. readCanonicalProjection skips the separate ordinal query when the loaded turn items already include every item type that can carry a loaded message's id (user → user_message, assistant → assistant_message, system → both), with no window and no run/node/status narrowing of the items. The order is then fully determined by the loaded items. Otherwise the query still runs, now limited to the loaded message ids and served by a message-id expression index.
  • Typed turn items. A type filter selects matching rowids through a new (thread_id, type, status) index and sorts outside the subquery; SQLite only chose that index when the match itself had no ORDER BY. Status is in the index so Stop's active-item read (types plus pending/running/waiting) no longer fetches every settled tool row to reject it.
  • Settled leaf nodes. omitSettledLeafNodes uses an index on the settled-leaf expression plus a UNION ALL branch for settled leaves of this thread's active runs, the same rows the old NOT (… AND NOT EXISTS …) predicate kept.
  • Provider-thread ownership. One shared subquery, used by canonical reads and getRuntimeRecoveryProjection, starts from this thread's node ids in a new covering (thread_id, node_id) index and probes the owner index per node. It no longer reads node rows, or every owned provider thread in the environment.
  • Promote to steer and Stop. queued-message.promote-to-steer and run.interrupt (which promote reaches through its stop path) now read nodes with omitSettledLeafNodes, as message.dispatch already did for the same steer path. Their node uses are root nodes, live nodes and nodes of the active run, which the filter keeps.
  • Migration 078 adds the four indexes.

Scope and approval

Fixes pingdotgg/t3code#29468

Triaged via the automated issue flow; GitCafe issue #29468.

Verification

  • New test queued-message commands read a long thread's history without walking all of it (Orchestrator.control-reads.test.ts) drives message.dispatch, queued-run.edit and queued-message.promote-to-steer through the Orchestrator and checks EXPLAIN QUERY PLAN of every read they run: no message-ordering GROUP BY pass, no node or turn-item row search bounded only by the thread, no environment-wide active-run lookup, no status-filtered read that fetches every typed row to check its status. It also checks message order (with the edit applied) and provider-thread membership. It fails on main for all three commands and passes here.
  • bounds runtime provider-thread reads by ownership… (ProjectionRecovery.test.ts) now also asserts the per-node owner probe; it fails with the previous owner-first shape.
  • Focused suites: ProjectionStore, ProjectionRecovery, ProjectionControlReads, Orchestrator.control-reads, ThreadStop, SteeringCompletion and persistence/: 33 files, 189 tests pass. vp run check:changed passes.
  • Reviewers compared old and new SQL on randomized synthetic data (settled-leaf node reads, typed turn items under combined status/run/node filters, provider-thread ownership, message order through the service): no differences in rows or order.

Runtime transcript, synthetic thread above, getThreadRecords medians of 3 warm runs:

Commandmainthis branch
message.dispatch277 ms108 ms
queued-run.edit164 ms74 ms
queued-message.promote-to-steer464 ms96 ms

Bytes read per statement (sqlite3 .stats, fresh immutable read-only connection), which is what dominates on a large database whose pages are not cached:

Statementmainthis branch
message ordering (GROUP BY over every user/assistant item)69.3 MBnot run on these commands
turn items filtered by type (1,085 rows)62.9 MB4.7 MB
nodes with settled leaves omitted (1,867 rows)22.7 MB10.2 MB
provider threads (0 rows)21.9 MB1.0 MB
Stop's active turn items (5 types, active statuses, 0 rows)62.9 MB0.1 MB

Unrelated history stays out of the reads: for a 10-node thread next to 50,000 owned provider threads in another thread, the provider-thread read takes 26 page misses (117 KB); next to 50,000 queued runs in another thread, the settled-node read takes 29 page misses (130 KB).

Write cost, measured by a reviewer: an assistant-message upsert growing to 117 KB in a 24,000-item thread went from 1,097 µs to 1,545 µs with both new turn-item indexes; with Codex flushing assistant text every 50 ms that is a few ms of CPU per second of streaming. Building the indexes on a 5.9 GB database took about 2.6 s once.

Not checked: the reporter's macOS desktop app on their own database, and end-to-end client latency.

Implemented with Claude Opus 5.5 (Claude Code) through T3 Code; reviewed by GPT-6.1-Sol (Codex) and Claude Opus 5.5.

🤖 Generated with Claude Code

Checks

23 successful · 11 skipped
All checks passed
02b32d9

Added 7 commits

  • perf(server): bound settled-node active-run lookups to the thread016cff8
  • Merge remote-tracking branch 'origin/main' into fix/issue-2946802b32d9
  • perf(server): queued-message commands stop walking a long thread's history069bcb9
  • perf(server): Stop's active turn-item read checks status in the index168017c
  • Merge remote-tracking branch 'origin/main' into fix/issue-29468388667c
  • perf(server): bound provider-thread ownership lookups to the thread's nodes6ab6b12
  • test(server): reproduce #29468 queued-message commands walking thread historyced36e1
Synced from GitHub

Approvability

Verdict: Approved at 02b32d9

Macroscope's review found this PR approvable — Thread-history reads become more selective, with supporting indexes and settled-node filtering for steering and Stop. The migration adds indexes without changing stored customer content or product defaults.

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

Synced from GitHub

Review in Change Stack →

No actionable comments were generated in the recent review.

External image Recent review info
External image Run configuration
External image Commits

Reviewing files that changed from the base of the PR and between 9bcf75335f9b2a8af877e1317c809b72a070b280 and 02b32d9ceef3a1806e0117f99330d0071c8b8534.

External image Files selected for processing: 7
  • apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts
  • apps/server/src/orchestration-v2/Orchestrator.ts
  • apps/server/src/orchestration-v2/ProjectionRecovery.test.ts
  • apps/server/src/orchestration-v2/ProjectionStore.ts
  • apps/server/src/persistence/Migrations.ts
  • apps/server/src/persistence/Migrations/078_ThreadHistoryReadIndexes.ts
  • apps/server/src/persistence/reconcileV2PreviewMigration.test.ts

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

Walkthrough

Migration 78 adds thread-history indexes. ProjectionStore changes turn-item, node, provider-thread, and message-ordinal reads. Two orchestration queries omit settled leaf nodes. Tests check query plans and long-history behavior for message dispatch, queued-run editing, and promotion to steer.

Changes: Long-thread reads

Layer / File(s)Summary
Add thread-history indexes
apps/server/src/persistence/Migrations/078_ThreadHistoryReadIndexes.ts, apps/server/src/persistence/Migrations.ts, apps/server/src/persistence/reconcileV2PreviewMigration.test.ts
Migration 78 adds indexes for turn-item filtering, node lookup, message ordinals, and settled leaf nodes. The migration loader and migration tests include the new migration.
Optimize projection reads
apps/server/src/orchestration-v2/ProjectionStore.ts, apps/server/src/orchestration-v2/ProjectionRecovery.test.ts
ProjectionStore shares turn-item filters and provider-thread ownership queries, changes node selection, and limits message-ordinal lookup to loaded message IDs. The recovery query-plan assertion checks owner-index use.
Filter orchestration reads
apps/server/src/orchestration-v2/Orchestrator.ts, apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts
Queued-message promotion and run-interrupt queries omit settled leaf nodes. The long-history test checks query plans and resulting projections for dispatch, queued-run editing, and promotion to steer.

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

Change: Bug fix · Severity of issue fixed: Medium

Suggested reviewers: yash-singh1, t3dotgg

Merge Risk: External image Minimal · up to 02b32

This change adds indexes and narrows history reads to speed up sending, editing and promoting queued messages in long threads. No concrete correctness or availability risk was found, so it looks safe to merge. The author did not check end-to-end latency in the desktop app.

Pre-merge checks: Passed 4
Check nameStatusExplanation
Title checkPassedThe title clearly identifies the main performance improvement: queued message operations no longer read an entire long thread history. It is specific and uses the repository's conventional commit form…
Description checkPassedThe description follows the required Problem, Change, Scope and approval, and Verification sections. It explains the issue, implementation, linked issue, focused tests, performance measurements, limit…
Linked Issues checkPassedDirect issue [#29468] requires lower-cost reads for send, queued edit, and promote-to-steer while preserving projection behavior. ProjectionStore skips message-order scans when loaded turn items cov…
Out of Scope Changes checkPassedThe changed production files implement the performance and correctness objectives in [#29468]. Migration registration and migration expectation updates support deployment. The query-plan and projectio…
Full details: Title check

Explanation

The title clearly identifies the main performance improvement: queued message operations no longer read an entire long thread history. It is specific and uses the repository's conventional commit format.

Full details: Description check

Explanation

The description follows the required Problem, Change, Scope and approval, and Verification sections. It explains the issue, implementation, linked issue, focused tests, performance measurements, limitations, and agent usage. The scope section does not include an explicit maintainer approval comment, but it provides a linked triaged issue and is otherwise sufficiently complete.

Full details: Linked Issues check

Explanation

Direct issue [#29468] requires lower-cost reads for send, queued edit, and promote-to-steer while preserving projection behavior. ProjectionStore skips message-order scans when loaded turn items cover the message roles, bounds fallback ordering to loaded message IDs, uses typed-item and settled-leaf index paths, and bounds provider-thread ownership from the current thread's node IDs. Orchestrator applies settled-leaf filtering to promote-to-steer and Stop. Migration 078 adds and registers the four supporting indexes. The added Orchestrator.control-reads.test.ts test exercises all three commands and checks query plans, message order, and provider-thread membership. The reported focused tests and changed-file checks pass. The optional command cache and unrelated closed issues are not required.

Full details: Out of Scope Changes check

Explanation

The changed production files implement the performance and correctness objectives in [#29468]. Migration registration and migration expectation updates support deployment. The query-plan and projection assertions provide automated coverage for the changed read paths. No unrelated production behavior or unrelated feature appears in the reviewed changes.

Finishing Touches
Generate docstrings
  • Commit to this branch
  • Create a new PR
Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts
Synced from GitHub

Thread transfer impact

✅ Thread transfer remains within every enforced ceiling.

ℹ️ The exact PR base did not have a successful artifact. Baseline uses the latest successful main measurement shown below.

ProviderMetricMain baselineThis PRImpactPR ceiling
CodexTotal thread wire5.0 KiB5.0 KiB−35 B (−0.7%)6.8 KiB✅
CodexThread snapshot wire3.8 KiB3.8 KiB0 B (0.0%)4.9 KiB✅
CodexLive turn WebSocket wire1.3 KiB1.2 KiB−35 B (−2.7%)2.0 KiB✅
CodexLive turn WebSocket decoded21.6 KiB21.5 KiB−41 B (−0.2%)24.4 KiB✅
CodexLive turn messages21−1 (−50.0%)8✅
ClaudeTotal thread wire5.1 KiB5.1 KiB0 B (0.0%)6.8 KiB✅
ClaudeThread snapshot wire3.8 KiB3.8 KiB0 B (0.0%)4.9 KiB✅
ClaudeLive turn WebSocket wire1.3 KiB1.3 KiB0 B (0.0%)2.0 KiB✅
ClaudeLive turn WebSocket decoded21.9 KiB21.9 KiB0 B (0.0%)24.4 KiB✅
ClaudeLive turn messages220 (0.0%)8✅

Baseline: 0847a44 · PR result: 02b32d9 · 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.

Synced from GitHub

Reporter of #29468 here. I compared the command projection-read sets on fresh copy-on-write clones of the affected 4.2 GB database, using PR head 02b32d9 with migration 078 applied. Send, edit and promote improve substantially. Cancel improves less, and acknowledge and one status-only background read stay expensive.

Projection-read timings (first read on fresh clones, live page layout; v0.1.6 vs this branch):

Commandv0.1.6this PR
message.dispatch11.7–12.4 s2.3–2.5 s
queued-run.edit5.2–7.5 s1.2 s
queued-message.promote-to-steer12.8–14.9 s2.0–2.1 s
queued-run.cancel6.9–7.1 s3.7–4.2 s
delegated_task.completion-delivery.acknowledge3.1–3.3 s3.1–3.3 s

Thread shape: about 25k turn items (most of them command_execution, dynamic_tool and reasoning), 17k nodes, 2k runs and 5.9k messages. On the real data, plans use all four new indexes. Old and new SQL return identical rows and order for items, nodes and provider threads. Message order is also identical for all 2,024 user messages, 852 of which have no user_message item. For dispatch, page misses fell from about 90k to 21.6k, about 76% fewer.

Still open, with suggested fixes (line numbers at 02b32d9):

  1. queued-run.cancel and completion-delivery acknowledge still read every node (about 2.2 s for cancel's node read). dispatchQueuedRunCancel (Orchestrator.ts:9675) and the acknowledge/dispose path (Orchestrator.ts:2446) call getThreadRecords with nodes and no omitSettledLeafNodes. Cancel also loads no user_message items, so the ordering query still runs, now bounded by the index (about 0.2–0.3 s). Cancel is called in loops from thread.settle (:3316) and the stop path (:10798). omitSettledLeafNodes: true looks safe here, because cancellation reads root nodes. Cancel could also omit settled subagents. Acknowledge/dispose must keep the target task and any tasks needed to rebuild the remaining delivery message.
  2. The turn-item status filter can't use the active-item partial index. turnItemMatch emits status IN (SELECT value FROM json_each(?)) (ProjectionStore.ts:3036), which never matches migration 073's WHERE status IN ('pending','running','waiting'). Status-only reads therefore walk the whole thread. Idle compaction (IdleCompactionService.ts:53) did this 50 times in about 80 minutes, taking about 4.2 s each in the first-read benchmark. Emitting the literal list when the filter is the active set brings that read to about 25 pages and 4 ms.
  3. Messages by role scan the thread's whole message history. At about 0.7 s, this is the largest remaining read on send. role IN (SELECT value FROM json_each(?)) (ProjectionStore.ts:3507) can't use the role = 'user' partial index, so the planner walks messages_thread_created_idx. Two options: a literal role = 'user' for the user-only filter, keeping the existing behavior for other filters, or a (thread_id, role, created_at) index.
  4. Smaller reads that still scale with history: runs, attempts, provider turns, subagents and context transfers, about 0.14–0.3 s each on the first read. Context transfers are read in full (ProjectionStore.ts:3608), although send's transfer checks only need pending ones. A caller-specific pending filter could help; merge-back and finalization still need historical transfers.
  5. Migration 078 took 59.7 s on the live-layout clone, versus about 5.2 s on a warm compacted copy. The cache was not forcibly reset. A startup progress note may be useful.

Other measurements:

  • Commit (EventSink.commitCommand) takes about 7 ms.
  • Effect decode of what a send loads takes about 0.1–0.15 s.
  • The new indexes add a few µs per upsert on this data.

Roughly half of the measured reader time was outside the three commands compared above. The heavy background readers were idle compaction, MCP loadCaller, startNextQueuedRun and subagent finalization. With 4 reader slots, the traces show 111 s of slot waits, up to 14 s for a single read. That may be worth a separate issue.

Synced from GitHub

Another data point from a much bigger thread: about 41k turn items / 37k nodes, in a 28 GB database. On 0.1.7-nightly.20261103.4119 one send to that thread took 354 s under host load. Roughly: message-order lookup 97 s, provider threads 78 s, nodes 65 s, turn items 63 s, messages 20 s, subagents 18 s.

The worse part is the knock-on effect. While that read was open the WAL couldn't checkpoint, and the writer sat waiting 335 s, so every other thread froze with it. These are exactly the queries this PR rewrites, so we'd really like to see it land.

Heads up: it conflicts with main now, since migration 078 is already taken there.

Synced from GitHub

Another data point from a much bigger thread: about 41k turn items / 37k nodes, in a 28 GB database. On 0.1.7-nightly.20261103.4119 one send to that thread took 354 s under host load. Roughly: message-order lookup 97 s, provider threads 78 s, nodes 65 s, turn items 63 s, messages 20 s, subagents 18 s.

The worse part is the knock-on effect. While that read was open the WAL couldn't checkpoint, and the writer sat waiting 335 s, so every other thread froze with it. These are exactly the queries this PR rewrites, so we'd really like to see it land.

Heads up: it conflicts with main now, since migration 078 is already taken there.

You continue using the thread after so much slowdown? How?

Synced from GitHub

It's an automated pipeline thread: subagent reports and scheduled tasks post to it (about 260 sends in 31 h), so nobody sits waiting on the composer. Most of those sends are much faster than that one, about 2,500 s of history reads across the 260; the 354 s send was the worst, under heavy host load. What actually hurts is the knock-on effect: while one of those reads is open the writer is blocked, so every other thread on the server freezes with it.

Synced from GitHub

Note

Written by Claude Opus 5.5 (Claude Code) on behalf of Navid

In case it helps get this lookup fixed sooner: #30873 adds the same index on the message-ordinal statement, with a json_valid guard so the index build and writes don't fail on a row whose payload isn't valid JSON. It is green and mergeable on current main (migration 081). Happy to close it if you'd rather land this PR instead.