Repository navigation
Conversation
There was a problem hiding this comment.
The retry works for single statements, but I don't think it fixes the prompt failures yet.
In packages/core, the new tests pass and typecheck is clean. With the base sqlite.ts and sqlite.node.ts swapped in, 4 of the 7 tests fail, so the retry and the node:sqlite error mapping do work for statements that run on their own.
The problem is that v2 does most of its writes inside transactions. Bus.publish, publishAll and replay (bus.ts) and the write in session.ts all run in deferred db.transaction blocks, and this PR makes statements inside a transaction fail fast on purpose. I ran a test shaped like Bus.publish (select, then update, inside db.transaction) while another connection held begin immediate for 200ms. On this branch it still failed at once with a query error. So most writes that fail a prompt get no retry here.
Inside a transaction the only safe recovery is to roll back and rerun the whole transaction. That is what #53704 does for durable event commits (StorageRetry.write). Please check how this fits with that PR, so the two don't overlap or retry the same write twice. If this one stays limited to single statements, please don't let it close #47566.
The tests are timing-based: setTimeout lock holders at 100 and 300ms, and the race test depends on 10, 30 and 120ms sleeps lining up. They may be flaky on slow CI. The file is also 361 lines for 7 cases, with a lot of repeated setup and casts. Please cut it down to a few small cases (bun retry, node classification, the cross-fiber race) with shared setup, and avoid timing where you can, for example by releasing the lock from the test instead of on a timer.
Public API: unchanged. These are internal database client changes only.
| let transactionOpen = false | ||
| const gateStatement = <A>(attempt: Effect.Effect<A, SqlError>): Effect.Effect<A, SqlError> => | ||
| Effect.withFiber((fiber) => { | ||
| if (Context.getOption(fiber.context, transactionService)._tag === "Some") return attempt |
There was a problem hiding this comment.
Because statements inside a transaction fail fast here, any write in db.transaction(...) still fails on a foreign write lock. That covers Bus.publish, publishAll and replay and the write in session.ts, which is most of what v2 writes during a prompt. I checked with a select-then-update transaction while another connection held begin immediate for 200ms: it still failed at once on this branch. Retrying at this layer can only help single statements. The transactional paths need the whole transaction rerun, which is what #53704 does.
Any statement that reaches this point outside a transaction context while some transaction is open runs straight away, without taking the semaphore. That's intended for this transaction's own BEGIN/COMMIT/ROLLBACK, but the client-wide transactionOpen flag can't tell who is calling. It would help to tie this to the transaction's own control statements, or document why nothing else can get here.
There was a problem hiding this comment.
Documented in a7af455. The invariant: every gated statement runs holding the client semaphore — both acquirers take it before handing out the connection, and the statement executes inside the acquirer's scope — and transactionOpen is true exactly while a transaction holds that semaphore. A foreign statement therefore never observes the flag true: it queues on the semaphore until the transaction closes, by which point the flag has flipped back. The only service-less callers that arrive with the flag set are effect-sql's BEGIN (run before it provides the transaction service) and COMMIT/ROLLBACK (run after that context is withdrawn); nested savepoints run inside the outer transaction's context, so they arrive on the fail-fast path above instead. Structurally tying the bypass to those three statements would mean matching their SQL text, which felt more brittle than stating the invariant — happy to switch if you prefer.
| // as LockTimeoutError and the retry sleeps. | ||
| const holder = new Database(filename) | ||
| holder.run("begin immediate") | ||
| setTimeout(() => { |
There was a problem hiding this comment.
This test depends on timers lining up: the lock holder releases on a 300ms setTimeout, and the fibers use 10, 30 and 120ms sleeps. On a loaded CI runner the order can change and the test can pass or fail for the wrong reason. Could you control the order directly, for example with a Deferred or latch, and release the holder from the test?
There was a problem hiding this comment.
Rewritten in a7af455. The holder is now a second connection released by the test itself (never setTimeout), and the cross-fiber race is ordered with a Deferred: the retrying statement forks first and gets a full second to enter the retry loop, the transaction then opens and parks mid-body on the Deferred, the test frees the foreign lock, and only then releases the transaction. The remaining sleeps are wide margins rather than aligned windows — effect sleeps never fire early, so the first attempt always precedes a release scheduled a second later. Shared setup (schema/WAL, holder, value reads) took the file from 361 lines / 7 cases to ~250 / 6 focused cases; the two fail-fast cases are unchanged in spirit and the stale-snapshot one has no timing at all. Verified 4 of the 6 fail against the pre-fix drivers, the race test deterministically.
When multiple opencode processes share one database, statements that cannot grab the SQLite write lock within busy_timeout fail with database is locked and kill the running prompt. The statement only needs the lock, so retry autocommit statements classified as LockTimeoutError (SQLITE_BUSY/SQLITE_LOCKED) with jittered exponential backoff (50 ms doubling to a 250 ms cap, 30 s total) in the bun and node sqlite drivers. The async sleep between attempts lets the competing process finish its write. Only autocommit statements are retried. Inside a transaction the same error repeats until the transaction is rolled back and restarted (SQLITE_BUSY_SNAPSHOT on a deferred transaction whose read snapshot went stale), so it surfaces at once for the caller to handle. Ports anomalyco#47567 to the V2 tree. The in-transaction fail-fast and its test originate from iceteaSA's work on that PR.
…etry paths
Review of the V2 port found three gaps:
- node:sqlite reports SQLITE_BUSY as { code: "ERR_SQLITE_ERROR", errcode: 5 }
with no errno and no SQLITE_* string code, which is all
classifySqliteError reads, so the lock timeout filed as UnknownError and
the retry never fired on the node driver. node's errcode is now copied
onto errno before classification, which the classifier maps to
LockTimeoutError (verified red against the old code, green after).
- The retry policy is now named constants in one place, with
Schedule.upTo replacing the hand-elapsed check.
- Tests cover the node retry path both ways (retry and the in-transaction
fail-fast case), the runValues wiring, and a non-lock error surfacing
fast, so dropping the pipe or widening the predicate breaks the suite.
A retry that only repeats the statement sleeps without holding the
semaphore, so a transaction another fiber opens while it sleeps swallows
the next attempt: its writes roll back with that transaction, or the
in-transaction check, which reads the shared connection, sees the
foreign transaction and gives up. Plain attempts now re-enter the
semaphore before executing. The running transaction's own statements
fail fast on BUSY_SNAPSHOT, and its BEGIN/COMMIT/ROLLBACK retry in
place while it holds the semaphore. The gate lives in makeClient, so
the drivers no longer carry a retryLocked pipe keyed on shared
connection state.
Also fixes the failures tsgo reports on the previous head:
Schedule.upTo takes an options object, the node busy-error guard
indexed an un-narrowed {}, the test file had a trailing comma inside an
as cast plus an unused import, and DatabaseSync was used as a type.
…ment the control-statement bypass Review follow-up: - The lock holder is released by the test at wide margins instead of setTimeout, so the first attempt always precedes the release; the cross-fiber race is ordered with a Deferred that parks the interfering transaction mid-body. The interleaving no longer depends on 10/30/120ms sleeps lining up, and the race test now fails on the pre-fix head deterministically instead of by luck. - Shared setup (schema, WAL, holder, value reads) shrinks the file from 361 to ~250 lines across 6 focused cases: bun retry, values-path retry, node classification, fail-fast on a stale snapshot, fail-fast on non-lock errors, and the cross-fiber race. - The transactionOpen bypass in gateStatement now documents why only the transaction's own BEGIN/COMMIT/ROLLBACK can reach it: every gated statement runs holding the client semaphore, and the flag is true exactly while a transaction holds it, so a foreign statement queues instead of observing it. The header comment states the layering with caller-side whole-transaction replay (anomalyco#53704) so the two retry layers stay disjoint.
a121b9d to
a7af455
Compare
|
a7af455 addresses the review, and the branch is rebased onto the latest dev (it was >1100 commits behind; the touched files were unchanged there apart from the On transaction coverage. I checked against #53704 and kept this PR at statement scope on purpose. Rerunning a whole transaction from inside the client is not safe to do generically — the bodies the review lists read and branch between statements ( On the tests. Rewritten: the holder is a second connection released by the test at wide margins (no On the |
There was a problem hiding this comment.
The retry now matches the narrower scope. On this head the 6 tests in sqlite-lock-retry.test.ts pass, and with the pre-fix sqlite.ts and sqlite.node.ts swapped in, the 4 retry and race tests fail. I couldn't run the package typecheck script here because it calls bun check, which the installed Bun doesn't have. Running tsgo directly shows no errors in the changed files.
Public API: unchanged.
Addressed: the scope is now clear. The PR no longer closes #47566 and leaves the writes inside transactions to #53704. The tests no longer depend on timers lining up: locks are released by the test, and the race test parks its transaction on a Deferred.
Still open: the new comment in gateStatement says why only a transaction's own BEGIN/COMMIT/ROLLBACK can reach the transactionOpen shortcut, but that reasoning doesn't hold for the current acquirer (see the inline comment).
packages/core/src/database/sqlite.ts:122: This says every gated statement runs while holding the client semaphore. Butacquirerissemaphore.withPermits(1)(Effect.succeed(gatedConnection)): it takes the permit and releases it as soon as it has handed back the connection. So a plain statement (whether it goes through effect-sql'sacquireror the per-attemptEffect.andThen(acquirer, attempt)) runs with the permit already released. If that fiber yields afteracquirerreturns and beforegateStatementruns, another fiber'stransactionAcquirercan take the semaphore and settransactionOpen. The foreign statement then takes the shortcut and runs inside that transaction, which is the hole this PR set out to close. It's unlikely, but the comment states it as impossible. Either hold the permit for the statement itself (for examplesemaphore.withPermits(1)(attempt)on the plain path), or reword the comment to describe what is actually guaranteed.
The review on this PR caught that the acquirer releases the client semaphore as soon as it hands back the connection, before the statement runs — so a transaction opening in that gap could swallow the attempt, and a plain statement reading the transactionOpen flag outside the permit could take the control-statement shortcut and join a foreign transaction. The comment claimed that was impossible; it was not. Plain attempts now hold the permit for the attempt itself. Control statements are told apart structurally instead of by the flag: the transaction acquirer hands out a connection whose gate retries its service-less statements (BEGIN, COMMIT, ROLLBACK) in place, since the transaction already holds the permit, while plain statements gate through the acquirer's connection and take the permit per attempt. transactionOpen is gone. The new test parks a plain write mid-statement, past its acquirer, and opens a transaction there; on the previous gate the write joins the transaction and is rolled back with it (fails deterministically), with the permit held it lands in autocommit and survives.
|
Fixed in 3e90cf2. You were right: the comment claimed a guarantee the acquirer didn't provide. It releases the permit as soon as it hands back the connection, so both the first statement and every retry ran with the permit already released. The gate now holds the permit across the attempt itself ( The new test parks a plain write mid-statement, after its acquirer has returned, and opens a transaction right there. On the old gate the write joins that transaction and gets rolled back with it; the test fails deterministically. With the permit held, the transaction can't open until the write commits, and the write survives. |
|
3e90cf2 addresses the open item from your last review (the acquirer releasing the permit before the statement runs). Could you re-review the current head? |
Recreates #54396 (closed when its head fork was deleted; branch restored from the preserved PR head commit
a121b9d).Issue for this PR
Part of #47566 — this covers the single-statement half. The transactional writes (
Bus.publish,publishAll,replay, the write insession.ts) need whole-transaction replay, which #53704'sStorageRetryadds at the caller layer; #47566 should stay open until that lands, so it is deliberately not closed here.Type of change
What does this PR do?
Port of #47567 to the dev line: statements that fail with SQLITE_BUSY after busy_timeout retry with jittered backoff instead of killing an unrelated prompt. Inside a transaction the error still surfaces at once (BUSY_SNAPSHOT repeats until rollback), and a BEGIN that loses the race still retries, since the connection is not in a transaction until BEGIN succeeds.
Scope, per review: the retry lives at statement scope only. A statement inside a transaction fails fast because the only recovery there is rolling back and rerunning the whole transaction, and only the caller can decide whether rerunning its body is safe — #53704 adds exactly that caller-side replay (
StorageRetry.write) for the durable event paths. The two layers compose without retrying the same write twice: a transaction's body statements never retry here, its BEGIN/COMMIT/ROLLBACK retry in place while it holds the client semaphore, and a caller-side replay only starts after this client's budget is spent.The review on the first port caught a hole: an attempt that only repeats the statement sleeps without holding the client semaphore, so a transaction another fiber opens while it sleeps swallows the next attempt. The second review caught a smaller version of the same hole: the acquirer releases the permit as soon as it hands back the connection, so even a first attempt, and the flag check that routed control statements, ran outside it. Plain attempts now hold the permit for the statement itself, and control statements are separated structurally instead of by a flag: the transaction acquirer hands out a connection whose service-less statements (BEGIN, COMMIT, ROLLBACK) retry in place because the transaction already holds the permit, while plain statements gate through the acquirer's connection and take the permit per attempt. Nothing reads connection state outside the permit, so a foreign statement can never join a transaction. A test parks a plain write mid-statement, past its acquirer, and opens a transaction there; it fails deterministically on the previous gate and passes with the permit held.
The port also fixed node:sqlite reporting busy as
ERR_SQLITE_ERRORwith no errno, which the classifier reads, so lock timeouts filed as UnknownError and the retry never fired on that driver. (#53704's StorageRetry carries its own copy of that normalization for its layer; once both land, the driver-level fix is the right home and the copy can fold in.)This push also rebases onto the latest
dev— the branch was over a thousand commits behind; the touched files had not changed there apart from theisRecordimport path, which this picks up.How did you verify your code works?
In packages/core:
bun run typecheckpasses andbun test test/sqlite-lock-retry.test.tsruns 7 tests, all green across repeated runs, alongside the database/drizzle/bus/kv/session-store suites. With the pre-fix drivers swapped in, 4 of the 7 fail — including the cross-fiber race, which now fails deterministically instead of by timing luck. Locks are held by a second connection and released by the test at wide margins (nosetTimeoutholders), the interfering transaction in the race test is parked mid-body on aDeferredthe test releases, and no test depends on sleeps lining up.Screenshots / recordings
Not a UI change.
Checklist
Added by the opencode bot (AI-generated).
Related: #47566, #53704, #56282