Skip to content

Reject transaction work after its connection closes - #1215

Open
ecsbeats wants to merge 2 commits into
porsager:masterfrom
ecsbeats:fix/disconnected-transactions
Open

Reject transaction work after its connection closes#1215
ecsbeats wants to merge 2 commits into
porsager:masterfrom
ecsbeats:fix/disconnected-transactions

Conversation

@ecsbeats

@ecsbeats ecsbeats commented Sep 9, 2026

Copy link
Copy Markdown

Fixes #1216.

If a backend disconnects while a sql.begin() callback is awaiting other work, begin() rejects but the callback keeps running. Once the pool reconnects, that callback can issue queries on the replacement connection. Returning or throwing from it can also commit or roll back a different transaction. Queries still queued inside the original transaction can remain pending indefinitely.

Keep the close error on the transaction, reject its queued queries, and reject subsequent queries through that transaction's handler, including automatic commit/rollback. Clear the connection's pending write and query/result/error state before reuse, too. Without that reset, the first query after reconnect can receive the old backend's fatal error.

The four regression tests cover queued queries, a query through the disconnected transaction, and late commit/rollback. The latter three share a fixture that pauses the original callback until a replacement transaction is open. No new dependencies or public API changes.

Related to #1204. This reproduction specifically covers a backend disconnect followed by connection reuse; I haven't verified that it explains the original report.

Reproduce

Save the following as repro.mjs in the repository root. It needs Node 22 and a PostgreSQL login that can terminate its own other sessions.

import assert from 'node:assert/strict'
import postgres from './src/index.js'

const pool = postgres({ max: 1 })
const admin = postgres({ max: 1 })
let resume
  , connected
const gate = new Promise(resolve => { resume = resolve })
const ready = new Promise(resolve => { connected = resolve })
const failed = pool.begin(async sql => {
  connected((await sql`select 1`).state.pid)
  await gate
}).catch(error => error)

try {
  await admin`select pg_terminate_backend(${ await ready }::int)`
  assert.equal((await failed).code, 'CONNECTION_CLOSED')
  await pool.begin(async sql => {
    const [{ id: before }] = await sql`select txid_current()::text as id`
    resume()
    await new Promise(resolve => setImmediate(resolve))
    const [{ id: after }] = await sql`select txid_current()::text as id`
    console.log({ before, after })
    assert.equal(after, before)
  })
} finally {
  resume()
  await Promise.all([pool.end({ timeout: 0 }), admin.end({ timeout: 0 })])
}

For a disposable local server:

docker run --rm -d --name postgres-disconnect-repro \
  -p 127.0.0.1:55432:5432 \
  -e POSTGRES_HOST_AUTH_METHOD=trust postgres:17
docker exec postgres-disconnect-repro pg_isready -U postgres
PGHOST=127.0.0.1 PGPORT=55432 PGUSER=postgres PGDATABASE=postgres node repro.mjs
docker stop postgres-disconnect-repro

Wait for pg_isready to report that the server is accepting connections before running the script. On upstream 411429e, the assertion fails because the transaction IDs differ: the old callback has committed the replacement transaction. On this branch they stay equal and the script exits successfully. I verified both outcomes.

Tests

The first commit, 0282301, adds only the regression tests; the second applies the fix. With the PostgreSQL setup from the existing test workflow in place:

git worktree add ../postgres-disconnect-before 0282301
(cd ../postgres-disconnect-before && PGUSER=postgres PGSOCKET=/var/run/postgresql npm run test:esm)
PGUSER=postgres PGSOCKET=/var/run/postgresql npm test
npm exec --yes --package=eslint@8.57.1 -- eslint src tests

The first run times out in Disconnect rejects queued transaction queries and allows reconnect. The fixed branch passes. The full suite needs both PostgreSQL servers from the workflow (ports 5432 and 5433), SSL, logical replication, prepared transactions, the PostgreSQL CLI tools, and Deno 1.x. Use disposable databases: the existing bootstrap changes server settings and recreates its test database and roles.

Validated against PostgreSQL 17.11:

  • Node 12.22.12 and 22.22.0: all 268 tests pass in both ESM and CommonJS.
  • Deno 1.46.3: all 268 tests pass.
  • Bun 1.4.2: the standalone reproduction and focused probes for all four failure cases pass. The full suite cannot register tests because tests/test.js:20 assumes a Node stack format; the same failure occurs on unmodified upstream.
  • ESLint passes.

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.

Disconnected transaction handles can read another user's rows through RLS

1 participant