From 8cf264699d0ae7b291788a3c13c36f6a150e095f Mon Sep 17 00:00:00 2001 From: SerhiyGreench Date: Wed, 23 Sep 2026 00:49:14 +0200 Subject: [PATCH] A pool survives losing an idle connection When the server closes a connection a pool holds idle, pg emits `error` on the pool itself, and with no listener Node exits on the unhandled event. dataPool and the audit trail's pool had none, so a pg_terminate_backend, a failover or a maintenance restart took the whole service down. Both are now guarded by survivesLostConnections, which is exported for a consumer building its own pool: the lost connection is reported, the pool has already dropped it, and the next checkout opens a fresh one. --- src/audit.ts | 3 +- src/pool.ts | 23 ++++++++++++++- test/unit/barrel.spec.ts | 1 + test/unit/pool.spec.ts | 63 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 88 insertions(+), 2 deletions(-) create mode 100644 test/unit/pool.spec.ts diff --git a/src/audit.ts b/src/audit.ts index f9492f8..20baf3a 100644 --- a/src/audit.ts +++ b/src/audit.ts @@ -26,6 +26,7 @@ import pg from 'pg'; import type { SqlMiddleware } from '@prisma/orm-postgres/family-runtime'; import type { AnyQueryAst } from '@prisma/orm-postgres/relational-core/ast'; import type { StampTables } from './derive.js'; +import { survivesLostConnections } from './pool.js'; /** The three write actions the trail records. */ export const AuditAction = { @@ -148,7 +149,7 @@ export function audit({ stamps = {}, getPrincipal, }: AuditOptions): SqlMiddleware & { close(): Promise } { - const pool = new pg.Pool({ connectionString }); + const pool = survivesLostConnections(new pg.Pool({ connectionString })); const table = config.table ?? DEFAULTS.table; const col = { ...DEFAULTS.columns, ...config.columns }; const pending = new WeakMap(); diff --git a/src/pool.ts b/src/pool.ts index 0eb6d01..538f3d5 100644 --- a/src/pool.ts +++ b/src/pool.ts @@ -25,6 +25,27 @@ import pg from 'pg'; import type { Pool, PoolConfig, PoolClient } from 'pg'; +/** + * Keep a pool's lost idle connection from ending the process. + * + * @remarks + * When the server closes a connection the pool is holding idle — a + * `pg_terminate_backend`, a failover, a maintenance restart — `pg` emits + * `error` on the pool itself. With no listener, that is an unhandled `error` + * event and Node exits. The pool has already discarded the client by then and + * the next checkout opens a fresh one, so the only thing left to do is say so. + * + * @param pool - The pool to guard. + * @returns The same pool. + */ +export function survivesLostConnections

(pool: P): P { + pool.on('error', error => { + console.error(`pg pool: idle connection lost: ${error.message}`); + }); + + return pool; +} + /** `text[]`, whose wire format an array of enums shares exactly. */ const TEXT_ARRAY = 1009; @@ -107,7 +128,7 @@ export function dataPool( config: PoolConfig, parsers: TypeParsers = pg.types as unknown as TypeParsers, ): Pool { - const pool = new pg.Pool(config); + const pool = survivesLostConnections(new pg.Pool(config)); // Bound before the override, so neither the lookup below nor the callback // form calls itself. diff --git a/test/unit/barrel.spec.ts b/test/unit/barrel.spec.ts index c9f5aa7..089eb38 100644 --- a/test/unit/barrel.spec.ts +++ b/test/unit/barrel.spec.ts @@ -69,6 +69,7 @@ const EXPORTS = [ 'sql', 'sqlRunner', 'stamp', + 'survivesLostConnections', 'toOrdering', 'toPredicate', 'toProjection', diff --git a/test/unit/pool.spec.ts b/test/unit/pool.spec.ts new file mode 100644 index 0000000..7c16e43 --- /dev/null +++ b/test/unit/pool.spec.ts @@ -0,0 +1,63 @@ +/*! + * @imqueue/pg-prisma — pool tests + * + * I'm Queue Software Project + * Copyright (C) 2026 imqueue.com + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * If you want to use this code in a closed source (commercial) project, you can + * purchase a proprietary commercial license. Please contact us at + * to get commercial licensing options. + */ + +import assert from 'node:assert/strict'; +import { mock, test } from 'node:test'; +import pg from 'pg'; +import { dataPool, survivesLostConnections } from '../../index.js'; + +const terminated = new Error( + 'terminating connection due to administrator command', +); + +test('an unguarded pool throws on a lost idle connection', async () => { + const pool = new pg.Pool(); + + assert.throws(() => pool.emit('error', terminated), terminated); + await pool.end(); +}); + +test('a guarded pool reports a lost idle connection and carries on', async t => { + const report = mock.method(console, 'error', () => undefined); + const pool = survivesLostConnections(new pg.Pool()); + + t.after(() => report.mock.restore()); + + assert.equal(pool.emit('error', terminated), true); + assert.match( + String(report.mock.calls[0]?.arguments[0]), + /administrator command/, + ); + await pool.end(); +}); + +test('dataPool is guarded', async t => { + const report = mock.method(console, 'error', () => undefined); + const pool = dataPool({}); + + t.after(() => report.mock.restore()); + + assert.doesNotThrow(() => pool.emit('error', terminated)); + await pool.end(); +});