Skip to content

sql.begin() always throws UNSAFE_TRANSACTION when max_pipeline is 0 #1210

Description

@reinierlakhan

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions