Note
Grok responding on behalf of Julius.
Triage
Thanks for the timings and the clear breakdown of the query, @saphid! This is a real bug. I confirmed it on t3code/codex-turn-mapping at de95adc336e, the branch tip you named. main doesn't do this read, since V1 already stores the shell fields on projection_threads and selects them directly.
ProjectionStore.getShellSnapshot wraps selectShellThreadRows in sql.withTransaction. That SELECT runs 13 correlated subqueries per thread, including the two COUNT(*)s over orchestration_v2_projection_turn_items (item_count and runless_item_count). The same transaction then runs selectShellRunItemCounts, a GROUP BY count over that table, and decodes every thread payload before it commits.
packages/shared/src/nodeSqliteClient.ts has a single node:sqlite DatabaseSync connection behind a semaphore that allows one statement at a time. statement.all runs synchronously on the calling thread, and the transaction keeps the semaphore until it commits. So for the whole snapshot the event loop is stuck, and every other statement, writes included, has to wait. That's why your main-thread gap is almost the same as the snapshot time.
Two callers run this on a live server:
- Every shell subscription.
subscribeOrchestrationV2ShellloadsgetShellSnapshot({ location: "active" })withoutunsettledOnly, inside anothersql.withTransaction(apps/server/src/ws.ts). Every reconnect repeats the full read of active threads. - The pull request discovery pass.
ThreadPullRequestServicequeues a full pass every minute, and that pass callsgetShellSnapshot({ location: "active", unsettledOnly }). If a backfill is still pending, it reads every active thread instead. The snapshot sync sweep isn't the culprit here:PullRequestSyncReactoralso runs every minute, but it callsgetThreadsWithPullRequests(), which doesn't use this query.
unsettledOnly is meant to skip settled threads before their item rows are counted, and the filter sits in the outer WHERE, so only the remaining threads get counted. A 1.4 to 2.6s trace once a minute means most of those threads are still unsettled, or a backfill is forcing the full read. A client connect always takes the full path.
On the disconnects: the client sends a Ping every 5 seconds and closes the socket if no Pong has arrived by the next tick. A stall only drops a client when it runs past that deadline, so a single snapshot doesn't always cause a drop. But each drop leads to a reconnect, the reconnect runs the full snapshot again, and that can snowball into the clusters you saw.
Suggested fix
- Stop counting turn items in the shell list. Either store those counts on the thread row or leave them out of this query.
- Have the once-a-minute discovery pass skip
getShellSnapshot. It only needs the branch, settlement, and pull request fields. - Don't hold the only connection for the whole read. Even once the SQL is cheaper, a long
statement.allon this client will still block the event loop. The correlated counts are what stretch it to seconds.
Related
#14517 (open issue) is the config snapshot stall on connect, and #14516 (open issue) is the client dropping and reconnecting when that first snapshot is slow. Same failure mode, different read.