Skip to content

sql(mysql): remove a statement that failed to prepare from the statement cache - #33189

Open
robobun wants to merge 1 commit into
mainfrom
farm/2419ddec/mysql-evict-failed-prepare
Open

robobun wants to merge 1 commit into
mainfrom
farm/2419ddec/mysql-evict-failed-prepare

Conversation

@robobun

@robobun robobun commented Jul 1, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #42720

Problem

  • After the server refuses a COM_STMT_PREPARE, each later query with that text on that connection rejects with the stored error (errno 1146, Table 'db.t' doesn't exist). The server receives no second prepare.
  • The ERROR arm of handle_prepared_statement (src/sql_jsc/mysql/MySQLConnection.rs) marks the statement Failed and leaves it in the statement map. run_prepared_query throws the stored error.

Fix

  • The ERROR arm calls the new fail_statement, which also removes the statement from the map. The next query with that key prepares again.
  • Queries that share the statement keep their own reference and reject with its error. The lookup check for Failed is deleted: the map holds none.
  • Verified: test/js/sql/sql-mysql-cached-error.test.ts (7 tests, 5 fail on main's src/). Also sql-mysql-queued-query-failure.test.ts and sql-statement-cache-hash-collision.test.ts.
  • Self-reviewed: 7 concerns raised, 6 addressed (Notes names the other).

Background

  • Each connection has a map of prepared statements, keyed by query text plus parameter types. A query that finds its key sends no COM_STMT_PREPARE.
  • handle_prepared_statement is generic and inlined into the reply path. fail_statement is #[cold] and not generic, so that path does not change.
  • Considered a lookup check that replaces a Failed statement: the same wire result, but refused statements stay in the map. The Postgres adapter already removes the statement in its error arm.

Downsides

  • A text that the server always refuses costs one round trip per use: 59 B out, 75 B back, 3 syscalls. On main a repeat costs 0.
  • JSMySQLQuery::run executes 14 more instructions per prepared query (352 to 366). .text grows by 512 B.
Notes

What the rebase removed from this PR

The reproduction of #42720 (max: 1, MariaDB 11.8.6)

main:
1. table missing     : rejected isError=true errno=1146 Table 'bun_sql_test.prepare_probe_…' doesn't exist
2. same text, exists : rejected isError=false errno=1146 Table 'bun_sql_test.prepare_probe_…' doesn't exist
3. text + one space  : fulfilled [{"password_changed_at":"2026-09-15T10:00:00.000Z"}]
COM_STMT_PREPARE seen by the server: attempt 1 = +1, attempt 2 = +0

this PR:
2. same text, exists : fulfilled [{"password_changed_at":"2026-09-15T10:00:00.000Z"}]
COM_STMT_PREPARE seen by the server: attempt 1 = +1, attempt 2 = +1

Measurements

Release builds of main at c86bd18 and of this diff on that base. Workload: 1 warm-up query, 50 queries awaited one by one, 50 queries in one tick, max: 1. Tools: gdb (instruction, call and syscall counters), nm, size, server status counters. strace, perf, valgrind and bloaty are not installed on the machine.

reply path: read_and_process_data 26,217 -> 26,217 instructions (250 per single-row reply on both), handle_command::<StackReader> 17,295 -> 17,294 (42 per packet on both)
lookup path, 100 prepared queries: run 39,873 -> 41,790 (+1,917, and 352 -> 366 for a sequential call), do_run 12,484 -> 12,484, advance 13,859 -> 13,859
  one do_run call with its callees: 11,035 -> 11,049
  variant that keeps the lookup check and adds only fail_statement: run 39,873
release binary: .text 58,298,556 -> 58,299,068 B (+512), stripped file 80,995,912 B on both
  run 9,733 -> 9,529 B, read_and_process_data 16,085 -> 15,887 B, handle_command::<StackReader> 2,747 -> 2,591 B, fail_statement 901 B (new)
failed prepare: fail_statement entered 0 times in 20,000 successful queries. Per failure: 1 hash of the key, 1 malloc, 1 free (main: 0, 1, 0)
Com_stmt_prepare deltas: 1,1,1,0, text plus a space +1, two sharers +1 (main: 1,0,0,0, +1, +1)
repeated failing text, per use: 1 COM_STMT_PREPARE, 59 B to the server, 75 B back, 3 syscalls (sendto, recvfrom, epoll_pwait2) (main: 0, 0, 0, 0)
failed statements freed before the connection closes: 5 of 5 (main: 0 of 5)
surface: 0 host functions, 0 codegen entries, 0 files outside src/sql_jsc/mysql and test/js/sql

The deleted compare was expected to make run shorter. run has 204 B less code but executes 14 more instructions per call: the compiler moves values between registers in a different way (+17 mov, -1 cmpb, -1 jne). The check is deleted all the same, because no statement in the map can have the status Failed.

Tests and controls (debug builds with ASAN, MariaDB 11.8.6)

Build sql-mysql-cached-error.test.ts
this PR 7 pass
main's src/ (a4f1429) 5 fail, 2 pass
this PR without statements.remove the three retry tests and the syntax-error test fail
this PR plus self.statements.clear() 1 fails: "a failed prepare removes its own statement and no other", prepares [1, 1, 1, 1, 1]
this PR with a borrowed message in place of the copy "a query that shares a failed prepare gets its message after the connection has read again" fails, 4 of 4 runs
  • The two tests that pass on main's src/ are the tests of the queries that share a failed prepare. They hold behaviour that this PR must keep.
  • Also run on this PR: sql-mysql-queued-query-failure.test.ts (18 pass), sql-statement-cache-hash-collision.test.ts (3 pass), sql-mysql-prepare-ok-zero-statement-id.test.ts (1 pass).
  • sql-mysql.test.ts needs docker, which the machine does not have. A copy without the docker condition, on MariaDB: the renamed test passes. The same 6 other tests fail on main and on this PR.
  • Not run locally: the docker images of CI (mysql:8.4, mysql:9, TLS).

Why the two old tests changed

  • sql-mysql-cached-error.test.ts asserted that the second use of a refused text sends no prepare. That is the defect. The test now asserts one more prepare and the same error.
  • sql-mysql.test.ts: the name and the comments of one test described the cached error. The assertions did not change: the server gives the same error again.

Other prepare errors

  • errno 1461 (max_prepared_stmt_count reached): with the limit at 0 the text is refused. After the limit is restored, main still rejects the text with the stored error. This PR returns the row.
  • errno 1205 (lock wait timeout): not a repaired case. In my probe the server reports it at execute, and main recovers too.

What this PR does not change

Why there is no pointer check before the removal

The Postgres adapter compares the pointer of the statement with the map entry before it removes the entry. Here the map has one inserter (get_or_put, JSMySQLConnection.rs) and one remover (fail_statement). The statement that fails is the statement under its key. A debug_assert! checks that.


[review] gate passed · iteration 7 · 6 files touched

fails on main (without fix)
ASAN without fix: 2 FAILED
$ 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-cached-error.test.ts test/js/sql/sql-mysql-failed-prepare-retry.test.ts test/js/sql/sql-mysql.test.ts
bun test v1.4.1 (65362b53b)

test/js/sql/sql-mysql.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) MySQL: binary TIME with a very large days field formats without integer wraparound [1720.49ms]
(pass) MySQL: a row split across several maximum-size wire packets is reassembled into one value [1740.44ms]

test/js/sql/sql-mysql-cached-error.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

test/js/sql/sql-mysql-failed-prepare-retry.test.ts:
127 |     expect({
128 |       connections: mock.connections(),
129 |       prepares: mock.preparesByText.get("SELECT * FROM t"),
130 |       second: second.status,
131 |
... (truncated)

release without fix: 2 FAILED
bun test v1.4.1-canary.1 (65362b53b)

test/js/sql/sql-mysql.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) MySQL: binary TIME with a very large days field formats without integer wraparound [22.64ms]
(pass) MySQL: a row split across several maximum-size wire packets is reassembled into one value [425.75ms]

test/js/sql/sql-mysql-cached-error.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

test/js/sql/sql-mysql-failed-prepare-retry.test.ts:
127 |     expect({
128 |       connections: mock.connections(),
129 |       prepares: mock.preparesByText.get("SELECT * FROM t"),
130 |       second: second.status,
131 |       third: third.status,
132 |     }).toEqual({
             ^
error: expect(received).toEqual(expected)

  {
    "connections": 1,
-   "prepares": 2,
-   "second": "fulfilled",
-   "third": "fulfilled",
+   "prepares": 1,
+   "second": "rejected",
... (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-cached-error.test.ts test/js/sql/sql-mysql-failed-prepare-retry.test.ts test/js/sql/sql-mysql.test.ts
bun test v1.4.1 (65362b53b)

test/js/sql/sql-mysql.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) MySQL: binary TIME with a very large days field formats without integer wraparound [1273.64ms]
(pass) MySQL: a row split across several maximum-size wire packets is reassembled into one value [1474.09ms]

test/js/sql/sql-mysql-cached-error.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

test/js/sql/sql-mysql-failed-prepare-retry.test.ts:
(pass) MySQL: a concurrent query sharing a failed prepare is rejected, not left pending [171.27ms]
(pass) MySQL: a failed prepare is evicted from the statement cache and 
... (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     afb4ffb69f
  features     baseline

23 deps, 131 codegen, 1172 objects in 1046ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1244] install /workspace/bun
bun install v1.4.1-canary.1 (65362b53b)

Checked 26 installs across 63 packages (no changes) [9.00ms]
[2/1244] gen bindgenv2
[3/1244] fetch zlib
[zlib] up to date
[4/1244] install /workspace/bun/packages/bun-error
bun install v1.4.1-canary.1 (65362b53b)

Checked 1 install across 2 packages (no changes) [1.00ms]
[5/1244] gen ErrorCode+*.h
[6/1244] fetch tinycc
[tinycc] up to date
[7/1243] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[8/1216] gen ProcessBindingConstants.lut.h
Generating /workspace/bun/build/release/codegen/ProcessBindingConstants.lut.h from /workspace/bun/src/jsc/bindings/ProcessBindingConstants.cpp
[9/1216] gen bake.{client,server,error}.js
-> bake.client.js, bake.server.js, bake.error.js
[10/1216] install /workspace/bun/src/node-fallbacks
bun install v1.4.1-canary.1 (65362b53b)

Checked 111 installs 
... (truncated)
diff hotspot
src/sql_jsc/mysql/JSMySQLQuery.rs                  |   2 +-
 src/sql_jsc/mysql/MySQLConnection.rs               |  19 ++-
 src/sql_jsc/mysql/MySQLQuery.rs                    |   6 -
 test/js/sql/sql-mysql-cached-error.test.ts         | 114 +++++++-------
 test/js/sql/sql-mysql-failed-prepare-retry.test.ts | 175 +++++++++++++++++++++
 test/js/sql/sql-mysql.test.ts                      |  16 +-
 6 files changed, 255 insertions(+), 77 deletions(-)

gate history · 2 passed · 0 rejected · iteration 7

evidence per changed file
file                                                reads  edits  tests
src/sql_jsc/mysql/JSMySQLQuery.rs                       7      7      0
src/sql_jsc/mysql/MySQLConnection.rs                   11      8      0
src/sql_jsc/mysql/MySQLQuery.rs                         7      4      0
test/js/sql/sql-mysql-cached-error.test.ts              1      1      0
test/js/sql/sql-mysql-failed-prepare-retry.test.ts      2     10      0
test/js/sql/sql-mysql.test.ts                           1      2      0

@github-actions github-actions Bot added the claude label Jul 1, 2026
@robobun

robobun commented Jul 1, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 12:17 AM PT - Sep 27th, 2026

✅ @robobun, your commit 941269e78ebaaa084f5cd942f14cdb3e9956f570 passed in Build #121152! 🎉


🧪   To try this PR locally:

bunx bun-pr 33189

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

bun-33189 --bun

@coderabbitai

coderabbitai Bot commented Jul 1, 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: 1ebe0b89-a5b4-4d19-a259-3d381ac94197

📥 Commits

Reviewing files that changed from the base of the PR and between b74b3a8 and 941269e.

📒 Files selected for processing (4)
  • src/sql_jsc/mysql/MySQLConnection.rs
  • src/sql_jsc/mysql/MySQLQuery.rs
  • test/js/sql/sql-mysql-cached-error.test.ts
  • test/js/sql/sql-mysql.test.ts
💤 Files with no reviewable changes (1)
  • src/sql_jsc/mysql/MySQLQuery.rs

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


Walkthrough

MySQL prepare failures now store an owned error message and evict the failed statement from the cache. Query execution handles failed statements through the shared status check. Regression tests cover retries, successful statement reuse, healthy statements, concurrent queries, and error consistency.

Changes

Failed prepared statement eviction and retry

Layer / File(s) Summary
Evict failed prepared statements and defer error handling
src/sql_jsc/mysql/MySQLConnection.rs, src/sql_jsc/mysql/MySQLQuery.rs
The prepare-error path copies the error message, records the failed status and packet, and removes the statement from the cache. Cached failed statements reach the shared status match.
Validate prepare retries and error propagation
test/js/sql/sql-mysql-cached-error.test.ts, test/js/sql/sql-mysql.test.ts
Tests cover re-preparing failed statements, reuse after successful preparation, healthy statements, concurrent queries, and consistent syntax errors.

Suggested reviewers: jarred-sumner

Priority: ⬇️ Low

Severity of issue fixed: Low

Merge Risk: ⚪ Minimal · up to 94126

No confirmed issue blocks merging. The MySQL regression tests remain to be run as part of normal validation.

🚥 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 and concisely describes the primary change: removing failed MySQL prepared statements from the statement cache.
Description check ✅ Passed The description explains the problem, implementation, verification steps, tests, limitations, and performance impact. It provides the information required by the repository template, even though it us…

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.

I didn't find any bugs — the eviction mirrors the Postgres handler at PostgresSQLConnection.rs:2945 and the refcount reasoning checks out — but this adds an unsafe intrusive-deref on a hot driver path and rewrites sql-mysql-cached-error.test.ts to assert the opposite invariant, so it's worth a human look.

Extended reasoning...

Overview

The PR touches the MySQL driver's prepared-statement cache: MySQLConnection::handle_prepared_statement now evicts a statement from self.statements and derefs the map's intrusive ref when the server answers COM_STMT_PREPARE with an ERR packet, and MySQLQuery::run_prepared_query drops the now-unreachable found_existing && Failed fast-throw. Test coverage is thorough: a new wire-frame mock test asserts prepare counts and reuse, the container test in sql-mysql-cached-error.test.ts is rewritten to assert re-prepare behavior against a real server, and wire-frames.ts gains mysqlErrorPacket.

Security risks

None identified. No new user-controlled input reaches native code; the change only affects when an already-parsed server ERR packet causes cache eviction. No auth, TLS, or path handling is touched.

Level of scrutiny

This deserves careful human review despite being small. It sits squarely in the category CLAUDE.md flags as most-blocked: an unsafe { MySQLStatement::deref(...) } on a live &mut borrow inside a driver hot path, with correctness depending on the invariant that the request always holds an independent ref (which I verified — init_exact_refs(2) on insert, ref_() on found_existing). The pattern is a near-verbatim copy of PostgresSQLConnection.rs:2937-2951, which is reassuring, but the MySQL side lacks the status == Parsing guard the Postgres side has — here the ERROR arm is only reached via the Parsing match in handle_command plus statement_id == 0, so it's equivalent, but a reviewer should confirm.

Other factors

The bigger judgment call is that sql-mysql-cached-error.test.ts is rewritten to assert the opposite of what it previously pinned (that Com_stmt_prepare now does increment on retry). The PR description justifies this — the old test existed to catch a dangling-slice read on a code path this PR deletes, and the dangling-slice concern is still covered by the (now comment-only-updated) test in sql-mysql.test.ts — but per the repo's landing guidance, inverting an existing test's invariant is exactly the kind of change a maintainer should sign off on rather than a bot.

@robobun

robobun commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator Author

Good flags, both worth pre-answering for whoever picks this up.

The missing status == Parsing guard vs. Postgres

The Postgres handler checks the pre-transition status and wraps both the Failed assignment and the eviction in it:

if stmt.status == StatementStatus::Parsing {
    stmt.status = StatementStatus::Failed;
    // ...
    if self.statements.with_mut(|m| m.remove(..)).is_some() { ... }
}

On the MySQL side that precondition is established one frame up instead of at the eviction site, so the guard would be redundant here. Concretely:

  • handle_command dispatches into handle_prepared_statement only from its mysql_statement::Status::Parsing arm (MySQLConnection.rs:965).
  • Inside handle_prepared_statement, the statement_id > 0 branch (prepare OK already received, reading param and column definitions) unconditionally returns at MySQLConnection.rs:1194, before the packet-type match. So the PacketType::ERROR arm at 1233 is only reachable for a statement that was Parsing when the packet arrived and whose prepare has not succeeded.

A literal copy of the Postgres check would also read the wrong thing at that point: by the time the eviction block runs, statement.status has already been assigned Failed a few lines up, so status == Parsing would be unconditionally false. I left it relying on the dispatch rather than adding a check that cannot be true.

One more invariant worth stating explicitly: the entry removed by self.statements.remove(&hash(statement.signature.name)) and the statement that gets deref'd are the same object. get_or_put only writes the slot when !found_existing, and nothing re-inserts under that signature hash between the prepare being enqueued (which inserted it) and the ERROR arriving, so the map value under that key is this statement. This is the same assumption the Postgres handler makes.

Inverting the test invariant

The Com_stmt_prepare "must not increment" assertion was pinning the bug, not a desired property. A failed COM_STMT_PREPARE allocates nothing on the MySQL server (it answers ERR; there is no statement handle), so there was never anything to serve from a cache, and re-preparing on the next use is what mysql2 and other clients do. The old test's own comment acknowledged that its message-equality assertions are silently satisfied by a fresh prepare and that the counter check existed only to prove the cache-hit path was exercised; with that path deleted, the assertion had to flip.

The property that test file was originally added for in #29847 (the stored ErrorPacket.error_message must be an owned copy, never a slice into the socket read buffer) is unchanged by this PR: the copy is still made in handle_prepared_statement, and it is still read by queries that attached to the statement before the failure. Only the observation point (a later same-text query served from the cache) no longer exists.

@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 `@src/sql_jsc/mysql/MySQLConnection.rs`:
- Around line 1242-1244: The comment in MySQLConnection should be shortened to
fit the 3-line maximum. Update the explanatory text around the statement error
response handling so it stays concise while still covering the owned copy
requirement; keep the change localized near the stmt.error_response logic and
preserve the meaning in fewer words.

In `@src/sql_jsc/mysql/MySQLQuery.rs`:
- Around line 345-349: Condense the explanatory comment in
MySQLQuery::handle_prepared_statement to fit within 3 lines while preserving the
key points about `ref_()`, `ParentRef` deref behavior, and the cached entry
never being `Failed`; remove redundant wording and keep only the essential
rationale near the existing `match`/prepare-error handling logic.
🪄 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: cb01614b-589a-438b-bc2e-ca3e87e88d02

📥 Commits

Reviewing files that changed from the base of the PR and between d816daf and d6654c2.

📒 Files selected for processing (6)
  • src/sql_jsc/mysql/MySQLConnection.rs
  • src/sql_jsc/mysql/MySQLQuery.rs
  • test/js/sql/sql-mysql-cached-error.test.ts
  • test/js/sql/sql-mysql-failed-prepare-retry.test.ts
  • test/js/sql/sql-mysql.test.ts
  • test/js/sql/wire-frames.ts

Comment thread src/sql_jsc/mysql/MySQLConnection.rs Outdated
Comment thread src/sql_jsc/mysql/MySQLQuery.rs Outdated
@robobun

robobun commented Jul 1, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status on head 941269e.

This PR is rebased on main. It now holds only the removal of a statement that failed to prepare. #36842 merged the other part of the earlier diff.

How I reproduced the defect: I ran the steps of #42720 against MariaDB 11.8.6 with max: 1, and I read the Com_stmt_prepare counter of the session.

  • main: after CREATE TABLE, the second use of the query text rejects with the stored errno 1146. The counter does not change.
  • This PR: the second use returns the row. The counter changes by 1.

test/js/sql/sql-mysql-cached-error.test.ts holds the same steps. On main's src/, 5 of its 7 tests fail. On this PR, all 7 pass.

PR: #33189

Comment thread src/sql_jsc/mysql/MySQLQuery.rs 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

Caution

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

⚠️ Outside diff range comments (1)
test/js/sql/sql-mysql-failed-prepare-retry.test.ts (1)

100-139: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider running these as test.concurrent.

Each test spins up its own independent mock server on a random port with no shared state between them; they're good candidates for concurrent execution.

-test("MySQL: a failed prepare is evicted from the statement cache and retried", async () => {
+test.concurrent("MySQL: a failed prepare is evicted from the statement cache and retried", async () => {
-test("MySQL: a concurrent query sharing a failed prepare is rejected, not left pending", async () => {
+test.concurrent("MySQL: a concurrent query sharing a failed prepare is rejected, not left pending", async () => {

As per coding guidelines: "Prefer concurrent tests over sequential tests: When multiple tests in the same file spawn processes or write files, make them concurrent with test.concurrent or describe.concurrent unless it's very difficult to make them concurrent."

Also applies to: 147-174

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/js/sql/sql-mysql-failed-prepare-retry.test.ts` around lines 100 - 139,
The MySQL prepare-retry test is independent and can run in parallel with the
other cases in this file, so update the `test("MySQL: a failed prepare is
evicted from the statement cache and retried", ...)` declaration to use
concurrent execution. Apply the same change to the related tests in this block
so they all use `test.concurrent` (or an equivalent `describe.concurrent` if
grouped), since each one creates its own `mockMySQLServer` and `SQL` instance
with no shared state.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/sql/sql-mysql-failed-prepare-retry.test.ts`:
- Around line 141-146: Trim the extra narrative comment in this test so it only
states the durable invariant and does not repeat internal failure mechanics
already covered by the file’s opening banner. Update the comment near the shared
prepare retry scenario to remove references to advance(), JSMySQLQuery::run, and
reject_with_js_value, and keep only the high-level behavior this test asserts.
Use the surrounding test name and the top banner as the guide for what should
remain.

---

Outside diff comments:
In `@test/js/sql/sql-mysql-failed-prepare-retry.test.ts`:
- Around line 100-139: The MySQL prepare-retry test is independent and can run
in parallel with the other cases in this file, so update the `test("MySQL: a
failed prepare is evicted from the statement cache and retried", ...)`
declaration to use concurrent execution. Apply the same change to the related
tests in this block so they all use `test.concurrent` (or an equivalent
`describe.concurrent` if grouped), since each one creates its own
`mockMySQLServer` and `SQL` instance with no shared state.
🪄 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: 08a1d189-de37-445a-8c49-6ac194b29d3b

📥 Commits

Reviewing files that changed from the base of the PR and between d6654c2 and b74b3a8.

📒 Files selected for processing (4)
  • src/sql_jsc/mysql/JSMySQLQuery.rs
  • src/sql_jsc/mysql/MySQLConnection.rs
  • src/sql_jsc/mysql/MySQLQuery.rs
  • test/js/sql/sql-mysql-failed-prepare-retry.test.ts

Comment thread test/js/sql/sql-mysql-failed-prepare-retry.test.ts Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

No further issues from my side — the concurrent-sibling hang I flagged earlier is fixed and covered. Deferring to a human for the unsafe MySQLStatement::deref on the eviction path and the errguard/mark_as_failed reshuffle, plus the inverted assertions in sql-mysql-cached-error.test.ts.

Extended reasoning...

Overview

This PR fixes two related bugs in the MySQL driver's prepared-statement handling: (1) a failed COM_STMT_PREPARE was cached forever with Status::Failed, so a transient error poisoned the connection for its lifetime; (2) a second identical query that attached to a shared in-flight prepare was left with an unsettled promise when that prepare failed. The fix evicts failed prepares from the per-connection statement map (mirroring the Postgres driver) and moves the fail() transition out of JSMySQLQuery::run's errguard so reject_with_js_value's settle-once guard can fire. Three Rust files are touched (MySQLConnection.rs, MySQLQuery.rs, JSMySQLQuery.rs), one test file is rewritten to assert the new behaviour, one new wire-level test file is added, wire-frames.ts gains mysqlErrorPacket, and one comment block in sql-mysql.test.ts is updated.

Security risks

None identified. The change is confined to client-side caching and error-path settling; no auth, TLS, or input-parsing surface is touched. The new mysqlErrorPacket builder is test-only.

Level of scrutiny

Medium-high. The eviction path adds an unsafe { MySQLStatement::deref(...) } on a &mut borrow of the statement while the request still holds its own ref — the SAFETY comment argues the count cannot reach zero, and I agree, but intrusive-refcount changes in a driver merit a maintainer's eyes. The errguard change (removing q.fail() from run()'s scopeguard and moving it to do_run) alters which caller owns the terminal-status transition on every error path reachable from run(); the PR description traces both callers (do_run and advance()) and I verified the trace, but this is exactly the class of change where a missed caller silently leaves a promise pending.

Other factors

My earlier inline comment (the concurrent-sibling hang) was confirmed, fixed in b74b3a8, and now has a dedicated test that would time out on the unfixed build. All CodeRabbit nits are resolved. sql-mysql-cached-error.test.ts previously asserted the opposite invariant (Com_stmt_prepare must not increment) to pin an unrelated dangling-slice read; the rewrite is justified in the PR body but a human should confirm the old coverage is genuinely subsumed. Test coverage is strong (wire-level mock + real-container), and the fix is symmetric with the Postgres driver's existing behaviour. No CODEOWNERS entry covers these paths.

@robobun

robobun commented Jul 1, 2026 •

Copy link
Copy Markdown
Collaborator Author

Thanks for the final pass. For whoever picks this up, all three of the deferred points already have written answers:

  • The unsafe { MySQLStatement::deref(...) } refcount invariant and why the missing status == Parsing guard is equivalent to the Postgres one: this comment.
  • The errguard / mark_as_failed reshuffle: JSMySQLQuery::run has exactly two callers, do_run and MySQLRequestQueue::advance, and each now owns its own terminal transition (do_run marks the query terminal and propagates the exception into JS, which settles the promise; advance reaches reject_with_js_value, which owns the fail() transition). The Postgres equivalent, PostgresSQLQuery::run, already had exactly this shape (its scopeguard only downgrades this_value). Traced in the PR description and in the resolved review thread.
  • The inverted sql-mysql-cached-error.test.ts assertion: the same comment as the first point explains why the old Com_stmt_prepare "must not increment" assertion was pinning the bug, and why the owned error_message copy from sql(mysql): own error_message when caching failed prepared statement #29847 is unchanged and not weakened.

CI is green for everything this PR touches; the red lanes are a repo-wide test/bake/dev/production.test.ts failure hitting every build of this pipeline since build 67594, the tracked musl flake #33044, and one artifact-download infra timeout. Details with links in the status comment.

@robobun

robobun commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator Author

This PR also fixes #40634: queued queries on a shared failed prepare are never rejected and the process hangs. The errguard change in JSMySQLQuery.rs (no fail() before reject_with_js_value) is the fix for it, and the second wire-frame test covers the concurrent case. The branch currently conflicts with main and needs a rebase.

@robobun
robobun force-pushed the farm/2419ddec/mysql-evict-failed-prepare branch from 2e3dc4d to 776ab64 Compare August 27, 2026 11:41
Comment thread src/sql_jsc/mysql/JSMySQLQuery.rs Outdated
Comment thread src/sql_jsc/mysql/JSMySQLQuery.rs Outdated
Comment thread src/sql_jsc/mysql/MySQLConnection.rs Outdated
Comment thread src/sql_jsc/mysql/MySQLConnection.rs Outdated
Comment thread src/sql_jsc/mysql/MySQLQuery.rs Outdated
Comment thread src/sql_jsc/mysql/MySQLQuery.rs Outdated
Comment thread src/sql_jsc/mysql/MySQLConnection.rs Outdated
@robobun

robobun commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto main (53a23a4). The conflict itself was one hunk in MySQLQuery.rs where main had renamed the error type inside the block this PR deletes, but main had moved under the MySQL driver in two ways that needed adapting (details are in the new "Rebase notes" section of the description):

  • The statement map is now keyed by the signature bytes rather than a wyhash u64, so the eviction uses statements.get(name) with a pointer-identity check before statements.remove(name), matching the shape main's Postgres ErrorResponse handler now has.
  • The driver now sends SET time_zone = '+00:00' after authentication, so the scripted server in the new test acknowledges it via main's mysqlAckSessionSetup. wire-frames.ts also gained an identical ERR_Packet builder in the meantime, so this PR no longer adds one.

Re-verified on the new base: both regression tests fail on a build from main's src/ and pass with the fix. The comment-cop findings on the rebased diff are addressed by dropping the narration comments; the src diff now carries only the SAFETY note on the unsafe ref release.

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

Code review found no issues

No high-confidence issues detected in this change.

@robobun
robobun force-pushed the farm/2419ddec/mysql-evict-failed-prepare branch from 53a23a4 to 4210b4d Compare August 28, 2026 03:37
Comment thread src/sql_jsc/mysql/MySQLConnection.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.

Code review found no issues

No high-confidence issues detected in this change.

@robobun

robobun commented Sep 14, 2026

Copy link
Copy Markdown
Collaborator Author

This PR also fixes #42720. That report is the real-server version of the first wire-frame test: a prepared SELECT on a missing table fails with 1146, the table is created on the same pool, and the same text keeps rejecting from the failed-statement cache while the same text with a trailing space succeeds. Reproduced on main at 5fce36e against MariaDB 11.8 with the script from the issue. The branch is still mergeable against main.

alii pushed a commit that referenced this pull request Sep 25, 2026
…der, pass thrown non-objects through (#36842)

Fixes #40634

### Problem
- When a queued MySQL query fails on the client, the queue skips the
next query, which resolves with another query's rows. In a server, one
request's input changes another's response.
- The failed query never settles. With `idleTimeout` it rejects late,
with `ERR_MYSQL_IDLE_TIMEOUT`. #24844 reported it.
- The `Err` arm of `advance()` pops the failed head and does `offset +=
1` (`src/sql_jsc/mysql/MySQLRequestQueue.rs:176-182`). The guard of
`run()` marks the query failed before `reject_with_js_value` runs
(`src/sql_jsc/mysql/JSMySQLQuery.rs:394`).

### Fix
- `advance()` calls `on_error` and continues. The loop's branch for a
completed request retires it. `run()` writes no status, and `do_run`
marks the query failed before it rethrows.
- Both adapters' query reject callbacks pass a non-object through: a
thrown `null` rejects with `null`.
- Verified: `test/js/sql/sql-mysql-queued-query-failure.test.ts` (main
fails 17 of 18), `test/js/sql/postgres-bind-encode-throw.test.ts` (main
fails 4 of 12). Other suites: Notes.
- Self-reviewed: 6 concerns raised, 6 addressed.

### Background
- The server answers in order, so the client gives each reply to the
head of its queue.
- A query goes out at once on an idle connection with a prepared
statement. Otherwise `advance()` writes it.
- #33189 and #31209 carry the `run()` change only. After this PR, #33189
keeps its eviction (#42720) and #31209 drops that hunk.

### Downsides
- A failed query with no rejection handler now raises an unhandled
rejection.
- Still open: #32005, #43992, #43993.
- Release build, 100 successful queries: +0 instructions in `do_run` and
`advance`, +195 of 39,326 in `run`. `.text`: unchanged.

<details><summary>Notes</summary>

**Reply order on main** (one connection, three queries started in one
tick, MariaDB 11.8.6)

```
q1  SELECT ? AS v  (first use, parameter of 0xFFFFFF bytes)  never settles
q2  SELECT 'q2' AS marker                                    resolves [{"marker":"q3"}]
q3  SELECT 'q3' AS marker                                    never settles
```

With this PR: q1 rejects with `ERR_MYSQL_OVERFLOW`, q2 and q3 get their
own rows.

- In a `Bun.serve` handler that runs one query for each request, with
`max: 1`: one request with a date before 1970 in its query string made
two other requests get the row of another request. With this PR each
request gets its own row, and the request with the bad date gets the
error. With the default pool size the effect is intermittent.
- With `idleTimeout: 2` the failed query rejects after 2002 ms with
`ERR_MYSQL_IDLE_TIMEOUT` on main. With this PR it rejects after 2 ms
with its own error. The examples in `docs/runtime/sql.mdx` use
`idleTimeout: 30`.
- #24844 is an insert of 18,730,521 characters on the first use of a
statement. It was a panic in 1.3.2. #31221 made it
`AnyMySQLError::Overflow`, and since then the query never settles.

**What fails in the queue.** A query waits in the queue on the first use
of its statement text, or when another query is running. Each of these
then never settles on main and rejects with this PR:

- a parameter whose `toJSON` throws (an Error, `null`, `undefined`, a
string, a number)
- a parameter that holds a BigInt or a cycle (`JSON.stringify` throws)
- a `Date` outside the range of DATETIME (`ERR_INVALID_ARG_TYPE`)
- a parameter or a query text of 16 MiB or more (`ERR_MYSQL_OVERFLOW`)
- a wrong number of parameters
(`ERR_MYSQL_WRONG_NUMBER_OF_PARAMETERS_PROVIDED`)
- a query that shares a statement whose prepare failed (#40634)

Waiters that hang on main and return with this PR: `sql.begin()`,
`sql.close()` and `sql.end()` with no timeout, and a script whose last
queries failed.

The same query on a prepared statement and an idle connection rejects on
main. That is why the existing test "rejects a bind parameter that
cannot be framed in a single wire packet" passes: it runs the same
statement text once before.

**Which edit fixes which symptom** (30 tests in the two files, debug
build with ASAN, each edit reverted alone)

| Edit reverted | Tests that fail |
|---|---|
| all of them (main) | 21 |
| `fail()` removed from the guard of `run()` | 17 |
| `Err` arm of `advance()` | 7 |
| `mark_as_failed()` in `do_run` | 1 |
| guard in `onRejectMySQLQuery` | 4 |
| guard in `onRejectPostgresQuery` | 4 |
| none | 0 |

- The change to `run()` makes the promise settle.
- The change to `advance()` keeps the order of the queries behind the
failed one.
- The line in `do_run` keeps the state of a query that failed in the
call that started it. Built-in JS runs a handle one time, so only the
test that runs a handle again sees it.
- The two guards let a thrown value that is not an object reach the
caller. Before this PR the `Err` arm did not call JS for MySQL. The
guard is in the two query callbacks and not in `wrapError`, because
`onclose`, `connect()` and `listen()` also call `wrapError` and keep
their behaviour.

**Tests.** The MySQL tests run on a real server
(`describeWithContainer`). To show that a rejected query sends nothing,
they read `Com_stmt_prepare` and `Com_stmt_execute` from `SHOW SESSION
STATUS`, as `sql-mysql-cached-error.test.ts` does. Each test closes its
connection with `close({ timeout: 5 })`. On a build without the fix the
queries that never settle then reject with
`ERR_MYSQL_CONNECTION_CLOSED`, and the test fails on its assertion.

**Measurements.** Release builds of the merge base (29d9638) and of
this PR, same flags. `valgrind` and `perf` are not available here, so
instruction counts come from gdb single steps (a call counts as one
instruction).

- Success path, 100 queries on one prepared statement (50 one by one, 50
in one tick): `do_run` 12,351 to 12,351 instructions,
`MySQLConnection::advance` 13,720 to 13,720, `run` 39,326 to 39,521. Per
entry `run` executes 4 more or 4 fewer instructions. The compiler
allocates registers differently. No branch, call or store is added.
- `mark_as_failed`, `reject_with_js_value` and `on_error` run 0 times in
20,000 successful queries, on both builds.
- Size: `run` 9733 B (was 9740), `advance` 1214 B (was 1422), `do_run`
1429 B (was 1417), `mark_as_failed` 332 B (was inlined into its one
caller). `.text` 58,301,116 B and the stripped binary 80,995,912 B do
not change.
- A query that fails in the call that starts it (100 queries): 1
`mark_as_failed` and 0 native reject callbacks per query, 1 rejection
per promise.
- A query that fails in the queue (100 queries): 1 `on_error` and 1
`reject_with_js_value` per query, 1 rejection per promise, 0 commands on
the wire for it.
- After 1,000 failed queued queries and a full GC: `MySQLQuery` objects
+0, `Promise` objects +0.
- A query that shares a failed prepare and waits behind a written query:
rejected before the reply of the query in front in 20 of 20 runs.
- The MySQL test file, 20 rounds of 4 concurrent copies on the debug
build: 0 of 80 processes fail (1,440 tests). The longest `close()` takes
226 ms against the bound of 5 s.

**Other suites** (real MariaDB 11.8.6 and PostgreSQL 17, release builds
of the merge base and of this PR)

- 12 MySQL test files, 150 tests: the same 9 tests fail on both builds.
They need MySQL 8 error texts, MySQL 9 or the TLS container.
- 35 PostgreSQL and pool test files, 1,141 tests: the same 18 tests fail
on both builds.
- The two test files of this PR pass with
`BUN_JSC_validateExceptionChecks=1`.

**Left open**

- #32005: the call that starts a query can write it ahead of a request
that waits in the queue. A query started from a getter or from `toJSON`
during bind can get another query's rows. This PR does not change that.
- #43992: a thrown object that is not an Error (a plain object, an
array, a class instance) is replaced by an empty `MySQLError` on the
queue path, as it is by a `PostgresError` on main. A client-side failure
rejects with a `MySQLError` on the queue path and with a plain object on
the other path.
- #43993: a statement text of 16 MiB or more rejects with `Overflow
failed to prepare query` and no `code`.
- #27102: MySQL takes no event loop reference when it queues a query.
- Not tracked, no effect found: a query that shares a failed prepare
behind a written query is rejected from the deferred flush task. Its
handler runs no later than the reply of the query in front.
- Not tracked, no effect found: `Status::Binding` is set before the
packet is framed (`MySQLQuery.rs:145`). Both callers of `run()` now make
the request terminal.

**Related pull requests**

- #33189 has the same change to `run()` and the same line in `do_run`
(commit c5a4ebc). This PR does not take its eviction of failed
prepares, which #42720 asks for.
- #31209 has the same change to `run()` next to its change to
`MySQLValue.rs`.
- #32008 (closed as stale) made `advance()` the only writer. It did not
change the `Err` arm.
- #40273 and #35357 rewrite `advance()` and keep both defects. To rebase
#40273 on this PR: delete `if offset == 0 { queue.discard_if_head(req);
} offset += 1;` from its `Err` arm, delete `fail()` from its guard, and
add the `do_run` line.
- This PR replaces its first version, which had the change to `run()`
only. Three reports that were left as comments on it have the same cause
and now have tests.

**Correction.** The first commit of this PR said that `do_run` rejects
through `reject_with_js_value`. It does not. `do_run` throws, and the
catch in `src/js/bun/sql.ts` rejects the query.

**Evidence block.** In the block below, the line `release without fix:
all passed` ran a release binary that was built from this branch. A
release build of the merge base fails 21 of the 30 tests: 17 of the 18
MySQL tests and 4 of the 12 tests in the PostgreSQL file. CI ran the
MySQL test file against the MySQL container on four Linux lanes, 18 of
18 pass on each.

**Question for a maintainer.** Is the `mark_as_failed()` line in
`do_run` wanted? Without it the state of a query that failed in the call
that started it is `Pending` or `Binding` and not `Fail`. No public path
reads it. A second `run()` on such a handle then blocks the connection.

</details>

<!-- robobun:evidence:begin -->

---

**[human-review]** gate passed · iteration 1 · 6 files touched

<details><summary>fails on main (without fix)</summary>

```console
ASAN without fix: 21 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/postgres-bind-encode-throw.test.ts test/js/sql/sql-mysql-queued-query-failure.test.ts
bun test v1.4.3 (367d939)

test/js/sql/sql-mysql-queued-query-failure.test.ts:
Container ready via docker-compose: mysql_plain at 127.0.0.1:3306
(fail) mysql > a parameter whose toJSON throws rejects the query and the queries behind it run in order [5009.65ms]
  ^ this test timed out after 5000ms.

# Unhandled error between tests
-------------------------------
81 |     const bad = () => sql`SELECT ${parameter()} AS v`;
82 | 
83 |     // The first `bad` prepares the statement and the second one shares it.
84 |     // Both wait in the queue for the prepare.
85 |     const [first, second, one, two, after] = await settle(sql, [bad(), bad(), marker(1), marker(2), commands()]);
86 |     expect({ first, second, one, two, after: counts(after) }).toEqual({
                                                                   ^
error: expect(received).toEqual(expected)

  {
    "after": {
-     "Com_stmt_execute": 3,
-     "Com_stmt_prepare": 2,
+   
... (truncated)

release without fix: all passed
bun test v1.4.3-canary.1 (6372fc9)

test/js/sql/sql-mysql-queued-query-failure.test.ts:
Container ready via docker-compose: mysql_plain at 127.0.0.1:3306
(pass) mysql > a parameter whose toJSON throws rejects the query and the queries behind it run in order [17.11ms]
(pass) mysql > a parameter that holds a BigInt rejects the query and the queries behind it run in order [95.85ms]
(pass) mysql > a parameter that holds itself rejects the query and the queries behind it run in order [13.77ms]
(pass) mysql > a Date that DATETIME cannot hold rejects the query and the queries behind it run in order [5.17ms]
(pass) mysql > a parameter too large for one packet rejects the query and the queries behind it run in order [78.89ms]
(pass) mysql > a parameter whose toJSON throws an Error rejects the query with that value [5.27ms]
(pass) mysql > a parameter whose toJSON throws a string rejects the query with that value [5.37ms]
(pass) mysql > a parameter whose toJSON throws a number rejects the query with that value [1.82ms]
(pass) mysql > a parameter whose toJSON throws null rejects the query with that value [1.54ms]
(pass) mysql > a parameter whose toJSON throws undefined reject
... (truncated)
```

</details>

<details><summary>passes on PR (with fix)</summary>

```console
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/postgres-bind-encode-throw.test.ts test/js/sql/sql-mysql-queued-query-failure.test.ts
bun test v1.4.3 (367d939)

test/js/sql/sql-mysql-queued-query-failure.test.ts:
Container ready via docker-compose: mysql_plain at 127.0.0.1:3306
(pass) mysql > a parameter whose toJSON throws rejects the query and the queries behind it run in order [390.41ms]
(pass) mysql > a parameter that holds a BigInt rejects the query and the queries behind it run in order [91.84ms]
(pass) mysql > a parameter that holds itself rejects the query and the queries behind it run in order [70.70ms]
(pass) mysql > a Date that DATETIME cannot hold rejects the query and the queries behind it run in order [42.95ms]
(pass) mysql > a parameter too large for one packet rejects the query and the queries behind it run in order [66.40ms]
(pass) mysql > a parameter whose toJSON throws an Error rejects the query with that value [31.19ms]
(pass) mysql > a parameter whose toJSON throws a string rejects the query with that value [17.22ms]
(pass) mysql > a parameter whose
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 2762ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/140] gen JS modules (bundle-modules)
Preprocess modules (13063ms)
Bundle modules (253ms)
Postprocesss modules (244ms)
Bundle Functions (824ms)
Generate Code (54ms)

[14.45s] Bundled "src/js" for production
  2597 kb
  197 internal modules
  13 native modules
  50 internal functions across 16 files
[2/20] rustc bun_install 
[3/20] rustc bun_jsc 
[4/20] rustc bun_ast_jsc 
[5/20] rustc bun_bundler_jsc 
[6/20] rustc bun_semver_jsc 
[7/20] rustc bun_patch_jsc 
[8/20] rustc bun_css_jsc 
[9/20] rustc bun_sourcemap_jsc 
[10/20] rustc bun_sys_jsc 
[11/20] rustc bun_js_parser_jsc 
[12/20] rustc bun_http_jsc 
[13/20] rustc bun_install_jsc 
[14/20] rustc bun_sql_jsc 
[15/20] rustc bun_runtime 
[16/20] link bun-profile
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-35e8970dfd926abf-lto/lib/libJavaScriptCore.a(UnifiedSource-inspector-2.cpp.o at 102520730)' is 'x86_64-unkn
... (truncated)
```

</details>

<details><summary>diff hotspot</summary>

```
src/js/internal/sql/mysql.ts                       |   3 +-
 src/js/internal/sql/postgres.ts                    |   3 +-
 src/sql_jsc/mysql/JSMySQLQuery.rs                  |   3 +-
 src/sql_jsc/mysql/MySQLRequestQueue.rs             |   8 +-
 test/js/sql/postgres-bind-encode-throw.test.ts     |  35 +++
 test/js/sql/sql-mysql-queued-query-failure.test.ts | 254 +++++++++++++++++++++
 6 files changed, 296 insertions(+), 10 deletions(-)
```

</details>

**gate history** · 3 passed · 0 rejected · iteration 1

<details><summary>evidence per changed file</summary>

```
file                                                reads  edits  tests
src/js/internal/sql/mysql.ts                            1      1     28
src/js/internal/sql/postgres.ts                         1      1     28
src/sql_jsc/mysql/JSMySQLQuery.rs                       6      5     28
src/sql_jsc/mysql/MySQLRequestQueue.rs                  3      2     29
test/js/sql/postgres-bind-encode-throw.test.ts          1      2     18
test/js/sql/sql-mysql-queued-query-failure.test.ts      4      6     21
```

</details>

<!-- robobun:evidence:end -->
…ent cache

The ERROR arm of handle_prepared_statement is the only writer of
Status::Failed. It now calls fail_statement, which copies the error, writes
the status, and removes the statement from the connection's statement map.
The next query with the same text sends COM_STMT_PREPARE again. The queries
that share the statement hold their own ref and are rejected with its error.

fail_statement is cold and not generic, so the reply path of a successful
query does not change. The copy runs before any write: an allocation failure
leaves the statement as it was.

run_prepared_query loses its check for a cached Failed statement: the map
holds none.
@robobun
robobun force-pushed the farm/2419ddec/mysql-evict-failed-prepare branch from afb4ffb to 941269e Compare September 27, 2026 06:48
@robobun robobun changed the title sql(mysql): retry a failed prepare instead of caching the error forever sql(mysql): remove a statement that failed to prepare from the statement cache Sep 27, 2026

@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 note, I also checked the refcount and ordering claims in fail_statement: the request owns its own RefPtr via MySQLQuery::statement, so the map's remove() cannot drop the last ref; the ? that now precedes the IS_READY_FOR_QUERY/queue updates only fires on OOM, which on_data already routes to on_error and tears the connection down, so the skipped flag updates are moot. fail_statement is the only writer of Status::Failed for statements (the Failed arms in MySQLQuery::run_prepared_query and MySQLConnection remain for queries attached before the failure), the Postgres ErrorResponse arm already evicts the same way, and describeWithContainer itself turns the suite into describe.todo without docker, so dropping the isDockerEnabled() gate matches the sibling MySQL container tests.

Extended reasoning...

The diff is small: a new cold fail_statement helper in src/sql_jsc/mysql/MySQLConnection.rs that copies the ERR packet message, marks the statement Failed, and evicts it from the per-connection statement map; a removed cache-hit Failed check in src/sql_jsc/mysql/MySQLQuery.rs; and a rewritten container-backed test file plus one renamed test. It touches refcounted native driver state but no auth, crypto, or injection surface. The only finding is a description/code mismatch (a scripted-server test the PR text names is not in the diff, and the JSMySQLQuery.rs errguard change it describes is already on the base), so the note records what else was ruled out rather than restating that nit.

Comment thread test/js/sql/sql-mysql-cached-error.test.ts

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.

SQL: MySQL failed prepare remains unusable after the missing table is created

1 participant