From ab8fddeeb4fd17b82f676f8fcf5866a0cf5b65b1 Mon Sep 17 00:00:00 2001 From: Brent Yates Date: Fri, 9 Oct 2026 23:03:15 +0000 Subject: [PATCH 1/9] plan(core): restore the SQLite lock-wait window Refs swxtchio/swx-opencode#153. 1. Keep the 5ms native busy_timeout on every client so bun:sqlite never blocks the event loop for long. Replace the four-attempt 25-75ms schedule in sqlite.bun.ts, sqlite.node.ts and effect-sqlite-node with a capped, jittered exponential backoff bounded by Schedule.during: each statement retries asynchronously for 5 seconds of elapsed contention, the old native window, then returns its classified LockTimeoutError. Retries stay inside the acquired connection permit, and nonretryable errors still fail immediately. 2. In packages/core/test/sqlite-busy.test.ts, rewrite the release-after- contention tests to hold a real write lock for over a second after the contender's first attempt, on the Bun production layer, core Node and standalone Node, for run and values. Probe an unrelated HTTP request during that hold, then require success, the returned rows and one persisted write. Move the exhaustion tests from the attempt count and the 1s bound to the elapsed window, raise their backstops above it, and check that independent work succeeds after release. 3. Raise the backstops in packages/opencode/test/fixture/sqlite-lock.ts and the affected consumer test bounds so the real driver exhausts first. 4. Prove each rewritten regression goes red on the old schedule, then run the focused core and opencode tests, typechecks, lint and the identity gate. The CHANGESET entry needs the PR number, so it lands at publication. Claude-Session: https://claude.ai/code/session_01BQzKnpxtC4SMFP6UwY4C1t From ac0df72e64e54b314ade1a3247d1c8365f45e5af Mon Sep 17 00:00:00 2001 From: Brent Yates Date: Fri, 9 Oct 2026 23:08:01 +0000 Subject: [PATCH 2/9] fix(core): wait out SQLite locks for the old 5s window Every runtime client gave up on a busy statement after four backoffs, about 250ms, so a competing writer on the shared store failed accepted prompts. Each statement now retries asynchronously between 5ms native attempts for 5 seconds of elapsed contention, with capped, jittered exponential backoff, before it returns the classified lock failure. The release-after-contention tests now hold a real write lock for 1.5s after the contender meets it, probe an unrelated request during the hold, and cover the Bun production layer, core Node and standalone Node through run and values. The exhaustion tests assert the elapsed window instead of the old attempt count and 1s bound, and Bun's responsiveness case no longer depends on node:sqlite. Refs swxtchio/swx-opencode#153. Claude-Session: https://claude.ai/code/session_01BQzKnpxtC4SMFP6UwY4C1t --- packages/core/src/database/sqlite.bun.ts | 10 +- packages/core/src/database/sqlite.node.ts | 10 +- packages/core/test/sqlite-busy.test.ts | 226 ++++++++++------------ packages/effect-sqlite-node/src/index.ts | 10 +- 4 files changed, 120 insertions(+), 136 deletions(-) diff --git a/packages/core/src/database/sqlite.bun.ts b/packages/core/src/database/sqlite.bun.ts index ef2f94b341ba..474eaca92b34 100644 --- a/packages/core/src/database/sqlite.bun.ts +++ b/packages/core/src/database/sqlite.bun.ts @@ -19,10 +19,12 @@ import { Sqlite } from "./sqlite" const ATTR_DB_SYSTEM_NAME = "db.system.name" const nativeBusyTimeoutMs = 5 -// Five 5ms native attempts plus four 25/50/75/75ms backoffs target ~250ms total. -const retrySchedule = Schedule.exponential("25 millis").pipe( - Schedule.modifyDelay((_output, delay) => Effect.succeed(Duration.millis(Math.min(Duration.toMillis(delay), 75)))), - Schedule.take(4), +// Keep the old 5s busy_timeout window, but wait it out asynchronously between 5ms native attempts. +// The window runs from the first busy failure; backoff is capped at SQLite's own 100ms busy-handler sleep. +const retrySchedule = Schedule.exponential("10 millis").pipe( + Schedule.modifyDelay((_output, delay) => Effect.succeed(Duration.millis(Math.min(Duration.toMillis(delay), 100)))), + Schedule.jittered, + Schedule.both(Schedule.during("5 seconds")), ) const statementError = (cause: unknown) => { diff --git a/packages/core/src/database/sqlite.node.ts b/packages/core/src/database/sqlite.node.ts index 796cebf612d7..5a3bb584ec2b 100644 --- a/packages/core/src/database/sqlite.node.ts +++ b/packages/core/src/database/sqlite.node.ts @@ -19,10 +19,12 @@ import { Sqlite } from "./sqlite" const ATTR_DB_SYSTEM_NAME = "db.system.name" const nativeBusyTimeoutMs = 5 -// Five 5ms native attempts plus four 25/50/75/75ms backoffs target ~250ms total. -const retrySchedule = Schedule.exponential("25 millis").pipe( - Schedule.modifyDelay((_output, delay) => Effect.succeed(Duration.millis(Math.min(Duration.toMillis(delay), 75)))), - Schedule.take(4), +// Keep the old 5s busy_timeout window, but wait it out asynchronously between 5ms native attempts. +// The window runs from the first busy failure; backoff is capped at SQLite's own 100ms busy-handler sleep. +const retrySchedule = Schedule.exponential("10 millis").pipe( + Schedule.modifyDelay((_output, delay) => Effect.succeed(Duration.millis(Math.min(Duration.toMillis(delay), 100)))), + Schedule.jittered, + Schedule.both(Schedule.during("5 seconds")), ) const statementError = (cause: unknown) => { diff --git a/packages/core/test/sqlite-busy.test.ts b/packages/core/test/sqlite-busy.test.ts index f08253e90b5b..295a05ed6200 100644 --- a/packages/core/test/sqlite-busy.test.ts +++ b/packages/core/test/sqlite-busy.test.ts @@ -18,6 +18,16 @@ const nodeSqliteDrivers = await import("node:sqlite").then( () => undefined, ) const nodeSqliteTest = test.skipIf(!nodeSqliteDrivers) +// The retry window each statement must give a competing writer: the old 5s native busy_timeout. +const lockWindowMs = 5_000 +// Exhaustion may overrun the window by one capped backoff, one native attempt and scheduling delay. +const lockWindowSlackMs = 1_500 +// Backstops sit above window plus slack so a statement that never stops fails with its own message. +const lockBackstop = "10 seconds" +// Well past the ~250ms the old four-attempt schedule gave up after, and well inside the restored window. +const heldLockMs = 1_500 +// An unrelated request must finish far sooner than a synchronous wait would let it. +const probeBoundMs = 1_000 const sqliteError = (error: unknown) => { if (isSqlError(error)) return error @@ -150,20 +160,21 @@ const clientWithAttemptCount = (input: { }) => Effect.gen(function* () { if (input.clientType === "bun") { - const bun = yield* Effect.promise(() => import("../src/database/sqlite.bun")) - const context = yield* Layer.build(bun.layer({ filename: input.filename })) const sqlite = yield* Effect.promise(() => import("bun:sqlite")) - const native = Context.get(context, Sqlite.Native) as InstanceType - const query = native.query - Object.defineProperty(native, "query", { - configurable: true, - writable: true, - value: (sql: string) => { + const prototype = sqlite.Database.prototype + const queryDescriptor = Object.getOwnPropertyDescriptor(prototype, "query") + if (!queryDescriptor) return yield* Effect.die(new Error("bun:sqlite query method was not found")) + const query = prototype.query + Object.defineProperty(prototype, "query", { + ...queryDescriptor, + value: function (this: InstanceType, sql: string) { if (sql === input.statement) input.attempts.count++ - return query.call(native, sql) + return query.call(this, sql) }, }) - return Context.get(context, SqlClient) + yield* Effect.addFinalizer(() => Effect.sync(() => Object.defineProperty(prototype, "query", queryDescriptor))) + const context = yield* Layer.build(Database.layerFromPath(input.filename)) + return Context.get(context, Database.Service).db.$client } const drivers = nodeSqliteDrivers @@ -229,14 +240,13 @@ describe("SQLite busy timeout and statement retries", () => { ) }) - for (const clientType of ["core", "standalone"] as const) { + for (const clientType of ["bun", "core-node", "standalone-node"] as const) { for (const method of ["run", "values"] as const) { - nodeSqliteTest( - `node:sqlite ${clientType} retries ${method} after a second process releases its write lock`, + test.skipIf(clientType !== "bun" && !nodeSqliteDrivers)( + `${clientType} ${method} waits out a write lock held past the old 250ms window`, async () => { - if (!nodeSqliteDrivers) return await using tmp = await tmpdir() - const filename = path.join(tmp.path, "node-busy.sqlite") + const filename = path.join(tmp.path, `${clientType}-held.sqlite`) const writerValue = `${clientType}-${method}-writer` const writerSql = method === "run" @@ -246,42 +256,59 @@ describe("SQLite busy timeout and statement retries", () => { Effect.scoped( Effect.gen(function* () { const attempts = { count: 0 } - const client = yield* clientWithAttemptCount({ - clientType: clientType === "core" ? "core-node" : "standalone-node", - filename, - statement: writerSql, - attempts, - }) + const client = yield* clientWithAttemptCount({ clientType, filename, statement: writerSql, attempts }) yield* client.unsafe("CREATE TABLE busy_retry_test (id INTEGER PRIMARY KEY, value TEXT NOT NULL)").raw - yield* client.unsafe("PRAGMA busy_timeout = 0").raw + expect(yield* client.unsafe("PRAGMA busy_timeout").values).toEqual([[5]]) + const server = Bun.serve({ port: 0, fetch: () => new Response("server-progress") }) + yield* Effect.addFinalizer(() => Effect.sync(() => server.stop(true))) + const holder = yield* Effect.promise(() => + startLockHolder(filename, clientType === "bun" ? holderScript : nodeHolderScript), + ) + yield* Effect.addFinalizer(() => Effect.promise(() => holder.release())) - const holder = yield* Effect.promise(() => startLockHolder(filename, nodeHolderScript)) + let writerFinished = false + const startedAt = performance.now() + const writer = yield* ( + method === "run" ? client.unsafe(writerSql).raw : client.unsafe(writerSql).values + ).pipe( + Effect.onExit(() => Effect.sync(() => void (writerFinished = true))), + Effect.forkChild({ startImmediately: true }), + ) yield* Effect.gen(function* () { - const statement = method === "run" ? client.unsafe(writerSql).raw : client.unsafe(writerSql).values - let writerFinished = false - const writer = yield* statement.pipe( - Effect.onExit(() => Effect.sync(() => void (writerFinished = true))), - Effect.forkChild({ startImmediately: true }), - ) + while (attempts.count === 0) yield* Effect.sleep("1 millis") + }).pipe( + Effect.timeoutOrElse({ + duration: "1 second", + orElse: () => Effect.fail(new Error("writer did not attempt the held-lock statement")), + }), + ) + // The holder owns the write lock and the writer has met it; keep holding well past the old schedule. + while (performance.now() - startedAt < heldLockMs) yield* Effect.sleep("10 millis") + expect(writerFinished).toBe(false) - yield* Effect.gen(function* () { - while (attempts.count === 0) yield* Effect.sleep("1 millis") - }).pipe( - Effect.timeoutOrElse({ - duration: "1 second", - orElse: () => Effect.fail(new Error("writer did not attempt the held-lock statement")), - }), - ) - expect(writerFinished).toBe(false) - yield* Effect.promise(() => holder.release()) - const result = yield* Fiber.await(writer) - expect(Exit.isSuccess(result)).toBe(true) - if (!Exit.isSuccess(result)) return - if (method === "values") expect(result.value).toEqual([[1]]) - expect( - yield* client.unsafe(`SELECT COUNT(*) FROM busy_retry_test WHERE value = '${writerValue}'`).values, - ).toEqual([[1]]) - }).pipe(Effect.ensuring(Effect.promise(() => holder.release()))) + const probeStartedAt = performance.now() + yield* Effect.promise(() => holder.probe(server.url.href)).pipe( + Effect.timeoutOrElse({ + duration: "2 seconds", + orElse: () => + Effect.fail(new Error("unrelated request did not progress while SQLite was contended")), + }), + ) + expect(performance.now() - probeStartedAt).toBeLessThan(probeBoundMs) + expect(writerFinished).toBe(false) + + yield* Effect.promise(() => holder.release()) + const result = yield* Fiber.await(writer).pipe( + Effect.timeoutOrElse({ + duration: "2 seconds", + orElse: () => Effect.fail(new Error("writer did not finish after the lock was released")), + }), + ) + if (Exit.isFailure(result)) return yield* Effect.fail(new Error(Cause.pretty(result.cause))) + if (method === "values") expect(result.value).toEqual([[1]]) + expect( + yield* client.unsafe(`SELECT COUNT(*) FROM busy_retry_test WHERE value = '${writerValue}'`).values, + ).toEqual([[1]]) }), ), ) @@ -318,7 +345,7 @@ describe("SQLite busy timeout and statement retries", () => { .unsafe("INSERT INTO busy_retry_test (id, value) VALUES (1, 'node-writer')") .raw.pipe( Effect.timeoutOrElse({ - duration: "5 seconds", + duration: lockBackstop, orElse: () => Effect.fail(new Error("node retry did not stop")), }), ) @@ -334,7 +361,7 @@ describe("SQLite busy timeout and statement retries", () => { ) expect(Exit.isSuccess(exit)).toBe(true) }, - 10_000, + 20_000, ) } @@ -358,7 +385,7 @@ describe("SQLite busy timeout and statement retries", () => { }) .pipe( Effect.timeoutOrElse({ - duration: "5 seconds", + duration: lockBackstop, orElse: () => Effect.fail(new Error("immediate transaction retries did not stop")), }), ) @@ -374,75 +401,11 @@ describe("SQLite busy timeout and statement retries", () => { ), ) if (Exit.isFailure(exit)) throw new Error(Cause.pretty(exit.cause)) - }, 10_000) - - for (const method of ["run", "values"] as const) { - test(`retries ${method} after a second process releases its write lock`, async () => { - await using tmp = await tmpdir() - const filename = path.join(tmp.path, "busy.sqlite") - const writerSql = - method === "run" - ? "INSERT INTO busy_retry_test (id, value) VALUES (1, 'writer')" - : "INSERT INTO busy_retry_test (id, value) VALUES (1, 'writer') RETURNING id" - const attempts = { count: 0 } - const exit = await Effect.runPromiseExit( - Effect.scoped( - Effect.gen(function* () { - const client = yield* clientWithAttemptCount({ - clientType: "bun", - filename, - statement: writerSql, - attempts, - }) - yield* client.unsafe("CREATE TABLE busy_retry_test (id INTEGER PRIMARY KEY, value TEXT NOT NULL)").raw - yield* client.unsafe("PRAGMA busy_timeout = 0").raw - - const holder = yield* Effect.promise(() => startLockHolder(filename, bunHolderScript)) - yield* Effect.gen(function* () { - const statement = Effect.gen(function* () { - if (method === "run") { - yield* client.unsafe(writerSql).raw - return { method: "run" as const } - } - return { - method: "values" as const, - rows: yield* client.unsafe(writerSql).values, - } - }) - let writerFinished = false - const writer = yield* statement.pipe( - Effect.onExit(() => Effect.sync(() => void (writerFinished = true))), - Effect.forkChild({ startImmediately: true }), - ) - - yield* Effect.gen(function* () { - while (attempts.count === 0) yield* Effect.sleep("1 millis") - }).pipe( - Effect.timeoutOrElse({ - duration: "1 second", - orElse: () => Effect.fail(new Error("writer did not attempt the held-lock statement")), - }), - ) - expect(writerFinished).toBe(false) - yield* Effect.promise(() => holder.release()) - const result = yield* Fiber.await(writer) - expect(Exit.isSuccess(result)).toBe(true) - if (!Exit.isSuccess(result)) return - if (result.value.method === "values") expect(result.value.rows).toEqual([[1]]) - expect( - yield* client.unsafe("SELECT COUNT(*) FROM busy_retry_test WHERE value = 'writer'").values, - ).toEqual([[1]]) - }).pipe(Effect.ensuring(Effect.promise(() => holder.release()))) - }), - ), - ) - expect(Exit.isSuccess(exit)).toBe(true) - }, 10_000) - } + }, 20_000) for (const clientType of ["bun", "core-node", "standalone-node"] as const) { - nodeSqliteTest( - `${clientType} serves unrelated requests and exhausts a persistent lock within budget`, + test.skipIf(clientType !== "bun" && !nodeSqliteDrivers)( + `${clientType} serves unrelated requests and exhausts a persistent lock after the retry window`, async () => { const drivers = nodeSqliteDrivers if (clientType !== "bun" && !drivers) return @@ -494,10 +457,16 @@ describe("SQLite busy timeout and statement retries", () => { }), ) expect(writerFinished).toBe(false) - const result = yield* Fiber.await(writer) + const result = yield* Fiber.await(writer).pipe( + Effect.timeoutOrElse({ + duration: lockBackstop, + orElse: () => Effect.fail(new Error("persistent-lock retries did not stop")), + }), + ) expect(writerFinished).toBe(true) expect(Exit.isFailure(result)).toBe(true) - expect(elapsedMs).toBeLessThan(1_000) + expect(elapsedMs).toBeGreaterThanOrEqual(lockWindowMs) + expect(elapsedMs).toBeLessThan(lockWindowMs + lockWindowSlackMs) if (!Exit.isFailure(result)) return const error = Option.getOrUndefined(Cause.findErrorOption(result.cause)) expect(isSqlError(error)).toBe(true) @@ -509,12 +478,17 @@ describe("SQLite busy timeout and statement retries", () => { expect( yield* client.unsafe("SELECT COUNT(*) FROM busy_retry_test WHERE value = 'persistent-writer'").values, ).toEqual([[0]]) + yield* client.unsafe("INSERT INTO busy_retry_test (id, value) VALUES (2, 'independent-writer')").raw + expect(yield* client.unsafe("SELECT id, value FROM busy_retry_test ORDER BY id").values).toEqual([ + [2, "independent-writer"], + [100, "holder"], + ]) }), ), ) if (Exit.isFailure(exit)) throw new Error(Cause.pretty(exit.cause)) }, - 10_000, + 20_000, ) } @@ -689,10 +663,11 @@ describe("SQLite busy timeout and statement retries", () => { yield* Effect.gen(function* () { let failure: unknown + const startedAt = performance.now() if (method === "run") { yield* database.run("INSERT INTO busy_retry_test (id, value) VALUES (1, 'writer')").pipe( Effect.timeoutOrElse({ - duration: "5 seconds", + duration: lockBackstop, orElse: () => Effect.fail(new Error("retry did not stop")), }), Effect.catch((error) => Effect.sync(() => (failure = error))), @@ -702,7 +677,7 @@ describe("SQLite busy timeout and statement retries", () => { .values("INSERT INTO busy_retry_test (id, value) VALUES (1, 'writer') RETURNING id") .pipe( Effect.timeoutOrElse({ - duration: "5 seconds", + duration: lockBackstop, orElse: () => Effect.fail(new Error("retry did not stop")), }), Effect.catch((error) => Effect.sync(() => (failure = error))), @@ -710,7 +685,10 @@ describe("SQLite busy timeout and statement retries", () => { } expect(failure).toBeInstanceOf(EffectDrizzleQueryError) if (!(failure instanceof EffectDrizzleQueryError)) return - expect(attempts.lock).toBe(5) + const elapsedMs = performance.now() - startedAt + expect(elapsedMs).toBeGreaterThanOrEqual(lockWindowMs) + expect(elapsedMs).toBeLessThan(lockWindowMs + lockWindowSlackMs) + expect(attempts.lock).toBeGreaterThan(1) expect(failure.query).toBe("Database is locked (SQLITE_BUSY)") expect(failure.params).toEqual([]) expect(failure.message).toContain("Database is locked (SQLITE_BUSY)") @@ -752,7 +730,7 @@ describe("SQLite busy timeout and statement retries", () => { ), ) if (Exit.isFailure(exit)) throw new Error(Cause.pretty(exit.cause)) - }, 10_000) + }, 20_000) } for (const method of ["run", "values"] as const) { diff --git a/packages/effect-sqlite-node/src/index.ts b/packages/effect-sqlite-node/src/index.ts index 28bff2d5acd4..2864f6f4c84e 100644 --- a/packages/effect-sqlite-node/src/index.ts +++ b/packages/effect-sqlite-node/src/index.ts @@ -19,10 +19,12 @@ import * as Statement from "effect/unstable/sql/Statement" const ATTR_DB_SYSTEM_NAME = "db.system.name" const nativeBusyTimeoutMs = 5 -// Five 5ms native attempts plus four 25/50/75/75ms backoffs target ~250ms total. -const retrySchedule = Schedule.exponential("25 millis").pipe( - Schedule.modifyDelay((_output, delay) => Effect.succeed(Duration.millis(Math.min(Duration.toMillis(delay), 75)))), - Schedule.take(4), +// Keep the old 5s busy_timeout window, but wait it out asynchronously between 5ms native attempts. +// The window runs from the first busy failure; backoff is capped at SQLite's own 100ms busy-handler sleep. +const retrySchedule = Schedule.exponential("10 millis").pipe( + Schedule.modifyDelay((_output, delay) => Effect.succeed(Duration.millis(Math.min(Duration.toMillis(delay), 100)))), + Schedule.jittered, + Schedule.both(Schedule.during("5 seconds")), ) const statementError = (cause: unknown) => { From e84689786f9058dd6c09cf0b774df043a45f5bf5 Mon Sep 17 00:00:00 2001 From: Brent Yates Date: Fri, 9 Oct 2026 23:15:45 +0000 Subject: [PATCH 3/9] test(core): guard SQLite lock waits against event-loop stalls The success-path probe could slip through a backoff gap between two long synchronous native waits. Track the longest gap between 5ms ticks across the contention, counting the gap still open when it is read, so any multi-second synchronous wait fails the extended-hold and persistent-lock tests. Claude-Session: https://claude.ai/code/session_01BQzKnpxtC4SMFP6UwY4C1t --- packages/core/test/sqlite-busy.test.ts | 29 ++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/packages/core/test/sqlite-busy.test.ts b/packages/core/test/sqlite-busy.test.ts index 295a05ed6200..88f89dc1951d 100644 --- a/packages/core/test/sqlite-busy.test.ts +++ b/packages/core/test/sqlite-busy.test.ts @@ -26,8 +26,27 @@ const lockWindowSlackMs = 1_500 const lockBackstop = "10 seconds" // Well past the ~250ms the old four-attempt schedule gave up after, and well inside the restored window. const heldLockMs = 1_500 -// An unrelated request must finish far sooner than a synchronous wait would let it. -const probeBoundMs = 1_000 +// The event loop must keep turning while a statement waits; a long synchronous native wait stalls it for seconds. +const stallBoundMs = 1_000 + +// Ticks every 5ms from now on and returns a reader for the longest gap between ticks. The reader includes the gap +// still open when it is called, so a synchronous call the ticker has not yet woken from still counts. +const eventLoopStalls = Effect.gen(function* () { + let lastTick = performance.now() + let longestMs = 0 + yield* Effect.sleep("5 millis").pipe( + Effect.andThen( + Effect.sync(() => { + const now = performance.now() + longestMs = Math.max(longestMs, now - lastTick) + lastTick = now + }), + ), + Effect.forever, + Effect.forkScoped, + ) + return () => Math.max(longestMs, performance.now() - lastTick) +}) const sqliteError = (error: unknown) => { if (isSqlError(error)) return error @@ -266,6 +285,7 @@ describe("SQLite busy timeout and statement retries", () => { ) yield* Effect.addFinalizer(() => Effect.promise(() => holder.release())) + const longestStallMs = yield* eventLoopStalls let writerFinished = false const startedAt = performance.now() const writer = yield* ( @@ -286,7 +306,6 @@ describe("SQLite busy timeout and statement retries", () => { while (performance.now() - startedAt < heldLockMs) yield* Effect.sleep("10 millis") expect(writerFinished).toBe(false) - const probeStartedAt = performance.now() yield* Effect.promise(() => holder.probe(server.url.href)).pipe( Effect.timeoutOrElse({ duration: "2 seconds", @@ -294,7 +313,7 @@ describe("SQLite busy timeout and statement retries", () => { Effect.fail(new Error("unrelated request did not progress while SQLite was contended")), }), ) - expect(performance.now() - probeStartedAt).toBeLessThan(probeBoundMs) + expect(longestStallMs()).toBeLessThan(stallBoundMs) expect(writerFinished).toBe(false) yield* Effect.promise(() => holder.release()) @@ -436,6 +455,7 @@ describe("SQLite busy timeout and statement retries", () => { ) yield* Effect.addFinalizer(() => Effect.promise(() => holder.release())) + const longestStallMs = yield* eventLoopStalls let writerFinished = false let elapsedMs = 0 const startedAt = performance.now() @@ -467,6 +487,7 @@ describe("SQLite busy timeout and statement retries", () => { expect(Exit.isFailure(result)).toBe(true) expect(elapsedMs).toBeGreaterThanOrEqual(lockWindowMs) expect(elapsedMs).toBeLessThan(lockWindowMs + lockWindowSlackMs) + expect(longestStallMs()).toBeLessThan(stallBoundMs) if (!Exit.isFailure(result)) return const error = Option.getOrUndefined(Cause.findErrorOption(result.cause)) expect(isSqlError(error)).toBe(true) From 0f5bbc83377a77b02a45440cb3cc40e7ccc20751 Mon Sep 17 00:00:00 2001 From: Brent Yates Date: Fri, 9 Oct 2026 23:22:44 +0000 Subject: [PATCH 4/9] test(opencode): let lock fixtures outlast the 5s driver window The produced-lock fixtures and the SQLITE_LOCKED middleware case now give the real driver room to exhaust its 5s retry window before their own backstop. The refused terminal-write recovery test takes a full driver window per refusal, so its wait for the second progress report and its test bound grow to match. Refs swxtchio/swx-opencode#153. Claude-Session: https://claude.ai/code/session_01BQzKnpxtC4SMFP6UwY4C1t --- packages/opencode/test/fixture/sqlite-lock.ts | 9 ++- .../server/httpapi-error-middleware.test.ts | 77 ++++++++++--------- .../test/session/processor-effect.test.ts | 4 +- 3 files changed, 48 insertions(+), 42 deletions(-) diff --git a/packages/opencode/test/fixture/sqlite-lock.ts b/packages/opencode/test/fixture/sqlite-lock.ts index 737a260f68eb..198471b3b5ca 100644 --- a/packages/opencode/test/fixture/sqlite-lock.ts +++ b/packages/opencode/test/fixture/sqlite-lock.ts @@ -3,6 +3,9 @@ import { sql } from "drizzle-orm" import { Context, Effect, Layer } from "effect" import { SqlClient } from "effect/unstable/sql/SqlClient" +// The driver gives up after its 5s retry window; this only catches a retry that never stops. +const retryBackstop = "10 seconds" + const withImmediateSqliteLock = ( database: Database.Interface["db"], filename: string, @@ -34,7 +37,7 @@ export const produceSqliteBusyError = (database: Database.Interface["db"], filen Effect.flip( database.run(sql`INSERT INTO secret_lock_fixture (id, value) VALUES (${1}, ${"secret_parameter"})`).pipe( Effect.timeoutOrElse({ - duration: "5 seconds", + duration: retryBackstop, orElse: () => Effect.fail(new Error("SQLite busy retries did not stop")), }), ), @@ -51,7 +54,7 @@ export const produceSqliteImmediateError = (database: Database.Interface["db"], ) .pipe( Effect.timeoutOrElse({ - duration: "5 seconds", + duration: retryBackstop, orElse: () => Effect.fail(new Error("SQLite immediate transaction retries did not stop")), }), ), @@ -78,7 +81,7 @@ export const produceSqliteLockedError = (filename: string) => return yield* Effect.flip( client.unsafe("DROP TABLE secret_lock_fixture").raw.pipe( Effect.timeoutOrElse({ - duration: "5 seconds", + duration: retryBackstop, orElse: () => Effect.fail(new Error("SQLite locked retries did not stop")), }), ), diff --git a/packages/opencode/test/server/httpapi-error-middleware.test.ts b/packages/opencode/test/server/httpapi-error-middleware.test.ts index 1a14fde57c81..ad32665a650c 100644 --- a/packages/opencode/test/server/httpapi-error-middleware.test.ts +++ b/packages/opencode/test/server/httpapi-error-middleware.test.ts @@ -111,45 +111,48 @@ describe("HttpApi error middleware", () => { 20_000, ) - it.live("maps a produced direct SQLITE_LOCKED error distinctly", () => - Effect.gen(function* () { - const tmp = yield* Effect.promise(() => tmpdir()) - yield* Effect.addFinalizer(() => Effect.promise(() => tmp[Symbol.asyncDispose]())) - const error = yield* produceSqliteLockedError(path.join(tmp.path, "sqlite-locked.sqlite")) - expect(error).not.toBeInstanceOf(EffectDrizzleQueryError) - expect(isSqlError(error)).toBe(true) - if (!isSqlError(error)) return - expect(error.reason._tag).toBe("LockTimeoutError") - expect(error.reason.cause).toMatchObject({ code: "SQLITE_LOCKED" }) - expect(error.message).not.toContain("secret_lock_fixture") - expect(error.message).not.toContain("secret_parameter") - - const converted = MessageV2.fromError(error, { providerID: ProviderV2.ID.make("test") }) - expect(converted).toMatchObject({ - name: "UnknownError", - data: { message: "Database is locked (SQLITE_LOCKED)" }, - }) - expect(JSON.stringify(converted)).not.toContain("secret_lock_fixture") - expect(JSON.stringify(converted)).not.toContain("secret_parameter") + it.live( + "maps a produced direct SQLITE_LOCKED error distinctly", + () => + Effect.gen(function* () { + const tmp = yield* Effect.promise(() => tmpdir()) + yield* Effect.addFinalizer(() => Effect.promise(() => tmp[Symbol.asyncDispose]())) + const error = yield* produceSqliteLockedError(path.join(tmp.path, "sqlite-locked.sqlite")) + expect(error).not.toBeInstanceOf(EffectDrizzleQueryError) + expect(isSqlError(error)).toBe(true) + if (!isSqlError(error)) return + expect(error.reason._tag).toBe("LockTimeoutError") + expect(error.reason.cause).toMatchObject({ code: "SQLITE_LOCKED" }) + expect(error.message).not.toContain("secret_lock_fixture") + expect(error.message).not.toContain("secret_parameter") - yield* HttpRouter.add("GET", "/sqlite-locked", Effect.die(error)).pipe( - Layer.provide(errorLayer), - HttpRouter.serve, - Layer.build, - ) - const response = yield* HttpClientRequest.get("/sqlite-locked").pipe(HttpClient.execute) - const body = yield* response.json - const serialized = JSON.stringify(body) + const converted = MessageV2.fromError(error, { providerID: ProviderV2.ID.make("test") }) + expect(converted).toMatchObject({ + name: "UnknownError", + data: { message: "Database is locked (SQLITE_LOCKED)" }, + }) + expect(JSON.stringify(converted)).not.toContain("secret_lock_fixture") + expect(JSON.stringify(converted)).not.toContain("secret_parameter") - expect(response.status).toBe(500) - expect(body).toMatchObject({ - name: "UnknownError", - data: { message: "Database is locked (SQLITE_LOCKED)" }, - }) - expect((body as { data?: { ref?: unknown } }).data?.ref).toMatch(/^err_[0-9a-f-]{8}$/) - expect(serialized).not.toContain("secret_lock_fixture") - expect(serialized).not.toContain("secret_parameter") - }), + yield* HttpRouter.add("GET", "/sqlite-locked", Effect.die(error)).pipe( + Layer.provide(errorLayer), + HttpRouter.serve, + Layer.build, + ) + const response = yield* HttpClientRequest.get("/sqlite-locked").pipe(HttpClient.execute) + const body = yield* response.json + const serialized = JSON.stringify(body) + + expect(response.status).toBe(500) + expect(body).toMatchObject({ + name: "UnknownError", + data: { message: "Database is locked (SQLITE_LOCKED)" }, + }) + expect((body as { data?: { ref?: unknown } }).data?.ref).toMatch(/^err_[0-9a-f-]{8}$/) + expect(serialized).not.toContain("secret_lock_fixture") + expect(serialized).not.toContain("secret_parameter") + }), + 20_000, ) it.live("returns invalid config defects as structured client errors", () => diff --git a/packages/opencode/test/session/processor-effect.test.ts b/packages/opencode/test/session/processor-effect.test.ts index 6b2f07d32ce5..7e49dbf58757 100644 --- a/packages/opencode/test/session/processor-effect.test.ts +++ b/packages/opencode/test/session/processor-effect.test.ts @@ -1790,7 +1790,7 @@ itHeldLock.live( yield* waitFor( Effect.sync(() => (progress.length >= 2 ? true : undefined)), "the refused terminal write reported no progress", - 30_000, + 120_000, ) expect(progress.slice(0, 2)).toEqual([ { "session.id": chat.id, messageID: msg.id, attempts: 4 }, @@ -1813,7 +1813,7 @@ itHeldLock.live( }), { config: cfg }, ), - 60_000, + 180_000, ) itHeldLock.live( From 4fc8c9bc38b626e172d4d5423fd1d811fdfbd97c Mon Sep 17 00:00:00 2001 From: Brent Yates Date: Fri, 9 Oct 2026 23:25:21 +0000 Subject: [PATCH 5/9] docs(core): keep values out of the SQLite retry comments The constants own the retry window, backoff and backstop values, so the comments now state only the goal: restore the old busy_timeout tolerance while waiting asynchronously between short native attempts, and catch a retry that never stops. Claude-Session: https://claude.ai/code/session_01BQzKnpxtC4SMFP6UwY4C1t --- packages/core/src/database/sqlite.bun.ts | 5 +++-- packages/core/src/database/sqlite.node.ts | 5 +++-- packages/effect-sqlite-node/src/index.ts | 5 +++-- packages/opencode/test/fixture/sqlite-lock.ts | 2 +- 4 files changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/core/src/database/sqlite.bun.ts b/packages/core/src/database/sqlite.bun.ts index 474eaca92b34..3c348cb1fd63 100644 --- a/packages/core/src/database/sqlite.bun.ts +++ b/packages/core/src/database/sqlite.bun.ts @@ -19,8 +19,9 @@ import { Sqlite } from "./sqlite" const ATTR_DB_SYSTEM_NAME = "db.system.name" const nativeBusyTimeoutMs = 5 -// Keep the old 5s busy_timeout window, but wait it out asynchronously between 5ms native attempts. -// The window runs from the first busy failure; backoff is capped at SQLite's own 100ms busy-handler sleep. +// Restore the lock tolerance the old busy_timeout gave, but wait asynchronously between short native attempts +// so a contended statement never blocks the event loop. The window runs from the first busy failure, and the +// backoff cap matches SQLite's own busy-handler sleep. const retrySchedule = Schedule.exponential("10 millis").pipe( Schedule.modifyDelay((_output, delay) => Effect.succeed(Duration.millis(Math.min(Duration.toMillis(delay), 100)))), Schedule.jittered, diff --git a/packages/core/src/database/sqlite.node.ts b/packages/core/src/database/sqlite.node.ts index 5a3bb584ec2b..615a23b930ce 100644 --- a/packages/core/src/database/sqlite.node.ts +++ b/packages/core/src/database/sqlite.node.ts @@ -19,8 +19,9 @@ import { Sqlite } from "./sqlite" const ATTR_DB_SYSTEM_NAME = "db.system.name" const nativeBusyTimeoutMs = 5 -// Keep the old 5s busy_timeout window, but wait it out asynchronously between 5ms native attempts. -// The window runs from the first busy failure; backoff is capped at SQLite's own 100ms busy-handler sleep. +// Restore the lock tolerance the old busy_timeout gave, but wait asynchronously between short native attempts +// so a contended statement never blocks the event loop. The window runs from the first busy failure, and the +// backoff cap matches SQLite's own busy-handler sleep. const retrySchedule = Schedule.exponential("10 millis").pipe( Schedule.modifyDelay((_output, delay) => Effect.succeed(Duration.millis(Math.min(Duration.toMillis(delay), 100)))), Schedule.jittered, diff --git a/packages/effect-sqlite-node/src/index.ts b/packages/effect-sqlite-node/src/index.ts index 2864f6f4c84e..e2badf66041d 100644 --- a/packages/effect-sqlite-node/src/index.ts +++ b/packages/effect-sqlite-node/src/index.ts @@ -19,8 +19,9 @@ import * as Statement from "effect/unstable/sql/Statement" const ATTR_DB_SYSTEM_NAME = "db.system.name" const nativeBusyTimeoutMs = 5 -// Keep the old 5s busy_timeout window, but wait it out asynchronously between 5ms native attempts. -// The window runs from the first busy failure; backoff is capped at SQLite's own 100ms busy-handler sleep. +// Restore the lock tolerance the old busy_timeout gave, but wait asynchronously between short native attempts +// so a contended statement never blocks the event loop. The window runs from the first busy failure, and the +// backoff cap matches SQLite's own busy-handler sleep. const retrySchedule = Schedule.exponential("10 millis").pipe( Schedule.modifyDelay((_output, delay) => Effect.succeed(Duration.millis(Math.min(Duration.toMillis(delay), 100)))), Schedule.jittered, diff --git a/packages/opencode/test/fixture/sqlite-lock.ts b/packages/opencode/test/fixture/sqlite-lock.ts index 198471b3b5ca..f0010becce18 100644 --- a/packages/opencode/test/fixture/sqlite-lock.ts +++ b/packages/opencode/test/fixture/sqlite-lock.ts @@ -3,7 +3,7 @@ import { sql } from "drizzle-orm" import { Context, Effect, Layer } from "effect" import { SqlClient } from "effect/unstable/sql/SqlClient" -// The driver gives up after its 5s retry window; this only catches a retry that never stops. +// Outlasts the driver's own retry window, so it only catches a retry that never stops. const retryBackstop = "10 seconds" const withImmediateSqliteLock = ( From fee47f61f73c227bc8045c6cea9f27631b2d61cd Mon Sep 17 00:00:00 2001 From: Brent Yates Date: Fri, 9 Oct 2026 23:28:05 +0000 Subject: [PATCH 6/9] test(core): keep values out of the SQLite busy test comments Claude-Session: https://claude.ai/code/session_01BQzKnpxtC4SMFP6UwY4C1t --- packages/core/test/sqlite-busy.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/core/test/sqlite-busy.test.ts b/packages/core/test/sqlite-busy.test.ts index 88f89dc1951d..270e2a039c7c 100644 --- a/packages/core/test/sqlite-busy.test.ts +++ b/packages/core/test/sqlite-busy.test.ts @@ -18,18 +18,18 @@ const nodeSqliteDrivers = await import("node:sqlite").then( () => undefined, ) const nodeSqliteTest = test.skipIf(!nodeSqliteDrivers) -// The retry window each statement must give a competing writer: the old 5s native busy_timeout. +// The retry window each statement must give a competing writer: the tolerance the old native busy_timeout gave. const lockWindowMs = 5_000 // Exhaustion may overrun the window by one capped backoff, one native attempt and scheduling delay. const lockWindowSlackMs = 1_500 // Backstops sit above window plus slack so a statement that never stops fails with its own message. const lockBackstop = "10 seconds" -// Well past the ~250ms the old four-attempt schedule gave up after, and well inside the restored window. +// Well past the point where the old four-attempt schedule gave up, and well inside the restored window. const heldLockMs = 1_500 // The event loop must keep turning while a statement waits; a long synchronous native wait stalls it for seconds. const stallBoundMs = 1_000 -// Ticks every 5ms from now on and returns a reader for the longest gap between ticks. The reader includes the gap +// Ticks continually from now on and returns a reader for the longest gap between ticks. The reader includes the gap // still open when it is called, so a synchronous call the ticker has not yet woken from still counts. const eventLoopStalls = Effect.gen(function* () { let lastTick = performance.now() From dec90796ecdb000aeab0c078d6e6221d7b1646ca Mon Sep 17 00:00:00 2001 From: Brent Yates Date: Fri, 9 Oct 2026 23:30:41 +0000 Subject: [PATCH 7/9] fix(core): fail stale-snapshot SQLite writes without retrying A write inside a transaction whose read snapshot went stale fails with SQLITE_BUSY_SNAPSHOT on every retry; only restarting the transaction recovers it. With the restored lock window, the statement retry spun on it for the whole window while holding the connection. All three clients now stop retrying on that code and still return the classified LockTimeoutError with its cause. A real two-connection WAL test per client reads inside a transaction, commits from another connection, then writes, and requires a fast failure with a single execution and no write. The shared attempt counter now counts executions of the prepared statement, since clients retry the execution, not the prepare. Closes swxtchio/swx-opencode#154. Claude-Session: https://claude.ai/code/session_01BQzKnpxtC4SMFP6UwY4C1t --- packages/core/src/database/sqlite.bun.ts | 12 ++- packages/core/src/database/sqlite.node.ts | 13 +++- packages/core/test/sqlite-busy.test.ts | 94 +++++++++++++++++++++-- packages/effect-sqlite-node/src/index.ts | 13 +++- 4 files changed, 120 insertions(+), 12 deletions(-) diff --git a/packages/core/src/database/sqlite.bun.ts b/packages/core/src/database/sqlite.bun.ts index 3c348cb1fd63..7c87b14672a7 100644 --- a/packages/core/src/database/sqlite.bun.ts +++ b/packages/core/src/database/sqlite.bun.ts @@ -28,6 +28,14 @@ const retrySchedule = Schedule.exponential("10 millis").pipe( Schedule.both(Schedule.during("5 seconds")), ) +// A stale read snapshot fails every retry of the same statement; only restarting its transaction can recover. +const retryable = (error: SqlError) => { + const cause = error.reason.cause + const snapshot = + typeof cause === "object" && cause !== null && "code" in cause && cause.code === "SQLITE_BUSY_SNAPSHOT" + return error.reason.isRetryable && !snapshot +} + const statementError = (cause: unknown) => { const reason = classifySqliteError(cause, { message: "Failed to execute statement", operation: "execute" }) if (reason._tag !== "LockTimeoutError") return new SqlError({ reason }) @@ -91,7 +99,7 @@ const make = (options: Config) => return Effect.try({ try: () => (statement.all(...(params as any)) ?? []) as Array>, catch: statementError, - }).pipe(Effect.retry({ schedule: retrySchedule, while: (error) => error.reason.isRetryable })) + }).pipe(Effect.retry({ schedule: retrySchedule, while: retryable })) }) const runValues = (query: string, params: ReadonlyArray = []) => @@ -102,7 +110,7 @@ const make = (options: Config) => return Effect.try({ try: () => (statement.values(...(params as any)) ?? []) as Array, catch: statementError, - }).pipe(Effect.retry({ schedule: retrySchedule, while: (error) => error.reason.isRetryable })) + }).pipe(Effect.retry({ schedule: retrySchedule, while: retryable })) }) const connection = identity({ diff --git a/packages/core/src/database/sqlite.node.ts b/packages/core/src/database/sqlite.node.ts index 615a23b930ce..dc838b32477e 100644 --- a/packages/core/src/database/sqlite.node.ts +++ b/packages/core/src/database/sqlite.node.ts @@ -19,6 +19,7 @@ import { Sqlite } from "./sqlite" const ATTR_DB_SYSTEM_NAME = "db.system.name" const nativeBusyTimeoutMs = 5 +const sqliteBusySnapshot = 517 // Restore the lock tolerance the old busy_timeout gave, but wait asynchronously between short native attempts // so a contended statement never blocks the event loop. The window runs from the first busy failure, and the // backoff cap matches SQLite's own busy-handler sleep. @@ -28,6 +29,14 @@ const retrySchedule = Schedule.exponential("10 millis").pipe( Schedule.both(Schedule.during("5 seconds")), ) +// A stale read snapshot fails every retry of the same statement; only restarting its transaction can recover. +const retryable = (error: SqlError) => { + const cause = error.reason.cause + const snapshot = + typeof cause === "object" && cause !== null && "errcode" in cause && cause.errcode === sqliteBusySnapshot + return error.reason.isRetryable && !snapshot +} + const statementError = (cause: unknown) => { const errcode = typeof cause === "object" && cause !== null && "errcode" in cause && typeof cause.errcode === "number" @@ -99,7 +108,7 @@ const make = (options: Config) => return Effect.try({ try: () => statement.all(...(params as SQLInputValue[])) as Array>, catch: statementError, - }).pipe(Effect.retry({ schedule: retrySchedule, while: (error) => error.reason.isRetryable })) + }).pipe(Effect.retry({ schedule: retrySchedule, while: retryable })) }) const runValues = (query: string, params: ReadonlyArray = []) => @@ -110,7 +119,7 @@ const make = (options: Config) => return Effect.try({ try: () => statement.all(...(params as SQLInputValue[])) as unknown as ReadonlyArray>, catch: statementError, - }).pipe(Effect.retry({ schedule: retrySchedule, while: (error) => error.reason.isRetryable })) + }).pipe(Effect.retry({ schedule: retrySchedule, while: retryable })) }) const connection = identity({ diff --git a/packages/core/test/sqlite-busy.test.ts b/packages/core/test/sqlite-busy.test.ts index 270e2a039c7c..2976706ad1b6 100644 --- a/packages/core/test/sqlite-busy.test.ts +++ b/packages/core/test/sqlite-busy.test.ts @@ -28,6 +28,8 @@ const lockBackstop = "10 seconds" const heldLockMs = 1_500 // The event loop must keep turning while a statement waits; a long synchronous native wait stalls it for seconds. const stallBoundMs = 1_000 +// A stale snapshot can never succeed on retry, so its write must fail far inside the retry window. +const snapshotBoundMs = 1_000 // Ticks continually from now on and returns a reader for the longest gap between ticks. The reader includes the gap // still open when it is called, so a synchronous call the ticker has not yet woken from still counts. @@ -178,6 +180,26 @@ const clientWithAttemptCount = (input: { attempts: { count: number } }) => Effect.gen(function* () { + // Clients prepare once and retry the execution, so count each execution of the prepared statement. + const counted = new WeakSet() + const countExecutions = (statement: S) => { + if (counted.has(statement)) return statement + counted.add(statement) + for (const method of ["all", "values"]) { + const execute: unknown = Reflect.get(statement, method) + if (typeof execute !== "function") continue + Object.defineProperty(statement, method, { + configurable: true, + writable: true, + value: (...params: unknown[]) => { + input.attempts.count++ + return execute.apply(statement, params) + }, + }) + } + return statement + } + if (input.clientType === "bun") { const sqlite = yield* Effect.promise(() => import("bun:sqlite")) const prototype = sqlite.Database.prototype @@ -187,8 +209,8 @@ const clientWithAttemptCount = (input: { Object.defineProperty(prototype, "query", { ...queryDescriptor, value: function (this: InstanceType, sql: string) { - if (sql === input.statement) input.attempts.count++ - return query.call(this, sql) + const statement = query.call(this, sql) + return sql === input.statement ? countExecutions(statement) : statement }, }) yield* Effect.addFinalizer(() => Effect.sync(() => Object.defineProperty(prototype, "query", queryDescriptor))) @@ -206,8 +228,8 @@ const clientWithAttemptCount = (input: { configurable: true, writable: true, value: function (this: InstanceType, sql: string) { - if (sql === input.statement) input.attempts.count++ - return prepare.call(this, sql) + const statement = prepare.call(this, sql) + return sql === input.statement ? countExecutions(statement) : statement }, }) return Context.get(context, SqlClient) @@ -221,8 +243,8 @@ const clientWithAttemptCount = (input: { Object.defineProperty(prototype, "prepare", { ...prepareDescriptor, value: function (this: InstanceType, sql: string) { - if (sql === input.statement) input.attempts.count++ - return prepare.call(this, sql) + const statement = prepare.call(this, sql) + return sql === input.statement ? countExecutions(statement) : statement }, }) yield* Effect.addFinalizer(() => Effect.sync(() => Object.defineProperty(prototype, "prepare", prepareDescriptor))) @@ -384,6 +406,66 @@ describe("SQLite busy timeout and statement retries", () => { ) } + for (const clientType of ["bun", "core-node", "standalone-node"] as const) { + test.skipIf(clientType !== "bun" && !nodeSqliteDrivers)( + `${clientType} fails a stale-snapshot transaction write without retrying it`, + async () => { + await using tmp = await tmpdir() + const filename = path.join(tmp.path, `${clientType}-snapshot.sqlite`) + const writerSql = "INSERT INTO busy_retry_test (id, value) VALUES (1, 'stale-writer')" + const exit = await Effect.runPromiseExit( + Effect.scoped( + Effect.gen(function* () { + const attempts = { count: 0 } + const client = yield* clientWithAttemptCount({ clientType, filename, statement: writerSql, attempts }) + yield* client.unsafe("CREATE TABLE busy_retry_test (id INTEGER PRIMARY KEY, value TEXT NOT NULL)").raw + expect(yield* client.unsafe("PRAGMA journal_mode").values).toEqual([["wal"]]) + const sqlite = yield* Effect.promise(() => import("bun:sqlite")) + const other = new sqlite.Database(filename) + yield* Effect.addFinalizer(() => Effect.sync(() => other.close())) + + let elapsedMs = 0 + const failure = yield* client + .withTransaction( + Effect.gen(function* () { + expect(yield* client.unsafe("SELECT COUNT(*) FROM busy_retry_test").values).toEqual([[0]]) + // Another connection commits after this transaction's read, so its snapshot is stale. + other.run("INSERT INTO busy_retry_test (id, value) VALUES (2, 'other-writer')") + const startedAt = performance.now() + yield* client + .unsafe(writerSql) + .raw.pipe( + Effect.onExit(() => Effect.sync(() => void (elapsedMs = performance.now() - startedAt))), + ) + }), + ) + .pipe( + Effect.timeoutOrElse({ + duration: lockBackstop, + orElse: () => Effect.fail(new Error("stale-snapshot retries did not stop")), + }), + Effect.flip, + ) + expect(isSqlError(failure)).toBe(true) + if (!isSqlError(failure)) return + expect(failure.reason._tag).toBe("LockTimeoutError") + expect(failure.reason.cause).toMatchObject( + clientType === "bun" ? { code: "SQLITE_BUSY_SNAPSHOT" } : { errcode: 517 }, + ) + expect(elapsedMs).toBeLessThan(snapshotBoundMs) + expect(attempts.count).toBe(1) + expect(yield* client.unsafe("SELECT id, value FROM busy_retry_test ORDER BY id").values).toEqual([ + [2, "other-writer"], + ]) + }), + ), + ) + if (Exit.isFailure(exit)) throw new Error(Cause.pretty(exit.cause)) + }, + 20_000, + ) + } + test("exhausts an immediate transaction begin into a direct SqlError", async () => { await using tmp = await tmpdir() const filename = path.join(tmp.path, "immediate-busy.sqlite") diff --git a/packages/effect-sqlite-node/src/index.ts b/packages/effect-sqlite-node/src/index.ts index e2badf66041d..bb11791df45e 100644 --- a/packages/effect-sqlite-node/src/index.ts +++ b/packages/effect-sqlite-node/src/index.ts @@ -19,6 +19,7 @@ import * as Statement from "effect/unstable/sql/Statement" const ATTR_DB_SYSTEM_NAME = "db.system.name" const nativeBusyTimeoutMs = 5 +const sqliteBusySnapshot = 517 // Restore the lock tolerance the old busy_timeout gave, but wait asynchronously between short native attempts // so a contended statement never blocks the event loop. The window runs from the first busy failure, and the // backoff cap matches SQLite's own busy-handler sleep. @@ -28,6 +29,14 @@ const retrySchedule = Schedule.exponential("10 millis").pipe( Schedule.both(Schedule.during("5 seconds")), ) +// A stale read snapshot fails every retry of the same statement; only restarting its transaction can recover. +const retryable = (error: SqlError) => { + const cause = error.reason.cause + const snapshot = + typeof cause === "object" && cause !== null && "errcode" in cause && cause.errcode === sqliteBusySnapshot + return error.reason.isRetryable && !snapshot +} + const statementError = (cause: unknown) => { const errcode = typeof cause === "object" && cause !== null && "errcode" in cause && typeof cause.errcode === "number" @@ -115,7 +124,7 @@ export const make = ( return Effect.try({ try: () => statement.all(...(params as SQLInputValue[])) as Array>, catch: statementError, - }).pipe(Effect.retry({ schedule: retrySchedule, while: (error) => error.reason.isRetryable })) + }).pipe(Effect.retry({ schedule: retrySchedule, while: retryable })) }) const runValues = (sql: string, params: ReadonlyArray = []) => @@ -127,7 +136,7 @@ export const make = ( try: () => statement.all(...(params as SQLInputValue[])) as unknown as ReadonlyArray>, catch: statementError, - }).pipe(Effect.retry({ schedule: retrySchedule, while: (error) => error.reason.isRetryable })) + }).pipe(Effect.retry({ schedule: retrySchedule, while: retryable })) }) return identity({ From 80b56662602ee7b2e53b6a7252e379ddba73e6fe Mon Sep 17 00:00:00 2001 From: Brent Yates Date: Fri, 9 Oct 2026 23:38:05 +0000 Subject: [PATCH 8/9] docs: record #155 in the fork changeset Claude-Session: https://claude.ai/code/session_01BQzKnpxtC4SMFP6UwY4C1t --- CHANGESET.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGESET.md b/CHANGESET.md index 716d9f591645..e45dfd8b8f21 100644 --- a/CHANGESET.md +++ b/CHANGESET.md @@ -69,6 +69,12 @@ Newest first. Each entry names the upstream range brought in. ### Runtime fixes +- **#155** Restore the SQLite lock-wait window that #88 cut to about 250ms (swxtchio/swx-opencode#153): every + runtime client keeps its short native attempt but retries a busy statement asynchronously for 5s of elapsed + contention, the old `busy_timeout` window, so a competing writer on the shared store no longer fails accepted + prompts and the event loop keeps turning. A stale-snapshot write (`SQLITE_BUSY_SNAPSHOT`), which no statement retry + can recover, now fails at once instead of holding the connection for the window (swxtchio/swx-opencode#154). + _Fork-only._ - **#144** Make `opencode db path` print the selected path without initializing `AppRuntime` or SQLite, defer global directory creation to runtime initialization, and preserve explicit and channel-specific database path selection. _Upstreamable._ - **#141** Reconcile `.git/HEAD` after an early or missed native callback through a shared read/change/publish path; keep a closed native watch `unavailable` while polling still delivers changed branch values. Cover root-off switches, native From 8bc0fde500b3ee41e6da3796ed1c8a6197118c21 Mon Sep 17 00:00:00 2001 From: Brent Yates Date: Sat, 10 Oct 2026 00:02:44 +0000 Subject: [PATCH 9/9] fix(core): build the SQLite retry schedule only after a lock failure Effect.retry builds its schedule's step function before the first attempt, so every statement, including the many that succeed at once, paid for the deeper elapsed-time schedule. That moved where fibers reach Effect's operation budget and yield. Two SessionRunnerLLM tests that check their results after a single Effect.yieldNow then saw the follow-up drain unfinished: "runs steering input accepted while the active provider turn fails" and "durably closes partial tool input when the provider stream is interrupted". With no SQLite error at all, they fail deterministically on the restored window and pass on the base. With the budget lifted they pass, and on the base they fail at a budget of 64. Each client now runs the statement once and enters the retry only on a retryable failure. The retry re-attempts at once, then backs off, so the window runs from the first failed retry. Successful statements build no schedule. Refs swxtchio/swx-opencode#153. Claude-Session: https://claude.ai/code/session_01BQzKnpxtC4SMFP6UwY4C1t --- packages/core/src/database/sqlite.bun.ts | 18 +++++++++++++----- packages/core/src/database/sqlite.node.ts | 18 +++++++++++++----- packages/effect-sqlite-node/src/index.ts | 18 +++++++++++++----- 3 files changed, 39 insertions(+), 15 deletions(-) diff --git a/packages/core/src/database/sqlite.bun.ts b/packages/core/src/database/sqlite.bun.ts index 7c87b14672a7..c7bb9f11f1cb 100644 --- a/packages/core/src/database/sqlite.bun.ts +++ b/packages/core/src/database/sqlite.bun.ts @@ -20,7 +20,7 @@ import { Sqlite } from "./sqlite" const ATTR_DB_SYSTEM_NAME = "db.system.name" const nativeBusyTimeoutMs = 5 // Restore the lock tolerance the old busy_timeout gave, but wait asynchronously between short native attempts -// so a contended statement never blocks the event loop. The window runs from the first busy failure, and the +// so a contended statement never blocks the event loop. The window runs from the first failed retry, and the // backoff cap matches SQLite's own busy-handler sleep. const retrySchedule = Schedule.exponential("10 millis").pipe( Schedule.modifyDelay((_output, delay) => Effect.succeed(Duration.millis(Math.min(Duration.toMillis(delay), 100)))), @@ -96,10 +96,14 @@ const make = (options: Config) => const statement = native.query(query) // @ts-ignore bun-types missing safeIntegers method, fixed in https://github.com/oven-sh/bun/pull/26627 statement.safeIntegers(Context.get(fiber.context, Client.SafeIntegers)) - return Effect.try({ + const execute = Effect.try({ try: () => (statement.all(...(params as any)) ?? []) as Array>, catch: statementError, - }).pipe(Effect.retry({ schedule: retrySchedule, while: retryable })) + }) + // Only a statement that met the lock builds the retry schedule, which re-attempts it at once before backing off. + return execute.pipe( + Effect.catchIf(retryable, () => Effect.retry(execute, { schedule: retrySchedule, while: retryable })), + ) }) const runValues = (query: string, params: ReadonlyArray = []) => @@ -107,10 +111,14 @@ const make = (options: Config) => const statement = native.query(query) // @ts-ignore bun-types missing safeIntegers method, fixed in https://github.com/oven-sh/bun/pull/26627 statement.safeIntegers(Context.get(fiber.context, Client.SafeIntegers)) - return Effect.try({ + const execute = Effect.try({ try: () => (statement.values(...(params as any)) ?? []) as Array, catch: statementError, - }).pipe(Effect.retry({ schedule: retrySchedule, while: retryable })) + }) + // Only a statement that met the lock builds the retry schedule, which re-attempts it at once before backing off. + return execute.pipe( + Effect.catchIf(retryable, () => Effect.retry(execute, { schedule: retrySchedule, while: retryable })), + ) }) const connection = identity({ diff --git a/packages/core/src/database/sqlite.node.ts b/packages/core/src/database/sqlite.node.ts index dc838b32477e..5b3aadebd945 100644 --- a/packages/core/src/database/sqlite.node.ts +++ b/packages/core/src/database/sqlite.node.ts @@ -21,7 +21,7 @@ const ATTR_DB_SYSTEM_NAME = "db.system.name" const nativeBusyTimeoutMs = 5 const sqliteBusySnapshot = 517 // Restore the lock tolerance the old busy_timeout gave, but wait asynchronously between short native attempts -// so a contended statement never blocks the event loop. The window runs from the first busy failure, and the +// so a contended statement never blocks the event loop. The window runs from the first failed retry, and the // backoff cap matches SQLite's own busy-handler sleep. const retrySchedule = Schedule.exponential("10 millis").pipe( Schedule.modifyDelay((_output, delay) => Effect.succeed(Duration.millis(Math.min(Duration.toMillis(delay), 100)))), @@ -105,10 +105,14 @@ const make = (options: Config) => Effect.withFiber>, SqlError>((fiber) => { const statement = native.prepare(query) statement.setReadBigInts(Context.get(fiber.context, Client.SafeIntegers)) - return Effect.try({ + const execute = Effect.try({ try: () => statement.all(...(params as SQLInputValue[])) as Array>, catch: statementError, - }).pipe(Effect.retry({ schedule: retrySchedule, while: retryable })) + }) + // Only a statement that met the lock builds the retry schedule, which re-attempts it at once before backing off. + return execute.pipe( + Effect.catchIf(retryable, () => Effect.retry(execute, { schedule: retrySchedule, while: retryable })), + ) }) const runValues = (query: string, params: ReadonlyArray = []) => @@ -116,10 +120,14 @@ const make = (options: Config) => const statement = native.prepare(query) statement.setReadBigInts(Context.get(fiber.context, Client.SafeIntegers)) statement.setReturnArrays(true) - return Effect.try({ + const execute = Effect.try({ try: () => statement.all(...(params as SQLInputValue[])) as unknown as ReadonlyArray>, catch: statementError, - }).pipe(Effect.retry({ schedule: retrySchedule, while: retryable })) + }) + // Only a statement that met the lock builds the retry schedule, which re-attempts it at once before backing off. + return execute.pipe( + Effect.catchIf(retryable, () => Effect.retry(execute, { schedule: retrySchedule, while: retryable })), + ) }) const connection = identity({ diff --git a/packages/effect-sqlite-node/src/index.ts b/packages/effect-sqlite-node/src/index.ts index bb11791df45e..55f4d2410d27 100644 --- a/packages/effect-sqlite-node/src/index.ts +++ b/packages/effect-sqlite-node/src/index.ts @@ -21,7 +21,7 @@ const ATTR_DB_SYSTEM_NAME = "db.system.name" const nativeBusyTimeoutMs = 5 const sqliteBusySnapshot = 517 // Restore the lock tolerance the old busy_timeout gave, but wait asynchronously between short native attempts -// so a contended statement never blocks the event loop. The window runs from the first busy failure, and the +// so a contended statement never blocks the event loop. The window runs from the first failed retry, and the // backoff cap matches SQLite's own busy-handler sleep. const retrySchedule = Schedule.exponential("10 millis").pipe( Schedule.modifyDelay((_output, delay) => Effect.succeed(Duration.millis(Math.min(Duration.toMillis(delay), 100)))), @@ -121,10 +121,14 @@ export const make = ( Effect.withFiber>, SqlError>((fiber) => { const statement = db.prepare(sql) statement.setReadBigInts(Context.get(fiber.context, Client.SafeIntegers)) - return Effect.try({ + const execute = Effect.try({ try: () => statement.all(...(params as SQLInputValue[])) as Array>, catch: statementError, - }).pipe(Effect.retry({ schedule: retrySchedule, while: retryable })) + }) + // Only a statement that met the lock builds the retry schedule, which re-attempts it at once before backing off. + return execute.pipe( + Effect.catchIf(retryable, () => Effect.retry(execute, { schedule: retrySchedule, while: retryable })), + ) }) const runValues = (sql: string, params: ReadonlyArray = []) => @@ -132,11 +136,15 @@ export const make = ( const statement = db.prepare(sql) statement.setReadBigInts(Context.get(fiber.context, Client.SafeIntegers)) statement.setReturnArrays(true) - return Effect.try({ + const execute = Effect.try({ try: () => statement.all(...(params as SQLInputValue[])) as unknown as ReadonlyArray>, catch: statementError, - }).pipe(Effect.retry({ schedule: retrySchedule, while: retryable })) + }) + // Only a statement that met the lock builds the retry schedule, which re-attempts it at once before backing off. + return execute.pipe( + Effect.catchIf(retryable, () => Effect.retry(execute, { schedule: retrySchedule, while: retryable })), + ) }) return identity({