Skip to content

sql: consolidate JS pool/connection plumbing into BasePooledConnection in shared.ts#32145

Merged
alii merged 6 commits into
mainfrom
ali/sql-js-shared
Jun 12, 2026
Merged

sql: consolidate JS pool/connection plumbing into BasePooledConnection in shared.ts#32145
alii merged 6 commits into
mainfrom
ali/sql-js-shared

Conversation

@alii

@alii alii commented Jun 12, 2026

Copy link
Copy Markdown
Member

What this does

Final slice from #31994 (closed as too big), on top of #32128, #32135, #32141. Consolidates the JS adapters' duplicated pool/connection/query plumbing from src/js/internal/sql/{postgres,mysql,sqlite}.ts into src/js/internal/sql/shared.ts as a BasePooledConnection class hierarchy. Net −995 lines across 4 files.

Two commits:

Behavioral equivalence

The original BasePooledConnection extraction in #31994 carried a per-file behavioral-equivalence audit against its merge-base (placeholders, escaping, helper commands, error message text, pool/transaction semantics all preserved) and was green on full CI build 61383. This PR re-applies that diff and adds the two missing fixes; the load-bearing verification is below.

Verification

  • bun bd: builds clean.
  • The three lifecycle test files (sql-onconnect-onclose-throw.test.ts, sql-close-pending-connection.test.ts, sql-connect-error-reporting.test.ts): 26 pass / 0 fail.
  • New tests in this PR: sql-onconnect-onclose-throw.test.ts gains a forced-close variant per driver (a throwing onclose while the pool is force-closed mid-handshake, against a fake net server). The two re-hosted fixes meet in BasePooledConnection's close handler, and that interaction had no coverage. Note these pass against origin/main's src/js/internal/sql too (verified): both underlying fixes already live on main per-driver, so versus main this PR is equivalence-preserving by design and no test can fail on one side only. The HEAD~1 stash test below is the proof that the re-integration commit is load-bearing.
  • Load-bearing stash test: built and ran the same three files at HEAD~1 (the cherry-pick before re-integration) — 6 fail (mysql: forced close() resolves when called before the native handle is stored timeout; postgres: pool calls from onclose are safe when connecting fails synchronouslyreentry threw: TypeError; both drivers' throwing onclose still rejects pending queries on connect refused timeouts). With the re-integration: 26 pass. The diff is required for both fixes.
  • Full test/js/sql/ against live postgres_plain/mysql_plain/*_tls containers: 1526 pass / 7 fail / 6 errors locally. Every failure reproduces on origin/main in the same environment — see Notes below.

Notes on local-only test failures (none introduced by this PR)

  • sql-mysql-query-string-leak.test.ts fails locally on a debug+ASAN build for both origin/main (314.6 MiB) and this branch (317.2 MiB), within noise of each other; both exceed the 256 MiB ASAN threshold. A heapStats() probe of the same workload shows MySQLQuery 2→1 after GC — wrappers are finalized correctly. The test passes on main's CI (release build); the threshold is tuned for release RSS overhead, not local debug.
  • sql.test.ts > query string memory leak test (postgres): same category.
  • sqlite-sql.test.ts > Query Normalization Fuzzing Tests > handles exotic but valid SQL patterns: 5.5s timeout on debug builds — pre-existing per Deduplicate SQL driver internals across postgres/mysql/sqlite #31994's original verification notes.
  • describeWithContainer cold-start races: docker compose up -d --wait returns non-zero on an already-healthy container (test/docker/index.ts:157), causing beforeEach hook timeouts in sql-mysql.test.ts (TLS), sql-mysql-bind-oob, sql-mysql.helpers, sql-mysql.auth. Pre-existing harness flake; clears on warm rerun.

The Rust-side changes from #32097 (src/sql_jsc/) were already on main before this PR — only the JS hooks needed re-integrating.

alii added 3 commits June 11, 2026 16:19
… from #31994

Baseline cherry-pick of origin/claude/split/sql:src/js/internal/sql/.
This intentionally clobbers #32041 and #32097 — they get re-integrated
into BasePooledConnection in the next commit(s).
The cherry-pick from #31994 clobbered two pool-lifecycle fixes that
landed on main since its merge-base. This re-hosts both in the new
shared class structure:

- #32041: try/finally around the user onconnect/onclose callbacks in
  handleConnected and #finishClose so a throwing callback never skips
  pool bookkeeping; createPooledConnectionHandle catch always defers
  via process.nextTick (drops the per-driver deferSyncCloseError flag,
  both drivers now match).
- #32097: startConnection is abstract Promise<void> and both subclasses
  await + assign the native handle at creation; #beginConnecting awaits
  it and closes the handle if the pool was force-closed in the
  microtask window before it materialized.
@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: c3f188ee-41ab-4f3d-ad99-3f0561114e06

📥 Commits

Reviewing files that changed from the base of the PR and between 39ccfdc and 60eb740.

📒 Files selected for processing (1)
  • test/js/sql/sql-onconnect-onclose-throw.test.ts

Walkthrough

Adds shared SQL normalization and pooled-connection base classes, refactors MySQL/Postgres/SQLite adapters to extend those bases and delegate normalization/binding, and adds tests for forced-close during a mid-handshake.

Changes

SQL adapter and pooling infrastructure consolidation

Layer / File(s) Summary
Shared pooling/adapter foundation
src/js/internal/sql/shared.ts
Adds SQLCommand and helper detection, pushBindParam and normalizeQuery, PooledConnectionState and BasePooledConnection, createPooledConnectionHandle, and BaseSQLAdapter implementing pooling, queueing, retry/backoff, and adapter hooks.
MySQL adapter consolidation
src/js/internal/sql/mysql.ts
Replaces custom pooled-connection state machine with PooledMySQLConnection extends BasePooledConnection; MySQLAdapter now extends BaseSQLAdapter, delegates unsafe-transaction checks to base, and provides MySQL-specific connection/error classification and helper/upsert hooks; default export reduced to { MySQLAdapter }.
PostgreSQL adapter consolidation
src/js/internal/sql/postgres.ts
Adds PooledPostgresConnection extends BasePooledConnection; PostgresAdapter extends BaseSQLAdapter, centralizes unsafe-transaction handling via checkUnsafeTransaction, adds unsafeTransactionError(), getHelperCommand, placeholder, and bindParam (with SQLArrayParameter handling); removes local validation helpers.
SQLite adapter refactoring
src/js/internal/sql/sqlite.ts
Delegates normalization to shared normalizeQuery(this, ...) and implements adapter hooks: placeholder, bindParam (via pushBindParam), getHelperCommand, isUpsertUpdate, and throwIfUpdateEmpty.
Forced-close mid-handshake tests
test/js/sql/sql-onconnect-onclose-throw.test.ts
Adds neverAnsweringServer and forcedCloseFixture tests asserting sql.close() resolves while a pending handshake query rejects and an onclose throw is surfaced as uncaughtException; runs for Postgres and MySQL.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: consolidating duplicated pool/connection plumbing from multiple adapter files into a shared BasePooledConnection class, which is the core objective of this refactoring PR.
Description check ✅ Passed The description provides comprehensive context including what the PR does, how it was verified (builds, tests, load-bearing stash test), and detailed notes on pre-existing failures. However, it does not strictly follow the required template structure with the two specified sections.
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.

@robobun

robobun commented Jun 12, 2026

Copy link
Copy Markdown
Collaborator
Updated 12:38 AM PT - Jun 12th, 2026

@autofix-ci[bot], your commit 60eb740931bcf385a155b794d4bbee0bdfec2721 passed in Build #62030! 🎉


🧪   To try this PR locally:

bunx bun-pr 32145

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

bun-32145 --bun

Comment thread src/js/internal/sql/mysql.ts
@alii

alii commented Jun 12, 2026

Copy link
Copy Markdown
Member Author

@robobun adopt

@alii

alii commented Jun 12, 2026

Copy link
Copy Markdown
Member Author

@claude review

@robobun

robobun commented Jun 12, 2026

Copy link
Copy Markdown
Collaborator

Adopted. Review swept: all bot findings are pre-existing behaviors this refactor preserves, verified against the released build and tracked in #32035 and #32155; threads resolved.
Added per-driver regression tests for a throwing onclose during a forced mid-handshake close (the point where the two re-hosted fixes meet in BasePooledConnection), plus updated verification notes in the PR body. CI is running on the new push.

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

Actionable comments posted: 3

🤖 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 `@src/js/internal/sql/mysql.ts`:
- Around line 282-284: The isUpsertUpdate check currently only matches
exact-case suffix; update isUpsertUpdate to perform a case-insensitive suffix
check (e.g., normalize the trimmed query to a consistent case or use a
case-insensitive regex) so "on duplicate key update" in any letter case is
detected and trailing whitespace is handled; keep the method name isUpsertUpdate
and ensure it still returns a boolean.

In `@src/js/internal/sql/shared.ts`:
- Around line 472-545: The IN and UPDATE branches currently dereference array
entries without guarding for empty arrays or non-object/null entries; add checks
to validate inputs before indexing: in the SQLCommand.in branch (use symbols:
SQLCommand.in, items, itemsCount, columns, columns[0], binding_values) reject
empty arrays (itemsCount === 0) and when columnCount > 0 ensure each items[j] is
a non-null object before using value[columns[0]] — throw a SyntaxError with a
clear message if not. In the UPDATE branch (use symbols: SQLCommand.update,
$isArray(items), item, columns, item[column], adapter.throwIfUpdateEmpty) reject
empty arrays up front (if $isArray(items) && items.length === 0) and validate
that the resolved item is a non-null object before accessing its properties; if
invalid, throw a SyntaxError instead of letting a TypeError occur. Ensure these
guards run before any property access and keep existing behavior for valid
inputs.

In `@src/js/internal/sql/sqlite.ts`:
- Around line 411-414: The update empty-check in throwIfUpdateEmpty currently
ignores the computed flag and only tests query.endsWith("SET "); change it to
use the provided _hasValues parameter (rename or use hasValues) and throw the
SyntaxError when that flag is false (e.g., if (!hasValues) throw new
SyntaxError("Update needs to have at least one column")); keep or remove the
endsWith fallback as you prefer but ensure throwIfUpdateEmpty uses the hasValues
signal (function: throwIfUpdateEmpty, param: _hasValues).
🪄 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: b7a39ae1-f993-493d-911f-57b057e88e96

📥 Commits

Reviewing files that changed from the base of the PR and between ddd1ff0 and 7e25355.

📒 Files selected for processing (4)
  • src/js/internal/sql/mysql.ts
  • src/js/internal/sql/postgres.ts
  • src/js/internal/sql/shared.ts
  • src/js/internal/sql/sqlite.ts

Comment thread src/js/internal/sql/mysql.ts
Comment thread src/js/internal/sql/shared.ts
Comment thread src/js/internal/sql/sqlite.ts
The re-integration commit hosts the #32041 try/finally and the #32097
forced-close settlement in one BasePooledConnection close handler, so pin
their interaction per driver: onclose throws while the pool is force-closed
mid-handshake, and close() must still resolve and the pending query must
still reject.

@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 new issues found on re-review — the only finding (MySQL upsert case-sensitivity) is pre-existing and tracked in #32035. That said, this is a ~1000-line restructuring of the SQL connection-pool lifecycle across all three drivers plus a re-hosting of two recent fixes, so it warrants a human pass before merge.

Extended reasoning...

Overview

This PR consolidates the duplicated pool/connection/query plumbing from postgres.ts, mysql.ts, and sqlite.ts into shared.ts as an abstract BasePooledConnection / BaseSQLAdapter hierarchy, plus a shared normalizeQuery with adapter hooks (placeholder, bindParam, getHelperCommand, isUpsertUpdate, throwIfUpdateEmpty). It also re-integrates two pool-lifecycle fixes (#32041 try/finally around user onconnect/onclose; #32097 forced-close-during-handshake) into the new structure. Net −995 lines across 4 files.

Security risks

None introduced. The injection guards (validateTransactionOptions, validateDistributedTransactionName) were moved verbatim to the base class; escapeIdentifier stays per-driver; checkUnsafeTransaction preserves the BEGIN/START TRANSACTION guard. createPooledConnectionHandle now passes an extra trailing allowPublicKeyRetrieval arg to the postgres native ctor, which ignores it — benign.

Level of scrutiny

High. This is the connection-pool state machine for Bun.sql — retry/backoff, force-close races, query distribution, graceful shutdown — restructured into an inheritance hierarchy, with two recent bug fixes re-hosted into it. The verification is thorough (lifecycle tests pass, stash test confirms the re-integration is load-bearing, full test/js/sql/ run with failures triaged as pre-existing), but the scope and criticality put it well outside what a bot should approve unilaterally.

Other factors

  • My earlier inline comment (the isUpsertUpdate case-sensitivity mismatch) was confirmed pre-existing, already tracked in #32035, and the thread is resolved.
  • The #close() switch now merges the pending/connected cases and calls cancelRetry() unconditionally; for connected it's a no-op (no retry timer), so behavior is preserved — but this is the kind of subtle merge a human should glance at.
  • The shared detectCommand now takes an anyAndAllMeanIn flag to preserve the prior MySQL-vs-Postgres divergence on leading ANY/ALL tokens.
  • SQLite keeps its own throwIfUpdateEmpty (string-based endsWith("SET ")) to preserve its prior, slightly different empty-update detection.

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

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/sql-onconnect-onclose-throw.test.ts`:
- Line 244: The test calls sql.close with a string timeout ("0") which
contradicts the expected timeout?: number; update the call to pass a numeric
literal instead (use timeout: 0) in the sql.close invocation in
sql-onconnect-onclose-throw.test.ts so the argument matches the declared
signature of sql.close and avoids relying on implicit coercion.
🪄 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: 40e694aa-7bef-438c-8324-9c87d554b15c

📥 Commits

Reviewing files that changed from the base of the PR and between 7e25355 and 39ccfdc.

📒 Files selected for processing (1)
  • test/js/sql/sql-onconnect-onclose-throw.test.ts

Comment thread test/js/sql/sql-onconnect-onclose-throw.test.ts

@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 new issues found — the consolidation looks faithful and the re-hosted #32041/#32097 fixes are covered by the new forced-close tests — but this is a ~1000-line refactor of the pool/connection state machine across all three SQL adapters, so it's worth a human pass (cirospaciari is the suggested reviewer).

Extended reasoning...

Overview

This PR consolidates ~1000 lines of duplicated pool/connection/query-normalization plumbing from src/js/internal/sql/{postgres,mysql,sqlite}.ts into shared.ts as an abstract BasePooledConnection / BaseSQLAdapter hierarchy. Each driver now subclasses the base and overrides only driver-specific hooks (startConnection, wrapError, isNonRetryableError, isConnectFailureError, placeholder, bindParam, getHelperCommand, isUpsertUpdate, throwIfUpdateEmpty). Two recent main-line fixes — #32041 (try/finally around throwing onconnect/onclose) and #32097 (forced close() mid-handshake) — are re-hosted in the shared base, and new per-driver tests cover the point where the two fixes intersect.

Security risks

None introduced. The security-relevant validators (validateTransactionOptions, validateDistributedTransactionName, escapeIdentifier, the getArrayType regex) are moved verbatim or left in place. createPooledConnectionHandle now passes a trailing allowPublicKeyRetrieval argument to the Postgres native constructor as well; per the Zig signatures this extra positional arg is ignored, so it doesn't change TLS/auth behavior.

Level of scrutiny

High. This is the core connection-pool state machine for Bun.SQL — lifecycle, retry/backoff, reservation, graceful/forced close — shared across three production drivers. The refactor is intended to be behavior-preserving, but it (a) collapses two per-driver #close switch arms into one with a cancelRetry() check now also reachable from the connected state, (b) hoists the post-startConnection forced-close check from the per-driver #startConnection into the shared #beginConnecting, and (c) re-expresses the per-driver retry switch as an isNonRetryableError predicate. Each of these is plausible-equivalent on inspection, and the load-bearing stash test in the PR body demonstrates the re-integration commit is required, but the surface area is large enough that a domain owner should sign off.

Other factors

  • All inline review threads (mine on isUpsertUpdate, CodeRabbit's on helper guards / throwIfUpdateEmpty / timeout: "0") are resolved as pre-existing behaviors tracked in #32035, #32155, #32091.
  • The bug-hunting system found nothing.
  • The robobun CI comment shows build-rust failures on 39ccfdc across several platforms; those are in scripts/build/ci.ts (untouched by this PR) and the latest push (60eb740, autofix) hasn't reported yet, so worth confirming green before merge.
  • The #close switch consolidation now calls cancelRetry() for connected connections too — harmless since retryTimer is always null once connected, but it's the kind of small structural shift a maintainer should eyeball.

@alii alii merged commit 5d8b292 into main Jun 12, 2026
77 checks passed
@alii alii deleted the ali/sql-js-shared branch June 12, 2026 15:29
alii added a commit that referenced this pull request Jun 12, 2026
…ave orphans (#32207)

When a test that does `await using proc = Bun.spawn(...)` times out
before the using-scope exits, the dispose never runs and the subprocess
survives indefinitely. The sql fixture subprocesses connect to a (mock
or real) database and await the pool draining; under a regression that
hangs the pool (e.g. while iterating on #32145), the fixture never
exits, the test times out, and the fixture is orphaned.

Locally observed 16 such orphans pinning the machine at load ~100 for
hours after a `bun bd test test/js/sql/` run that exercised a buggy
intermediate commit.

Adding `timeout` to the `Bun.spawn` options gives the child its own hard
deadline independent of the test runner, so an abandoned fixture
self-kills within a minute even when the parent test has already moved
on.

`sql.test.ts` already had `timeout` on all three of its spawns; this
brings the other 8 files in line.
robobun added a commit that referenced this pull request Jun 12, 2026
Rebuilt on main after the pool plumbing was consolidated into
BaseSQLAdapter/BasePooledConnection in shared.ts (#32145) — the fix now
lands once in the shared base class instead of twice in the
postgres/mysql adapters.

Previously `new Bun.SQL({ max: N })` opened all N backend connections
on the first query: `connect()` instantiated every pool slot eagerly,
and each pooled-connection constructor dials TCP immediately. A single
`SELECT 1` on a `max: 500` pool burst 500 real connections.

The fix, ported from the pre-rebase branch:

- Pool starts empty; `#tryGrowPool()` appends one connection at a
  time, capped at `maxPoolSize` (= `options.max`).
- First `connect()` opens exactly one connection.
- `flushConcurrentQueries()` grows the pool when queued queries can't
  be served by ready or pending slots (discounting pending slots
  already earmarked for `reservedQueue`).
- `maxDistribution()` divides by the pool ceiling rather than the
  current size.
- `connect()`'s retry branch grows in parallel while waiting behind
  in-flight slots, and for non-retryable auth failures fails fast with
  the cached error — unless `password` is a function (dynamic
  credential), in which case it grows or force-retries a closed slot
  via the new `forceRetry()` so a rotated token can take effect even
  at `max: 1`.

Tests (test/js/sql/sql-pool-lazy-growth.test.ts, 4 of 5 fail on
unfixed main; the sync-password-throw guard passes since main now
defers createConnection failures via process.nextTick):

- single query opens 1 socket, not max (postgres + mysql)
- static-password auth failure fast-fails without new sockets
- function-password retries auth per query at max: 1
- synchronous password() throw still rejects both queries
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants