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
A script scope holds one Postgres transaction open for the entire duration of a request or bash script, and each in-flight write needs a second connection concurrently. Under transaction-mode pooling — which is how we connect in production, always — that pins pooled server connections for the whole script and deadlocks once concurrency reaches roughly default_pool_size / 2.
Mechanism
SqlFs.#openScriptTx (src/sql-fs/sql-fs.ts:279-319) opens a transaction and holds it awaiting an end promise. In transaction mode PgBouncer pins a server connection for a transaction's whole lifetime, so the backend sits idle in transaction for as long as the script runs — including any sleep, Python/JS step, network fetch, or agent turn.
While that transaction is open, every writeFile first calls commitBlob, which deliberately uses a separate pool connection (src/sql-fs/dialects/postgres.ts:615-629, "Self-committing single-statement INSERT on its OWN pool connection").
So each in-flight write script needs 2 server connections simultaneously, and it will not release the first until it gets the second.
That is a textbook pool deadlock: it does not drain and it does not degrade, it wedges.
Operational, immediate: set pgbouncer.default_pool_size >= 4 x peak concurrent write execs per replica, and query_wait_timeout low (15 s, not the 120 s default) so saturation fails fast into a retry instead of hanging a script for two minutes.
Structural, the real cure: stop holding a transaction open across arbitrary user code. Buffer a script's mutations and flush them in one short transaction at endScriptScope, or checkpoint at safe points. This is what makes the bug window milliseconds instead of minutes.
Narrower: let commitBlob reuse the script-tx connection when a scope is open, removing the 2-connections-per-write requirement and halving the deadlock pressure. Needs care — the separate connection exists so blob writes self-commit and dedup outside the script's rollback scope.
Provenance
Found during pre-merge load testing of #162. Not introduced by that PR. Related prior art: thoughts/shared/plans/2026-05-02_bulk-fs-ops-script-tx.md already recorded that the Neon transaction pooler "terminates long-lived transactions and is incompatible with script-tx".
Summary
A script scope holds one Postgres transaction open for the entire duration of a request or bash script, and each in-flight write needs a second connection concurrently. Under transaction-mode pooling — which is how we connect in production, always — that pins pooled server connections for the whole script and deadlocks once concurrency reaches roughly
default_pool_size / 2.Mechanism
SqlFs.#openScriptTx(src/sql-fs/sql-fs.ts:279-319) opens a transaction and holds it awaiting an end promise. In transaction mode PgBouncer pins a server connection for a transaction's whole lifetime, so the backend sitsidle in transactionfor as long as the script runs — including anysleep, Python/JS step, network fetch, or agent turn.writeFilefirst callscommitBlob, which deliberately uses a separate pool connection (src/sql-fs/dialects/postgres.ts:615-629, "Self-committing single-statement INSERT on its OWN pool connection").That is a textbook pool deadlock: it does not drain and it does not degrade, it wedges.
Measured (real PgBouncer, transaction mode, 12 concurrent 8-second write execs)
default_pool_sizecl_waiting=13,maxwait0→120 sidle in transaction, transaction age 69 s for an 8 s script; whole replica unusable, evenGET /v1/sandboxeshungcl_waiting=0pool_size == concurrencyis not enough. The floor is 2x; 4x is the safe margin.Impact
idle in transactionbackend is what triggers the torn-commit class of bug (fixed to fail closed in52cc836, but the failure still happens).Options
pgbouncer.default_pool_size >= 4 x peak concurrent write execs per replica, andquery_wait_timeoutlow (15 s, not the 120 s default) so saturation fails fast into a retry instead of hanging a script for two minutes.endScriptScope, or checkpoint at safe points. This is what makes the bug window milliseconds instead of minutes.commitBlobreuse the script-tx connection when a scope is open, removing the 2-connections-per-write requirement and halving the deadlock pressure. Needs care — the separate connection exists so blob writes self-commit and dedup outside the script's rollback scope.Provenance
Found during pre-merge load testing of #162. Not introduced by that PR. Related prior art:
thoughts/shared/plans/2026-05-02_bulk-fs-ops-script-tx.mdalready recorded that the Neon transaction pooler "terminates long-lived transactions and is incompatible with script-tx".