Version: 3.4.9 (also current master, 411429e)
Node: v26.8.1, plain PostgreSQL 16 (no pooler involved in the repro)
With the connection option max_pipeline: 0, every sql.begin() rejects with UNSAFE_TRANSACTION: Only use sql.begin, sql.reserved or max: 1, even though the transaction is being run through sql.begin.
Reproduction
import postgres from 'postgres'
const url = process.env.DATABASE_URL
for (const [label, opts] of [['default', {}], ['max_pipeline: 1', { max_pipeline: 1 }], ['max_pipeline: 0', { max_pipeline: 0 }]]) {
const sql = postgres(url, { max: 4, ...opts })
try { const r = await sql.begin(tx => tx`select 1 as ok`); console.log(label, 'OK', r) }
catch (e) { console.log(label, 'FAIL', e.code, e.message) }
finally { await sql.end({ timeout: 2 }) }
}
Output on 3.4.9 / master:
default OK Result(1) [ { ok: 1 } ]
max_pipeline: 1 OK Result(1) [ { ok: 1 } ]
max_pipeline: 0 FAIL UNSAFE_TRANSACTION UNSAFE_TRANSACTION: Only use sql.begin, sql.reserved or max: 1
Cause
execute(q) in src/connection.js returns a single && chain:
build(q)
return write(toBuffer(q))
&& !q.describeFirst
&& !q.cursorFn
&& sent.length < max_pipeline
&& (!q.options.onexecute || q.options.onexecute(connection))
begin() in src/index.js sends BEGIN via sql.unsafe('begin ...', [], { onexecute }) and relies on that onexecute hook to capture the connection and move(c, reserved) / set c.reserved. Because the hook sits after sent.length < max_pipeline in the same chain, max_pipeline: 0 (0 < 0 is false) short-circuits before it, so the hook never runs. The connection is never reserved, and when the BEGIN's CommandComplete arrives the guard
if (result.command === 'BEGIN' && max !== 1 && !connection.reserved)
return errored(Errors.generic('UNSAFE_TRANSACTION', 'Only use sql.begin, sql.reserved or max: 1'))
fires. One expression is doing two unrelated jobs: "may the pool pipeline another query onto this connection" and "may the reservation hook run".
Why max_pipeline: 0
Behind a transaction-mode connection pooler (e.g. Supavisor/PgBouncer) a second query pipelined onto the socket before the first is answered is not handled correctly, so disabling pipelining entirely is the natural setting there. max_pipeline: 1 still lets one extra query be queued on the socket.
Expected / actual
- Expected:
sql.begin() works with max_pipeline: 0 exactly as with any other value; the option should only affect how many queries are queued on a connection.
- Actual: every
sql.begin() rejects with UNSAFE_TRANSACTION.
A PR with a fix and tests follows.
Version: 3.4.9 (also current
master, 411429e)Node: v26.8.1, plain PostgreSQL 16 (no pooler involved in the repro)
With the connection option
max_pipeline: 0, everysql.begin()rejects withUNSAFE_TRANSACTION: Only use sql.begin, sql.reserved or max: 1, even though the transaction is being run throughsql.begin.Reproduction
Output on 3.4.9 / master:
Cause
execute(q)insrc/connection.jsreturns a single&&chain:begin()insrc/index.jssendsBEGINviasql.unsafe('begin ...', [], { onexecute })and relies on thatonexecutehook to capture the connection andmove(c, reserved)/ setc.reserved. Because the hook sits aftersent.length < max_pipelinein the same chain,max_pipeline: 0(0 < 0is false) short-circuits before it, so the hook never runs. The connection is never reserved, and when the BEGIN's CommandComplete arrives the guardfires. One expression is doing two unrelated jobs: "may the pool pipeline another query onto this connection" and "may the reservation hook run".
Why
max_pipeline: 0Behind a transaction-mode connection pooler (e.g. Supavisor/PgBouncer) a second query pipelined onto the socket before the first is answered is not handled correctly, so disabling pipelining entirely is the natural setting there.
max_pipeline: 1still lets one extra query be queued on the socket.Expected / actual
sql.begin()works withmax_pipeline: 0exactly as with any other value; the option should only affect how many queries are queued on a connection.sql.begin()rejects withUNSAFE_TRANSACTION.A PR with a fix and tests follows.