Conversation
|
Updated 12:17 AM PT - Sep 27th, 2026
✅ @robobun, your commit 941269e78ebaaa084f5cd942f14cdb3e9956f570 passed in 🧪 To try this PR locally: bunx bun-pr 33189That installs a local version of the PR into your bun-33189 --bun |
|
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 configurationConfiguration used: Repository: oven-sh/bun/.coderabbit.yaml Review profile: ASSERTIVE Plan: Essentials Run ID: 📒 Files selected for processing (4)
💤 Files with no reviewable changes (1)
Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 5 remain after this review. WalkthroughMySQL 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. ChangesFailed prepared statement eviction and retry
Suggested reviewers: Priority: ⬇️ Low Severity of issue fixed: Low Merge Risk: ⚪ Minimal · up to 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)
Comment |
There was a problem hiding this comment.
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.
|
Good flags, both worth pre-answering for whoever picks this up. The missing The Postgres handler checks the pre-transition status and wraps both the 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:
A literal copy of the Postgres check would also read the wrong thing at that point: by the time the eviction block runs, One more invariant worth stating explicitly: the entry removed by Inverting the test invariant The The property that test file was originally added for in #29847 (the stored |
There was a problem hiding this comment.
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
📒 Files selected for processing (6)
src/sql_jsc/mysql/MySQLConnection.rssrc/sql_jsc/mysql/MySQLQuery.rstest/js/sql/sql-mysql-cached-error.test.tstest/js/sql/sql-mysql-failed-prepare-retry.test.tstest/js/sql/sql-mysql.test.tstest/js/sql/wire-frames.ts
|
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
PR: #33189 |
There was a problem hiding this comment.
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 winConsider 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.concurrentordescribe.concurrentunless 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
📒 Files selected for processing (4)
src/sql_jsc/mysql/JSMySQLQuery.rssrc/sql_jsc/mysql/MySQLConnection.rssrc/sql_jsc/mysql/MySQLQuery.rstest/js/sql/sql-mysql-failed-prepare-retry.test.ts
There was a problem hiding this comment.
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.
|
Thanks for the final pass. For whoever picks this up, all three of the deferred points already have written answers:
CI is green for everything this PR touches; the red lanes are a repo-wide |
|
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 |
2e3dc4d to
776ab64
Compare
|
Rebased onto main (53a23a4). The conflict itself was one hunk in
Re-verified on the new base: both regression tests fail on a build from main's |
53a23a4 to
4210b4d
Compare
|
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. |
…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.
afb4ffb to
941269e
Compare
There was a problem hiding this comment.
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.
Fixes #42720
Problem
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.handle_prepared_statement(src/sql_jsc/mysql/MySQLConnection.rs) marks the statementFailedand leaves it in the statement map.run_prepared_querythrows the stored error.Fix
fail_statement, which also removes the statement from the map. The next query with that key prepares again.Failedis deleted: the map holds none.test/js/sql/sql-mysql-cached-error.test.ts(7 tests, 5 fail on main'ssrc/). Alsosql-mysql-queued-query-failure.test.tsandsql-statement-cache-hash-collision.test.ts.Background
COM_STMT_PREPARE.handle_prepared_statementis generic and inlined into the reply path.fail_statementis#[cold]and not generic, so that path does not change.Failedstatement: the same wire result, but refused statements stay in the map. The Postgres adapter already removes the statement in its error arm.Downsides
JSMySQLQuery::runexecutes 14 more instructions per prepared query (352 to 366)..textgrows by 512 B.Notes
What the rebase removed from this PR
run()anddo_runinJSMySQLQuery.rs. sql: reject a MySQL query whose queued write fails, keep the queue order, pass thrown non-objects through #36842 merged it. This PR does not touch that file.mysqlErrorPackethelper inwire-frames.ts. The tests now run on a real server and read theCom_stmt_preparecounter of the session.The reproduction of #42720 (
max: 1, MariaDB 11.8.6)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,valgrindandbloatyare not installed on the machine.The deleted compare was expected to make
runshorter.runhas 204 B less code but executes 14 more instructions per call: the compiler moves values between registers in a different way (+17mov, -1cmpb, -1jne). The check is deleted all the same, because no statement in the map can have the statusFailed.Tests and controls (debug builds with ASAN, MariaDB 11.8.6)
sql-mysql-cached-error.test.tssrc/(a4f1429)statements.removeself.statements.clear()[1, 1, 1, 1, 1]src/are the tests of the queries that share a failed prepare. They hold behaviour that this PR must keep.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.tsneeds 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.mysql:8.4,mysql:9, TLS).Why the two old tests changed
sql-mysql-cached-error.test.tsasserted 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
max_prepared_stmt_countreached): 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.What this PR does not change
Pending. It never reaches the server, so no reply removes it: sql(mysql): a statement text of 16 MiB or more rejects with nocode#43993.Errorwith nocode: Bun.SQL: a query that fails on the client rejects with a different value on each path #43992.COM_STMT_CLOSE. sql(mysql): cap the prepared-statement cache and send COM_STMT_CLOSE on eviction #33190 adds that and a limit for the map. It complements this PR and can land after it.USE b, a cached text still reads databasea. With this PR, a text that failed inaand prepared inbreadsbalso afterUSE a.mysql23.7.0 gives the same result withexecute(). This is the one concern of the self-review with no change and no tracking issue.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. Adebug_assert!checks that.[review] gate passed · iteration 7 · 6 files touched
fails on main (without fix)
passes on PR (with fix)
diff hotspot
gate history · 2 passed · 0 rejected · iteration 7
evidence per changed file