From a0222fe7bcfaaa3734387afb4358238c5d520c70 Mon Sep 17 00:00:00 2001 From: felipe stival Date: Wed, 9 Sep 2026 14:06:12 -0300 Subject: [PATCH 1/2] Fix sql.begin() sending BEGIN on an unreserved connection sql.begin() could send BEGIN to the server without reserving the connection. Depending on pool size, this errored with UNSAFE_TRANSACTION or TypeError. The transaction stayed open on the server either way, and the connection went back to the pool, so unrelated queries then ran inside it. With max_pipeline: 0 this error happened consistently. With the default max_pipeline of 100 the same thing happened only when BEGIN was the query that filled a connection's pipeline, which is why the failure was intermittent. The issue is in execute(): after writing the query it returns an && chain that states whether the connection can accept more queries, and one of its terms is sent.length < max_pipeline. The onexecute hook, which is how sql.begin() reserves the connection, was the last term of that chain. Whenever the capacity term was false the chain short-circuited and the hook never ran, even though BEGIN had already been written. With max_pipeline: 0 that term is false for every query, so the hook never ran at all. Move the hook ahead of the capacity terms so it runs whenever the query was written. Reserving the connection is a consequence of having sent BEGIN, unrelated to the query limit in the connection. Add tests for BEGIN at the pipeline boundary and with max_pipeline: 0. Fixes #1189 --- src/connection.js | 9 +++++++-- tests/index.js | 15 +++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/connection.js b/src/connection.js index 1b1cccd..431e244 100644 --- a/src/connection.js +++ b/src/connection.js @@ -170,11 +170,16 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose : (query = q, query.active = true) build(q) - return write(toBuffer(q)) + const written = write(toBuffer(q)) + // Run the hook whenever the bytes were written, even if the pipeline is + // full or socket.write() reported backpressure. sql.begin() relies on it + // to reserve the connection, and its falsy return keeps the connection + // out of the busy queue until BEGIN completes. + return (!q.options.onexecute || q.options.onexecute(connection)) + && written && !q.describeFirst && !q.cursorFn && sent.length < max_pipeline - && (!q.options.onexecute || q.options.onexecute(connection)) } catch (error) { sent.length === 0 && write(Sync) errored(error) diff --git a/tests/index.js b/tests/index.js index 23e6c4d..05aca64 100644 --- a/tests/index.js +++ b/tests/index.js @@ -300,6 +300,21 @@ t('Many transactions at beginning of connection', async() => { return [100, xs.length] }) +t('Transaction at pipeline boundary is reserved', async() => { + const sql = postgres({ ...options, max: 2, max_pipeline: 1, fetch_types: false }) + await Promise.all([sql`select 1`, sql`select 1`]) + const inflight = [sql`select pg_sleep(0.1)`.execute(), sql`select pg_sleep(0.1)`.execute()] + const x = await sql.begin(sql => sql`select 1 as x`).then(x => x[0].x, x => x.code) + await Promise.all(inflight) + return [1, x, await sql.end()] +}) + +t('Transaction is reserved with pipelining disabled', async() => { + const sql = postgres({ ...options, max: 2, max_pipeline: 0, fetch_types: false }) + const x = await sql.begin(sql => sql`select 1 as x`).then(x => x[0].x, x => x.code) + return [1, x, await sql.end()] +}) + t('Transactions array', async() => { await sql`create table test (a int)` From 6007af7b7eb1a85f828f7422de0a28b0e17d2c1a Mon Sep 17 00:00:00 2001 From: felipe stival Date: Wed, 9 Sep 2026 14:07:06 -0300 Subject: [PATCH 2/2] Test that a query issued during BEGIN stays out of the transaction execute() must keep returning a falsy value for BEGIN so the pool puts the connection in the full queue rather than busy. Otherwise a query issued while BEGIN is in flight is pipelined behind it and runs inside the transaction. --- tests/index.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/index.js b/tests/index.js index 05aca64..d48b559 100644 --- a/tests/index.js +++ b/tests/index.js @@ -315,6 +315,18 @@ t('Transaction is reserved with pipelining disabled', async() => { return [1, x, await sql.end()] }) +t('Query issued while BEGIN is in flight does not join the transaction', async() => { + const sql = postgres({ ...options, max: 1, fetch_types: false }) + await sql`create table test (a int)` + const tx = sql.begin(async sql => { + await sql`select 1` + throw new Error('rollback') + }).catch(() => {}) + const insert = sql`insert into test values (1)` + await Promise.all([tx, insert]) + return [1, (await sql`select count(*)::int as n from test`)[0].n, await sql`drop table test`, await sql.end()] +}) + t('Transactions array', async() => { await sql`create table test (a int)`