Opening a long thread blocks the database writer for 7–10 s#21840

opened5d agobyJJ-Giggles

Version: 0.1.0, Linux server, statev2.sqlite ≈ 10 GB.

What happens: opening a thread calls http.orchestration.loadThreadSnapshotWindow → environment.orchestration.threadBoundedSnapshot. Its whole duration is one sqlite span: the WITH eligible AS NOT MATERIALIZED (...) turn-items query in readCanonicalProjection. It runs inside sql.transaction with db.connection: writer. Seven consecutive opens measured 8.5 s median, 9.6 s max. A warm repeat of the same thread took 50 ms.

Why it matters: because the read holds the writer, every write waits behind it: creating a thread, dispatching a message, event-sink writes. From the client this looks like the app freezing when you switch threads or start a new one.

Suggestion: run snapshot reads on a reader connection outside the write transaction (WAL already allows concurrent readers). Separately, consider an index-only path for the window boundary so a cold open doesn't scan every turn item of the thread.

Repro data: span tree and timings from server.trace.ndjson are available on request. They contain no thread content.

Note

Grok responding on behalf of Julius.

Thanks for the measurements. Current main still behaves the way you describe.

The windowed thread snapshot read runs in a plain sql.withTransaction (apps/server/src/orchestration-v2/ProjectionStore.ts:5225-5274). A write transaction there starts with BEGIN IMMEDIATE on the writer connection (packages/shared/src/nodeSqliteClient.ts:818), so it holds the write lock for as long as the read takes. The server already has a way to send a read transaction to the reader connection (nodeSqliteClient.ts:54-63), and some other reads use it, but this snapshot read doesn't.

None of the open pull requests move this read off the writer. #21193 adds indexes for windowed lookups, which addresses the cold query cost, your second suggestion, but not where the read runs.

Accepting this as a bug.

Synced from GitHub

#21869 would move getThreadSnapshotWindow to the reader. Four other ProjectionStore reads still default to the writer on main at e45ddea067e8, because neither they nor their callers below opt into readOnly. On 0.1.1-nightly.20261019.3360 (1451e7cb2303), with a real 36.7 GB statev2.sqlite, each one blocked the server's main event loop for the whole statement. Times are CEST, UTC+2.

Read on the writerCallerStatementStall log record
getShellSnapshotThreadLaunchService branch rename, 18:26:1311,701 ms11,256 ms
samesame, 19:21:0511,697 ms12,514 ms
samestartup heartbeat, which reads the full shell to count threads, 19:27:548,091 ms7,942 ms
getThreadRecords, message-id queryMCP tools/call, 19:23:5012,380 ms11,482 ms
getRecoveryThreadIds, plain querystartup recovery, 19:26:0811,448 msnone logged, across the restart
getSettlementCandidates11 auto-settlement sweeps, 18:10 to 19:28813 to 5,311 ms2,444 to 5,170 ms for the 9 SELECTs over 2 s

While each statement ran, no other server span started or ended, and every row with a stall record matched its statement to within a second. That fits the writer being a synchronous DatabaseSync on the main thread that gets every statement not marked readOnly.

The host was overloaded (Apple M1, 8 cores, 16 GB, load average 57.28 at 18:25:57), and the times include whatever that added. For comparison, the same shell-row query ran on the reader worker in the same minutes: all 12 runs over 5 s, taking 8.72 to 20.93 s. Six had stall records of at most 4,813 ms in their windows, and four had none. One shares a 30 s window with the heartbeat read above. The last shares a window with a 15,962 ms record I can't explain, although server spans kept starting and ending throughout it. I read this as the writer connection blocking the event loop while the reader worker lets other server work run. It doesn't separate out the host load or explain why the statements took 8 to 20 s.

If you'd rather keep this issue to the snapshot window, I can file the remaining reads separately. Span trees are available on request.

Reader runs, call sites, and method

Reader runs, each with a parent sql.transaction on db.connection: reader:

StartCallerStatementLongest gap with no server spanStall records in its windows
18:23:15pull-request sync17.74 s2.14 s15,962 ms
18:23:49HTTP shell load20.93 s0.78 s2,188 ms
18:24:21WebSocket shell load18.56 s2.84 snone
18:25:22pull-request sync10.34 s1.83 snone
19:20:01pull-request sync9.29 s1.02 s4,209 ms
19:21:38pull-request sync11.27 s3.24 s4,813 ms
19:24:30pull-request sync, inferred from sibling spans12.11 s2.38 s4,520 ms
19:28:02pull-request sync11.12 s6.28 s, during a separate AgentAwarenessRelay writer read7,942 ms
19:28:39HTTP shell load11.18 s0.75 snone
19:28:59WebSocket shell load10.85 s0.75 snone
19:29:11HTTP shell load9.73 s0.97 s2,471 ms
19:29:24WebSocket shell load8.72 s1.84 s2,471 ms

Call sites on main at e45ddea067e8:

Method:

  • A gap is the longest time inside a statement with no server span starting or ending. It leaves out spans the desktop renderer forwards, which carry the renderer's clock and reached the trace only after each freeze ended. Span boundaries alone don't prove that requests were answered.
  • "Same query" rests on both paths calling readShellSnapshotRows and on matching query text, which the trace cuts after about 110 characters of SQL. Reader callers ask for active threads only and writer callers for all, so the row sets differ.
  • A stall record is the largest sampled event-loop delay minus the monitor's 1 s resolution, sampled about every 30 s. I matched a record at time T to the 30 s before it. The monitor logs only delays above 2 s, and only when the loop's active time is at least the delay, so "none" means no record, not proof of a delay under 2 s. The log has no record from 19:25:14 to 19:28:07, across the restart, and I didn't check why.

Traces analyzed with GPT-6.1 Sol in Codex and Claude Opus 5.5 in Claude Code, written by Claude Opus 5.5, on behalf of @ImSynem.

Synced from GitHub

More evidence from 0.1.2-nightly.20261021.3499 (17 GB statev2.sqlite, macOS). Right after a restart with a cold cache, readShellSnapshotRows takes about 34 s, and several callers run it inside a writer sql.transaction: ThreadPullRequestServiceV2.synchronize (68.7 s and 34.3 s), StorageCleanup.readThreads (35.0 s) and server.startup.heartbeat.record (33.8 s). Writer statements waited up to 68.8 s behind them, and the event loop recorded a 34 s stall in the same window. Once the cache was warm, the same read took about 4 s.

Synced from GitHub

Another effect of the same cold-start wait: today the desktop window failed at startup with DesktopLocalEnvironmentAuthSessionBootstrapError: Failed to create the local desktop bearer session. The desktop's POST to /oauth/token timed out after 10000 ms, because SessionStore.issue waited behind two ProjectionStore.readShellSnapshotRows reads (34.5 s and 35.4 s) that ran inside writer transactions. The longest write wait in that minute was 35.3 s. A minute later the server answered normally, so the 10 s client timeout turns a slow first minute into a failed launch.

Synced from GitHub

Note

Claude Opus 5.5 in Claude Code, writing on behalf of @tsmith165. Measured on his Mac on 2026-10-22.

Another data point, from a larger database on the current nightly: 0.1.2-nightly.20261022.3526, macOS desktop app, statev2.sqlite at 44.2 GiB.

Database size

TableRows
orchestration_events6,618,661
orchestration_command_receipts3,479,494
orchestration_v2_projection_turn_items834,009
threads2,630

The largest thread has about 512,000 events and 78,268 turn items (about 210 MB of payload_json).

Stalls on this build (event-loop-stalls.ndjson)

  • In the 54 minutes after a restart at 06:48 local, the log has 56 records over 1 s. 23 of them are over 5 s, and the longest is 13.3 s.
  • In the 20 minutes before that, the first run of this build after the nightly update, it has 21 records. The longest is 13.6 s.

What the user sees

The desktop shows "Main Workstation is reconnecting" for 15 to 45 s, and actions wait until it reconnects. Cheap calls wait behind the blocked loop: on this build ServerSecretStore.get took up to 9.6 s and SessionStore.verify up to 14.1 s. On the 20261021 nightly the same calls took up to 45.7 s.

Several long-running orchestrator threads on this machine read other threads through the T3 tools, so reads of large threads happen often. The four writer reads @ImSynem listed above match what we see. History and timeline pages are covered in #20427.

Synced from GitHub

Same pattern here, through MCP rather than the UI.

  • Setup: Windows, Nightly 0.1.2-nightly.20261021.3473, statev2.sqlite 31.1 GB. The longest thread has over 6,300 runs.
  • OrchestratorMcpService.readThread → loadReadableThread ran a writer-connection transaction that executed 25.5 s (twice in about 95 minutes).
  • OrchestratorMcpService.listThreads and ws.orchestrationV2.threadSnapshotThenLive also each held the writer for 5 s or more.
  • Over the same window, writer-connection statements spent 97.3% of their time waiting for the lock (p99 5.7 s, max 90.8 s).
  • So agents reading threads through MCP freeze writes the same way opening a thread does. +1 to running these snapshot reads on the reader connection, outside a write transaction.
Synced from GitHub

The four reads are filed separately, as you offered, and a pull request is up for them: #23689.

It covers all four named here, plus getThreadShell, which is the shell row for one thread and is read on every thread read:

ReadCallerCovered by #23689
getShellSnapshotthread launch, startup heartbeat, agent awareness relayyes
getThreadRecordsMCP tools/call, message-id queryyes
getRecoveryThreadIdsstartup recoveryyes
getSettlementCandidatesthe auto-settlement sweepyes
getThreadShellthe shell row for one threadyes

It is the same shape of change as #21869: each read is wrapped in NodeSqliteClient.readOnly, the existing helper that routes a read to the reader connection. The queries, the transaction shape, the error mapping and the results are unchanged; only the connection moves.

One extra measurement, from a host serving a 25.5 GB statev2.sqlite under load 68-116 on 10 cores, which is the MCP-heavy case:

1MCP tool calls: 1,857 in ~10 minutes (peak 506 in one minute)
2DB lock waits > 100 ms: 1,608 (518 seconds of lock wait in that window)
3correlation calls/min vs
4 total lock wait/min: r = 0.751
5
6sqlite ops by connection:
7 18,408 (writer, read) <-- reads also go through the writer
8 234 (writer, write)
9
10sql.transaction by connection:
11 writer n=15,839 mean=35.9ms
12 reader n=8 mean=497.7ms

78.5% of every query in that window ran inside an MCP call, and the worst single lock wait was 6,622 ms on SELECT payload_json FROM orchestration_v2_projection_nodes WHERE node_id = ?. The reader line in the transaction table is the same starvation you measured: the reader pool was used for 8 transactions in ten minutes, and those were the ones that averaged 497.7 ms.

For the test, the reader-worker and the writer are put on either side of a held write lock rather than a stream of inserts, so the read cannot land after the writes are done. The same read then behaves differently on the two connections:

1MEASURE read-on-reader: connection=reader lock_wait_ms=0.024 lock_held_for_ms=13 uncommitted_rows_visible=0
2MEASURE read-on-writer: outcome=TimeoutError lock_held_for_ms=3002 connections_acquired=0

The read on the reader connection completes in 13 ms with the lock held; the same read on the writer connection cannot acquire a connection at all. The uncommitted_rows_visible=0 is the check that the writer's row was still uncommitted when the read finished, so the write really was in flight for the whole read.

I have not touched the query cost, which you also raised - the indexes are separate work against a different bottleneck, and #21869's scope note says the same.