-
Notifications
You must be signed in to change notification settings - Fork 5k
sql(mysql): retry a failed prepare instead of caching the error forever #33189
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
robobun
wants to merge
5
commits into
main
Choose a base branch
from
farm/2419ddec/mysql-evict-failed-prepare
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 1 commit
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
46d89ed
sql(mysql): evict a failed prepare from the statement cache so it is …
robobun d6654c2
ci: retrigger
robobun 1c206ca
sql(mysql): keep the failed-prepare comments within the 3-line limit
robobun b74b3a8
sql(mysql): settle a query rejected from advance() instead of leaving…
robobun 2e3dc4d
test(sql): run the failed-prepare tests concurrently and trim a comment
robobun File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,73 +1,77 @@ | ||
| // Regression test: MySQLConnection.handlePreparedStatement stored an ErrorPacket whose | ||
| // error_message was a Data{ .temporary = ... } slice pointing into the socket read buffer. | ||
| // The statement is cached in the connection's statements map with status = .failed, so | ||
| // re-running the same failing query would read the stale slice after subsequent packets | ||
| // overwrote the buffer. | ||
| // Regression test: MySQLConnection cached a prepared statement whose | ||
| // COM_STMT_PREPARE failed (status = .failed) in the per-connection statement map | ||
| // and never evicted it, so every later execution of the same query text on that | ||
| // connection rethrew the stale ErrorPacket without ever re-preparing. A | ||
| // transient prepare-time error (a table created by a concurrent migration, a | ||
| // deadlock, ER_TOO_MANY_CONCURRENT_STMTS) therefore poisoned the connection for | ||
| // the process lifetime. handlePreparedStatement now evicts the failed statement | ||
| // from the map (as the Postgres driver already did) so the prepare is retried on | ||
| // the next use of that text. | ||
| // | ||
| // This file previously asserted the opposite (Com_stmt_prepare must NOT | ||
| // increment across an identical re-run) to pin a dangling-slice read in the | ||
| // cached ErrorPacket's error_message; that cache-hit path no longer exists. | ||
| // test/js/sql/sql-mysql-failed-prepare-retry.test.ts is the wire-level | ||
| // counterpart that runs without a container. | ||
|
|
||
| import { SQL } from "bun"; | ||
| import { SQL, randomUUIDv7 } from "bun"; | ||
| import { expect, test } from "bun:test"; | ||
| import { describeWithContainer, isDockerEnabled } from "harness"; | ||
|
|
||
| if (isDockerEnabled()) { | ||
| describeWithContainer("mysql", { image: "mysql_plain" }, container => { | ||
| test("MySQL: cached failed prepared statement error_message is not a dangling slice", async () => { | ||
| test("MySQL: a failed prepare is re-prepared instead of served from the statement cache", async () => { | ||
| await container.ready; | ||
| // max: 1 so every query runs on the same connection / same statement map, | ||
| // and Com_stmt_prepare (a SESSION counter) observes exactly that session. | ||
| await using sql = new SQL({ | ||
| url: `mysql://root@${container.host}:${container.port}/bun_sql_test`, | ||
| max: 1, | ||
| }); | ||
|
|
||
| // Long bogus identifiers so the server's echoed error_message exceeds the 15-byte | ||
| // inline-string threshold and is heap-backed, and so the two messages differ at | ||
| // bytes the second packet would overwrite in the read buffer. MySQL truncates the | ||
| // "near '...'" clause to ~80 chars, so keep these short enough to appear in full. | ||
| const longA = Buffer.alloc(50, "A").toString(); | ||
| const longZ = Buffer.alloc(50, "Z").toString(); | ||
| const table = "t_retry_" + randomUUIDv7("hex").replaceAll("-", ""); | ||
| // .simple() = COM_QUERY (text protocol), so the counter read itself never | ||
| // sends a COM_STMT_PREPARE. | ||
| const prepares = async () => | ||
| Number((await sql.unsafe("SHOW SESSION STATUS LIKE 'Com_stmt_prepare'").simple())[0].Value); | ||
|
|
||
| // First failing query → statement cached as .failed with error_message. | ||
| const err1 = await sql`wat ${1} ${sql.unsafe(longA)}`.catch((x: any) => x); | ||
| expect(err1).toBeInstanceOf(Error); | ||
| expect(err1.code).toBe("ERR_MYSQL_SYNTAX_ERROR"); | ||
| expect(err1.errno).toBe(1064); | ||
| expect(err1.message).toContain(longA); | ||
| try { | ||
| // 1. The table does not exist yet: the prepare fails (ER_NO_SUCH_TABLE). | ||
| const err1 = await sql`SELECT n FROM ${sql(table)}`.catch((x: any) => x); | ||
| expect(err1).toBeInstanceOf(Error); | ||
| expect(err1.errno).toBe(1146); | ||
| const afterFirst = await prepares(); | ||
| expect(afterFirst).toBeGreaterThan(0); | ||
|
|
||
| // Different failing query → server sends a different ERROR packet that overwrites | ||
| // the connection read buffer where err1's message slice used to point. | ||
| const errOverwrite = await sql`other ${1} ${sql.unsafe(longZ)}`.catch((x: any) => x); | ||
| expect(errOverwrite).toBeInstanceOf(Error); | ||
| expect(errOverwrite.message).toContain(longZ); | ||
| expect(errOverwrite.message).not.toBe(err1.message); | ||
| // 2. Same text, table still missing. Before the fix the stale cached | ||
| // ErrorPacket was replayed and the server never saw a second | ||
| // COM_STMT_PREPARE; now Bun re-prepares and the server answers the | ||
| // same (still true) error. | ||
| const err2 = await sql`SELECT n FROM ${sql(table)}`.catch((x: any) => x); | ||
| expect({ errno: err2.errno, message: err2.message, prepares: await prepares() }).toEqual({ | ||
| errno: 1146, | ||
| message: err1.message, | ||
| prepares: afterFirst + 1, | ||
| }); | ||
|
|
||
| // Same as the first failing query → hits the cached .failed statement and calls | ||
| // stmt.error_response.toJS(). Before the fix this read the overwritten buffer and | ||
| // returned bytes from errOverwrite's packet; after the fix it returns the original. | ||
| // Com_stmt_prepare (read via .simple() so the status query itself does not prepare) | ||
| // must not increment across this call — proving the third query was served from | ||
| // Bun's failed-statement cache, not re-prepared on the server. A fresh prepare | ||
| // would return an identical error for identical SQL and silently satisfy every | ||
| // assertion below without exercising the cached-slice path. | ||
| const [{ Value: preparesBefore }] = await sql.unsafe("SHOW SESSION STATUS LIKE 'Com_stmt_prepare'").simple(); | ||
| // err1 and errOverwrite each reached COM_STMT_PREPARE, so the counter is | ||
| // already non-zero here; if it were 0 the "no increment" check below would | ||
| // be vacuous because the prepared path was never taken. | ||
| expect(Number(preparesBefore)).toBeGreaterThan(0); | ||
| const err2 = await sql`wat ${1} ${sql.unsafe(longA)}`.catch((x: any) => x); | ||
| const [{ Value: preparesAfter }] = await sql.unsafe("SHOW SESSION STATUS LIKE 'Com_stmt_prepare'").simple(); | ||
| expect({ | ||
| code: err2.code, | ||
| errno: err2.errno, | ||
| sqlState: err2.sqlState, | ||
| message: err2.message, | ||
| preparesAfter: Number(preparesAfter), | ||
| }).toEqual({ | ||
| code: err1.code, | ||
| errno: err1.errno, | ||
| sqlState: err1.sqlState, | ||
| message: err1.message, | ||
| preparesAfter: Number(preparesBefore), | ||
| }); | ||
| expect(err2.message).toContain(longA); | ||
| expect(err2.message).not.toContain(longZ); | ||
| // 3. The migration lands. The same text on the same connection must now | ||
| // prepare successfully and return rows. | ||
| await sql.unsafe(`CREATE TABLE \`${table}\` (n INT)`).simple(); | ||
| await sql.unsafe(`INSERT INTO \`${table}\` VALUES (42)`).simple(); | ||
| const beforeThird = await prepares(); | ||
| expect(await sql`SELECT n FROM ${sql(table)}`).toEqual([{ n: 42 }]); | ||
| expect(await prepares()).toBe(beforeThird + 1); | ||
|
|
||
| // 4. Only Failed entries are evicted: the now-Prepared statement is | ||
| // served from the cache, so the counter does not move. | ||
| expect(await sql`SELECT n FROM ${sql(table)}`).toEqual([{ n: 42 }]); | ||
| expect(await prepares()).toBe(beforeThird + 1); | ||
| } finally { | ||
| await sql | ||
| .unsafe(`DROP TABLE IF EXISTS \`${table}\``) | ||
| .simple() | ||
| .catch(() => {}); | ||
| } | ||
| }); | ||
| }); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,117 @@ | ||
| // Regression test: MySQLConnection cached a prepared statement whose | ||
| // COM_STMT_PREPARE failed (status = .failed) in the per-connection statement | ||
| // map and never evicted it, so every later execution of the same query text on | ||
| // that connection rethrew the stale ErrorPacket without ever re-preparing. | ||
| // Transient prepare failures are normal (a table that appears after a | ||
| // migration, deadlocks, ER_TOO_MANY_CONCURRENT_STMTS); with pooling this | ||
| // poisons a connection for the process lifetime. | ||
| // | ||
| // The oracle is the number of COM_STMT_PREPARE frames the client emits for one | ||
| // query text, so the server here is a scripted mock that observes the client's | ||
| // outbound frames directly: the first prepare of the text fails, every later | ||
| // prepare of it succeeds. A real container cannot make the same prepare fail | ||
| // once and then succeed without an out-of-band DDL racing the client. | ||
| // All wire-protocol bytes come from test/js/sql/wire-frames.ts; do not inline | ||
| // Buffer.alloc frame construction here. | ||
|
|
||
| import { SQL } from "bun"; | ||
| import { expect, test } from "bun:test"; | ||
| import type { Socket } from "node:net"; | ||
| import { | ||
| listeningServer, | ||
| mysqlErrorPacket, | ||
| mysqlHandshakeV10, | ||
| mysqlOkPacket, | ||
| mysqlReadPackets, | ||
| mysqlStmtPrepareOk, | ||
| } from "./wire-frames"; | ||
|
|
||
| const COM_QUIT = 0x01; | ||
| const COM_STMT_PREPARE = 0x16; | ||
| const COM_STMT_EXECUTE = 0x17; | ||
| const COM_STMT_CLOSE = 0x19; | ||
|
|
||
| test("MySQL: a failed prepare is evicted from the statement cache and retried", async () => { | ||
| // First COM_STMT_PREPARE for a given text answers ERR 1146 (table missing), | ||
| // every later one answers OK. COM_STMT_EXECUTE always answers OK. | ||
| const preparesByText = new Map<string, number>(); | ||
| let connections = 0; | ||
| let stmtId = 0; | ||
| const sockets = new Set<Socket>(); | ||
| const { server, port } = await listeningServer(socket => { | ||
| connections++; | ||
| sockets.add(socket); | ||
| let buffered = Buffer.alloc(0); | ||
| let authed = false; | ||
| socket.write(mysqlHandshakeV10()); | ||
| socket.on("data", chunk => { | ||
| buffered = mysqlReadPackets(Buffer.concat([buffered, chunk]), (seq, payload) => { | ||
| if (!authed) { | ||
| authed = true; | ||
| socket.write(mysqlOkPacket(seq + 1)); | ||
| return; | ||
| } | ||
| const cmd = payload[0]; | ||
| if (cmd === COM_STMT_PREPARE) { | ||
| const text = payload.subarray(1).toString("utf-8"); | ||
| const n = (preparesByText.get(text) ?? 0) + 1; | ||
| preparesByText.set(text, n); | ||
| if (n === 1) { | ||
| socket.write(mysqlErrorPacket(1, 1146, "42S02", "Table 'db.t' doesn't exist")); | ||
| } else { | ||
| socket.write(mysqlStmtPrepareOk(1, ++stmtId, 0, 0)); | ||
| } | ||
| } else if (cmd === COM_STMT_EXECUTE) { | ||
| socket.write(mysqlOkPacket(1)); | ||
| } else if (cmd === COM_STMT_CLOSE) { | ||
| // COM_STMT_CLOSE expects no response. | ||
| } else if (cmd === COM_QUIT) { | ||
| socket.end(); | ||
| } else { | ||
| socket.end(); | ||
| } | ||
| }); | ||
| }); | ||
| socket.on("error", () => {}); | ||
| socket.on("close", () => sockets.delete(socket)); | ||
| }); | ||
|
|
||
| try { | ||
| await using sql = new SQL({ url: `mysql://root@127.0.0.1:${port}/db`, max: 1 }); | ||
|
|
||
| const settled = (q: Promise<any>) => | ||
| q.then( | ||
| value => ({ status: "fulfilled", value }) as const, | ||
| reason => ({ status: "rejected", reason }) as const, | ||
| ); | ||
|
|
||
| // 1. The prepare fails with a transient server error. | ||
| const first = await settled(sql`SELECT * FROM t`); | ||
| expect(first.status).toBe("rejected"); | ||
| expect((first as any).reason).toMatchObject({ errno: 1146, code: "ERR_MYSQL_SERVER_ERROR" }); | ||
|
|
||
| // 2. The same text again on the same connection. Before the fix the stale | ||
| // ErrorPacket was replayed from the statement cache and the server never | ||
| // saw a second COM_STMT_PREPARE; after the fix it re-prepares and runs. | ||
| const second = await settled(sql`SELECT * FROM t`); | ||
|
|
||
| // 3. Same text a third time: the now-Prepared statement IS served from the | ||
| // cache, proving only Failed entries are evicted, not the cache itself. | ||
| const third = await settled(sql`SELECT * FROM t`); | ||
|
|
||
| expect({ | ||
| connections, | ||
| prepares: preparesByText.get("SELECT * FROM t"), | ||
| second: second.status, | ||
| third: third.status, | ||
| }).toEqual({ | ||
| connections: 1, | ||
| prepares: 2, | ||
| second: "fulfilled", | ||
| third: "fulfilled", | ||
| }); | ||
| } finally { | ||
| for (const s of sockets) s.destroy(); | ||
| await new Promise<void>(resolve => server.close(() => resolve())); | ||
| } | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.