Skip to content

Any read transaction left open on statev2.sqlite freezes every server write once the WAL passes 32 MB #23482

Open

Description

@realhasanshoaib

What happened

The desktop app froze ("T3Code is stuck"). For about 15 minutes threads and the sidebar did not load, and nothing the server writes went through. It recovered within a minute, without a restart, once an outside process that was reading statev2.sqlite exited.

Diagnosis

Since #21799 the checkpoint worker gates writer admission. gated() (packages/shared/src/nodeSqliteClient.ts:655) holds every write while blocked is true. The worker sets blocked when the WAL is at the 10,000-frame ceiling or the -wal file is larger than journal_size_limit (32 MB, apps/server/src/persistence/Sqlite.ts:12), and the checkpoint can't reach the end of the log.

A checkpoint can't move past the snapshot of an open read transaction. So once the WAL passes 32 MB, any connection that keeps a read open keeps blocked true, and every write waits for as long as that read lasts. runWithAdmission (line 794) has no deadline. The wait also happens while the statement holds the writer connection's lock (lines 1118 and 1026), so reads routed to the writer queue behind it too.

Holding writes doesn't help the checkpoint here: the reader blocks it, not the writes. So the gate turns WAL growth, which SQLite handles fine, into a full stall. A fix probably means not gating on blocked while a reader pins the WAL, or giving admission a short deadline after which writes go through and the WAL grows.

On this machine the reader was migrate-dev-db, run by an agent in a worktree, copying the live 17 GB statev2.sqlite in one read transaction. AGENTS.md says reading and copying the live database is fine, so agents do this routinely. A user script that snapshots a few tables every hour, holding a read for 60 to 171 s, is a second trigger on this machine. The server's own reader connection can pin the WAL the same way, because any open read does.

Steps to reproduce

Save this as packages/shared/repro-wal-gate.ts on main and run it from packages/shared:

// Repro: an outside read transaction stalls every T3 write once the WAL passes journal_size_limit.
// Save in packages/shared, then: node repro-wal-gate.ts worker   (or: noworker)
import * as NodeSqlite from "node:sqlite";
import { mkdtempSync, statSync } from "node:fs";
import { tmpdir } from "node:os";
import { randomBytes } from "node:crypto";
import * as Effect from "effect/Effect";
import * as Exit from "effect/Exit";
import * as SqlClient from "effect/sql/SqlClient";
import * as NodeSqliteClient from "./src/nodeSqliteClient.ts";

const checkpointWorker = process.argv[2] === "worker";
const filename = `${mkdtempSync(`${tmpdir()}/t3-wal-gate-`)}/statev2.sqlite`;
const walMb = () => (statSync(`${filename}-wal`, { throwIfNoEntry: false })?.size ?? 0) / 1048576;
const payload = randomBytes(64 * 1024);

const program = Effect.gen(function* () {
  const sql = yield* SqlClient.SqlClient;
  // Same pragmas as apps/server/src/persistence/Sqlite.ts.
  yield* sql`PRAGMA busy_timeout = 5000`;
  yield* sql`PRAGMA journal_mode = WAL`;
  yield* sql`PRAGMA synchronous = NORMAL`;
  yield* sql`PRAGMA checkpoint_fullfsync = ON`;
  yield* sql.unsafe(`PRAGMA journal_size_limit = ${32 * 1024 * 1024}`);
  yield* sql`CREATE TABLE t (id INTEGER PRIMARY KEY, data BLOB)`;
  yield* sql`INSERT INTO t (data) VALUES (${payload})`;

  // An outside reader, like `sqlite3 statev2.sqlite` or a backup script.
  const outside = new NodeSqlite.DatabaseSync(filename, { readOnly: true });
  outside.exec("BEGIN");
  outside.prepare("SELECT count(*) FROM t").get();
  console.log(`checkpointWorker=${checkpointWorker}: outside reader holds a read transaction`);

  for (let i = 1; i <= 1500; i++) {
    const started = performance.now();
    const exit = yield* Effect.exit(sql`INSERT INTO t (data) VALUES (${payload})`.pipe(Effect.timeout("5 seconds")));
    if (Exit.isFailure(exit)) {
      console.log(`write ${i} STALLED for 5s+ (WAL ${walMb().toFixed(1)} MB)`);
      outside.exec("COMMIT");
      const resumed = performance.now();
      yield* sql`INSERT INTO t (data) VALUES (${payload})`;
      console.log(`outside reader closed; next write took ${(performance.now() - resumed).toFixed(0)} ms`);
      return;
    }
    if (performance.now() - started > 500) console.log(`write ${i} slow: ${(performance.now() - started).toFixed(0)} ms`);
  }
  console.log(`all 1500 writes finished with the reader open (WAL ${walMb().toFixed(1)} MB)`);
  outside.exec("COMMIT");
});

await Effect.runPromise(
  program.pipe(
    Effect.provide(NodeSqliteClient.layer({ filename, readerWorker: true, checkpointWorker })),
  ),
);
$ node repro-wal-gate.ts worker
checkpointWorker=true: outside reader holds a read transaction
write 552 STALLED for 5s+ (WAL 39.3 MB)
outside reader closed; next write took 102 ms

$ node repro-wal-gate.ts noworker
checkpointWorker=false: outside reader holds a read transaction
all 1500 writes finished with the reader open (WAL 106.9 MB)

Expected: writes keep going while an outside reader is open, and the WAL grows until the reader leaves, as SQLite's WAL mode allows. Actual: once the WAL passes 32 MB, writes stop until the reader closes. The stall hit write 552 on both runs.

Version

Desktop 0.1.2-nightly.20261021.3499. The local server reports the same serverVersion. Line references are from main @ 5ffe045, which has no later change to nodeSqliteClient.ts.

Environment

macOS 26.6.2 on an Apple M4 MacBook Air with 16 GB. Desktop app with its bundled server (Electron 44.9.3, Node 24.22.0, SQLite 3.53.4). statev2.sqlite is 17 GB. About 14 Claude and Codex threads were running.

Evidence

sqlite spans on the writer connection from server.trace.ndjson, in local time (UTC+4). The outside copy started at 11:56 and was killed at about 12:11. Each duration is almost entirely db.lock_wait_ms, and all of them completed within a second of the kill.

start     durationMs  connection  db.lock_wait_ms  query
11:56:30  901121      writer      901120           SELECT * FROM scheduled_tasks WHERE enabled = 1 AND next_run_at IS NOT NULL ...
11:56:34  897756      writer      897755           SELECT MAX(sequence) AS sequence FROM orchestration_events INDEXED BY ...
11:56:48  883879      writer      883878           SELECT project_id AS "projectId", title, workspace_root AS "workspaceRoot", ...
11:57:28  844924      writer      844922           SELECT thread_id, transcript_imported_at FROM orchestration_v2_legacy_imports ...
12:01:09  624913      writer      624913           SELECT session_id AS "sessionId", subject AS "subject", scopes AS "scopes", ...

50 writer statements waited more than 10 minutes, the longest 901 s. From 11:57 to 12:10 the server started only 1 to 18 SQL statements a minute, against 1,000 to 2,800 a minute before. In the minute after the reader exited it ran 5,072, and after that every statement took 2 ms or less. At this event rate the trace keeps only about 20 minutes (10 MB files rotated every two minutes), so an earlier 104 s stall at 11:50 rotated out before I could classify it.

Related issues

Fix applied or workaround

I killed the outside reader. The server drained its backlog within a minute and needed no restart. The local stopgap is to turn the checkpoint worker off (checkpointWorker: null in the bundled server's SQLite layer), which falls back to SQLite's passive auto-checkpoint. The noworker run above shows that path doesn't stall.

Filed by

Claude Code (Claude Opus 5.5), following .github/triage/PLAYBOOK.md.

Activity

  1. realhasanshoaib commented on Oct 22, 2026

    @realhasanshoaib
    ContributorAuthor

    It froze again 40 minutes later with no outside process holding the database; only the server had statev2.sqlite open. This time the reader was the server's own shell snapshot. loadShellSnapshot ran a sql.transaction on the reader connection for 39 to 66 s while the machine was under load (load average 15) and the WAL was 33.7 MB. Writer statements waited up to 78 s for the writer lock until that read ended. It happened from 12:49 to 12:51 and again from 12:52 to 12:53 (UTC+4).

    start     end       s     span                                 connection  lock wait s
    12:52:29  12:53:27  58.2  sql.transaction (loadShellSnapshot)  reader      0.0
    12:52:42  12:53:37  55.4  sql.transaction                      writer      55.4
    12:52:48  12:53:38  50.4  sql.transaction                      writer      50.4
    12:53:00  12:53:39  38.9  sql.transaction                      writer      38.9
    

    So the gate needs no outside reader. Once the WAL is past 32 MB, any slow read on the server's own reader connection freezes writes for as long as that read lasts.

  2. aryaxo commented on Oct 22, 2026

    @aryaxo

    Another data point from a large Windows install, in case it helps size this.

    Setup

    • T3 Code Nightly 0.1.2-nightly.20261021.3473, desktop on Windows 11.
    • 16 logical CPUs, 64 GB RAM.
    • statev2.sqlite is 31.1 GB with about 2,790 threads. 959 are unarchived, after I archived 1,829 finished ones today.
    • 10-20 agent threads run at once, with heavy MCP orchestration (about 17-28 tool calls a minute).

    Stalls

    • From 20 Oct 22:31 to 22 Oct 13:30, event-loop-stalls.ndjson has 1,470 records. Median max delay is 4.6 s and p90 is 9.7 s; 22 records are over 20 s and the worst is 79.9 s. Utilisation median is 0.93.
    • Two full client resubscribes (the "Reconnecting" banner) at 13:23:51 and 13:27:31 lined up with 10.8 s and 8.5 s stalls.
    • Archiving the 1,829 threads didn't help. From 19:53 to 21:31 there were 67 records: median 7.2 s, p90 17.6 s, worst 27.0 s.

    Where the time goes (trace, 19:53-21:30, UTC+5)

    • Writer-connection sqlite spans: 17,940.
      • 97.3% of their total duration is db.lock_wait_ms.
      • Lock wait: p99 5.7 s, max 90.8 s.
      • Execution: p50 about 0 ms, p99 384 ms.
    • 28 sql.transactions executed for 5 s or more, not counting lock wait:
      • reader: ThreadPullRequestServiceV2.synchronize 7 times, up to 120.7 s, and ShellStream.loadShellSnapshotParts 3 times, 31-32 s, via loadShellSnapshot and subscribeShell;
      • writer: Orchestrator.dispatchMessage (from sendToThread and dispatchContinuation) 42-53 s; Orchestrator.finalizeAppOwnedSubagentTask 29-69 s; providerTurnStart.start 31.5 s; OrchestratorMcpService.readThread/loadReadableThread 25.5 s.
    • The -wal file was 18.9-29.8 MB each time I sampled it. So I can't tell whether the 32 MB gate fired here, but the long reader transactions above are the kind that would pin it.

    I can share span trees with timings only, no thread content, if useful.

  3. realhasanshoaib commented on Oct 23, 2026

    @realhasanshoaib
    ContributorAuthor

    Related: #23372 and #23378.

  4. xdotli commented on Oct 25, 2026

    @xdotli

    same here

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions