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:
robobun
2026-01-14 12:53:04 -08:00
committed by GitHub
parent 7333500df8
commit a9b5f5cbd1
2 changed files with 140 additions and 3 deletions

View File

@@ -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();

View 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)}`;
}
});
},
);