You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
arbitrary UNSAFE_TRANSACTION errors when using sql.begin #823
The following query arbitrary causes an UNSAVE_TRANSACTION error.
awaitthis.sql.begin(async(tsql)=>{awaittsql`DELETE FROM mapping_table WHERE a_id = ${aId}`;if(items.length===0){return;}awaittsql`INSERT INTO mapping_table ${// eslint-disable-next-line @ts-safeql/check-sqltsql(items,'a_id','b_id')}`;});
the error most likely happens when this code is executed very frequently and items is usually an array betwenn 0 and a few thousand items.
error:
Error: UNSAFE_TRANSACTION: Only use sql.begin, sql.reserved or max: 1
at CommandComplete (/Users/juhwon/Projects/xx/xx/node_modules/postgres/cjs/src/connection.js:580:29)
at handle (/Users/juhwon/Projects/xx/xx/node_modules/postgres/cjs/src/connection.js:476:6)
at Socket.data (/Users/juhwon/Projects/xx/xx/node_modules/postgres/cjs/src/connection.js:315:9)
at Socket.emit (node:events:514:28)
at addChunk (node:internal/streams/readable:376:12)
at readableAddChunk (node:internal/streams/readable:349:9)
at Socket.Readable.push (node:internal/streams/readable:286:10)
at TCP.onStreamRead (node:internal/stream_base_commons:190:23)
at TCP.callbackTrampoline (node:internal/async_hooks:130:17)
to my understanding using sql.begin for something like this should be fine. have we found a bug here or is there something wrong with our code?
changed the title [-]Arbitrary UNSAFE_TRANSACTION errors when using sql.begin[/-][+]arbitrary UNSAFE_TRANSACTION errors when using sql.begin[/+]on Mar 9, 2024
{
"severity_local":"WARNING",
"severity":"WARNING",
"code":"25001",
"message":"there is already a transaction in progress",
"file":"xact.c",
"line":"3799",
"routine":"BeginTransactionBlock"
}
according to the debug logs the client executes a BEGIN statement on connection id e.g. 4, and then a few ms laters it executes a BEGIN statement with the same connection id again, before the first transaction was handled. there were no other statements between with the affected connection id.
We hit this in production and traced a mechanism that fits the "arbitrary" part. Sharing in case it helps — we could not reproduce it locally, so treat the root-cause part as analysis of the source, not a verified repro.
Where the lock is skipped
src/connection.js (v3.4.9, and master at time of writing):
onexecute is what marks the connection as reserved for the transaction. It sits at the end of an && chain, so it is skipped entirely whenever an earlier term is falsy:
write(toBuffer(q)) returns false under socket backpressure
sent.length < max_pipeline is false once the pipeline is full (default 100)
If BEGIN goes out without onexecute running, c.reserved is never set, and when the BEGIN response arrives the guard at src/connection.js (errored(Errors.generic('UNSAFE_TRANSACTION', ...))) fires.
Why it looks arbitrary
The chain can only break when BEGIN is pipelined onto an already-busy connection. On an idle connection sent.length is 0 and BEGIN is ~50 bytes, well under the write threshold, so neither term can be false — it is structurally safe there. That matches the reporter's "large variable-size operations very often": the failure needs a connection that is already saturated at the moment BEGIN is queued.
Blast radius is wider than one query
errored() calls stream.destroy(), so other queries pipelined on the same connection are torn down too. A single UNSAFE_TRANSACTION in your logs may correspond to more than one failed request.
Reproduction attempts (all negative)
Against a local Postgres 17, six shapes: TCP proxy with read stalls and throttling, forced socket.write() returning false, max_pipeline: 1, and 1000 pool queries against 200 pipelined transactions. Zero occurrences. Loopback kernel buffers are large enough that real backpressure never materialises, which is consistent with this only showing up over real network paths.
Workaround we are using
Retrying once on error.code === 'UNSAFE_TRANSACTION'. This appears safe because the guard fires at the BEGIN response — nothing in the transaction body has executed — and errored() destroys the socket, so the server rolls back. We verified no partial commits locally (committed rows always equalled the number of successful transactions).
Happy to test a patch if someone wants to move onexecute out of the chain; note its return value currently participates in the busy/full queue decision, so it is not a pure one-liner.
We hit this in production on postgres@3.4.7 and traced it to the same code path @ngsgh described.
Deterministic reproduction against unmodified PostgreSQL on localhost — no proxy, no protocol tampering:
Pool: max: 3, max_pipeline: 2
Hold all 3 connections busy with pg_sleep(0.5)
Fire 6 regular queries + 5 sql.begin() concurrently
Run this scenario 20 times: 12–16 iterations produce UNSAFE_TRANSACTION
PostgreSQL itself reports 25001 "there is already a transaction in progress"
The 25001 warning is the smoking gun — PostgreSQL is telling us the driver sent two BEGINs on one connection.
Root cause: Under load, connection.reserved can be cleared or overwritten while BEGIN is in flight. Three code paths in src/connection.js / src/index.js:
execute() short-circuits: when sent.length >= max_pipeline or write() returns false, the && chain skips onexecute, so connection.reserved is never set — this is the path @ngsgh described, and it's what our test triggers
handler() dispatches queued BEGIN queries onto already-busy connections
drain() calls onopen() on a reserved connection, returning it to the pool mid-transaction
Fix (three parts, all needed):
execute() fires onexecute unconditionally when write() succeeds, and returns false so no more queries pipeline onto the connection
handler() always queues onexecute queries (BEGIN) instead of dispatching to busy connections
drain() guards on !connection.reserved before calling onopen()
Adding data to this thread, because one conclusion above is unsafe to act on: retrying on UNSAFE_TRANSACTION is not safe in 3.4.9, and the damage lands on other callers.
stream here is the COPY stream (assigned only in the Copy*Response handlers), not the socket. When the guard fires, the BEGIN has already run on the server, the socket stays open, and the connection goes back to the pool inside the open transaction.
What that does to unrelated queries
Default options, max: 2. BEGIN is made the 100th in-flight query on a busy connection, then 20 unrelated autocommit INSERTs run through the same pool:
sql.begin
rejects with UNSAFE_TRANSACTION
unrelated inserts that resolved successfully
20
visible to another connection right after
1
sessions idle in transaction
1
rows persisted after sql.end() (same as a deploy or idle_timeout)
1
19 writes resolved and were silently rolled back. If a later sql.begin lands on that connection, its COMMIT commits them instead, as part of someone else's transaction. PostgreSQL's 25001 warning reported by @JuHwon above (a BEGIN arriving on a connection already in a transaction) is consistent with that second case.
All four paths, three builds
Standalone script, no application code: only the library under test and a disposable database (below). Run against PostgreSQL 16.12 on localhost, Node 24.13.
execute() as suggested in #1207 review + drain() guard, no handler() change
1. BEGIN at the pipeline boundary, default max_pipeline
FAIL
PASS
PASS
2. Unrelated writes after UNSAFE_TRANSACTION
FAIL (19/20 lost)
PASS
PASS
3. BEGIN while socket.write() returns false
FAIL
FAIL
PASS
4. 'drain' while a transaction sits between statements
FAIL (other caller's write lost)
PASS
PASS
Scenarios 3 and 4 use the documented socket option. Bytes go to the server for real; only the return value of write() and the 'drain' event are what Node itself produces under backpressure.
repro.mjs
// Standalone reproduction for porsager/postgres #823 / #1189 / PR #1207.// No application code: only the library under test and a disposable database.//// DATABASE_URL=postgres://... node repro.mjs <path-to-postgres/src/index.js>//// Four scenarios. Each prints PASS/FAIL and the evidence behind it.importnetfrom'node:net'import{pathToFileURL}from'node:url'constlibPath=process.argv[2]if(!libPath)thrownewError('usage: node repro.mjs <path to postgres/src/index.js>')const{default: postgres}=awaitimport(pathToFileURL(libPath).href)consturl=process.env.DATABASE_URLif(!url)thrownewError('Set DATABASE_URL to a disposable database')constendpoint=newURL(url)constprobe=postgres(url,{max: 1,onnotice: ()=>{}})consttable='pgjs_repro_'+Date.now()awaitprobe`create table ${probe(table)} (id text primary key)`constcount=async(like)=>(awaitprobe`select count(*)::int as n from ${probe(table)} where id like ${like}`)[0].nconstidleInTx=async(app)=>(awaitprobe` select count(*)::int as n from pg_stat_activity where state = 'idle in transaction' and application_name = ${app}`)[0].nconstsleep=ms=>newPromise(r=>setTimeout(r,ms))asyncfunctionopenSocket({ writeReturnsFalse =false}={}){constsocket=net.createConnection({host: endpoint.hostname,port: Number(endpoint.port||5432)})awaitnewPromise((ok,fail)=>{socket.once('connect',ok);socket.once('error',fail)})if(writeReturnsFalse){// What Node does under backpressure: the bytes are ACCEPTED and will be// sent; only the return value says "stop writing for now".constnative=socket.write.bind(socket)socket.write=(...args)=>{native(...args);returnfalse}}returnsocket}constresults=[]functionreport(name,pass,evidence){results.push({ name, pass, evidence })console.log(`${pass ? 'PASS' : 'FAIL'}${name}\n ${JSON.stringify(evidence)}`)}// 1. Pipeline boundary, DEFAULT options (max_pipeline 100). BEGIN becomes the// 100th in-flight query on a busy connection.{constapp='repro-s1'letfails=0for(constKof[98,99,100]){constsql=postgres(url,{max: 2,onnotice: ()=>{},connection: {application_name: app+K}})awaitPromise.all([sql`select 1`,sql`select 1`])constpending=[sql`select pg_sleep(0.3)`.execute(),sql`select pg_sleep(0.3)`.execute()]for(leti=0;i<2*K;i++)pending.push(sql`select 1`.execute())leterr=nullconstb=sql.begin(tx=>tx`select 1`).catch(e=>{err=e.code})awaitPromise.all([...pending,b])if(err)fails++awaitsql.end({timeout: 5})}report('1. BEGIN at the pipeline boundary (default max_pipeline)',fails===0,{beginsFailedOutOf3: fails})}// 2. Collateral damage after UNSAFE_TRANSACTION: is the connection destroyed// (so the server rolls back and retrying is safe), or does it go back to// the pool inside the open transaction?{constapp='repro-s2'constsql=postgres(url,{max: 2,idle_timeout: 20,onnotice: ()=>{},connection: {application_name: app}})awaitPromise.all([sql`select 1`,sql`select 1`])constpending=[sql`select pg_sleep(0.3)`.execute(),sql`select pg_sleep(0.3)`.execute()]for(leti=0;i<2*99;i++)pending.push(sql`select 1`.execute())leterr=nullconstb=sql.begin(tx=>tx`select 1`).catch(e=>{err=e.code})awaitPromise.all([...pending,b])// 20 unrelated autocommit inserts from "other callers", all resolve OK.constids=Array.from({length: 20},(_,i)=>'s2-'+i)constsettled=awaitPromise.allSettled(ids.map(id=>sql`insert into ${sql(table)} (id) values (${id})`))constresolvedOk=settled.filter(s=>s.status==='fulfilled').lengthconstvisible=awaitcount('s2-%')constidle=awaitidleInTx(app)awaitsql.end({timeout: 5})// like a deploy / idle_timeout closing the connectionconstpersisted=awaitcount('s2-%')report('2. After UNSAFE_TRANSACTION, unrelated writes survive',err===null||persisted===resolvedOk,{beginError: err, resolvedOk,visibleRightAfter: visible,idleInTransaction: idle,persistedAfterClose: persisted})}// 3. Backpressure: socket.write() returns false for BEGIN (bytes accepted).{constsql=postgres(url,{max: 2,onnotice: ()=>{},socket: ()=>openSocket({writeReturnsFalse: true})})leterr=null// write() only reaches socket.write with >= 1024 buffered bytes; begin// options (letters/spaces, public API) push this BEGIN past it.awaitsql.begin(' '.repeat(1100),tx=>tx`insert into ${tx(table)} (id) values ('s3')`).catch(e=>{err=e.code})awaitsql.end({timeout: 5})report('3. BEGIN while socket.write() returns false (backpressure)',err===null&&awaitcount('s3')===1,{beginError: err,rowPersisted: awaitcount('s3')})}// 4. 'drain' fires while a transaction sits between two statements.{constsockets=[]constsql=postgres(url,{max: 1,onnotice: ()=>{},socket: async()=>{consts=awaitopenSocket();sockets.push(s);returns}})letrelease,startedconstidle=newPromise(r=>release=r)constbegan=newPromise(r=>started=r)consttx=sql.begin(asynct=>{awaitt`insert into ${t(table)} (id) values ('s4-tx')`started()awaitidle// between statements, nothing in flightthrownewError('rollback on purpose')}).catch(()=>'rolled back')awaitbeganletotherResolved=falseconstother=sql`insert into ${sql(table)} (id) values ('s4-other')`.execute().then(()=>{otherResolved=true})awaitsleep(50)sockets[0].emit('drain')// what Node emits when the write buffer emptiesawaitsleep(200)release()awaittxawaitotherawaitsql.end({timeout: 5})constpersisted=awaitcount('s4-other')report("4. 'drain' mid-transaction keeps other callers out",!(otherResolved&&persisted===0),{otherCallerResolved: otherResolved,otherCallerRowPersisted: persisted})}awaitprobe`drop table ${probe(table)}`awaitprobe.end()console.log('\nSUMMARY '+results.map(r=>(r.pass ? 'P' : 'F')).join(''))
Activity
[-]Arbitrary UNSAFE_TRANSACTION errors[/-][+]Arbitrary UNSAFE_TRANSACTION errors when using sql.begin[/+][-]Arbitrary UNSAFE_TRANSACTION errors when using sql.begin[/-][+]arbitrary UNSAFE_TRANSACTION errors when using sql.begin[/+]JuHwon commentedon Mar 15, 2024
error object of notice callback:
{ "severity_local":"WARNING", "severity":"WARNING", "code":"25001", "message":"there is already a transaction in progress", "file":"xact.c", "line":"3799", "routine":"BeginTransactionBlock" }maybe this is similar to this one: #274
according to the debug logs the client executes a
BEGINstatement on connection id e.g.4, and then a few ms laters it executes aBEGINstatement with the same connection id again, before the first transaction was handled. there were no other statements between with the affected connection id.ngsgh commentedon Sep 1, 2026
We hit this in production and traced a mechanism that fits the "arbitrary" part. Sharing in case it helps — we could not reproduce it locally, so treat the root-cause part as analysis of the source, not a verified repro.
Where the lock is skipped
src/connection.js(v3.4.9, andmasterat time of writing):onexecuteis what marks the connection as reserved for the transaction. It sits at the end of an&&chain, so it is skipped entirely whenever an earlier term is falsy:write(toBuffer(q))returnsfalseunder socket backpressuresent.length < max_pipelineis false once the pipeline is full (default 100)If BEGIN goes out without
onexecuterunning,c.reservedis never set, and when the BEGIN response arrives the guard atsrc/connection.js(errored(Errors.generic('UNSAFE_TRANSACTION', ...))) fires.Why it looks arbitrary
The chain can only break when BEGIN is pipelined onto an already-busy connection. On an idle connection
sent.lengthis 0 and BEGIN is ~50 bytes, well under the write threshold, so neither term can be false — it is structurally safe there. That matches the reporter's "large variable-size operations very often": the failure needs a connection that is already saturated at the moment BEGIN is queued.Blast radius is wider than one query
errored()callsstream.destroy(), so other queries pipelined on the same connection are torn down too. A singleUNSAFE_TRANSACTIONin your logs may correspond to more than one failed request.Reproduction attempts (all negative)
Against a local Postgres 17, six shapes: TCP proxy with read stalls and throttling, forced
socket.write()returning false,max_pipeline: 1, and 1000 pool queries against 200 pipelined transactions. Zero occurrences. Loopback kernel buffers are large enough that real backpressure never materialises, which is consistent with this only showing up over real network paths.Workaround we are using
Retrying once on
error.code === 'UNSAFE_TRANSACTION'. This appears safe because the guard fires at the BEGIN response — nothing in the transaction body has executed — anderrored()destroys the socket, so the server rolls back. We verified no partial commits locally (committed rows always equalled the number of successful transactions).Happy to test a patch if someone wants to move
onexecuteout of the chain; note its return value currently participates in the busy/full queue decision, so it is not a pure one-liner.dominikwagner commentedon Sep 1, 2026
We hit this in production on
postgres@3.4.7and traced it to the same code path @ngsgh described.Deterministic reproduction against unmodified PostgreSQL on localhost — no proxy, no protocol tampering:
max: 3, max_pipeline: 2pg_sleep(0.5)sql.begin()concurrently25001"there is already a transaction in progress"The
25001warning is the smoking gun — PostgreSQL is telling us the driver sent twoBEGINs on one connection.Root cause: Under load,
connection.reservedcan be cleared or overwritten whileBEGINis in flight. Three code paths insrc/connection.js/src/index.js:execute()short-circuits: whensent.length >= max_pipelineorwrite()returnsfalse, the&&chain skipsonexecute, soconnection.reservedis never set — this is the path @ngsgh described, and it's what our test triggershandler()dispatches queuedBEGINqueries onto already-busy connectionsdrain()callsonopen()on a reserved connection, returning it to the pool mid-transactionFix (three parts, all needed):
execute()firesonexecuteunconditionally whenwrite()succeeds, and returnsfalseso no more queries pipeline onto the connectionhandler()always queuesonexecutequeries (BEGIN) instead of dispatching to busy connectionsdrain()guards on!connection.reservedbefore callingonopen()@porsager: I opened a PR with a fix — #1207
matheusbrramos commentedon Sep 25, 2026
Adding data to this thread, because one conclusion above is unsafe to act on: retrying on
UNSAFE_TRANSACTIONis not safe in 3.4.9, and the damage lands on other callers.errored()does not destroy the socketstreamhere is the COPY stream (assigned only in theCopy*Responsehandlers), not the socket. When the guard fires, theBEGINhas already run on the server, the socket stays open, and the connection goes back to the pool inside the open transaction.What that does to unrelated queries
Default options,
max: 2.BEGINis made the 100th in-flight query on a busy connection, then 20 unrelated autocommitINSERTs run through the same pool:sql.beginUNSAFE_TRANSACTIONidle in transactionsql.end()(same as a deploy oridle_timeout)19 writes resolved and were silently rolled back. If a later
sql.beginlands on that connection, itsCOMMITcommits them instead, as part of someone else's transaction. PostgreSQL's25001warning reported by @JuHwon above (aBEGINarriving on a connection already in a transaction) is consistent with that second case.All four paths, three builds
Standalone script, no application code: only the library under test and a disposable database (below). Run against PostgreSQL 16.12 on localhost, Node 24.13.
execute()as suggested in #1207 review +drain()guard, nohandler()changeBEGINat the pipeline boundary, defaultmax_pipelineUNSAFE_TRANSACTIONBEGINwhilesocket.write()returnsfalse'drain'while a transaction sits between statementsScenarios 3 and 4 use the documented
socketoption. Bytes go to the server for real; only the return value ofwrite()and the'drain'event are what Node itself produces under backpressure.repro.mjs
Happy to test further changes against this.
fix: bind reserve() and begin() handles to a connection lease