Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/sql_jsc/postgres/PostgresRequest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -423,9 +423,11 @@ pub(crate) fn execute_query<Context: WriterContext>(
query: &[u8],
mut writer: protocol::NewWriter<Context>,
) -> Result<(), AnyPostgresError> {
// A simple Query ('Q') is its own sync point: the backend always answers it
// with exactly one ReadyForQuery. Do not append a Sync here: it would elicit
// a second, unaccounted ReadyForQuery that re-arms advance() mid-prepare.
protocol::write_query(query, &mut writer)?;
writer.write(&protocol::FLUSH)?;
writer.write(&protocol::SYNC)?;
Ok(())
}

Expand Down
80 changes: 80 additions & 0 deletions test/js/sql/postgres-simple-query-pipeline.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// A simple-protocol Query ('Q') is its own sync point: the backend answers it
// with exactly one ReadyForQuery. Bun also appended an extended-protocol Sync
// after every 'Q', so the server replied with a second, unaccounted
// ReadyForQuery per simple query. That spurious ReadyForQuery re-armed the
// connection's "ready" state while the next query's Parse+Describe round trip
// was still in flight, and advance() then pipelined a third query into that
// window, so its replies were delivered to the wrong query.
//
// The simple protocol is used for query.simple(), for sql.unsafe(text) with
// no parameters, and for the BEGIN/COMMIT/ROLLBACK of sql.begin(), so every
// one of those emitted the spurious ReadyForQuery.
import { SQL } from "bun";
import { expect, test } from "bun:test";
import { describeWithContainer } from "harness";

describeWithContainer("postgres", { image: "postgres_plain" }, container => {
const url = () => `postgres://bun_sql_test@${container.host}:${container.port}/bun_sql_test`;

// Before the fix: the second ReadyForQuery from A's redundant Sync lets C's
// 'Q' go out inside B's Parse+Describe window. C's result set then arrives
// while B is still the current query, so B resolves with C's row and C
// resolves with B's (field-less) Bind+Execute row: b = [{v:"CCCC"}], c = [{}].
test("a simple query does not steal the rows of an in-flight prepare", async () => {
await container.ready;
await using sql = new SQL({ url: url(), max: 1, idleTimeout: 5, connectionTimeout: 5 });

const [a, b, c] = await Promise.all([
sql`SELECT 'AAAA'::text AS v`.simple(),
sql`SELECT ${"BBBB"}::text AS v`,
sql`SELECT 'CCCC'::text AS v`.simple(),
]);

expect({ a, b, c }).toEqual({
a: [{ v: "AAAA" }],
b: [{ v: "BBBB" }],
c: [{ v: "CCCC" }],
});
});

// sql.unsafe(text) with no parameters routes through the same simple ('Q')
// protocol, so the same misattribution happens without the caller ever
// opting into simple mode.
test("unsafe() with no parameters does not steal the rows of an in-flight prepare", async () => {
await container.ready;
await using sql = new SQL({ url: url(), max: 1, idleTimeout: 5, connectionTimeout: 5 });

const [a, b, c] = await Promise.all([
sql.unsafe(`SELECT 'AAAA'::text AS v`),
sql.unsafe(`SELECT $1::text AS v`, ["BBBB"]),
sql.unsafe(`SELECT 'CCCC'::text AS v`),
]);

expect({ a, b, c }).toEqual({
a: [{ v: "AAAA" }],
b: [{ v: "BBBB" }],
c: [{ v: "CCCC" }],
});
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Same root, different symptom: when the third query needs its own Parse, the
// spurious ReadyForQuery also clears the waiting-to-prepare state, so C's
// Parse+Describe is pipelined inside B's. C's describe reply is then consumed
// under B, C's statement never leaves the Parsing state, and C never settles.
test("a second prepare queued behind an in-flight prepare still settles", async () => {
await container.ready;
await using sql = new SQL({ url: url(), max: 1, idleTimeout: 5, connectionTimeout: 5 });

const [a, b, c] = await Promise.all([
sql`SELECT 'AAAA'::text AS v`.simple(),
sql`SELECT ${"BBBB"}::text AS v`,
sql`SELECT ${"CCCC"}::text AS x`,
]);

expect({ a, b, c }).toEqual({
a: [{ v: "AAAA" }],
b: [{ v: "BBBB" }],
c: [{ x: "CCCC" }],
});
});
});
Loading