Skip to content

sql: learn that a query settled with one reaction, not with finally() - #44315

Open
robobun wants to merge 2 commits into
mainfrom
robobun/39fd5345/sql-query-one-reaction
Open

robobun wants to merge 2 commits into
mainfrom
robobun/39fd5345/sql-query-one-reaction

Conversation

@robobun

@robobun robobun commented Sep 30, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • The client calls query.finally() on each query that it runs: the pool at src/js/internal/sql/shared.ts:807, a transaction or a reservation at src/js/bun/sql.ts:195.
  • finally() calls then() of Query, which starts an async function: 17 of the 76 cells of an awaited PostgreSQL query.

Fix

  • onQuerySettled() adds one reaction with the private $then() and one callback for both outcomes.
  • The reaction is added at the same point, so the callback keeps its place among the reactions of the caller, and its async context.
  • Verified: test/js/sql/sql-pool-transaction-isolation.test.ts, 5 new tests, 3 fail on main. test/js/sql on release builds: no new failure.
  • Self-reviewed: 8 concerns raised, 7 addressed, 1 is a fault that main has (Notes).

Background

  • A reaction is a callback added with then() or finally(). Reactions run as microtasks, in registration order.
  • $then is Promise.prototype.then under a private name. User code cannot replace it.
  • Considered a separate promise for the callback, resolved by Query.resolve() and Query.reject(): 3 more cells saved, but the callback moves before or after all reactions of the caller, and both change results (Notes).

Downsides

  • None found for behaviour. On release builds of main and this PR, 9 call shapes, 36 pool calls and 8 close() calls from reactions print the same (Notes).
  • Size: +171 bytes of text.
  • Other waits of the client still call the public finally(), for example sql.close() with a query in flight. A program that replaces Promise.prototype.finally still stops those (Notes).
Notes

Cells for each successful query (release builds of main and of this PR, linux x64, PostgreSQL 17.11, MariaDB 11.8.6). A sample is the change of heapStats().objectTypeCounts over 500 queries with no collection between the two counts, after 30,000 warmup queries, with BUN_GC_TIMER_DISABLE=1. Median of 15 samples. The spread is under 0.2, except query.then(cb) on PostgreSQL with this PR (59.77 to 60.95).

Call main this PR
PostgreSQL, await query 75.94 58.79
PostgreSQL, query.then(cb) 75.90 60.79
PostgreSQL, await in a transaction 66.24 50.17
PostgreSQL, await on a reserved connection 65.96 49.96
MySQL, await query 66.90 51.89
MySQL, query.then(cb) 67.90 51.89
MySQL, await in a transaction 58.21 42.14
MySQL, await on a reserved connection 57.98 41.97
SQLite, await query 47.63 47.63
SQLite, await in a transaction 69.08 54.02

An awaited PostgreSQL query makes 7 fewer functions, 6 fewer promises, 2 fewer scopes and 2 fewer async function generators. A SQLite query outside a transaction has no such callback, so it is the control.

CPU time for each query shows no difference above the noise of this machine: PostgreSQL 38.9 to 55.9 µs on main and 45.7 to 53.3 µs with this PR, MySQL 29.9 to 36.3 µs and 18.5 to 38.6 µs (user and system time of the process, 8 interleaved runs of 30,000 awaited queries each). This PR makes no claim on time.

A replaced Promise.prototype.finally. This is a side effect, not the goal. With Promise.prototype.finally = function () { return this; } set before the first query, a script of 11 steps (queries, begin(), reserve(), a failing query, a savepoint, close()) passes its first 3 steps on main. Then begin() waits for a connection that never goes back, and steps 4 to 11 hang or end in ERR_POSTGRES_IDLE_TIMEOUT. With this PR all 11 steps pass. With a counting wrapper on Promise.prototype.then the same script makes 119 calls on main and 27 with this PR.

Other waits of the client still call the public finally() or then(): sql.close() with a query in flight (src/js/internal/sql/shared.ts:1364, :1371, :1387), close({ timeout }) of a transaction or a reservation (src/js/bun/sql.ts:511, :797, which #39617 and #32149 rewrite), savepoint() (sql.ts:870) and the LISTEN connection of PostgreSQL (src/js/internal/sql/postgres.ts:770, :814, :835). With the same replacement, sql.close() with a query in flight does not resolve, on main and with this PR. This PR changes only the two calls that run for each query.

What prints the same on both builds

  • The order of the pool callback and the code of the caller, seen through Set.prototype.delete, which the callback calls: await query, query.then(cb), query.execute() then then(cb), query.execute().then(cb), query.execute() then await, and then(cb) before and after execute() in a transaction and on a reservation.
  • 36 calls on the pool from a reaction of a query: close(), close({ timeout: 30 }), close({ timeout: 0 }), end(), reserve(), begin(), new queries, new queries followed by close(), for a pool of 1 and of 2, for query.then(cb) and query.execute().then(cb).
  • close() and close({ timeout: 30 }) of a transaction and of a reservation, called in a reaction of one of their queries (8 cases).
  • From the review of the first design: the AsyncLocalStorage store of a sql.begin() callback that a reaction starts, which connection a query from a reaction gets in a pool of 2, the statements on the wire for 24 calls from reactions, the order logs of 35 transaction scenarios, and the unhandled-rejection reports of 19 kinds of unread query.
  • Each on PostgreSQL and MariaDB, or on mock servers.

The first design, and why it is not in this PR. It gave the callback a promise of its own, which Query.resolve() and Query.reject() resolve, so that the query has only the reactions of the caller. That removes 3 more cells: then() on an object of a subclass of Promise makes a promise capability (two functions and an object), and then() on a plain promise does not. But on main the place of the callback depends on when the caller added its reaction: before or after the query got its connection. A callback that is not a reaction of the query must take one fixed place, and each place changes something that a program can see:

  • After the reactions of the caller: a query that a reaction starts with execute() can go behind a slow statement on another connection (pool of 2), and sql.begin() from a reaction runs in the async context of the query, not of its caller.
  • Before them: close({ timeout }) in a reaction of a query no longer counts that query as pending, so it rolls back where main commits, and it waits for the timeout where main returns at once.

Those changes can be right, but each needs its own decision.

The 8 concerns of the self-review. Most of the review ran on the first design. Three concerns were its behaviour changes (the three above) and do not exist here. Four were about its tests: a count on the shared prototype of Query that fails with bun test --concurrent, a test name that said "no reaction", a test whose title named an AsyncLocalStorage property that does not hold, and a 30 s test timeout that is under the timeout of CI. Here the count is on one query object, the name says what is counted, and the other two tests are gone. The last one is a fault that main has: identifier helpers, fragments and queries that fail before they run never leave the pending set of a transaction or a reservation, so close({ timeout }) waits for them. This PR does not change it.

Found on the way, not changed here. tx.unsafe() and reserved.unsafe() (and file()) do not check that the transaction or the reservation is still open (src/js/bun/sql.ts:390 and :696). After tx.close(), tx.unsafe("INSERT ...") runs outside the transaction and is committed. After reserved.release(), reserved.unsafe() runs on a connection that another caller can have in a transaction. main and 1.4.2 do this. The tagged calls reject with ERR_POSTGRES_CONNECTION_CLOSED.

await sql.begin(async tx => {
  await tx.unsafe("INSERT INTO t VALUES (1)");
  await tx.close(); // sends ROLLBACK
  await tx.unsafe("INSERT INTO t VALUES (3)"); // runs, outside the transaction
}).catch(() => {}); // begin() rejects with ERR_POSTGRES_CONNECTION_CLOSED
console.log(await sql`SELECT id FROM t`); // [ { id: 3 } ]

Suites. Debug build with ASAN: sql-pool-transaction-isolation.test.ts (23 pass), sql-reserve-abort.test.ts, sql-close-pending-connection.test.ts, wire-frames.test.ts. Release builds of main and of this PR, all of test/js/sql against local PostgreSQL and MariaDB servers: 30 tests fail on both for the setup of that machine (TLS, password methods, MySQL 9), the 3 new tests fail only on main, and no test fails only with this PR. One 5 s timeout in postgres-listen-notify.test.ts did not repeat in 3 more runs of that file on each build.


[human-review] gate passed · iteration 0 · 4 files touched

fails on main (without fix)
ASAN without fix: 3 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/sql/sql-pool-transaction-isolation.test.ts
bun test v1.4.3 (367d939d9)

test/js/sql/sql-pool-transaction-isolation.test.ts:
(pass) postgres > concurrent sql.begin() stays serialized after a server-side disconnect with queries in flight [1201.18ms]
(pass) postgres > a pool slot is reusable after a server-side disconnect during sql.reserve() [174.34ms]
(pass) postgres > a pool slot is reusable after sql.reserve() is closed explicitly [91.79ms]
(pass) postgres > concurrent sql.begin() stays serialized after a server-side disconnect during a transaction [184.08ms]
(pass) postgres > the reservation keeps its pool slot after reserved begin() with invalid options rejects [92.89ms]
(pass) postgres > the reservation keeps its pool slot after reserved beginDistributed() with an invalid name rejects [50.67ms]
(pass) postgres > reserved.close({ timeout }) waits for a transaction started on the reservation [62.48ms]
(pass) postgres > reserved.close({ timeout }) waits for a failing transaction without reporting its handled error [47.29ms]
(pa
... (truncated)

release without fix: 3 FAILED
bun test v1.4.3-canary.1 (367d939d9)

test/js/sql/sql-pool-transaction-isolation.test.ts:
(pass) postgres > concurrent sql.begin() stays serialized after a server-side disconnect with queries in flight [803.33ms]
(pass) postgres > a pool slot is reusable after a server-side disconnect during sql.reserve() [4.00ms]
(pass) postgres > a pool slot is reusable after sql.reserve() is closed explicitly [2.49ms]
(pass) postgres > concurrent sql.begin() stays serialized after a server-side disconnect during a transaction [3.63ms]
(pass) postgres > the reservation keeps its pool slot after reserved begin() with invalid options rejects [2.01ms]
(pass) postgres > the reservation keeps its pool slot after reserved beginDistributed() with an invalid name rejects [1.08ms]
(pass) postgres > reserved.close({ timeout }) waits for a transaction started on the reservation [1.14ms]
(pass) postgres > reserved.close({ timeout }) waits for a failing transaction without reporting its handled error [1.00ms]
(pass) postgres > a rejected reserved begin() is reported as unhandled only when the caller ignores it [756.44ms]
475 |         transaction = countReactionCalls(inTransaction);
476 |     
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/sql/sql-pool-transaction-isolation.test.ts
bun test v1.4.3 (367d939d9)

test/js/sql/sql-pool-transaction-isolation.test.ts:
(pass) postgres > concurrent sql.begin() stays serialized after a server-side disconnect with queries in flight [1131.48ms]
(pass) postgres > a pool slot is reusable after a server-side disconnect during sql.reserve() [190.39ms]
(pass) postgres > a pool slot is reusable after sql.reserve() is closed explicitly [100.08ms]
(pass) postgres > concurrent sql.begin() stays serialized after a server-side disconnect during a transaction [112.90ms]
(pass) postgres > the reservation keeps its pool slot after reserved begin() with invalid options rejects [69.97ms]
(pass) postgres > the reservation keeps its pool slot after reserved beginDistributed() with an invalid name rejects [47.77ms]
(pass) postgres > reserved.close({ timeout }) waits for a transaction started on the reservation [45.47ms]
(pass) postgres > reserved.close({ timeout }) waits for a failing transaction without reporting its handled error [40.24ms]
(p
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 7413ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/139] gen JS modules (bundle-modules)
Preprocess modules (8923ms)
Bundle modules (824ms)
Postprocesss modules (164ms)
Bundle Functions (1370ms)
Generate Code (48ms)

[11.35s] Bundled "src/js" for production
  2613 kb
  198 internal modules
  13 native modules
  50 internal functions across 16 files
[2/6] link bun-profile
ld.lld: warning: Linking two modules of different target triples: 'obj/vendor/mimalloc/src/static.c.o' is 'x86_64-pc-linux-gnu' whereas '../../../../root/.bun/build-cache/webkit-f20ce7744553c910-lto/lib/libJavaScriptCore.a(UnifiedSource-bytecompiler-1.cpp.o at 42486912)' is 'x86_64-unknown-linux-gnu'


ld.lld: warning: Linking two modules of different target triples: 'obj/unified/UnifiedSource-src_jsc_bindings-0.cpp.o' is 'x86_64-pc-linux-gnu' whereas '../../../../root/.bun/build-cache/webkit-f20ce7744553c910-lto/lib/libJavaScriptCore.a(Parser.cpp.o at 129319788)' is 'x86_64-unknown-linux-gnu'


ld.lld: warning: Linking two modules of different target triples: 'obj/unified/UnifiedSource
... (truncated)
diff hotspot
src/js/bun/sql.ts                                  |  4 +-
 src/js/internal/sql/query.ts                       |  6 ++
 src/js/internal/sql/shared.ts                      |  3 +-
 test/js/sql/sql-pool-transaction-isolation.test.ts | 99 ++++++++++++++++++++++
 4 files changed, 109 insertions(+), 3 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                                reads  edits  tests
src/js/bun/sql.ts                                       5      0     56
src/js/internal/sql/query.ts                            1      1     57
src/js/internal/sql/shared.ts                           5      0     57
test/js/sql/sql-pool-transaction-isolation.test.ts      1      1     56

The pool and a transaction each called query.finally() on every query
that they run. finally() goes through then() of Query, which starts an
async function and makes closures and promises that nothing needs.

onQuerySettled() adds one reaction with the private then(). The
reaction is added at the same point, so the order of the callback and
of the reactions of the caller does not change.
@robobun

robobun commented Sep 30, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: ready for review. CI is green on c29573e (Buildkite build 121946).

How to see the problem on main:

import { SQL } from "bun";
const sql = new SQL("postgres://localhost/postgres", { max: 1 });
const query = sql`SELECT 1`;
const calls = { then: 0, finally: 0 };
for (const name of ["then", "finally"]) {
  const original = query[name];
  query[name] = function (...args) {
    calls[name]++;
    return original.apply(this, args);
  };
}
await query;
console.log(calls); // main: { then: 2, finally: 1 }. This PR: { then: 1, finally: 0 }

The test does the same with mock servers: bun bd test test/js/sql/sql-pool-transaction-isolation.test.ts. On main 3 of the 5 new tests fail. With this PR all 23 tests of the file pass.

@coderabbitai

coderabbitai Bot commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository: oven-sh/bun/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 974ea43d-0fa8-4179-b896-848210eb5ae5

📥 Commits

Reviewing files that changed from the base of the PR and between 3317503 and c29573e.

📒 Files selected for processing (2)
  • src/js/internal/sql/query.ts
  • test/js/sql/sql-pool-transaction-isolation.test.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 6 remain after this review.


Walkthrough

SQL transaction and pooled-connection cleanup now uses a shared query-settlement helper instead of attaching cleanup with query.finally(...). Tests check reaction calls and connection reuse after a parameter access error.

Changes

SQL query settlement cleanup

Layer / File(s) Summary
Add query settlement helper
src/js/internal/sql/query.ts
onQuerySettled registers callbacks for fulfillment and rejection through $then() and marks the resulting promise as handled.
Use settlement helper for cleanup
src/js/bun/sql.ts, src/js/internal/sql/shared.ts, test/js/sql/sql-pool-transaction-isolation.test.ts
Transaction cleanup and pooled-connection release use onQuerySettled. Tests check reaction calls for awaited queries and verify pool connection reuse after parameter access throws.

Priority: ⬇️ Low

Merge Risk: ⚪ Minimal · up to c2957

The change avoids replaceable finally methods while preserving SQL cleanup behavior. No actionable merge-blocking risk was identified; merge after normal checks pass.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
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.
Title check ✅ Passed The title clearly describes the main change: using one query settlement reaction instead of finally().
Description check ✅ Passed The description explains the problem, fix, behavior, trade-offs, test coverage, and verification results. It does not use the template headings exactly, but it provides the required information in equ…
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Autopilot is currently an internal CodeRabbit preview.


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

@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.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline findings, I also checked the semantic differences between finally(cb) and $then(cb, cb): both call sites register only from inside the query's handler, so skipping Query.then()'s #runAsyncAndCatch() start side effect changes nothing; both bound callbacks (onTransactionQueryDisconnected, onQueryFinish) ignore the value/reason argument $then now passes; and the synchronous notTaggedCallError throw from Query.finally() was unreachable at these sites since identifier helpers never reach the handler.

Extended reasoning...

The change replaces two internal query.finally() calls in the SQL client (src/js/bun/sql.ts, src/js/internal/sql/shared.ts) with a $then-based helper in src/js/internal/sql/query.ts, and adds tests. It touches no security-sensitive surface beyond tamper-resistance of built-ins. Inline findings already exist, so this note only records the refactor-preservation checks that were ruled out.

Comment thread src/js/internal/sql/query.ts Outdated
Comment thread src/js/internal/sql/query.ts
Comment thread src/js/bun/sql.ts
@robobun

robobun commented Sep 30, 2026

Copy link
Copy Markdown
Collaborator Author

The three inline findings have answers in their threads. In short:

  • Comments: cut to one line each in c29573e.
  • Other waits that call the public finally() or then(): checked and true, on main and with this PR. They are not changed here. The description now names them under Downsides and Notes, and the prototype is no longer listed as a problem that this PR solves.
  • Queries that never leave the pending set of a transaction: a fault that main has. It is not changed here, and the thread says why a registration when the query is made changes results.

@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.

LGTM — the second commit trimmed the comments to one line, and the swap from query.finally() to onQuerySettled() is behavior-preserving at both sites.

What was reviewed:

  • Both call sites (queryFromTransactionHandler in src/js/bun/sql.ts:195, bindQuery in src/js/internal/sql/shared.ts:808) run inside the query handler, after #run/#runAsync set executed and after the notTagged check — so the old finally() override's #runAsyncAndCatch() and notTagged throw were already no-ops there.
  • $then(cb, cb) registers one reaction at the same point as the old this.then(thenFinally, catchFinally), so callback order among reactions is unchanged; both mark the query handled, and the derived promise is poked as handled in both versions. The extra value/reason argument is ignored by onQueryFinish and onTransactionQueryDisconnected.
  • query.$then on a Query subclass follows the existing .$then( pattern in src/js (fs.promises, dgram, BundlerPlugin); the remaining .finally sites in postgres.ts/shared.ts are on plain promises, not queries.
Extended reasoning...

The change adds a 3-line onQuerySettled helper to src/js/internal/sql/query.ts, exports it from the module's export default, and rewires the two internal settle-wait sites (transaction bookkeeping in src/js/bun/sql.ts and pooled-connection release in src/js/internal/sql/shared.ts) plus adds three tests to test/js/sql/sql-pool-transaction-isolation.test.ts. It touches no security-sensitive surface (no auth, injection, or data exposure paths); the risk would be a leaked pooled connection or changed reaction ordering, and tracing both call sites shows they are only reached from inside the query handler where the old override's side effects were already inert. The bug hunt ran dry with no findings, the second commit addressed the one optional comment from the prior review, no CODEOWNERS entry covers the changed files, and the diff is small and self-contained with tests that exercise pool, reservation, transaction and pre-send-rejection paths.

@robobun

robobun commented Sep 30, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 1:12 PM PT - Sep 30th, 2026

✅ @robobun, your commit c29573e91e4fc58a1e5b5ba20e30ace9533d570e passed in Build #121946! 🎉


🧪   To try this PR locally:

bunx bun-pr 44315

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

bun-44315 --bun

This branch has not been deployed

No deployments
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.

2 participants