Skip to content

fix(postgres): listen for errors on a pool transaction's connection - #31

Merged
kamilmysliwiec merged 2 commits into
nestjs:masterfrom
rodrigobnogueira:fix/pg-checkout-error-listener
Oct 19, 2026
Merged

kamilmysliwiec merged 2 commits into
nestjs:masterfrom
rodrigobnogueira:fix/pg-checkout-error-listener

Conversation

@rodrigobnogueira

Copy link
Copy Markdown
Contributor

PR Checklist

Please check if your PR fulfills the following requirements:

PR Type

What kind of change does this PR introduce?

  • Bugfix
  • Feature
  • Code style update (formatting, local variables)
  • Refactoring (no functional changes, no api changes)
  • Build related changes
  • CI related changes
  • Other... Please describe:

What is the current behavior?

Issue Number: N/A

fromPg(pool).transaction() checks a connection out of the pool and runs BEGIN, the work and COMMIT on it with no error listener: pg-pool removes its own idle listener on checkout and restores it on release. A node-postgres client emits error when the server ends its connection, mid-statement too, so when that happens during a store transaction (a restart, a failover, pg_terminate_backend(), idle_in_transaction_session_timeout), the process exits, whether or not the application listens on the pool. Every store transaction on fromPg(pool) is exposed, among them @nestjs/outbox's claims, @nestjs/workflows' step writes and signals, @nestjs/webhooks' fan-outs and secret rotations, and the kit's own migrate().

What is the new behavior?

The pool transaction listens on its connection until it releases it, so a connection the server ends fails the transaction instead of the process. The transaction still rejects with the error of the statement in flight: the server's (57P01 for pg_terminate_backend()), or pg's Client has encountered a connection error and is not queryable when the connection dropped between two statements. The connection is still discarded, since pg-pool drops one that emitted an error; the transaction also releases it with an error, as it does a connection whose transaction couldn't end cleanly. The listener comes off before the release, so the pool's idle listener is the only one again. fromPg()'s JSDoc example now listens on the pool too, for its idle connections.

The new test terminates a pool transaction's backend during a pg_sleep() on the CI Postgres service. Without the change it fails with an unhandled Connection terminated unexpectedly (run); with it, the transaction rejects with 57P01, the pool drops the connection, and the next transaction runs on a new one, leaving no listener behind (run).

Does this PR introduce a breaking change?

  • Yes
  • No

Other information

fromKysely() and fromDrizzle() inherit the same gap from their drivers, out of the kit's reach: Kysely's PostgresDriver checks out a client for each query or transaction with no listener, and so does drizzle-orm's node-postgres transaction() (drizzle-team/drizzle-orm#6437, with a fix proposed in drizzle-team/drizzle-orm#6438). @nestjs/drizzle 0.0.2 already guards the pools DrizzleModule registers (nestjs/drizzle#30), so this change is for the pools an application builds and passes to fromPg(); on a DrizzleModule pool, that guard sees the transaction's listener and stays quiet. TypeORM's query runner and @prisma/adapter-pg listen on the connections they check out.

The command line's one-connection pool (openPostgres()) has no error listener of its own, so a connection lost between two statements of a command still ends the process; a pool.on('error', ...) there would cover that too, if you want it in this PR.

rodrigobnogueira and others added 2 commits October 18, 2026 13:30
fromPg(pool).transaction() checked a connection out of the pool and ran
BEGIN, the work and COMMIT on it with no 'error' listener: pg-pool takes
its own listener off a connection it hands out and puts it back on
release. A node-postgres client emits 'error' when the server ends its
connection, during a statement too, so a restart, a failover,
pg_terminate_backend() or idle_in_transaction_session_timeout during a
store's transaction (an outbox claim, a workflow step's write,
migrate()) ended the process with an unhandled 'error' event, whether
or not the application listened on the pool.

The transaction now listens on its connection until it releases it, so
a connection the server ends fails the transaction instead of the
process. The transaction still fails with the error of the statement in
flight (57P01 for pg_terminate_backend()), and the connection is still
discarded, since pg-pool drops one that emitted an error; the
transaction releases it with an error as well. fromPg()'s example
listens on the pool too, for its idle connections' errors.

The test ends a pool transaction's backend during a statement, and
checks that no transaction leaves its listener on a connection. Without
the fix, vitest reports the uncaught "Connection terminated
unexpectedly".
…ed the connection

The transaction attached its 'error' listener when `await pool.connect()`
resumed, but pg-pool hands a connection out (and stops listening to it) while
it reads from the socket: a new connection's startup, or a queued transaction
served by a pool.query() release. A FATAL in the rest of that read reached no
listener and ended the process. The transaction now checks the connection out
through pg-pool's callback, and listens in that same tick.

A statement after the connection is lost now fails with what ended it (the
server's 25P03 or 57P01, a reset) instead of pg's "Client has encountered a
connection error and is not queryable", so callers and migrate()'s message
see the cause.

The command line's pool listens for an idle connection's error, which ended
the process after a command, even one that succeeded. fromPg()'s example
listens on each connection too, for the application's own transactions.

Tests: a connection the server ends between two statements (an
idle-in-transaction timeout, which emits 'error' twice), and a statement
timeout under the mid-statement test's own timeout.
@kamilmysliwiec
kamilmysliwiec merged commit 08ef67d into nestjs:master Oct 19, 2026
5 checks passed
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.

2 participants