Files
bun-src/test/js/sql/postgres-split-prepare-reorder-fixture.ts
2026-08-27 21:09:14 +00:00

150 lines
5.0 KiB
TypeScript
Generated

// Mock postgres server that replies to a named-statement prepare with its four
// backend messages in separate socket writes, so ParameterDescription arrives
// in an earlier on_data() than ReadyForQuery. That is ordinary inbound TCP
// segmentation; the bytes and their order are identical to a coalesced reply.
//
// Queries issued during that gap must not overtake earlier-enqueued queries on
// the wire: responses are attributed by enqueue order (requests.peek_item(0)),
// so a Bind written out of order delivers another query's rows.
import net from "node:net";
import {
pgAuthenticationOk,
pgBindComplete,
pgCommandComplete,
pgDataRow,
pgParameterDescription,
pgParseComplete,
pgReadFrontendMessages,
pgReadyForQuery,
pgRowDescription,
} from "./wire-frames";
const SPLIT = process.env.SPLIT !== "0";
const tick = () => new Promise<void>(r => setImmediate(r));
// Parse a Bind body far enough to return the first parameter value as text.
function bindFirstParam(body: Buffer): string {
let o = body.indexOf(0) + 1; // skip portal name
o = body.indexOf(0, o) + 1; // skip statement name
const nFmt = body.readInt16BE(o);
o += 2 + 2 * nFmt;
o += 2; // nParams
const len = body.readInt32BE(o);
o += 4;
return body.subarray(o, o + len).toString("utf-8");
}
let bindsServed = 0;
const server = net.createServer(socket => {
socket.on("error", () => {});
let pending = Buffer.alloc(0);
let sawStartup = false;
// Replies go out in the order they were enqueued even though each reply is
// written message-by-message across event-loop turns.
let replyChain: Promise<void> = Promise.resolve();
const reply = (msgs: Buffer[]) => {
replyChain = replyChain.then(async () => {
if (!SPLIT) return void socket.write(Buffer.concat(msgs));
for (const m of msgs) {
socket.write(m);
await tick();
await tick();
}
});
};
socket.on("data", chunk => {
pending = Buffer.concat([pending, chunk]);
if (!sawStartup) {
if (pending.length < 4) return;
const len = pending.readInt32BE(0);
if (pending.length < len) return;
pending = pending.subarray(len);
sawStartup = true;
socket.write(Buffer.concat([pgAuthenticationOk(), pgReadyForQuery()]));
}
pending = pgReadFrontendMessages(pending, (type, body) => {
if (type === 0x50 /* 'P' Parse */) {
// Followed by Describe('S') + Sync; reply with the four-message prepare
// response, one write per message so the client sees them across reads.
reply([
pgParseComplete(),
pgParameterDescription([25]),
pgRowDescription([{ name: "v", typeOid: 25 }]),
pgReadyForQuery(),
]);
} else if (type === 0x42 /* 'B' Bind */) {
const v = bindFirstParam(body);
// Followed by Execute + Sync; echo the bound parameter back as one row.
reply([pgBindComplete(), pgDataRow([Buffer.from(v)]), pgCommandComplete("SELECT 1"), pgReadyForQuery()]);
bindsServed++;
}
});
});
});
await new Promise<void>(r => server.listen(0, "127.0.0.1", r));
const port = (server.address() as net.AddressInfo).port;
const sql = new Bun.SQL({
adapter: "postgres",
hostname: "127.0.0.1",
port,
username: "u",
password: "",
database: "db",
tls: false,
max: 1,
prepare: true,
connectionTimeout: 20,
});
// q0 triggers Parse+Describe+Sync; q1..q3 share the statement and sit Pending
// behind it. Then one query per event-loop turn so several land between the
// split ParameterDescription and ReadyForQuery reads.
const expected: string[] = [];
const got: (string | undefined)[] = [];
const settled: boolean[] = [];
const errors: string[] = [];
const issue = (tag: string) => {
const i = expected.length;
expected.push(tag);
got.push(undefined);
settled.push(false);
sql`SELECT ${tag} AS v`.then(
rows => {
got[i] = rows[0]?.v;
settled[i] = true;
},
err => {
errors.push(`${tag}: ${err?.message ?? err}`);
settled[i] = true;
},
);
};
for (let i = 0; i < 4; i++) issue(`q${i}`);
for (let i = 4; i < 16; i++) {
await tick();
issue(`q${i}`);
}
// Await settlement by condition: the server first has to answer as many Binds
// as were issued, then every query has to have settled. Both polls are bounded
// so the never-settle face surfaces as a reported failure instead of a hang.
for (let t = 0; t < 10000 && bindsServed < expected.length; t++) await tick();
for (let t = 0; t < 10000 && settled.some(s => !s); t++) await tick();
await sql.close({ timeout: 0 }).catch(() => {});
server.close();
const unsettled = expected.filter((_, i) => !settled[i]);
const mismatches = expected.map((e, i) => [e, got[i]] as const).filter(([e, g], i) => settled[i] && e !== g);
if (unsettled.length > 0 || mismatches.length > 0 || errors.length > 0) {
console.error(
JSON.stringify({ unsettled: unsettled.length, mismatches, errors, got, total: expected.length }),
);
process.exit(1);
}
console.log(`ok ${expected.length}/${expected.length}`);
process.exit(0);