Skip to content

sql: make transaction.close({ timeout }) roll back when pending queries drain before the timeout - #32149

Closed
robobun wants to merge 5 commits into
mainfrom
farm/16cadcb4/sql-transaction-close-rollback
Closed

robobun wants to merge 5 commits into
mainfrom
farm/16cadcb4/sql-transaction-close-rollback

Conversation

@robobun

@robobun robobun commented Jun 12, 2026

Copy link
Copy Markdown
Collaborator

Fixes #32148. Split out of the review of #32101, which fixed the identical shape in reserved_sql.close({ timeout }).

Repro

await sql.begin(async tx => {
  await tx`INSERT INTO t VALUES (1)`;
  const slow = tx`SELECT pg_sleep(0.01)`.execute();
  await tx.close({ timeout: 30 }); // queries drain first -> no ROLLBACK, closed bit not set
  await slow;
}); // COMMIT runs; the INSERT is persisted

Cause

transaction_sql.close({ timeout }) in src/js/bun/sql.ts races the pending queries/savepoints against a timer. The drain branch (pending work settles before the timer) only ran clearTimeout(timer); resolve();:

  1. It never issued ROLLBACK and never set ReservedConnectionState.closed, so when the begin() callback returned, onTransactionConnected proceeded to COMMIT (the closed gate in run_internal_transaction_sql was not set) and a transaction the user explicitly closed got committed. The no-timeout path correctly rolls back and blocks the later COMMIT with ERR_POSTGRES_CONNECTION_CLOSED.
  2. The internal Promise.all([...]).finally(...) chain was dropped, so a pending query rejecting during the grace period surfaced as an unhandledRejection (nonzero exit) even when the user handled their own query promise.

The timer branch additionally ran its rollback in a bare async callback: a rollback failure there was another unhandled rejection and left close() pending forever.

Fix

Converge all three paths (drain, timer, no-timeout) on one memoized helper, mirroring the #32101 treatment of reserved_sql.close:

  • cancels pending queries, runs BEFORE_COMMIT_OR_ROLLBACK_COMMAND (mysql XA) then ROLLBACK, and sets the closed bit in a finally so even a failed rollback blocks a later COMMIT
  • the grace period waits with Promise.allSettled, so one rejecting query neither cuts the grace period short for the rest nor leaks an unhandled rejection
  • rollback failures propagate to the close() caller instead of hanging it
  • unlike reserved_sql.close, the pooled connection intentionally stays open; it is released back to the pool by onTransactionConnected once the begin callback settles (unchanged)

Verification

test/js/sql/sql-transaction-close.test.ts uses minimal mock postgres/mysql servers (same approach as #32101) to assert which commands reach the wire. All 5 tests fail on the unfixed build with Expected promise that rejects, Received promise that resolved (the COMMIT went through) and pass with the fix:

  • postgres: drain-before-timer rolls back, blocks later COMMIT, close is idempotent
  • postgres: rejecting pending query during the grace period produces no unhandledRejection and does not skip the rollback
  • postgres: pending savepoints are awaited (including RELEASE SAVEPOINT) before the rollback
  • postgres: timer path still cancels and rolls back
  • mysql: drain-before-timer rolls back (shared code path, different adapter/protocol)

Also verified against a real postgres 17: the issue's repro now rolls back (begin() rejects with ERR_POSTGRES_CONNECTION_CLOSED, the INSERT is not persisted, matching the no-timeout path), and commit/rollback/savepoint/timer-path/pool-reuse flows behave as before.

…es drain before the timeout

transaction_sql.close({ timeout }) resolved without issuing ROLLBACK and
without setting the closed bit when the pending queries settled before
the timer fired, so the COMMIT issued after the begin() callback
returned persisted writes the user explicitly closed. The internal
Promise.all chain was also dropped, turning a rejecting pending query
into an unhandledRejection even when the user handled it.

Converge every close path (drain, timer, no-timeout) on one memoized
helper that cancels pending queries, runs
BEFORE_COMMIT_OR_ROLLBACK/ROLLBACK, and sets the closed bit in a finally
so a failed rollback still blocks a later COMMIT. Wait out the grace
period with Promise.allSettled so one rejecting query neither cuts the
grace period short nor leaks an unhandled rejection, and propagate
rollback failures to the close() caller instead of hanging it.
@robobun

robobun commented Jun 12, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 11:26 PM PT - Jun 11th, 2026

✅ @robobun, your commit 7f10658adcd086e3fe07ddc36a5ce5d4f401ccd7 passed in Build #62018! 🎉


🧪   To try this PR locally:

bunx bun-pr 32149

That installs a local version of the PR into your bun-32149 executable, so you can run:

bun-32149 --bun

@coderabbitai

coderabbitai Bot commented Jun 12, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 779da6ab-d2ff-48e0-a562-44d101f13f97

📥 Commits

Reviewing files that changed from the base of the PR and between 3c10069 and 3b54722.

📒 Files selected for processing (1)
  • test/js/sql/sql-transaction-close.test.ts

Walkthrough

Centralizes reserved transaction shutdown into a memoized closeTransaction helper and changes transaction.close({ timeout }) to wait for pending queries/savepoints (grace period) before performing rollback. Adds in-process Postgres and MySQL protocol-mock tests validating drain, savepoint, rejection, and timeout ordering scenarios.

Changes

Transaction close grace-period behavior

Layer / File(s) Summary
Centralized close transaction helpers
src/js/bun/sql.ts
New memoized closeTransaction() helpers cancel pending transaction queries, optionally run adapter BEFORE_COMMIT_OR_ROLLBACK SQL, execute ROLLBACK, and mark the reserved connection closed exactly once.
Close timeout grace-period implementation
src/js/bun/sql.ts
Refactors transaction_sql.close({ timeout }) from an immediate-race cancel to a grace-period approach: waits (via Promise.allSettled) for pending queries/savepoints up to the timeout, then invokes the centralized rollback/close flow.
Test infrastructure: Mock protocol servers
test/js/sql/sql-transaction-close.test.ts
Adds in-process Postgres and MySQL protocol helpers, packet/handshake builders, simple-query parsing, server start/teardown, and command/query recording for deterministic tests without Docker.
Test scenarios: Close timeout behavior
test/js/sql/sql-transaction-close.test.ts
Adds tests for: pending queries draining before timeout, handled query rejection during drain (no unhandledRejection), savepoint drain before timeout, timeout firing before in-flight query completion, and a MySQL variant — all asserting rollback (no COMMIT) and post-close closed-state semantics.

Possibly related issues

  • Issue #32148 — implements the same centralized, idempotent close/rollback helper and grace-period draining fixes referenced in this PR.

Suggested reviewers

  • alii
  • cirospaciari
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and specifically describes the main fix: transaction.close({ timeout }) now rolls back when pending queries drain before the timeout, which is the core issue addressed in this PR.
Description check ✅ Passed The PR description is comprehensive and follows the template with clear sections covering 'Repro', 'Cause', 'Fix', and 'Verification'. It thoroughly explains the issue, root causes, solution, and validation approach.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands and usage tips.

Comment thread test/js/sql/sql-transaction-close.test.ts
The explicit close stays only in the unhandledRejection test, where the
pool must tear down while the listener is still attached.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
test/js/sql/sql-transaction-close.test.ts (1)

320-335: 🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Assert the protocol-level negative and ordering invariants.

These tests currently prove the client eventually rejects and that ROLLBACK eventually appears, but they do not prove the two load-bearing properties this PR is fixing: a post-close query never reaches the wire, and the timeout path does not emit ROLLBACK before the held query settles. A regression in either direction could still pass here.

🔧 Tighten the assertions
   await expect(begin).rejects.toMatchObject({ code: "ERR_POSTGRES_CONNECTION_CLOSED" });
   expect(pendingResult).toEqual([{ x: "hold" }]);
   expect(queryAfterClose).toBe("ERR_POSTGRES_CONNECTION_CLOSED");
+  expect(pg.commands).not.toContain("select 1 as x");
   expect(pg.commands).toContain("ROLLBACK");
   expect(pg.commands).not.toContain("COMMIT");
     while (!pending.cancelled) {
       await Bun.sleep(5);
     }
+    expect(pg.commands).not.toContain("ROLLBACK");
     // ROLLBACK can only go out on the wire after the in-flight query
     // completes, so answer it now (settlement of a cancelled in-flight
     // query is not the subject here)
     socket.write(selectResponse("hold"));

As per coding guidelines, "Every assertion must be able to fail, and must assert the strongest invariant."

Also applies to: 430-445

🤖 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 `@test/js/sql/sql-transaction-close.test.ts` around lines 320 - 335, The test
must assert protocol-level ordering: ensure a post-close query never reached the
wire and that ROLLBACK is emitted only after the held query settles. After
running the operations, capture pg.commands and assert (1) there is no entry
corresponding to the post-close query (use the same query string used for
queryAfterClose and the tx template) and (2) that the index of "ROLLBACK" in
pg.commands is greater than the index of the held query's command (the select
used to produce pendingResult) so ROLLBACK occurs after that query finished;
apply the same strengthened assertions in the corresponding block around lines
430-445 using the same variables (tx, pendingResult, queryAfterClose, begin,
pg.commands).

Source: Coding guidelines

🤖 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 `@test/js/sql/sql-transaction-close.test.ts`:
- Around line 320-335: The test must assert protocol-level ordering: ensure a
post-close query never reached the wire and that ROLLBACK is emitted only after
the held query settles. After running the operations, capture pg.commands and
assert (1) there is no entry corresponding to the post-close query (use the same
query string used for queryAfterClose and the tx template) and (2) that the
index of "ROLLBACK" in pg.commands is greater than the index of the held query's
command (the select used to produce pendingResult) so ROLLBACK occurs after that
query finished; apply the same strengthened assertions in the corresponding
block around lines 430-445 using the same variables (tx, pendingResult,
queryAfterClose, begin, pg.commands).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 30c9dbf3-8432-43d1-a441-8ddcf38df434

📥 Commits

Reviewing files that changed from the base of the PR and between 35383f3 and cb1784c.

📒 Files selected for processing (1)
  • test/js/sql/sql-transaction-close.test.ts

@robobun

robobun commented Jun 12, 2026

Copy link
Copy Markdown
Collaborator Author

Applied the assertion strengthening from the CodeRabbit review in 3c10069: the drain test now asserts the rejected post-close query never reached the wire, and the timer test asserts ROLLBACK is not on the wire before the held query is answered (combined with the existing toContain("ROLLBACK") this pins the ordering, so a separate indexOf check would be redundant).

Comment thread test/js/sql/sql-transaction-close.test.ts Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found and the earlier nits are addressed — deferring only because this reworks the transaction close/rollback state machine (data-integrity path), which is worth a quick look from a SQL maintainer.

Extended reasoning...

Overview

This PR fixes #32148 by converging all three transaction_sql.close() paths (drain-before-timeout, timer-fires-first, no-timeout) onto a single memoized closeTransaction() helper in src/js/bun/sql.ts. Previously, the drain branch resolved without issuing ROLLBACK or setting the closed bit, so a transaction the user explicitly closed could still be committed by onTransactionConnected. The fix is ~30 net lines in production code plus ~480 lines of new tests using minimal in-process postgres/mysql protocol mocks.

Security risks

None identified. No auth, crypto, or permission surfaces are touched. The change is internal control-flow in the SQL transaction lifecycle.

Level of scrutiny

This is squarely in data-integrity-critical territory: the bug being fixed is "a transaction the user closed gets committed instead of rolled back," and the fix changes when/whether ROLLBACK reaches the wire and when the closed bit gates a subsequent COMMIT. The refactor is careful (memoized promise for idempotence, allSettled to avoid short-circuiting the grace period and to observe rejections, finally to set the closed bit even on rollback failure) and mirrors the already-landed #32101 treatment of reserved_sql.close. But subtle ordering in async transaction state machines is exactly where a domain owner's review pays off — e.g., interaction with onTransactionDisconnected setting the closed bit mid-grace-period, or the pooled connection's reuse after a failed ROLLBACK.

Other factors

  • Both of my earlier inline nits (redundant manual sql.close() alongside await using; 1s wall-clock sleep in the timer test) have been addressed in cb1784c and 3b54722, and the threads are resolved.
  • The bug-hunting pass on the current revision found nothing.
  • Test coverage is thorough: 5 tests covering drain, rejection-during-grace (no unhandledRejection), savepoint drain, timer-fires-first, and the mysql adapter path, plus a real-postgres manual verification per the description.
  • CodeRabbit suggested alii / cirospaciari as reviewers, which matches who typically owns this area.

Given the category of change (transaction commit/rollback semantics) rather than any specific concern with the implementation, I'm deferring rather than approving.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found and the earlier nits are addressed — the fix and tests look solid, but since this changes transaction commit/rollback semantics in the SQL driver I'd like a maintainer to sign off rather than auto-approving.

Extended reasoning...

Overview

This PR fixes #32148 by refactoring transaction_sql.close({ timeout }) in src/js/bun/sql.ts. The drain-before-timer branch previously resolved without issuing ROLLBACK or setting the closed bit, allowing a closed transaction to be committed. The fix converges all three close paths (drain, timer, no-timeout) on a single memoized closeTransaction() helper that cancels pending queries, runs ROLLBACK, and sets the closed bit in a finally. It also swaps Promise.all for Promise.allSettled to avoid leaking unhandled rejections and cutting the grace period short. A new test file (test/js/sql/sql-transaction-close.test.ts, ~480 lines) adds minimal mock postgres/mysql wire-protocol servers and 5 tests covering drain, rejection-during-grace, savepoint drain, timer-fires-first, and the mysql adapter path.

Security risks

None identified. The change is internal transaction-lifecycle bookkeeping; no auth, crypto, input parsing, or external surface is touched. The mock servers in the test file bind to 127.0.0.1:0 and are torn down via await using.

Level of scrutiny

High. This is production-critical data-integrity code: the bug being fixed is that an explicitly closed transaction was silently committed instead of rolled back. The fix changes the ordering and error-propagation of ROLLBACK relative to the closed bit and the outer onTransactionConnected commit path. While the diff is modest (~30 net lines in sql.ts) and mirrors the already-landed shape from #32101 for reserved_sql.close, transaction commit/rollback semantics are exactly where a subtle regression would cause user data loss or corruption. A maintainer familiar with the SQL adapter (cirospaciari/alii per the CodeRabbit suggestion) should confirm the interaction with onTransactionConnected's own rollback path and the BEFORE_COMMIT_OR_ROLLBACK_COMMAND (mysql XA) ordering.

Other factors

  • Both of my earlier inline nits (redundant await sql.close() alongside await using, and the 1s timer-test wall-clock) were addressed in cb1784c and 3b54722; the only commit since is a CI retrigger.
  • The bug-hunting pass on the current revision found nothing.
  • Test coverage is thorough and asserts wire-level command ordering against mock servers, which is strong evidence the fix behaves as described.
  • No CODEOWNERS entry for src/js/bun/sql.ts.

Given the criticality of the code path, I'm deferring rather than approving even though I have no specific concerns with the implementation.

@robobun

robobun commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator Author

Independently reproduced and arrived at the same root cause via a sqlite repro. Pushed a simpler variant on farm/a135afc5/sql-tx-close-timeout-noop that just awaits the race and falls through to the existing close body, plus applies the same change to reserved_sql.close. The approach in this PR is more thorough (handles the dropped .finally() rejection and sets the closed bit even when ROLLBACK fails), so not opening a competing PR.

That branch also has sqlite-backed tests in test/js/sql/sqlite-sql.test.ts that cover the same scenario without needing a mock wire server; feel free to cherry-pick if useful.

@robobun

robobun commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator Author

Related: #39617 fixes the same drained arm for reserved.close({ timeout }). As of 25edb8d it adds a module-level waitForPendingWork(pending, timeout) helper in src/js/bun/sql.ts (timer plus allSettled) that close() awaits before it runs its one close body. transaction.close({ timeout }) can await the same helper before its ROLLBACK, which would remove the need for a memoized close promise here. Its tests live next to the reserved.close cases in test/js/sql/sql-pool-transaction-isolation.test.ts, whose mocks now have FAIL and HOLD markers.

@robobun

robobun commented Sep 24, 2026

Copy link
Copy Markdown
Collaborator Author

Context from #41492 (a query cancelled before it runs now settles). That change depends on this fix for one sequence: const q = tx...; q.cancel(); with no await, then tx.close({ timeout }). close() consumes the cancelled query itself, the query rejects at once, and the drained branch then resolves without the ROLLBACK, so the transaction commits. On main the same sequence waits the full timeout and rolls back from the timer. With this PR's behaviour and #41492 together, measured on postgres: the transaction rolls back after 0.2 s with no unhandled rejection.

I wrote the same change before I found this PR, so I did not open a second one. For reference only: main...robobun/455fc2ef/sql-close-timeout-settle covers the reserved scope (#39617) and this scope with one helper. This PR handles a failed ROLLBACK and a repeated close() call, which that branch does not.

@robobun

robobun commented Oct 8, 2026

Copy link
Copy Markdown
Collaborator Author

Closing: #44799 carries this fix. There transaction.close() awaits the same wait helper as reserved.close(), and the begin() runner sends no COMMIT after close(). The five cases of this PR moved into sql-pool-transaction-isolation.test.ts and run on both adapters. #44799 has the Fixes #32148 line.

@robobun robobun closed this Oct 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bun.SQL: transaction.close({ timeout }) commits instead of rolling back when pending queries drain before the timeout

1 participant