sql: roll back when COMMIT itself fails (SQLite SQLITE_BUSY left tx open)#33748
sql: roll back when COMMIT itself fails (SQLite SQLITE_BUSY left tx open)#33748robobun wants to merge 6 commits into
Conversation
The adapter-generic transaction runner cleared needs_rollback before awaiting COMMIT, so when COMMIT failed the error path issued no ROLLBACK. Postgres and MySQL destroy the transaction server-side on a failed COMMIT so the missing ROLLBACK was a no-op there, but SQLite leaves the transaction open (e.g. SQLITE_BUSY when another connection holds a read lock). The pooled connection was then returned still inside an open transaction: later writes on the instance fulfilled but were silently discarded at close, every other connection hit 'database is locked', and every later sql.begin() rejected with 'cannot start a transaction within a transaction'. Clear needs_rollback only after COMMIT succeeds, and null out BEFORE_COMMIT_OR_ROLLBACK once it has run so the error path does not re-issue it (MySQL XA END).
|
Updated 8:48 AM PT - Jul 8th, 2026
❌ @robobun, your commit c830b33 has 1 failures in 🧪 To try this PR locally: bunx bun-pr 33748That installs a local version of the PR into your bun-33748 --bun |
|
Found 2 issues this PR may fix:
🤖 Generated with Claude Code |
|
Neither suggested issue is fixed here:
This PR is scoped to the COMMIT-fails path (SQLite SQLITE_BUSY leaving the transaction open). |
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (1)
WalkthroughTransaction commit and rollback handling in ChangesTransaction cleanup behavior
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/js/bun/sql.ts (1)
764-784: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick winDon't retry
BEFORE_COMMIT_OR_ROLLBACKbefore rollbacksrc/js/bun/sql.ts:764-779If
run_internal_transaction_sql(BEFORE_COMMIT_OR_ROLLBACK_COMMAND)throws on the first attempt, the catch block runs it again and can return beforeROLLBACK_COMMAND, leaving the MySQL distributed transaction open. Clear or guard that hook after the first attempt so rollback still runs.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/js/bun/sql.ts` around lines 764 - 784, The transaction cleanup path in run_internal_transaction_sql is retrying BEFORE_COMMIT_OR_ROLLBACK_COMMAND inside the catch block, which can short-circuit before ROLLBACK_COMMAND and leave the distributed transaction open. Update the commit/rollback flow in src/js/bun/sql.ts so the BEFORE_COMMIT_OR_ROLLBACK_COMMAND hook is only attempted once (or is cleared/guarded immediately after the first attempt), and ensure the catch path always proceeds to rollback when needs_rollback is still true.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@src/js/bun/sql.ts`:
- Around line 764-784: The transaction cleanup path in
run_internal_transaction_sql is retrying BEFORE_COMMIT_OR_ROLLBACK_COMMAND
inside the catch block, which can short-circuit before ROLLBACK_COMMAND and
leave the distributed transaction open. Update the commit/rollback flow in
src/js/bun/sql.ts so the BEFORE_COMMIT_OR_ROLLBACK_COMMAND hook is only
attempted once (or is cleared/guarded immediately after the first attempt), and
ensure the catch path always proceeds to rollback when needs_rollback is still
true.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 5e5d770f-e9b1-4e93-a907-dbe3ed7c9e5e
📒 Files selected for processing (2)
src/js/bun/sql.tstest/js/sql/sqlite-sql.test.ts
|
Re the CodeRabbit note on re-running That hook is only set for MySQL distributed transactions ( Nulling the hook before the first attempt would instead make the catch skip |
The test inserts 10,000 rows one by one; under debug+ASAN builds this exceeds the default per-test timeout. Use 1,000 iterations there.
Now that a failed COMMIT routes through the cleanup ROLLBACK, a COMMIT failure where the engine already tore the transaction down (SQLite SQLITE_FULL/IOERR/NOMEM/INTERRUPT) would have the ROLLBACK fail with 'cannot rollback - no transaction is active' and that would mask the real error via the shadowing inner catch. The ROLLBACK is best-effort cleanup; always reject with the original error. Also use 'using' for the bun:sqlite handles in the new test so a failed assertion cannot leak the reader fd (and block rm on Windows).
Always reject with the original transaction error. The inner catch previously shadowed it, so a COMMIT failure where the engine already rolled back (SQLite SQLITE_FULL/IOERR/NOMEM/INTERRUPT) would surface 'cannot rollback - no transaction is active' instead of the real error.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@test/js/sql/sqlite-sql.test.ts`:
- Line 2074: The finalize-loop workload in the sqlite-sql test is being reduced
in debug builds instead of extending the test timeout, which weakens coverage of
the statement-finalize path. In the test around the iterations constant, keep
the 10000-iteration workload unchanged and adjust this test’s own isDebug-scaled
timeout to accommodate debug/ASAN slowdown, so the full finalize path remains
exercised in both modes.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: e08a9d22-1fac-425a-b833-df1550bccf89
📒 Files selected for processing (1)
test/js/sql/sqlite-sql.test.ts
…rror path Same treatment as the transaction-level catch: if the engine already tore the whole transaction down (SQLite SQLITE_FULL/IOERR/NOMEM/INTERRUPT) the savepoint no longer exists and ROLLBACK TO SAVEPOINT throws 'no such savepoint', masking the original error. Always rethrow the original.
There was a problem hiding this comment.
All prior feedback addressed and I don't see anything further — deferring to a human for the final sign-off since this changes shared transaction error-path semantics across all three SQL adapters.
Extended reasoning...
Overview
This PR fixes onTransactionConnected in src/js/bun/sql.ts so that when COMMIT itself fails (notably SQLite SQLITE_BUSY), the pooled connection is rolled back rather than returned to the pool still inside an open transaction. The core change is moving needs_rollback = false from before the await COMMIT to after it. Follow-up commits (all in response to review feedback) additionally:
- Null
BEFORE_COMMIT_OR_ROLLBACK_COMMANDafter it runs so the error path doesn't re-issue MySQL'sXA ENDwhenXA PREPAREfails. - Swallow best-effort
ROLLBACK/ROLLBACK TO SAVEPOINTfailures in the catch blocks so the caller always sees the original error rather than a secondary "cannot rollback - no transaction is active" / "no such savepoint" when SQLite has already auto-rolled-back. - Add a regression test in
test/js/sql/sqlite-sql.test.tsthat forcesSQLITE_BUSYat COMMIT via a secondbun:sqliteconnection holding a read lock, then verifies later writes are durable, a fresh connection can read, and a subsequentbegin()works. - Reduce an unrelated finalize-loop test's iteration count under
isDebug.
Security risks
None. No auth, crypto, permissions, or untrusted-input parsing is touched. The change is confined to transaction lifecycle bookkeeping in the built-in SQL client.
Level of scrutiny
Moderate-to-high. The diff is small (~15 logic lines) and the mechanism is clearly correct — clearing needs_rollback before COMMIT was an ordering bug, and the SQLite docs explicitly document that a busy COMMIT leaves the transaction open. However, this is the shared commit/rollback error path for all three adapters (Postgres, MySQL, SQLite), and the follow-up changes alter error-reporting semantics: the cleanup ROLLBACK's own failure is now silently swallowed rather than rejected. The PR discussion reasons through each adapter's behavior (Postgres/MySQL treat ROLLBACK-outside-transaction as a warning/no-op; MySQL XA's BEFORE_COMMIT_OR_ROLLBACK_COMMAND interaction is no worse than main), and I agree with that analysis — but transaction error-path correctness has data-integrity implications, so a maintainer familiar with the SQL subsystem should confirm.
Other factors
- All three of my prior inline nits (inner-catch shadowing,
usingfor Database handles, the sibling savepoint catch) were addressed in follow-up commits and the threads are resolved. - The CodeRabbit suggestion on debug iteration count was reasonably declined per repo convention.
- The new test is hermetic (temp dir, no network,
usingdisposal for allbun:sqlitehandles), asserts durable state from a fresh connection rather than the connection under test, and has no timing dependencies. - No CODEOWNERS entry covers these paths.
- The bug-hunting system found no issues in the current revision.
|
CI status: the diff itself is green. The remaining red on 70484 is unrelated infra/flake:
Ready for review. |
Repro
One SQLITE_BUSY collision at COMMIT time wedges the instance permanently: later writes on it fulfill but are never committed, every other connection to the file gets
database is locked, and every latersql.begin()rejects. Reproduces on 1.4.0 and main with default options (journal_mode=delete, nobusy_timeout).Cause
onTransactionConnectedinsrc/js/bun/sql.tsclearedneeds_rollback = falsebefore awaiting COMMIT:so the catch path issued no ROLLBACK. Postgres and MySQL destroy the transaction server-side when COMMIT fails, so the missing ROLLBACK was a no-op there; SQLite keeps the transaction open on a busy COMMIT by design (so the caller can retry), and the pooled connection was returned still inside it.
bun:sqlite's owndb.transaction()helper already handles this correctly (it checksdb.inTransactionafter a failure).Fix
needs_rollbackonly after COMMIT succeeds; nullBEFORE_COMMIT_OR_ROLLBACK_COMMANDonce it has run so the error path does not re-issue MySQL'sXA ENDifXA PREPAREfails. ROLLBACK after a failed COMMIT is a no-op warning on Postgres/MySQL and the required cleanup on SQLite.ROLLBACK(and the siblingROLLBACK TO SAVEPOINTinrun_internal_savepoint) are now best-effort: their own failure is swallowed sosql.begin()/tx.savepoint()always reject with the original error. Previously the inner catch shadowed it, so a COMMIT failure where SQLite had already auto-rolled-back (SQLITE_FULL/IOERR/NOMEM/INTERRUPT) would surfacecannot rollback - no transaction is activeinstead of the real error.Verification
test/js/sql/sqlite-sql.test.tsgains a test that holds a read lock from a secondbun:sqliteconnection, forcessql.begin()'s COMMIT to fail with SQLITE_BUSY, releases the lock, then asserts:sql.begin()succeedssql.close()Fails on the unfixed build with
SQLiteError: database is lockedat the fresh-connection read; passes with the fix. Also reduced the unrelatedproperly finalizes prepared statementstest to 1,000 iterations under debug builds (it was timing out under ASAN on main).