mirror of
https://github.com/oven-sh/bun
synced 2026-02-17 22:32:06 +00:00
fix(sql): prevent hang in sequential MySQL transactions with returned array queries (#26048)
## Summary - Fix a hang in sequential MySQL transactions where an INSERT is awaited followed by a SELECT returned in an array - The issue occurred because `handleResultSetOK`'s defer block only called `queue.advance()` without flushing, causing queries added during the JS callback to not be properly sent - Changed to call `flushQueue()` instead of just `advance()` to ensure data is actually sent to the server Fixes #26030 ## Test plan - Added regression test `test/regression/issue/26030.test.ts` with three test cases: - `Sequential transactions with INSERT and returned SELECT should not hang` - reproduces the exact pattern from the bug report - `Sequential transactions with returned array of multiple queries` - tests returning multiple queries in array - `Many sequential transactions with awaited INSERT and returned SELECT` - stress tests with 5 sequential transactions 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Bot <claude-bot@bun.sh> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -646,7 +646,7 @@ pub fn handleCommand(this: *MySQLConnection, comptime Context: type, reader: New
|
||||
.failed => {
|
||||
const connection = this.getJSConnection();
|
||||
defer {
|
||||
this.queue.advance(connection);
|
||||
this.flushQueue() catch {};
|
||||
}
|
||||
this.#flags.is_ready_for_query = true;
|
||||
this.queue.markAsReadyForQuery();
|
||||
@@ -933,7 +933,11 @@ fn handleResultSetOK(this: *MySQLConnection, request: *JSMySQLQuery, statement:
|
||||
const connection = this.getJSConnection();
|
||||
debug("handleResultSetOK: {d} {}", .{ status_flags.toInt(), is_last_result });
|
||||
defer {
|
||||
this.queue.advance(connection);
|
||||
// Use flushQueue instead of just advance to ensure any data written
|
||||
// by queries added during onQueryResult is actually sent.
|
||||
// This fixes a race condition where the auto flusher may not be
|
||||
// registered if the queue's current item is completed (not pending).
|
||||
this.flushQueue() catch {};
|
||||
}
|
||||
this.#flags.is_ready_for_query = is_last_result;
|
||||
if (is_last_result) {
|
||||
@@ -977,7 +981,7 @@ fn handleResultSet(this: *MySQLConnection, comptime Context: type, reader: NewRe
|
||||
try err.decode(reader);
|
||||
defer err.deinit();
|
||||
defer {
|
||||
this.queue.advance(connection);
|
||||
this.flushQueue() catch {};
|
||||
}
|
||||
if (request.getStatement()) |statement| {
|
||||
statement.reset();
|
||||
|
||||
133
test/regression/issue/26030.test.ts
Normal file
133
test/regression/issue/26030.test.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { SQL, randomUUIDv7 } from "bun";
|
||||
import { beforeEach, expect, test } from "bun:test";
|
||||
import { describeWithContainer } from "harness";
|
||||
|
||||
describeWithContainer(
|
||||
"mysql",
|
||||
{
|
||||
image: "mysql_plain",
|
||||
env: {},
|
||||
args: [],
|
||||
},
|
||||
container => {
|
||||
const getOptions = () => ({
|
||||
url: `mysql://root@${container.host}:${container.port}/bun_sql_test`,
|
||||
max: 1,
|
||||
bigint: true,
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await container.ready;
|
||||
});
|
||||
|
||||
// Regression test for https://github.com/oven-sh/bun/issues/26030
|
||||
// Bun hangs when executing multiple sequential MySQL transactions in a loop where:
|
||||
// 1. An INSERT is awaited inside the transaction callback
|
||||
// 2. A SELECT query (e.g., SELECT LAST_INSERT_ID()) is returned as an array without being awaited
|
||||
test("Sequential transactions with INSERT and returned SELECT should not hang", async () => {
|
||||
await using sql = new SQL(getOptions());
|
||||
const random_name = ("t_" + randomUUIDv7("hex").replaceAll("-", "")).toLowerCase();
|
||||
|
||||
// Create a table similar to the reproduction case
|
||||
await sql`CREATE TABLE IF NOT EXISTS ${sql(random_name)} (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
contract_name VARCHAR(255),
|
||||
amount INT
|
||||
)`;
|
||||
|
||||
try {
|
||||
const rows = [
|
||||
{ contract_name: "Contract A", amount: 100000 },
|
||||
{ contract_name: "Contract B", amount: 200000 },
|
||||
{ contract_name: "Contract C", amount: 300000 },
|
||||
];
|
||||
|
||||
const contractIds: number[] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
// This is the pattern from the bug report:
|
||||
// - INSERT is awaited
|
||||
// - SELECT LAST_INSERT_ID() is returned as array (not awaited individually)
|
||||
const [[result]] = await sql.begin(async tx => {
|
||||
await tx`
|
||||
INSERT INTO ${sql(random_name)} (contract_name, amount)
|
||||
VALUES (${row.contract_name}, ${row.amount})
|
||||
`;
|
||||
// Return array with non-awaited query - this triggers the hang
|
||||
return [tx`SELECT LAST_INSERT_ID() as id`];
|
||||
});
|
||||
|
||||
contractIds.push(Number(result.id));
|
||||
}
|
||||
|
||||
// Verify all transactions completed
|
||||
expect(contractIds.length).toBe(3);
|
||||
expect(contractIds[0]).toBe(1);
|
||||
expect(contractIds[1]).toBe(2);
|
||||
expect(contractIds[2]).toBe(3);
|
||||
|
||||
// Verify data in database
|
||||
const count = await sql`SELECT COUNT(*) as count FROM ${sql(random_name)}`;
|
||||
expect(Number(count[0].count)).toBe(3);
|
||||
} finally {
|
||||
await sql`DROP TABLE IF EXISTS ${sql(random_name)}`;
|
||||
}
|
||||
});
|
||||
|
||||
test("Sequential transactions with returned array of multiple queries", async () => {
|
||||
await using sql = new SQL(getOptions());
|
||||
const random_name = ("t_" + randomUUIDv7("hex").replaceAll("-", "")).toLowerCase();
|
||||
|
||||
await sql`CREATE TABLE IF NOT EXISTS ${sql(random_name)} (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
value INT
|
||||
)`;
|
||||
|
||||
try {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const results = await sql.begin(async tx => {
|
||||
await tx`INSERT INTO ${sql(random_name)} (value) VALUES (${i * 10})`;
|
||||
// Return multiple queries as array
|
||||
return [tx`SELECT LAST_INSERT_ID() as id`, tx`SELECT COUNT(*) as count FROM ${sql(random_name)}`];
|
||||
});
|
||||
|
||||
expect(results.length).toBe(2);
|
||||
}
|
||||
|
||||
const count = await sql`SELECT COUNT(*) as count FROM ${sql(random_name)}`;
|
||||
expect(Number(count[0].count)).toBe(3);
|
||||
} finally {
|
||||
await sql`DROP TABLE IF EXISTS ${sql(random_name)}`;
|
||||
}
|
||||
});
|
||||
|
||||
test("Many sequential transactions with awaited INSERT and returned SELECT", async () => {
|
||||
await using sql = new SQL(getOptions());
|
||||
const random_name = ("t_" + randomUUIDv7("hex").replaceAll("-", "")).toLowerCase();
|
||||
|
||||
await sql`CREATE TABLE IF NOT EXISTS ${sql(random_name)} (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(255)
|
||||
)`;
|
||||
|
||||
try {
|
||||
// Multiple sequential transactions with awaited INSERT and returned SELECT
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const [[result]] = await sql.begin(async tx => {
|
||||
// First insert
|
||||
await tx`INSERT INTO ${sql(random_name)} (name) VALUES (${"item_" + i})`;
|
||||
// Return array with SELECT
|
||||
return [tx`SELECT LAST_INSERT_ID() as id`];
|
||||
});
|
||||
|
||||
expect(Number(result.id)).toBe(i + 1);
|
||||
}
|
||||
|
||||
const count = await sql`SELECT COUNT(*) as count FROM ${sql(random_name)}`;
|
||||
expect(Number(count[0].count)).toBe(5);
|
||||
} finally {
|
||||
await sql`DROP TABLE IF EXISTS ${sql(random_name)}`;
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
Reference in New Issue
Block a user