From bc8838a461847fdc7c9e46d31ce4a38846350bbe Mon Sep 17 00:00:00 2001 From: Mirhet Julardzija Date: Wed, 2 Sep 2026 23:16:50 +0200 Subject: [PATCH] Send transaction control on the simple protocol With `prepare: true`, `begin()` sent `savepoint`, `rollback to`, `rollback`, `commit` and `prepare transaction` as tagged templates, so each became a named prepared statement cached on the client connection. On a transaction pooler that does not track named statements (or after the pooler evicts one), the Bind of a named `commit` reaches a backend that never parsed it and fails with SQLSTATE 26000. That error aborts the transaction, and `FetchPreparedStatement` is in `retryRoutines`, so the driver re-sends `commit` on the aborted transaction. Postgres answers that COMMIT with ROLLBACK and no error, and the transaction's writes are lost silently. Reproduced against a Supavisor pooler in transaction mode with one client connection and a second client keeping the other backends busy, twenty sequential `sql.begin` inserts, body statement unnamed: before: 9 of 20 rows, 31 `commit` sends, 0 errors after: 20 of 20 rows, 20 `commit` sends, 0 errors `begin` already goes through `unsafe`. This sends the other five control statements the same way. A zero-argument `unsafe` is `simple: true`, so no `prepare` setting can name it, and the transaction lifecycle (connection close, release only when idle, per-query error capture) is unchanged. Identifier quoting matches `sql(name)`; the `prepare transaction` name doubles single quotes instead of being spliced in raw. --- cf/src/index.js | 18 +++++++++++++----- cjs/src/index.js | 18 +++++++++++++----- deno/src/index.js | 18 +++++++++++++----- src/index.js | 18 +++++++++++++----- 4 files changed, 52 insertions(+), 20 deletions(-) diff --git a/cf/src/index.js b/cf/src/index.js index ffbe7aef..e7dbf805 100644 --- a/cf/src/index.js +++ b/cf/src/index.js @@ -232,6 +232,14 @@ function Postgres(a, b) { } } + // Transaction control runs on the simple protocol (a zero-argument unsafe) so that + // `prepare: true` never names it. A named `commit` whose Bind reaches a pooler backend + // that never parsed it fails with 26000, and the retry of that error re-sends `commit` + // on the now aborted transaction, which Postgres answers with ROLLBACK and no error. + function quoteIdent(name) { + return '"' + name.replace(/"/g, '""') + '"' + } + async function begin(options, fn) { !fn && (fn = options, options = '') const queries = Queue() @@ -256,7 +264,7 @@ function Postgres(a, b) { let uncaughtError , result - name && await sql`savepoint ${ sql(name) }` + name && await sql.unsafe('savepoint ' + quoteIdent(name)) try { result = await new Promise((resolve, reject) => { const x = fn(sql) @@ -267,16 +275,16 @@ function Postgres(a, b) { throw uncaughtError } catch (e) { await (name - ? sql`rollback to ${ sql(name) }` - : sql`rollback` + ? sql.unsafe('rollback to ' + quoteIdent(name)) + : sql.unsafe('rollback') ) throw e instanceof PostgresError && e.code === '25P02' && uncaughtError || e } if (!name) { prepare - ? await sql`prepare transaction '${ sql.unsafe(prepare) }'` - : await sql`commit` + ? await sql.unsafe('prepare transaction \'' + prepare.replace(/'/g, '\'\'') + '\'') + : await sql.unsafe('commit') } return result diff --git a/cjs/src/index.js b/cjs/src/index.js index f09c61c7..f5e70767 100644 --- a/cjs/src/index.js +++ b/cjs/src/index.js @@ -231,6 +231,14 @@ function Postgres(a, b) { } } + // Transaction control runs on the simple protocol (a zero-argument unsafe) so that + // `prepare: true` never names it. A named `commit` whose Bind reaches a pooler backend + // that never parsed it fails with 26000, and the retry of that error re-sends `commit` + // on the now aborted transaction, which Postgres answers with ROLLBACK and no error. + function quoteIdent(name) { + return '"' + name.replace(/"/g, '""') + '"' + } + async function begin(options, fn) { !fn && (fn = options, options = '') const queries = Queue() @@ -255,7 +263,7 @@ function Postgres(a, b) { let uncaughtError , result - name && await sql`savepoint ${ sql(name) }` + name && await sql.unsafe('savepoint ' + quoteIdent(name)) try { result = await new Promise((resolve, reject) => { const x = fn(sql) @@ -266,16 +274,16 @@ function Postgres(a, b) { throw uncaughtError } catch (e) { await (name - ? sql`rollback to ${ sql(name) }` - : sql`rollback` + ? sql.unsafe('rollback to ' + quoteIdent(name)) + : sql.unsafe('rollback') ) throw e instanceof PostgresError && e.code === '25P02' && uncaughtError || e } if (!name) { prepare - ? await sql`prepare transaction '${ sql.unsafe(prepare) }'` - : await sql`commit` + ? await sql.unsafe('prepare transaction \'' + prepare.replace(/'/g, '\'\'') + '\'') + : await sql.unsafe('commit') } return result diff --git a/deno/src/index.js b/deno/src/index.js index b6d23db1..3977d878 100644 --- a/deno/src/index.js +++ b/deno/src/index.js @@ -232,6 +232,14 @@ function Postgres(a, b) { } } + // Transaction control runs on the simple protocol (a zero-argument unsafe) so that + // `prepare: true` never names it. A named `commit` whose Bind reaches a pooler backend + // that never parsed it fails with 26000, and the retry of that error re-sends `commit` + // on the now aborted transaction, which Postgres answers with ROLLBACK and no error. + function quoteIdent(name) { + return '"' + name.replace(/"/g, '""') + '"' + } + async function begin(options, fn) { !fn && (fn = options, options = '') const queries = Queue() @@ -256,7 +264,7 @@ function Postgres(a, b) { let uncaughtError , result - name && await sql`savepoint ${ sql(name) }` + name && await sql.unsafe('savepoint ' + quoteIdent(name)) try { result = await new Promise((resolve, reject) => { const x = fn(sql) @@ -267,16 +275,16 @@ function Postgres(a, b) { throw uncaughtError } catch (e) { await (name - ? sql`rollback to ${ sql(name) }` - : sql`rollback` + ? sql.unsafe('rollback to ' + quoteIdent(name)) + : sql.unsafe('rollback') ) throw e instanceof PostgresError && e.code === '25P02' && uncaughtError || e } if (!name) { prepare - ? await sql`prepare transaction '${ sql.unsafe(prepare) }'` - : await sql`commit` + ? await sql.unsafe('prepare transaction \'' + prepare.replace(/'/g, '\'\'') + '\'') + : await sql.unsafe('commit') } return result diff --git a/src/index.js b/src/index.js index c7fba3da..5882dc3a 100644 --- a/src/index.js +++ b/src/index.js @@ -231,6 +231,14 @@ function Postgres(a, b) { } } + // Transaction control runs on the simple protocol (a zero-argument unsafe) so that + // `prepare: true` never names it. A named `commit` whose Bind reaches a pooler backend + // that never parsed it fails with 26000, and the retry of that error re-sends `commit` + // on the now aborted transaction, which Postgres answers with ROLLBACK and no error. + function quoteIdent(name) { + return '"' + name.replace(/"/g, '""') + '"' + } + async function begin(options, fn) { !fn && (fn = options, options = '') const queries = Queue() @@ -255,7 +263,7 @@ function Postgres(a, b) { let uncaughtError , result - name && await sql`savepoint ${ sql(name) }` + name && await sql.unsafe('savepoint ' + quoteIdent(name)) try { result = await new Promise((resolve, reject) => { const x = fn(sql) @@ -266,16 +274,16 @@ function Postgres(a, b) { throw uncaughtError } catch (e) { await (name - ? sql`rollback to ${ sql(name) }` - : sql`rollback` + ? sql.unsafe('rollback to ' + quoteIdent(name)) + : sql.unsafe('rollback') ) throw e instanceof PostgresError && e.code === '25P02' && uncaughtError || e } if (!name) { prepare - ? await sql`prepare transaction '${ sql.unsafe(prepare) }'` - : await sql`commit` + ? await sql.unsafe('prepare transaction \'' + prepare.replace(/'/g, '\'\'') + '\'') + : await sql.unsafe('commit') } return result