Skip to content

sql(mysql): retry a failed prepare instead of caching the error forever - #33189

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

sql(mysql): retry a failed prepare instead of caching the error forever#33189
robobun wants to merge 5 commits into
mainfrom
farm/2419ddec/mysql-evict-failed-prepare

Conversation

@robobun

@robobun robobun commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator

Problem

In Bun.SQL (MySQL), a prepared statement whose COM_STMT_PREPARE the server answers with an ERR packet is stored in the per-connection statement cache with Status::Failed. Every later execution of the same query text on that connection finds the cached entry and replays its stored ErrorPacket without ever re-preparing, so a transient prepare-time error (a table created by a concurrent migration, a deadlock, ER_TOO_MANY_CONCURRENT_STMTS) poisons the connection for the process lifetime. With a pool this looks like "the same query works on some requests and permanently fails on others". mysql2 and other clients re-prepare on the next use.

Against a scripted server whose first COM_STMT_PREPARE for a text answers ERR 1146 and every later one answers OK:

query 1: rejected errno=1146 Table 'db.t' doesn't exist
query 2: rejected errno=1146 Table 'db.t' doesn't exist   <- replayed from cache
query 3: rejected errno=1146 Table 'db.t' doesn't exist   <- replayed from cache
server saw COM_STMT_PREPARE for the text: 1

A second, related problem surfaced during review (thanks, claude-bot): if two identical queries start in the same synchronous turn, the second attaches to the first's in-flight statement instead of issuing its own prepare. When that shared prepare fails, the second query's promise is never settled:

Promise.all([sql`SELECT * FROM t`, sql`SELECT * FROM t`])   // table missing
// first:  rejected errno=1146
// second: pending forever

Cause

  1. handle_prepared_statement's PacketType::ERROR arm sets statement.status = Failed and stores the error packet but never removes the statement from self.statements, so run_prepared_query serves the entry on every later lookup of the same signature hash. The Postgres driver already does this correctly: PostgresSQLConnection's ErrorResponse arm removes the failed statement from the map and drops the map's ref.
  2. advance() re-runs the attached second query, which hits the Failed status arm and returns an error. JSMySQLQuery::run's errguard set the query's status to Fail on unwind, and reject_with_js_value's settle-once guard (if !q.fail() { return }) then returned before ever invoking the JS reject callback. PostgresSQLQuery::run's equivalent guard already only rolls back the this_value upgrade.

Fix

  • MySQLConnection::handle_prepared_statement (ERROR arm): remove the failed statement from self.statements and deref the map's ref, mirroring the Postgres handler. Queries that attached to the statement before the failure still hold their own refs and still observe the error; the next query with the same text gets a fresh prepare.
  • MySQLQuery::run_prepared_query: delete the found_existing && Failed guard. The only place that sets Status::Failed now also removes the entry, so a cached entry is never Failed; the status match a few lines below already rejects on Failed for statements a query attached to before the failure.
  • JSMySQLQuery::run: the errguard no longer calls query.fail(); it only rolls back the this_value upgrade, matching Postgres. The advance()-driven caller settles through reject_with_js_value, which owns the fail() transition; the do_run caller propagates the exception into JS (internal/sql/query.ts rejects the promise) and marks the native query terminal itself. This also makes that rejection a real MySQLError (via onRejectMySQLQuery's wrapError) instead of the raw error-options object.

Only Failed entries are affected by the eviction. A successfully prepared statement is still cached and reused without re-preparing.

Tests

test/js/sql/sql-mysql-failed-prepare-retry.test.ts (new, scripted wire server):

  • eviction and retry: the first COM_STMT_PREPARE for a text answers ERR 1146, every later one OK. Asserts the second identical query fulfills, the server saw exactly 2 prepares on 1 connection, and a third identical query reuses the now-prepared statement (still 2 prepares). Fails on the unfixed build with prepares: 1, second: rejected, third: rejected.
  • concurrent siblings: two identical queries in the same Promise.all, every prepare answers ERR 1146. Asserts both reject with a real Error carrying errno: 1146 and that exactly one prepare reached the wire. On the unfixed build the test times out because the second promise never settles.

test/js/sql/sql-mysql-cached-error.test.ts (rewritten, describeWithContainer): queries a missing table twice on one connection (both prepares fail, Com_stmt_prepare increments both times), creates the table, and the same text succeeds. This file previously asserted the opposite (Com_stmt_prepare must not increment across an identical re-run) to pin a dangling-slice read on the cache-hit path added in #29847; that path no longer exists.

test/js/sql/sql-mysql.test.ts: the "Cached failed prepared statement returns stable error message" case still passes (a fresh prepare of the same text returns the identical error); its comments are updated since the cache-hit path is gone.

test/js/sql/wire-frames.ts: adds the mysqlErrorPacket (ERR_Packet) builder.

Both tests were verified to fail on a build from main's src/ and pass with the fix, and the rest of the test/js/sql wire-frame suites still pass.

…retried

A COM_STMT_PREPARE that the server answers with an ERR packet left the
statement in the per-connection statement cache with status Failed. Every
later execution of the same query text on that connection found the cached
entry and replayed its stored ErrorPacket without ever re-preparing, so a
transient prepare-time error (a table created by a concurrent migration, a
deadlock, ER_TOO_MANY_CONCURRENT_STMTS) poisoned the connection for the
process lifetime. With connection pooling that surfaces as the same query
permanently failing on an arbitrary subset of the pool.

handle_prepared_statement's ERROR arm now removes the failed statement from
the map and drops the map's ref on it, exactly as PostgresSQLConnection's
ErrorResponse handler already did. Queries that attached to the statement
before the failure still hold their own refs and still observe the error;
the next query with the same text gets a fresh prepare.

The found_existing + Failed guard in run_prepared_query is deleted: the only
place that sets Status::Failed now also removes the entry, so a cached entry
is never Failed, and the status match below it already rejects on Failed.

test/js/sql/sql-mysql-cached-error.test.ts and the 'Cached failed prepared
statement' case in sql-mysql.test.ts asserted the old no-re-prepare behavior
(Com_stmt_prepare must not increment); both are updated to the new contract.
@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 11:32 AM PT - Jul 1st, 2026

@robobun, your commit 2e3dc4d has 3 failures in Build #67601 (All Failures):


🧪   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 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: 14 minutes

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: 51c9651f-f9a0-4d26-a46e-93e659123c7b

📥 Commits

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

📒 Files selected for processing (1)
  • test/js/sql/sql-mysql-failed-prepare-retry.test.ts

Walkthrough

MySQL prepared-statement failure handling now evicts failed cache entries, query execution defers terminal failure handling to the shared status path, and the regression tests were updated to verify re-prepare behavior with both a mock server and a live MySQL connection.

Changes

MySQL prepared statement eviction and retry

Layer / File(s) Summary
Evict failed prepared statement and release cache reference
src/sql_jsc/mysql/MySQLConnection.rs
On prepare failure, the cached statement is removed by signature hash and its intrusive reference is decremented; the ERROR-path comment now states the error bytes must remain owned.
Defer failed-status handling in query execution
src/sql_jsc/mysql/MySQLQuery.rs, src/sql_jsc/mysql/JSMySQLQuery.rs
The cached-statement setup path now defers Failed handling to the shared status match, and do_run marks the query failed before mapping or throwing errors.
Add mysqlErrorPacket wire-frame helper
test/js/sql/wire-frames.ts
Adds mysqlErrorPacket for framing MySQL ERR packets with error code, SQLSTATE, and message.
Mock-server regression test for cache eviction/retry
test/js/sql/sql-mysql-failed-prepare-retry.test.ts
Adds a mock-server test that fails the first prepare, then checks re-prepare, concurrent sharing, and cache reuse across repeated executions.
Docker-backed cached-error test rewritten for eviction behavior
test/js/sql/sql-mysql-cached-error.test.ts, test/js/sql/sql-mysql.test.ts
Reworks the cached-error regression test to assert eviction, re-prepare, and later cache hits via Com_stmt_prepare, and updates the stable-error test description/comments to match the new behavior.

Possibly related PRs

  • oven-sh/bun#29847: Both PRs modify the MySQL prepared-statement error path and cached-failure behavior.
  • oven-sh/bun#32467: Both PRs update the cached-failed prepared-statement regression coverage to assert eviction and re-prepare behavior.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed Clearly summarizes the main change: failed MySQL prepares are retried instead of cached forever.
Description check ✅ Passed It covers the bug, root cause, fix, and verification, though it uses custom headings instead of the template.
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.

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

CI status: the diff is green. The final matrix for the final sha is build 67601 (267 passed, 19 failed). I pulled the log of every one of the 19 failed test jobs: not one test/js/sql/ test failed on any lane, and both new regression tests in test/js/sql/sql-mysql-failed-prepare-retry.test.ts passed everywhere. Every red lane is one of four unrelated causes, the top two of which are currently failing on every PR in the pipeline:

  1. test/bake/dev/production.test.ts ("works with sourcemaps - error thrown in React component", 90s timeout), on essentially every test lane. Repo-wide: it appears on every build of this pipeline from 67594 onward (at least builds 67594, 67600, 67601, 67605, 67606, 67607, 67608, spanning 6 unrelated branches) and on none of the ~15 builds before that. It started at a point in time, not in any one diff; this PR's earlier builds (67582, 67587) predate the onset and did not hit it.
  2. test/js/bun/http/serve-body-leak.test.ts (40s timeout / child killed) on the linux lanes. Also cross-branch: it appears on builds 67580, 67594, 67600, 67601, 67605, and 67607.
  3. test/js/node/test/parallel/test-net-connect-memleak.js on the alpine (linux-x64-musl) lanes: the known flake tracked in CI: test-net-connect-memleak.js fails on half of PR builds on linux-x64-musl since June 28 ~23:00 UTC #33044, with quarantine PR Quarantine test-net-connect-memleak on linux-x64-musl #33045 already open; it is on nearly every recent build of the pipeline.
  4. Both :darwin: 26 aarch64 - test-bun shards failed before running a single test: buildkite-agent artifact download timed out after 120s for step 'darwin-aarch64-build-bun' (the build step that produced the artifact passed).

A few further single-shard one-offs (proxy-stress-concurrent.test.ts, handle-leak.test.ts, regression/issue/20144) are the usual load-sensitive flake class and none touch SQL.

I used my one retrigger earlier, so I am not pushing more. Ready for review.

Comment thread src/sql_jsc/mysql/MySQLQuery.rs
… it pending

When two identical queries start in the same synchronous turn, the second
attaches to the first's in-flight statement. If that shared prepare fails,
advance() re-runs the second query, which hits the Failed status arm and
returns an error. JSMySQLQuery::run's errguard then set the query's status
to Fail on unwind, and reject_with_js_value's settle-once guard saw fail()
return false and returned before ever invoking the JS reject callback, so
the second query's promise was never settled.

Move the fail() out of run()'s errguard: the guard now only rolls back the
this_value upgrade, matching PostgresSQLQuery::run. The advance()-driven
caller settles through reject_with_js_value, which owns the fail()
transition; the do_run caller propagates the exception into JS (query.ts
rejects the promise there) and marks the native query terminal itself.

This also fixes the rejection value on that path: it now goes through
onRejectMySQLQuery's wrapError and is a MySQLError rather than the raw
error-options object thrown by run_prepared_query.

The new concurrent test in sql-mysql-failed-prepare-retry.test.ts times out
on the unfixed build (the second promise never settles) and passes with the
fix.

@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
The two tests each start their own mock server on an ephemeral port with no
shared state, so they can run as test.concurrent. The comment above the second
test narrated the bug's mechanism, which belongs in the PR description; it now
states only the invariant the test asserts.

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

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.

1 participant