Skip to content

fix: prevent UNSAFE_TRANSACTION under concurrent sql.begin() - #1206

Closed
dominikwagner wants to merge 1 commit into
porsager:masterfrom
essecca:upstream-fix/unsafe-transaction
Closed

dominikwagner wants to merge 1 commit into
porsager:masterfrom
essecca:upstream-fix/unsafe-transaction

Conversation

@dominikwagner

@dominikwagner dominikwagner commented Sep 1, 2026

Copy link
Copy Markdown

Root cause

When you call sql.begin(), the driver:

  1. Picks a connection from the pool
  2. Marks it reserved — "this connection belongs to this transaction, don't give it to anyone else"
  3. Sends BEGIN to PostgreSQL
  4. Runs your queries
  5. Sends COMMIT and un-reserves the connection

Under concurrent load, the "reserved" mark can be lost or overwritten before step 4 finishes. Another sql.begin() grabs the same connection and sends its own BEGIN. PostgreSQL sees a second BEGIN while already in a transaction, warns 25001 "there is already a transaction in progress", and the driver throws UNSAFE_TRANSACTION.

Three code paths in src/connection.js and src/index.js can produce this:

  1. execute() short-circuitonexecute (which sets connection.reserved) is at the tail of an && chain. When sent.length >= max_pipeline (or write() returns false), the chain short-circuits and onexecute is skipped, so the connection is never marked reserved.
  2. handler() pipelining — a queued BEGIN can be dispatched onto a busy connection that already holds another transaction.
  3. drain() on reserved connection — the socket drain callback can call onopen() on a connection that is currently reserved, returning it to the pool mid-transaction.

Fix

Three small, focused changes:

Where What it does
src/connection.js execute() Fire onexecute unconditionally when write() succeeds, and return false so the connection is marked full (no further pipelining onto it).
src/connection.js drain() Don't call onopen() on a reserved connection.
src/index.js handler() Never dispatch an onexecute query (BEGIN) onto a busy connection — always queue it.

Reproduction

tests/race-condition.js reproduces the bug against unmodified PostgreSQL on localhost. It runs the scenario 20 times: pool max: 3, max_pipeline: 2, hold all 3 connections busy with pg_sleep, then fire 6 regular queries + 5 sql.begin() concurrently.

  • On unfixed master: 12–16 / 20 iterations fail, PostgreSQL reports 25001 "there is already a transaction in progress"
  • On this fix: 20 / 20 iterations pass

Setup:

createuser postgres_js_test
createdb -O postgres_js_test postgres_js_test
node tests/race-condition.js

Three code paths in src/connection.js and src/index.js can clear or
overwrite connection.reserved while BEGIN is in flight, causing a second
sql.begin() to send BEGIN on the same connection. PostgreSQL warns 25001
'there is already a transaction in progress' and the driver throws
UNSAFE_TRANSACTION.

- execute(): fire onexecute unconditionally when write() succeeds, and
  return false so no more queries pipeline onto the connection
- drain(): don't call onopen() on a reserved connection
- handler(): always queue onexecute queries (BEGIN) instead of dispatching
  onto busy connections

Reproduction in tests/race-condition.js: pool max: 3, max_pipeline: 2,
hold all 3 connections busy with pg_sleep, fire 6 regular queries + 5
sql.begin() concurrently, repeat 20 times.

Before: 12-16 iterations fail with 25001 warning from PostgreSQL.
After: 20/20 pass.

Fixes porsager#823
@dominikwagner

Copy link
Copy Markdown
Author

Sorry, opened by mistake. Will re-submit properly.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant