Skip to content

sql: don't kill in-flight queries when idleTimeout/maxLifetime fires - #30648

Open
robobun wants to merge 10 commits into
mainfrom
farm/602f60f3/sql-drain-before-timer-close
Open

sql: don't kill in-flight queries when idleTimeout/maxLifetime fires#30648
robobun wants to merge 10 commits into
mainfrom
farm/602f60f3/sql-drain-before-timer-close

Conversation

@robobun

@robobun robobun commented May 13, 2026

Copy link
Copy Markdown
Collaborator

Fixes #30646

Problem

  • A healthy in-flight query was rejected the moment a client-side timer fired:
    PostgresError: Max lifetime timeout reached after 30m (ERR_POSTGRES_LIFETIME_TIMEOUT) and
    PostgresError: Idle timeout reached after 2m (ERR_POSTGRES_IDLE_TIMEOUT), same shape for MySQL.
  • on_connection_timeout / on_max_lifetime_timeout failed the connection unconditionally; for Postgres the idle timer was even armed while a query was outstanding. Reporter saw 137 occurrences/24h in production.

Fix

Postgres (src/sql_jsc/postgres/PostgresSQLConnection.rs):

  • get_timeout_interval returns 0 while has_query_running(), so the idle timer never arms with a query outstanding (mirrors MySQL's existing is_idle gate; that gate already existed on the MySQL side, so only Postgres needed this).
  • on_max_lifetime_timeout: if a query is in flight, set a LIFETIME_EXCEEDED flag instead of failing; the ReadyForQuery arm acts on it before advance() dispatches more work. If idle, fail immediately as before.
  • The ReadyForQuery retirement only happens once the head request is finished (Success/Fail). A named statement with parameters sends Parse+Describe+Sync first and gets its own ReadyForQuery before Bind+Execute; retiring on that one would reject the head query before it ran, so that ReadyForQuery falls through to advance() and the next one retires.
  • The failure path is unchanged: fail_fmt(ERR_POSTGRES_LIFETIME_TIMEOUT). onclose still reports the documented code, and max_lifetime stays a hard bound under steady traffic (retires at the first query-completion after expiry; verified 799 back-to-back queries retire at ~2.1s with max_lifetime: 2).
  • do_run now calls reset_connection_timeout after advance_and_flush, so a request that advance() discards synchronously can't leave an idle connection with no timer armed.

MySQL (src/sql_jsc/mysql/JSMySQLConnection.rs, MySQLRequestQueue.rs):

  • Same flag design as Postgres: on_max_lifetime_timeout sets LIFETIME_EXCEEDED while a query is in flight instead of failing. Every completion path ends in MySQLRequestQueue::advance(), which retires the connection via the original fail_fmt(ERR_MYSQL_LIFETIME_TIMEOUT) once pipelined_requests and nonpipelinable_requests are both 0, before dispatching the next request. (Replaces the earlier 1s re-poll, which never retired under steady traffic.)

Scope notes:

  • Requests still queued (pipelined behind a finished head, or a prepare in flight that has not executed yet) are rejected at retirement, like any queued request on a failing connection. No executed work is lost, and it is no worse than main, which killed everything including the running query.
  • has_query_running() is false between the statements of sql.begin() / on a reserve()d connection that is momentarily idle, so a lifetime expiry in that window still retires the connection mid-transaction — same as current main, where the timer killed it in that window too.

Verification (local Postgres/MariaDB, debug build)

Case main this PR
max_lifetime=1, pg_sleep(3) in flight query rejected with LIFETIME_TIMEOUT query returns 42; then onclose fires with ERR_POSTGRES_LIFETIME_TIMEOUT; pool reconnects on new pid
max_lifetime=1, idle onclose ERR_POSTGRES_LIFETIME_TIMEOUT unchanged
idle_timeout=1, pg_sleep(3) in flight query rejected with IDLE_TIMEOUT query returns 42; then onclose ERR_POSTGRES_IDLE_TIMEOUT
steady traffic, max_lifetime=2 (Postgres) first in-flight query at 2s killed 799 queries complete; retires at ~2.1s with LIFETIME_TIMEOUT
steady traffic, max_lifetime=2 (MySQL) first in-flight query at 2s killed 851 queries complete; retires at ~2.2s with ERR_MYSQL_LIFETIME_TIMEOUT
MySQL max_lifetime=1, SLEEP(3) in flight rejected with ERR_MYSQL_LIFETIME_TIMEOUT query returns 42; then onclose ERR_MYSQL_LIFETIME_TIMEOUT

Tests

test/js/sql/sql.test.ts / sql-mysql.test.ts (container): the old Max lifetime works / Idle timeout works at start tests asserted the in-flight kill; rewritten to assert the query completes, then onclose fires with the documented ERR_*_LIFETIME_TIMEOUT / ERR_*_IDLE_TIMEOUT code and the pool reconnects (pid / CONNECTION_ID() changes). A parameterized (named statement) in-flight variant covers the prepared-statement path end to end; the head-finished guard itself protects a few-ms prepare window a real server cannot stretch on demand, so it is verified by reading. The in-flight tests fail on main (the awaited query rejects).

Background

  • Bun's SQL pool keeps native connections (Rust) driven by EventLoopTimers: a connection/idle timer and a max-lifetime timer. The idle timer re-arms after each data batch; the lifetime timer arms once at socket open.
  • IS_READY_FOR_QUERY tracks the Postgres ReadyForQuery protocol state; requests is the native FIFO of dispatched/queued queries. has_query_running() = either is active.
  • fail_fmt marks the connection failed, rejects queued requests, runs the JS onclose, and closes the socket; the JS pool then reconnects on demand.

[review] gate passed · iteration 15 · 8 files touched

fails on main (without fix)
ASAN without fix: BUILD FAILED (no junit output)
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/sql/sql-mysql.test.ts test/js/sql/sql.test.ts
error: bindgenv2 emitted unexpected output type: /workspace/bun/build/debug/codegen/GeneratedSocketConfigBinaryType.h, /workspace/bun/build/debug/codegen/GeneratedSocketConfigHandlers.h, /workspace/bun/build/debug/codegen/GeneratedSocketConfig.h, /workspace/bun/build/debug/codegen/GeneratedSocketConfigTLS.h, /workspace/bun/build/debug/codegen/GeneratedALPNProtocols.h, /workspace/bun/build/debug/codegen/GeneratedSSLConfig.h, /workspace/bun/build/debug/codegen/GeneratedSSLConfigFile.h, /workspace/bun/build/debug/codegen/GeneratedSSLConfigSingleFile.h, /workspace/bun/build/debug/codegen/GeneratedFakeTimersConfig.h
error: script "bd" exited with code 1
__F:-1:S:0

release without fix: 1 FAILED
bun test v1.4.0-canary.1 (da3851e57)

test/js/sql/sql.test.ts:
failed to connect to the docker API at unix:///var/run/docker.sock; check if the path is correct and if the daemon is running: dial unix /var/run/docker.sock: connect: no such file or directory
(pass) text-format json[] with a malformed boolean literal returns an error instead of looping [68.01ms]
(pass) rejects Postgres connection options containing null bytes [2.06ms]
(pass) shared createInstance validation (no server) > rejects username containing null bytes [2.71ms]
(pass) shared createInstance validation (no server) > rejects password containing null bytes [0.20ms]
(pass) shared createInstance validation (no server) > rejects database containing null bytes [0.09ms]
(pass) shared createInstance validation (no server) > SSL_CTX creation failure throws the structured BoringSSL error [0.49ms]
(pass) shared createInstance validation (no server) > postgres: rejects tls that is neither a boolean nor an object [0.28ms]
(pass) shared createInstance validation (no server) > mysql: rejects tls that is neither a boolean nor an object [0.34ms]
(pass) shared createInstance validation (no server) > rejects simple 
... (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/mechgate.xml" test/js/sql/sql-mysql.test.ts test/js/sql/sql.test.ts
bun test v1.4.0 (5fcae38f0)

test/js/sql/sql.test.ts:
failed to connect to the docker API at unix:///var/run/docker.sock; check if the path is correct and if the daemon is running: dial unix /var/run/docker.sock: connect: no such file or directory
(pass) text-format json[] with a malformed boolean literal returns an error instead of looping [2396.25ms]
(pass) rejects Postgres connection options containing null bytes [109.56ms]
(pass) shared createInstance validation (no server) > rejects username containing null bytes [161.57ms]
(pass) shared createInstance validation (no server) > rejects password containing null bytes [10.65ms]
(pass) shared createInstance validation (no server) > rejects database containing null bytes [6.55ms]
(pass) shared createInstance validation (no server) > SSL_CTX creation failure throws the structured BoringSSL error [20.25ms]
(pass) shared createInstance validation (no server) > postgres: rejects tls that is neither a boolean nor an object [17.07ms]
(pas
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     5fcae38f0c
  features     baseline

22 deps, 107 codegen, 1176 objects in 1781ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1238] gen ErrorCode+*.h
[2/1238] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[3/1238] fetch tinycc
[tinycc] up to date
[4/1237] gen .bind.ts → GeneratedBindings.cpp
[5/1237] fetch zlib
[zlib] up to date
[6/1237] gen bindgenv2
[7/1237] gen JSBuffer.lut.h
Generating /workspace/bun/build/release/codegen/JSBuffer.lut.h from /workspace/bun/src/jsc/bindings/JSBuffer.cpp
[8/1237] subst deps/zlib/zlib.h
[9/1237] install /workspace/bun
bun install v1.4.0-canary.1 (da3851e57)

Checked 107 installs across 153 packages (no changes) [224.00ms]
[10/1237] subst deps/zlib/zconf.h
[11/1237] gen ProcessBindingConstants.lut.h
Generating /workspace/bun/build/release/codegen/ProcessBindingConstants.lut.h from /workspace/bun/src/jsc/bindings/ProcessBindingConstants.cpp
[12/1237] install /workspace/bun/packages/bun-error
bun install v1.4.0-canary.1 (da3851e57)

Che
... (truncated)
diff hotspot
src/sql/shared/ConnectionFlags.rs             |   2 +
 src/sql_jsc/mysql/JSMySQLConnection.rs        |  24 +++++
 src/sql_jsc/mysql/MySQLConnection.rs          |  10 ++
 src/sql_jsc/mysql/MySQLRequestQueue.rs        |  11 ++
 src/sql_jsc/postgres/PostgresSQLConnection.rs |  35 +++++++
 src/sql_jsc/postgres/PostgresSQLQuery.rs      |   4 +-
 test/js/sql/sql-mysql.test.ts                 |  83 ++++++++++++---
 test/js/sql/sql.test.ts                       | 142 +++++++++++++++++++-------
 8 files changed, 257 insertions(+), 54 deletions(-)

gate history · 2 passed · 0 rejected · iteration 15

evidence per changed file
file                                           reads  edits  tests
src/sql/shared/ConnectionFlags.rs                  1      1     12
src/sql_jsc/mysql/JSMySQLConnection.rs             4     10     13
src/sql_jsc/mysql/MySQLConnection.rs               1      1     12
src/sql_jsc/mysql/MySQLRequestQueue.rs             3      4     12
src/sql_jsc/postgres/PostgresSQLConnection.rs     18     15     13
src/sql_jsc/postgres/PostgresSQLQuery.rs           2      1     12
test/js/sql/sql-mysql.test.ts                     10     12      5
test/js/sql/sql.test.ts                           17     17     10

root cause · written by the author bot

The root cause was that the idleTimeout and maxLifetime timers fired unconditionally, immediately failing the connection and rejecting any queries that happened to be in flight, which surfaced as spurious ERR_POSTGRES_IDLE_TIMEOUT and ERR_POSTGRES_LIFETIME_TIMEOUT errors under normal traffic. The fix makes both timers idle-aware in the Postgres and MySQL adapters: when a timer fires on a connection with in-flight queries, it reschedules itself and retries until the connection returns to idle, and the Postgres idle timer is additionally disarmed while requests are pending. As a result, conne…

@coderabbitai

coderabbitai Bot commented May 13, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 1 minute

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 55ab9b13-58ab-4b02-9baa-7d88de210081

📥 Commits

Reviewing files that changed from the base of the PR and between c04101b and d76bf17.

📒 Files selected for processing (8)
  • src/sql/shared/ConnectionFlags.rs
  • src/sql_jsc/mysql/JSMySQLConnection.rs
  • src/sql_jsc/mysql/MySQLConnection.rs
  • src/sql_jsc/mysql/MySQLRequestQueue.rs
  • src/sql_jsc/postgres/PostgresSQLConnection.rs
  • src/sql_jsc/postgres/PostgresSQLQuery.rs
  • test/js/sql/sql-mysql.test.ts
  • test/js/sql/sql.test.ts

Walkthrough

MySQL and PostgreSQL connection handlers now reschedule idle and max-lifetime timeout timers when in-flight queries are present, allowing queries to complete before connection closure. Regression tests across both databases verify idle-aware behavior closes idle connections while preserving in-flight query execution.

Changes

Idle-aware connection timeout and max-lifetime closure

Layer / File(s) Summary
MySQL idle-aware max-lifetime retirement
src/sql_jsc/mysql/JSMySQLConnection.zig
onMaxLifetimeTimeout now checks connection idle state before retiring; if not idle, reschedules the timer for 1000ms retry instead of immediately failing the connection.
PostgreSQL idle-aware idle and max-lifetime timeout handling
src/sql_jsc/postgres/PostgresSQLConnection.zig
getTimeoutInterval() returns 0 when connection has readable requests or is not ready-for-query (disarming idle timer), otherwise returns idle_timeout_interval_ms. Both onConnectionTimeout() and onMaxLifetimeTimeout() now reschedule instead of failing when connections have in-flight queries.
MySQL max-lifetime and in-flight query regression tests
test/js/sql/sql-mysql.test.ts
Tests verify max-lifetime closes idle pooled connections and triggers reconnection, and confirm in-flight SLEEP queries complete without lifetime-induced termination.
PostgreSQL mock-server regression tests for timeout draining
test/js/sql/sql-timer-drain.test.ts
New test file with Postgres protocol mock server and delayed response handling. Tests verify idleTimeout and maxLifetime do not abort in-flight queries and maxLifetime closes idle connections after they return.
PostgreSQL integration tests for idle and lifetime behavior
test/js/sql/sql.test.ts
Integration tests verify idle timeout fires only during truly-idle states (not during in-flight queries), max-lifetime closes idle connections and triggers reconnection, and both timeouts allow in-flight queries to complete before closing, with pool reconnection validated via backend PID changes.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The PR fully implements the core coding objectives from issue #30646: preventing in-flight queries from being killed and draining them before closing on both Postgres and MySQL adapters.
Out of Scope Changes check ✅ Passed All changes (timer logic in both adapters and comprehensive test rewrites/additions) are directly scoped to fixing the in-flight query draining issue and preventing query failure on timer fire.
Title check ✅ Passed The title clearly and concisely summarizes the main change: preventing idleTimeout and maxLifetime from terminating in-flight SQL queries.
Description check ✅ Passed The description explains the problem, implementation, scope, verification results, and tests, despite using different section headings from the template.

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

@robobun

robobun commented May 13, 2026

Copy link
Copy Markdown
Collaborator Author

@github-actions

Copy link
Copy Markdown
Contributor

Found 2 issues this PR may fix:

  1. Postgres send error randomly when run heavy integration tests #24326 - Postgres idle timeout fires during heavy integration tests, killing active queries with "Idle timeout reached after 30s"
  2. Bun 1.3.9 MySQL (Bun.SQL) keeps dropping/ending during a real read+write workload #27102 - MySQL connections drop during real read+write workloads, likely caused by max_lifetime timeout firing on busy connections

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #24326
Fixes #27102

🤖 Generated with Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. fix(sql): gracefully retire connections on maxLifetime instead of killing in-flight queries #28591 - Also fixes graceful retirement of connections on maxLifetime instead of killing in-flight queries, touching the same files (PostgresSQLConnection.zig, JSMySQLConnection.zig) and referencing the same issue (Mysql Client maxLifetime Throws Error #25405)

🤖 Generated with Claude Code

Comment thread src/sql_jsc/postgres/PostgresSQLConnection.zig Outdated

@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: 2

🤖 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-timer-drain.test.ts`:
- Around line 104-116: The test schedules a response on every "data" chunk which
can cause duplicate responses when a query is TCP-chunked; update the
socket.on("data") handler (referencing socket.on("data"), gotStartup, HANDSHAKE,
QUERY_RESPONSE, queryDelayMs, timers) to accumulate incoming bytes into a buffer
and only schedule/send a mock reply once a full query boundary is detected (e.g.
parse until the protocol's message delimiter or length-prefixed frame is
complete), deduplicate by clearing the buffer or marking the query as handled,
remove/avoid the flaky setTimeout-based delay (and timers collection) and
instead send the reply immediately when the full request is received and have
the test await the expected condition rather than sleeping.

In `@test/js/sql/sql.test.ts`:
- Around line 802-829: The test "Max lifetime does not kill an in-flight query
(`#30646`)" and the similar block at 831-859 are flaky because they rely on an
unbounded wait for a 3s sleep; wrap the awaited operations with explicit
timeouts instead of using setTimeout so the test fails fast when the condition
isn't met. Concretely, when awaiting the long-running query (sql`select
pg_sleep(3), 42 as x`) and when awaiting onClosePromise.promise, use a helper
timeout pattern (e.g. Promise.race between the target promise and a short
timeout-rejecting promise) so the test will reject with a clear timeout if the
condition isn't met; apply the same pattern to the other test block referenced
(lines 831-859) and keep the unique symbols: onClosePromise, onclose, onconnect,
and the sql query calls to locate where to wrap the awaits.
🪄 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: a9a64679-c967-42d8-bed2-87cb6ca70ea2

📥 Commits

Reviewing files that changed from the base of the PR and between b8ecc78 and 5e12daa.

📒 Files selected for processing (5)
  • src/sql_jsc/mysql/JSMySQLConnection.zig
  • src/sql_jsc/postgres/PostgresSQLConnection.zig
  • test/js/sql/sql-mysql.test.ts
  • test/js/sql/sql-timer-drain.test.ts
  • test/js/sql/sql.test.ts

Comment thread test/js/sql/sql-timer-drain.test.ts Outdated
Comment thread test/js/sql/sql.test.ts Outdated
Comment thread test/js/sql/sql-timer-drain.test.ts Outdated

@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.test.ts`:
- Around line 744-748: After each awaited long-running query (e.g. the
sql`select pg_sleep(2)` call) add a negative assertion that the connection close
handler has not been invoked yet (assert the mocked onclose callback has not
been called) before awaiting onClosePromise.promise; specifically, insert
expect(onclose).not.toHaveBeenCalled() (or the actual mock name used for the
close callback) immediately after the pg_sleep result to ensure the timer didn't
trigger onclose while the query was still running; repeat the same insertion for
the other similar blocks referenced (around the other pg_sleep assertions).
🪄 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: 3a547255-69f3-4810-ac21-ee417e93f864

📥 Commits

Reviewing files that changed from the base of the PR and between 5e12daa and 7ddba25.

📒 Files selected for processing (3)
  • test/js/sql/sql-mysql.test.ts
  • test/js/sql/sql-timer-drain.test.ts
  • test/js/sql/sql.test.ts

Comment thread test/js/sql/sql.test.ts Outdated
Comment thread src/sql_jsc/postgres/PostgresSQLConnection.zig Outdated
@robobun

robobun commented May 14, 2026

Copy link
Copy Markdown
Collaborator Author

Local verification:

  • Debug (ASAN) build: all 3 regression tests pass.
  • Release build (fresh): all 3 regression tests pass.
  • Fail-before verified: git checkout main -- src/ + rebuild + test → 2/3 fail with ERR_POSTGRES_IDLE_TIMEOUT / ERR_POSTGRES_LIFETIME_TIMEOUT as expected.

CI red lanes are on shards my diff doesn't touch:

  • Build 54153 failed on musl build-bun (3 shards) and Windows 2019 HTTP test timeout — pre-existing CI flake, unrelated to the SQL timer change.
  • Build 54160 has 1 of 20 ASAN shards failing (exit 2), while the other 19 pass — again, looks like unrelated flake rather than a regression from this diff.

Used my one ci-retrigger (79c0a6d). Diff itself is green. Needs a maintainer to merge.

@robobun

robobun commented May 14, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto main (post-Rust-rewrite) and ported to:

  • src/sql_jsc/postgres/PostgresSQLConnection.rs: get_timeout_interval returns 0 when requests are outstanding or IS_READY_FOR_QUERY is clear; on_connection_timeout reschedules on the same condition; on_max_lifetime_timeout calls disconnect() only when idle, otherwise re-arms for 1s
  • src/sql_jsc/mysql/JSMySQLConnection.rs: on_max_lifetime_timeout calls close() only when idle, otherwise re-arms for 1s

Gate verified with the mock-server test (no Docker needed): without the .rs change, both in-flight tests reject with ERR_POSTGRES_IDLE_TIMEOUT / ERR_POSTGRES_LIFETIME_TIMEOUT; with it, 3/3 pass. .zig kept as reference.

@robobun
robobun force-pushed the farm/602f60f3/sql-drain-before-timer-close branch from 79c0a6d to bbbd7fb Compare May 14, 2026 17:07
@robobun

robobun commented May 14, 2026

Copy link
Copy Markdown
Collaborator Author

CI failure on build 54337 is pre-existing, not caused by this PR.

The failing test is test/js/web/fetch/fetch-tcp-keepalive.test.ts (2 fail / 2 pass) — a Linux-only test added in #30627 that reads /proc/self/net/tcp to verify SO_KEEPALIVE on fetch sockets. The kernel's timer_active field reads "00" (no timer) instead of the expected "02" (keepalive timer).

Reproduced on main directly: git checkout 11a2e2c20b && bun bd test test/js/web/fetch/fetch-tcp-keepalive.test.ts → same 2/4 failure. This is an existing bug on main.

Main's CI jobs are build-only (build-bun/build-cpp/build-rust), so the test failure didn't surface when #30627 landed. PRs run the full test matrix which is how we're seeing it now.

This PR's SQL tests pass locally (bun bd): 3/3 in test/js/sql/sql-timer-drain.test.ts. My diff only touches src/sql_jsc/** and test/js/sql/** — it cannot affect fetch-tcp-keepalive.test.ts.

Already used my one ci: retrigger (49b1603). Not pushing another — the red lane is unrelated to this diff and needs a maintainer's attention for merge.

@robobun

robobun commented Jun 27, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto current main and force-pushed (e73248a).

The conflicts were non-trivial because main completed the Zig→Rust transition and deleted the .zig SQL files that my original commits edited. Resolved by:

  • Dropping the obsolete .zig edits (those files no longer exist on main).
  • Re-applying the same logical fix to main's current .rs files: src/sql_jsc/postgres/PostgresSQLConnection.rs (get_timeout_interval returns 0 when requests are outstanding or IS_READY_FOR_QUERY is clear; on_connection_timeout reschedules on the same condition; on_max_lifetime_timeout disconnect()s only when idle, else re-arms 1s) and src/sql_jsc/mysql/JSMySQLConnection.rs (on_max_lifetime_timeout close()s only when idle, else re-arms 1s).
  • 3-way merging the test changes in sql.test.ts / sql-mysql.test.ts; sql-timer-drain.test.ts carried over unchanged.

Collapsed the old 7-commit history (which included the now-obsolete .zig work and a ci-retrigger) into one commit.

Verified locally: fail-before bun bd test test/js/sql/sql-timer-drain.test.ts → 2/3 fail with ERR_POSTGRES_LIFETIME_TIMEOUT / ERR_POSTGRES_IDLE_TIMEOUT; pass-after → 3/3 pass.

@robobun

robobun commented Jun 27, 2026

Copy link
Copy Markdown
Collaborator Author

CI build 65506: 47/48 jobs green. The one red lane (darwin 26 aarch64 - test-bun) is a BuildKite infra flake, not a test failure:

Error: buildkite-agent artifact download timed out after 120s for step 'darwin-aarch64-build-bun'.
Refusing to continue with a partial download (would silently fall back to the wrong binary).

The runner couldn't download the build artifact, so the test suite never started — unrelated to this diff. Every lane that actually ran tests passed. A re-run of that single job (or a merge) should clear it; I've already spent my one ci-retrigger on this PR so I'm not pushing another.

Comment thread test/js/sql/sql-timer-drain.test.ts Outdated
Comment thread test/js/sql/sql-timer-drain.test.ts Outdated
@robobun

robobun commented Jun 27, 2026

Copy link
Copy Markdown
Collaborator Author

Same darwin-lane infra flake recurred on build 65524 (45/46 jobs green). darwin aarch64 - build-bun passed (exit 0, artifact produced), but darwin 26 aarch64 - test-bun failed downloading that artifact:

Error: buildkite-agent artifact download timed out after 120s for step 'darwin-aarch64-build-bun'.

The suite never ran — it's a BuildKite artifact-transfer timeout to the darwin test agent, not a test failure, and independent of this diff (which only touches src/sql_jsc/** + test/js/sql/**). It's now hit twice (65506, 65524), so it looks like a persistent darwin-aarch64 artifact-download issue rather than one-off flake. Every lane that actually ran tests is green. I've spent my ci-retrigger, so this needs a maintainer to re-run the darwin job (or merge).

@robobun
robobun force-pushed the farm/602f60f3/sql-drain-before-timer-close branch from 103bcad to adad059 Compare July 8, 2026 03:25
@robobun

robobun commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto current main and force-pushed (adad059).

The conflict was in test/js/sql/sql.test.ts: main independently retuned the old Idle timeout works at start / Max lifetime works tests (faster idle_timeout/max_lifetime values) while still asserting the pre-fix behavior (the in-flight query is killed with ERR_POSTGRES_IDLE_TIMEOUT / ERR_POSTGRES_LIFETIME_TIMEOUT). Since this PR changes exactly that behavior, I replaced those two tests with the drained-behavior versions (query completes, connection retires once idle, pool reconnects on a fresh backend pid) and kept main's untouched Idle timeout is reset when a query is run test as-is.

The .rs changes merged cleanly — main's edits to PostgresSQLConnection.rs (prepared-statement map, set_status Failed guard, skip_message) and JSMySQLConnection.rs (statement lookup rename) are in different regions from the timer functions this PR touches.

Verified locally: bun bd test test/js/sql/sql-timer-drain.test.ts → 3/3 pass on the rebased tree.

@robobun

robobun commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator Author

CI build 70304: 48/49 jobs green. The one red lane (windows 2019 x64 - test-bun) fails on four fault-injection SQL tests — postgres-binary-array-bounds, postgres-binary-numeric, postgres-invalid-message-length, sql-mysql.transactions — all with PostgresError: Failed to connect.

These tests connect to a local mock TCP server (listeningServer from wire-frames.ts); the connect fails on the Windows runner. All four files are byte-identical to main (this PR doesn't touch them) and the failure is at the pre-connection stage, so it can't come from this diff — which only changes post-connection idle/maxLifetime timer logic. Looks like a pre-existing Windows mock-server connect issue for the SQL fault-injection suite.

This PR's own regression test (sql-timer-drain.test.ts) is in a different shard that passed. ci-retrigger already spent, so this needs a maintainer to re-run the Windows job (or merge).

Comment thread src/sql_jsc/postgres/PostgresSQLConnection.rs Outdated
Comment thread src/sql_jsc/postgres/PostgresSQLConnection.rs 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.

All prior feedback is addressed and no new issues found, but this changes native connection-lifecycle/refcount paths and user-facing timeout semantics (existing tests rewritten, ERR_*_LIFETIME_TIMEOUT no longer emitted, 1s-poll retirement trade-off deferred as a follow-up), so it warrants a maintainer sign-off.

Extended reasoning...

Overview

This PR changes on_max_lifetime_timeout / get_timeout_interval in both src/sql_jsc/postgres/PostgresSQLConnection.rs and src/sql_jsc/mysql/JSMySQLConnection.rs so that idle/maxLifetime timers no longer kill in-flight queries — they reschedule (1s poll) until the connection is idle, then close gracefully via disconnect()/close(). It rewrites the existing "Max lifetime works" / "Idle timeout works at start" tests in sql.test.ts and sql-mysql.test.ts (which previously asserted the old kill-on-timer behavior) and adds a Docker-free mock-server regression test (sql-timer-drain.test.ts).

Security risks

None identified. No auth, crypto, or untrusted-input parsing is touched; the change is client-side timer scheduling and connection teardown ordering.

Level of scrutiny

This deserves maintainer review rather than bot approval:

  • Native lifecycle / memory safety: the Postgres path now calls disconnect() from an unrooted timer callback and brackets it with self.ref_()Self::deref(self.as_ctx_ptr()) to survive synchronous on_close → JS onclose re-entry. The reasoning is sound and mirrors fail_with_js_value, but refcount discipline around GC-eligible wrappers is exactly the class of change CLAUDE.md flags as most-blocked.
  • User-facing behavior change: ERR_POSTGRES_LIFETIME_TIMEOUT / ERR_MYSQL_LIFETIME_TIMEOUT are no longer emitted (graceful close instead), and existing tests that asserted those codes were deleted/rewritten. That's the right outcome for the bug, but it's an observable API change a maintainer should ratify.
  • Design trade-off explicitly deferred: the author acknowledged that the 1s-poll approach can, in theory, delay retirement of a continuously-busy connection indefinitely, and chose it over a deterministic retire-at-next-RFQ flag (which would need JS-pool coordination). That's a reasonable call matching #28591's shape, but it's a design decision worth a human nod.

Other factors

The PR has been through several review rounds; every inline comment (mine and CodeRabbit's) is resolved in the current head (e20031a), including the has_query_running() helper reuse, the ref-guard around disconnect(), await using pool disposal, and the wire-frames.ts migration. The bug-hunting pass found nothing new. Test coverage is solid (fail-before/pass-after verified against a mock server plus real-DB integration tests). CI has been green on all lanes that actually ran; remaining red lanes were unrelated infra/artifact-download flakes.

@robobun

robobun commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator Author

I independently arrived at a fix for the same maxLifetime in-flight kill and pushed it as claude/farm-8a426cfa/sql-max-lifetime-in-flight before finding this PR. Leaving it here in case any of it is useful; not opening a competing PR.

Difference in approach: instead of re-arming the lifetime timer on a 1s poll, that branch sets a MAX_LIFETIME_EXCEEDED flag when the timer fires while busy, and on_data retires the connection at the next idle point (just before the microtask drain, so the next awaited query is routed to a fresh backend). The timer only ever fires once per connection.

It also keeps the retirement going through fail_fmt so onclose still receives ERR_POSTGRES_LIFETIME_TIMEOUT / ERR_MYSQL_LIFETIME_TIMEOUT, which the existing "Max lifetime works" tests assert on.

Verified against Postgres 17 and MariaDB: pg_sleep(1.5) / SLEEP(1.5) with maxLifetime: 1 completes, the connection is retired immediately after, and the next query lands on a new pg_backend_pid() / CONNECTION_ID(). A back-to-back await loop also rotates connections at the boundary.

@alii alii left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Requesting changes. The idle half (the has_query_running gate) is the right fix. The max_lifetime half should be the same busy gate in front of the existing fail_fmt, not a switch to disconnect()/close().

  • onclose now gets ERR_CONNECTION_CLOSED instead of ERR_LIFETIME_TIMEOUT in both drivers, the body's table says this row is unchanged, and the rewritten tests dropped the one assertion that would have caught it
  • the 1s re-poll never retires a connection that has steady traffic
  • the new gate disarms the idle timer on enqueue and nothing re-arms it when the request fails synchronously
  • sql-timer-drain.test.ts should go, the container tests already cover it
    Also drop #25405 from the get_timeout_interval comment and the same-root-cause line in the body: MySQL's idle gate has been there since 1.2.22 and this PR does not touch the MySQL idle path, so whatever that issue is, this does not address it.

Comment thread src/sql_jsc/postgres/PostgresSQLConnection.rs Outdated
Comment thread src/sql_jsc/mysql/JSMySQLConnection.rs Outdated
Comment thread src/sql_jsc/postgres/PostgresSQLConnection.rs Outdated
Comment thread src/sql_jsc/postgres/PostgresSQLConnection.rs
Comment thread test/js/sql/sql.test.ts Outdated
Comment thread test/js/sql/sql-timer-drain.test.ts Outdated
Comment thread src/sql/shared/ConnectionFlags.rs Outdated
@robobun
robobun force-pushed the farm/602f60f3/sql-drain-before-timer-close branch from 1e668d4 to 7d6b4bf Compare August 13, 2026 03:22
Comment thread src/sql_jsc/mysql/JSMySQLConnection.rs Outdated
Comment thread src/sql_jsc/mysql/JSMySQLConnection.rs Outdated
Comment thread src/sql_jsc/mysql/MySQLRequestQueue.rs Outdated
Comment thread src/sql_jsc/postgres/PostgresSQLConnection.rs Outdated
Comment thread src/sql_jsc/postgres/PostgresSQLConnection.rs
@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

@alii round 2 addressed, rebased onto current main (c8be23f):

  • MySQL: the 1s re-poll is gone. on_max_lifetime_timeout sets the shared LIFETIME_EXCEEDED flag while busy; MySQLRequestQueue::advance() retires through the original fail_fmt(ERR_MYSQL_LIFETIME_TIMEOUT) once pipelined_requests and nonpipelinable_requests are both 0, before dispatching the next request. Steady-traffic probe: 851 back-to-back queries with max_lifetime: 2 retire at ~2.2s, none killed. The hunk no longer touches timer insertion, so the ForceRealTime/addr_of shape question no longer applies to it.
  • Postgres: the ReadyForQuery retire is now gated on the head request being finished (Success/Fail, or empty queue), so the ReadyForQuery answering a named statement's Parse+Describe+Sync falls through to advance() and the following one retires. Added an in-flight parameterized-query test for that shape.
  • Body updated: pipelined-behind-finished-head (and prepare-in-flight) rejection at retirement is noted next to the begin()/reserve() scope note, plus the MySQL steady-traffic row.

Verified locally against Postgres and MariaDB: the lifetime/idle suites in sql.test.ts and sql-mysql.test.ts pass on the debug build.

Comment thread src/sql_jsc/mysql/MySQLRequestQueue.rs Outdated
Comment thread src/sql_jsc/mysql/MySQLRequestQueue.rs
Comment thread src/sql_jsc/mysql/MySQLRequestQueue.rs
Comment thread test/js/sql/sql.test.ts Outdated
@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

CI on 5fcae38 is red only on lanes unrelated to this diff (a bake deinit segfault on Windows 2019, asan leak-test timeouts, and tests the harness itself marks flaky/passed-alone); both sql suites pass. Diff is ready for review.

@alii alii left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Both remaining points are addressed on 5fcae38: the MySQL re-poll is gone and the connection retires from MySQLRequestQueue::advance() once both counters are 0 and the head is finished, and the postgres ReadyForQuery retire is gated on the head being Success/Fail so the Parse+Describe+Sync round trip of a named statement falls through to advance(); the body now carries the pipelined-behind-finished-head note. Read the follow-up commits and the surrounding queue and event-loop code (retire runs inside the on_data enter scope, so the microtask that hands the connection its next query runs after it is already closed), tests not run here; nothing left from this side, looks ready to merge.

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks for the careful reads. Correct on the ordering: the retire runs inside the on_data ref scope, so onclose and the queue rejection happen before the JS pool's release microtask can hand this connection another query; the pool sees a closed connection and reconnects. Nothing further from my side.

alii
alii previously approved these changes Aug 14, 2026
@alii

alii commented Aug 14, 2026

Copy link
Copy Markdown
Member

@robobun rebase fix conflicts

robobun and others added 9 commits August 14, 2026 22:46
Postgres and MySQL rejected any in-flight query when idleTimeout or
maxLifetime fired, because onConnectionTimeout/onMaxLifetimeTimeout failed
the connection unconditionally. The query itself was healthy; only a
client-side timer raced it.

Postgres (src/sql_jsc/postgres/PostgresSQLConnection.rs):
  - get_timeout_interval returns 0 when requests are queued or
    IS_READY_FOR_QUERY is clear, so the idle timer never arms while a
    query is outstanding (mirrors MySQL's is_idle gate).
  - on_connection_timeout reschedules on the .connected branch if a
    request slipped in between arming and firing.
  - on_max_lifetime_timeout disconnect()s when idle, otherwise reschedules
    for 1s and retries until the connection returns to idle.

MySQL (src/sql_jsc/mysql/JSMySQLConnection.rs):
  - on_max_lifetime_timeout close()s when idle, otherwise reschedules 1s.

Tests: rewrite the idle/maxLifetime tests to the drained behavior (query
completes, then the connection retires and the pool reconnects), add a
Docker-free mock-server regression in sql-timer-drain.test.ts.

Fixes #30646. Related: #25405 (MySQL idle).
…_query_running()

- on_max_lifetime_timeout: hold an intrinsic ref across disconnect(), whose
  socket.close() can synchronously run the JS onclose callback and make the
  wrapper GC-eligible before ref_and_close's clean_up_requests touches self.
  Mirrors fail_with_js_value's ref/deref discipline (the pre-rewrite path
  took this ref; the MySQL side already guards via ref_guard()).
- Replace the inlined busy predicate with the existing has_query_running()
  helper in get_timeout_interval and on_max_lifetime_timeout.
- Drop the dead idle guard in on_connection_timeout: get_timeout_interval()
  already returns 0 for a busy .connected connection, so the early return
  above covers it.
…n boundary

Address review feedback:
- Postgres: when max_lifetime fires with a query in flight, set a
  LIFETIME_EXCEEDED flag instead of polling; the ReadyForQuery arm acts on
  it before advance() dispatches more work, so max_lifetime stays a hard
  bound under steady traffic and onclose still reports
  ERR_POSTGRES_LIFETIME_TIMEOUT (no disconnect()/CONNECTION_CLOSED, no
  TLS close_notify window, no ref_/deref bracket needed).
- MySQL: reschedule 1s when busy, otherwise fall through to the original
  fail_fmt(LifetimeTimeout), so ERR_MYSQL_LIFETIME_TIMEOUT is preserved.
- Postgres do_run: move reset_connection_timeout after advance_and_flush
  so a synchronously-discarded request can't leave an idle connection with
  no timer armed.
- Tests: drop the mock-server sql-timer-drain.test.ts (container tests
  cover the scenarios); restore ERR_*_LIFETIME_TIMEOUT assertions in the
  idle and in-flight container tests.
- Drop the stale #25405 reference.
…ndary; gate Postgres retirement on the head request finishing
@robobun
robobun force-pushed the farm/602f60f3/sql-drain-before-timer-close branch from 5fcae38 to 8730e98 Compare August 14, 2026 22:51
@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto main at 8730e98. One conflict: main added KEEP_ALIVE_REQUESTED at bit 5 in ConnectionFlags, so LIFETIME_EXCEEDED moved to bit 6. Built and re-ran the lifetime/idle suites for both drivers locally, all green; PR is mergeable again.

The container suites are skipped entirely where docker and the test
services are unavailable, leaving #30646 unprovable there. The wire mock
delays the query response past the client-side timer; on main both tests
reject with ERR_POSTGRES_IDLE_TIMEOUT / ERR_POSTGRES_LIFETIME_TIMEOUT.
@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Added serverless twins of the two in-flight timer tests at the bottom of sql.test.ts (d76bf17). The container suites are skipped entirely in environments without docker, which left the regression unprovable there; the mock only delays the query response past the client timer and carries a header pointing at the real-server twins. Verified: with src/ reverted to main both reject with the timeout codes at ~1.5s, with the fix both pass.

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 Postgres: idleTimeout and maxLifetime kill in-flight queries instead of draining

2 participants