Repository navigation
fix(postgres): listen for errors on a pool transaction's connection - #31
Merged
kamilmysliwiec merged 2 commits intoOct 19, 2026
Merged
kamilmysliwiec merged 2 commits into
kamilmysliwiec merged 2 commits into
Conversation
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.
5 of 12 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
PR Checklist
Please check if your PR fulfills the following requirements:
PR Type
What kind of change does this PR introduce?
What is the current behavior?
Issue Number: N/A
fromPg(pool).transaction()checks a connection out of the pool and runsBEGIN, the work andCOMMITon it with noerrorlistener: pg-pool removes its own idle listener on checkout and restores it on release. A node-postgres client emitserrorwhen 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 onfromPg(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 ownmigrate().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'sClient has encountered a connection error and is not queryablewhen 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 unhandledConnection 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?
Other information
fromKysely()andfromDrizzle()inherit the same gap from their drivers, out of the kit's reach: Kysely'sPostgresDriverchecks out a client for each query or transaction with no listener, and so does drizzle-orm's node-postgrestransaction()(drizzle-team/drizzle-orm#6437, with a fix proposed in drizzle-team/drizzle-orm#6438).@nestjs/drizzle0.0.2 already guards the poolsDrizzleModuleregisters (nestjs/drizzle#30), so this change is for the pools an application builds and passes tofromPg(); on aDrizzleModulepool, that guard sees the transaction's listener and stays quiet. TypeORM's query runner and@prisma/adapter-pglisten on the connections they check out.The command line's one-connection pool (
openPostgres()) has noerrorlistener of its own, so a connection lost between two statements of a command still ends the process; apool.on('error', ...)there would cover that too, if you want it in this PR.