From f37f99ca9e04dd0c4d0ee814402ae224175adc8d Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 14 Oct 2026 05:37:44 -0700 Subject: [PATCH] fix(shared): SQLite checkpoints no longer fall back to the main thread silently (#19417) Co-authored-by: Claude Opus 5.5 --- packages/shared/src/nodeSqliteClient.test.ts | 48 +++++++++++++++++++- packages/shared/src/nodeSqliteClient.ts | 30 +++++++++--- 2 files changed, 70 insertions(+), 8 deletions(-) diff --git a/packages/shared/src/nodeSqliteClient.test.ts b/packages/shared/src/nodeSqliteClient.test.ts index 18b3de54d8e34322388cb8b70c391cf0d3c00674..c5001907e9e96b875137ee54859b1c668a791364 100644 --- a/packages/shared/src/nodeSqliteClient.test.ts +++ b/packages/shared/src/nodeSqliteClient.test.ts @@ -18,6 +18,8 @@ import * as SqliteClient from "./nodeSqliteClient.ts"; const startedWorkers = vi.hoisted((): Array => []); // When on, workers run a script that never announces, like a worker stuck opening the file. const silentWorkers = vi.hoisted(() => ({ on: false, readyOnTerminate: false })); +// When set, workers throw this at startup, like a checkpoint worker on SQLite before 3.51. +const failingWorkers = vi.hoisted(() => ({ message: undefined as string | undefined })); vi.mock("node:worker_threads", async (importOriginal) => { const actual = await importOriginal(); @@ -26,7 +28,9 @@ vi.mock("node:worker_threads", async (importOriginal) => { super( ...((silentWorkers.on ? ["setInterval(() => {}, 1 << 30);", { eval: true }] - : args) as ConstructorParameters), + : failingWorkers.message !== undefined + ? [`throw new Error(${JSON.stringify(failingWorkers.message)});`, { eval: true }] + : args) as ConstructorParameters), ); startedWorkers.push(this); } @@ -762,3 +766,45 @@ it.effect("hands checkpoints back to the writer when the worker stops", () => }), ), ); + +it.effect("warns once and hands checkpoints back when the worker fails at startup", () => { + const { warnings, layer: logs } = captureWarnings(); + const reason = "SQLite 3.50.4 has no NOOP checkpoint"; + return Effect.acquireUseRelease( + Effect.sync(() => { + failingWorkers.message = reason; + }), + () => + withCheckpointClient((sql) => + Effect.gen(function* () { + const worker = startedWorkers.at(-1)!; + // threadId is -1 once the worker has exited and the client has seen "error" and "exit". + if (worker.threadId !== -1) { + yield* Effect.promise(() => new Promise((resolve) => worker.once("exit", resolve))); + } + const autocheckpoint = yield* sql<{ + readonly wal_autocheckpoint: number; + }>`PRAGMA wal_autocheckpoint`; + assert.equal(autocheckpoint[0]?.wal_autocheckpoint, 1000); + assert.deepEqual(warnings, [ + [ + "SQLite checkpoint worker unavailable; checkpoints run on the main connection", + { reason }, + ], + ]); + }), + ), + () => + Effect.sync(() => { + failingWorkers.message = undefined; + }), + ).pipe(Effect.provide(logs)); +}); + +it.effect("does not warn when the client stops its checkpoint worker", () => { + const { warnings, layer: logs } = captureWarnings(); + return Effect.gen(function* () { + yield* withCheckpointClient(() => Effect.void); + assert.lengthOf(warnings, 0); + }).pipe(Effect.provide(logs)); +}); diff --git a/packages/shared/src/nodeSqliteClient.ts b/packages/shared/src/nodeSqliteClient.ts index c8ddece4ac9baa9ddc6d4bd4de6256693d954835..996b8618bdd4589702c8ef6222ae2a29640cb416 100644 --- a/packages/shared/src/nodeSqliteClient.ts +++ b/packages/shared/src/nodeSqliteClient.ts @@ -526,28 +526,36 @@ parentPort.on("message", (request) => { /** * Moves the writer's WAL checkpoints to a worker. Call the returned function * after each statement on the writer. While the worker runs, the writer only - * checkpoints at WRITER_BACKSTOP_FRAMES. If the worker fails or exits, the - * writer goes back to SQLite's default. + * checkpoints at WRITER_BACKSTOP_FRAMES. If the worker cannot start, fails or + * exits, the writer goes back to SQLite's default and one warning says why. */ const startCheckpointWorker = Effect.fnUntraced(function* ( filename: string, writer: NodeSqlite.DatabaseSync, ) { + const warn = (reason: string) => + Effect.logWarning( + "SQLite checkpoint worker unavailable; checkpoints run on the main connection", + { reason }, + ); let worker: NodeWorkerThreads.Worker; try { worker = new NodeWorkerThreads.Worker(checkpointWorkerSource, { eval: true, workerData: { filename, frames: CHECKPOINT_FRAMES, idleMs: CHECKPOINT_IDLE_MS }, }); - } catch { + } catch (cause) { // No worker threads here: the writer keeps checkpointing. + yield* warn(String(cause)); return undefined; } writer.exec(`PRAGMA wal_autocheckpoint = ${WRITER_BACKSTOP_FRAMES};`); + // Worker events arrive outside any fiber; this keeps the client's loggers. + const runFork = Effect.runForkWith(yield* Effect.context()); let stopped = false; let checking = false; let checkAgain = false; - const handBack = () => { + const handBack = (reason: string) => { if (stopped) return; stopped = true; try { @@ -555,10 +563,18 @@ const startCheckpointWorker = Effect.fnUntraced(function* ( } catch { // The writer is already closed. } + runFork(warn(reason)); }; - worker.on("error", handBack); - worker.on("exit", handBack); - yield* Effect.addFinalizer(() => Effect.promise(() => worker.terminate())); + // A worker that throws, such as on SQLite before 3.51, emits "error" before "exit". + worker.on("error", (cause) => handBack(cause.message)); + worker.on("exit", (code) => handBack(`exited with code ${code}`)); + yield* Effect.addFinalizer(() => + Effect.promise(() => { + // A normal shutdown: the writer closes next, so there is nothing to hand back or report. + stopped = true; + return worker.terminate(); + }), + ); // At most one request is in flight. A statement that runs during it asks for // one more check, so the frames of a last commit before idle still get one. -- 2.54.0