Skip to content

mysql: route query writes through advance() so wire order matches queue order - #32008

Open
robobun wants to merge 5 commits into
mainfrom
farm/7a0da7b3/mysql-queue-write-order
Open

mysql: route query writes through advance() so wire order matches queue order#32008
robobun wants to merge 5 commits into
mainfrom
farm/7a0da7b3/mysql-queue-write-order

Conversation

@robobun

@robobun robobun commented Jun 9, 2026

Copy link
Copy Markdown
Collaborator

Fixes #32005. MySQL twin of the postgres native-queue fix in #32006 (no file overlap with that PR; the pool-level change there already covers the MySQL adapter).

Problem

The MySQL request queue matches server responses to requests strictly in FIFO queue order (handle_command reads queue.current()), so query packets must reach the wire in that same order. JSMySQLQuery::do_run broke that invariant structurally: it called this.run(connection), which writes COM_QUERY / Bind+Execute / COM_STMT_PREPARE optimistically, before enqueue_request. A write from the enqueue path can jump ahead of an earlier queued-but-unwritten request, and the bypassed request then consumes the new query's response packets, permanently desyncing the connection. The gates (can_pipeline etc.) make that window hard to hit from public APIs today, but nothing enforces the ordering.

The optimistic path also had a concrete user-visible bug, reproducible on main: when run() failed for a query that was already queued, the promise never settled. run()'s error guard pre-marked the query as failed, so on_error -> reject_with_js_value hit the settle-once fail() gate, concluded the query was already settled, and silently dropped the rejection:

// mock server answers COM_STMT_PREPARE with an ERROR packet
const q1 = sql`wat ${1}`;
const q2 = sql`wat ${1}`; // same signature, queued while q1's prepare is in flight
q1.execute(); q2.execute();
await q1.catch(() => {}); // rejects with the server error
await q2;                 // hangs forever on main

Fix (src/sql_jsc/mysql/JSMySQLQuery.rs)

  • do_run no longer calls run(); it upgrades the wrapper's ref (so the cached target/binding/columns survive GC while queued, as run() used to do) and enqueues. enqueue_request already registers the auto-flusher, whose drain_internal -> flush_queue -> advance() performs the write; on a busy connection the response handlers advance the queue as before. advance() walks the queue FIFO and already enforces every ordering barrier, so wire order now matches queue order by construction.
  • run() no longer marks the query failed on error. Its only caller is now advance(), which routes every failure to on_error -> reject; the reject path owns the Fail transition and the ref downgrade, and its settle-once gate stays open so the rejection is actually delivered.

Wire behavior is otherwise unchanged: the old do_run write only reached the socket at the auto-flusher anyway, and MySQL's gates never allowed writing behind an in-flight request (can_pipeline requires is_ready_for_query), so routing through advance() removes no pipelining.

Tests

test/js/sql/sql-mysql-queue-write-order.test.ts (mock MySQL server, no Docker):

  • queued-behind-failing-prepare: both promises must reject with the server error, only one COM_STMT_PREPARE may reach the wire, and the connection stays usable. Fails on the unfixed build (q2 times out, never settles), passes with the fix.
  • wire-order: queries issued in one tick reach the server in issuance order and all settle.

Other verification on the fixed debug (ASAN) build:

  • all existing mock-based MySQL suites pass (sql-mysql-cached-error, clean-reentry, raw-length-prefix, datetime-roundtrip, columns-realloc-oom, auth-short-nonce, tls-plaintext-injection)
  • against a real MariaDB 11.8: sql-mysql-bind-oob (its bind failure now fires on the advance() path and still rejects with ERR_MYSQL_WRONG_NUMBER_OF_PARAMETERS_PROVIDED), sql-mysql-bind-blob-borrow, plus ordering/stress scripts: 1200 mixed ops (transactions + concurrent pooled simple/prepared queries, pool max 2) and a 3000-query continuation storm, 0 mismatches, and BUN_JSC_validateExceptionChecks=1 clean on the new tests.

…ue order

JSMySQLQuery::do_run wrote a query's packets optimistically before
enqueueing it, while responses are matched to requests in FIFO queue
order. A write from the enqueue path could jump ahead of an earlier
queued-but-unwritten request, making that request consume the new
query's response packets and desyncing the connection. do_run now only
enqueues; the queue's advance() walk (pumped by the auto-flusher and
the response handlers) performs every write in queue order, same as the
postgres fix in #32006.

This also fixes a hang: when run() failed for an already-queued query
(e.g. queued behind an in-flight COM_STMT_PREPARE of the same statement
whose prepare then failed), run()'s error guard pre-marked the query as
failed, so reject_with_js_value's settle-once gate dropped the
rejection and the promise never settled. run() no longer marks the
query failed; the reject path owns that transition.

Fixes #32005
@robobun

robobun commented Jun 9, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 3:05 AM PT - Jun 10th, 2026

@robobun, your commit 3d07e55 has 1 failures in Build #61701 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 32008

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

bun-32008 --bun

@github-actions

github-actions Bot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Found 3 issues this PR may fix:

  1. [Bug] MySQL pool size > 1 causes Bun to hang during sql execution #26235 - MySQL pool size > 1 causes hangs during sql execution; concurrent pool connections could get request/response pairs crossed due to wire-order desync
  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; wire-order desync causes protocol violations leading the server to close the connection
  3. MySQL transaction is hanging/freezing on Windows. #25552 - MySQL transactions hang/freeze on Windows; successive queries within/across transactions get desynced, causing the connection to wait for a response consumed by the wrong request

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

Fixes #26235
Fixes #27102
Fixes #25552

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Jun 9, 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: 6e51ee27-b3c2-440d-b981-0bb864305230

📥 Commits

Reviewing files that changed from the base of the PR and between 69ca8ac and e14fba9.

📒 Files selected for processing (1)
  • test/js/sql/sql-mysql-queue-write-order.test.ts

Walkthrough

Enqueue MySQL requests before executing them and delegate run() error settlement to the caller so all writes/response matching go through the FIFO advance path. Adds a mock MySQL server and three regression tests validating prepare-failure propagation and FIFO write/drain ordering.

Changes

MySQL native request queue ordering

Layer / File(s) Summary
Query enqueue and wrapper lifecycle management
src/sql_jsc/mysql/JSMySQLQuery.rs
do_run enqueues the request via connection.enqueue_request() before execution and conditionally upgrades the JS wrapper to keep it alive while pending; comments require all query/buffer writes to occur only through advance() to preserve FIFO matching.
Error handling delegation in run()
src/sql_jsc/mysql/JSMySQLQuery.rs
run()'s failure path no longer calls fail()/rollback on run_query error; it returns JSError so the caller's on_error → reject flow performs settlement with the proper fail gate state.
Queue ordering regression tests
test/js/sql/sql-mysql-queue-write-order.test.ts
New test file implementing a minimal MySQL protocol mock server and three tests: prepare-failure propagation for queued identical queries, same-tick FIFO ordering for parameterless queries, and parked-prepare/drain ordering with a held prepare response.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically describes the main structural change: routing MySQL query writes through advance() to ensure wire order matches queue order, which directly addresses the issue of requests bypassing each other.
Description check ✅ Passed The PR description is comprehensive and well-structured. It includes the problem statement, the concrete bug manifestation, the fix with code explanations, and testing approach across multiple verification strategies.
Linked Issues check ✅ Passed The PR fully addresses the objectives from #32005: it removes the immediate-write fast path from do_run, routes all writes through advance() to enforce FIFO queue ordering, provides deterministic test coverage with a mock MySQL server, and verifies no desynchronization occurs.
Out of Scope Changes check ✅ Passed All changes are scoped to the stated objectives: JSMySQLQuery.rs modifications route writes through advance(), the new test file provides required FIFO ordering verification with mock server, and no unrelated changes are present.

✏️ 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.

@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-mysql-queue-write-order.test.ts`:
- Around line 167-183: Change the test to create a real queue barrier before
issuing the simple-protocol queries: start by issuing a prepared/parameterized
request that will be held in-flight by the mockServer (e.g. call
sql.prepare(...) or sql.query(...) with params and ensure the mockServer delays
responding to that prepare), then while that prepare is still in-flight create
the simple-protocol queries with sql.unsafe(...) and call (q as any).execute()
for each; this ensures one request is actually parked behind the in-flight
prepare and reproduces the queued-but-unwritten window so the subsequent
expect(wireLog) assertion will fail on the buggy path. Use the existing
mockServer, SQL constructor, sql.prepare/sql.query and sql.unsafe symbols to
locate and modify the test.
🪄 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: 16710c2f-d267-491b-861c-ff9ef788eddf

📥 Commits

Reviewing files that changed from the base of the PR and between a988615 and 84cf749.

📒 Files selected for processing (2)
  • src/sql_jsc/mysql/JSMySQLQuery.rs
  • test/js/sql/sql-mysql-queue-write-order.test.ts

Comment thread test/js/sql/sql-mysql-queue-write-order.test.ts
The mock can now hold the first COM_STMT_PREPARE response until the test
releases it, so queries issued during that window park in the native
queue behind an in-flight request. The wire log must show them only
after the release marker, in issuance order.
@robobun

robobun commented Jun 9, 2026

Copy link
Copy Markdown
Collaborator Author

Checked the find-issues suggestions against local runs (MariaDB 11.8 on 127.0.0.1, pre-fix released builds vs this branch):

So no auto-close lines for these three.

@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-mysql-queue-write-order.test.ts`:
- Around line 89-90: The heldPrepare promise (created via let onPrepareHeld /
const heldPrepare = new Promise<() => void>(resolve => (onPrepareHeld =
resolve))) never rejects on socket/server failure when holdPrepare is enabled;
wire the mock socket/server error and close handlers to reject that promise so
tests fail fast. Concretely, change the heldPrepare construction to capture both
resolve and reject, then in the mock's 'error' and 'close' (and any abort/exit)
handlers call the captured reject with an explanatory Error when holdPrepare is
true; also ensure any existing onPrepareHeld resolve still works and that
handlers are removed after resolve/reject to avoid leaks. This targets the
heldPrepare/onPrepareHeld logic and the mock's error/close paths used when
holdPrepare is toggled.
🪄 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: 4ad3843a-aa9c-48bf-9497-dc65d8b4990f

📥 Commits

Reviewing files that changed from the base of the PR and between 84cf749 and 69ca8ac.

📒 Files selected for processing (1)
  • test/js/sql/sql-mysql-queue-write-order.test.ts

Comment thread test/js/sql/sql-mysql-queue-write-order.test.ts Outdated
If the handshake or the held COM_STMT_PREPARE never arrives, the
awaiting test now fails immediately with a message instead of burning
the per-test timeout.
@robobun

robobun commented Jun 9, 2026

Copy link
Copy Markdown
Collaborator Author

CI status for build 61701 (final): 283 of 286 test/build jobs passed, including every lane that runs the sql tests. The three non-green jobs are all unrelated to this diff:

  • debian 13 x64-baseline failed on a segfault inside the prebuilt duckdb native addon in test/js/third_party/@duckdb/node-api/duckdb.test.ts (panic: Segmentation fault, then SIGILL, reproduced on the in-job retry). That test dlopens a third-party .node binary and never loads the Bun.SQL MySQL adapter this PR changes; recent main builds pass the same lane.
  • two darwin 14 aarch64 test jobs expired waiting for CI agents (capacity, same as the previous build).

Earlier reds on this PR were the bunx @angular/cli@latest breakage (since skipped on main in #32042 and merged into this branch). The one retrigger for this branch is spent.

The diff (src/sql_jsc/mysql + one sql test file) is unchanged since e14fba9 and ready for review.

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

The window this PR closes is reachable through public APIs after all, and it produces wrong results rather than just a hang, so this is worth landing on its own merits.

bind() runs user JS for every parameter (index getters on the array passed to sql.unsafe(), toJSON()/toString() of a value). The gates (can_pipeline / can_execute_query / can_prepare_query) are checked before that JS runs, the packet is written unconditionally afterwards, and the request being bound is not in any counter yet. So a query dispatched synchronously from that JS on the same connection (max: 1 pool, or a transaction) sees an idle connection, writes its own packet, and the outer query's packet lands behind it. handle_command attributes replies in queue order, so the two requests get each other's replies:

const sql = new SQL({ url, max: 1 });
let nested, reads = 0;
const values = [0];
Object.defineProperty(values, "0", {
  get() {
    // 1st read: Signature.generate(); 2nd read: bind()
    if (++reads === 2) (nested = sql.unsafe("select ? as y", ["nested"])).execute();
    return 42;
  },
});
await sql.unsafe("select ? as x", values); // resolves [] (consumed the nested COM_STMT_PREPARE reply)
await nested;                                // rejects ERR_MYSQL_UNEXPECTED_PACKET, connection closed

Observed on main (b5afcac, debug build) and on the released canary, against MariaDB 11.8 (the trigger and the misattribution are client side; only the garbage values in row 2 would differ by server):

outer statement nested query result on main
first execution uncached prepared outer resolves [], nested rejects ERR_MYSQL_UNEXPECTED_PACKET
first execution simple each gets the other's columns, values decoded as garbage (null, -2147483648)
first execution cached prepared rows silently swapped
cached uncached prepared rows silently swapped
cached simple ok (both packets happen to be written in queue order)
cached cached prepared ok (same)
first execution, inside sql.begin() uncached prepared begin() rejects with ERR_MYSQL_UNEXPECTED_PACKET

With this PR's diff merged onto current main every shape above gets its own result (7/7 pass; 5/7 fail on main alone), which follows from the fix: the nested do_run only appends to the queue while the outer request, already at the head, finishes writing, and advance() only reaches the nested one after the outer reply.

Two things for whoever picks this up:

  • Merged onto current main the diff no longer compiles: self as jsc in src/sql_jsc/mysql/JSMySQLQuery.rs becomes unused once jsc::JsError::Thrown is gone from do_run, and the build runs with -D unused-imports. Dropping it from the import list is the only change needed.
  • The matrix above is a real-server test (describeWithContainer("mysql", { image: "mysql_plain" })) at test/js/sql/sql-mysql-reentrant-bind-order.test.ts on branch farm/1afdf85f/mysql-reentrant-bind-order-test (commit 4944844, test only, no src changes). It fails 5/7 on main and passes with this PR; feel free to pull it in here, since the mock-server tests in this PR do not cover the bind-time dispatch.

Not opening a separate PR for this; the fix is this one.

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.

MySQL: query writes can bypass queued-but-unwritten requests in the native request queue

1 participant