From af7d4ad2eff2b0ad0f4cd52b5448312a28d4009c Mon Sep 17 00:00:00 2001 From: Leonardo Zanivan Date: Mon, 9 Feb 2026 12:41:17 -0300 Subject: [PATCH 1/7] feat: close connections with open transactions on release --- packages/pg-pool/index.js | 10 +++- packages/pg-pool/test/poison-pool.js | 82 ++++++++++++++++++++++++++++ packages/pg/lib/client.js | 2 + 3 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 packages/pg-pool/test/poison-pool.js diff --git a/packages/pg-pool/index.js b/packages/pg-pool/index.js index f53a85ab1..7a73ba529 100644 --- a/packages/pg-pool/index.js +++ b/packages/pg-pool/index.js @@ -359,7 +359,15 @@ class Pool extends EventEmitter { this.emit('release', err, client) // TODO(bmc): expose a proper, public interface _queryable and _ending - if (err || this.ending || !client._queryable || client._ending || client._poolUseCount >= this.options.maxUses) { + if ( + err || + this.ending || + !client._queryable || + client._ending || + client._txStatus === 'T' || + client._txStatus === 'E' || + client._poolUseCount >= this.options.maxUses + ) { if (client._poolUseCount >= this.options.maxUses) { this.log('remove expended client') } diff --git a/packages/pg-pool/test/poison-pool.js b/packages/pg-pool/test/poison-pool.js new file mode 100644 index 000000000..057b4d28f --- /dev/null +++ b/packages/pg-pool/test/poison-pool.js @@ -0,0 +1,82 @@ +'use strict' + +const expect = require('expect.js') +const describe = require('mocha').describe +const it = require('mocha').it +const Pool = require('..') + +describe('poison connection pool defense (_txStatus check)', function () { + it('removes a client with an open transaction on release', async function () { + const pool = new Pool({ max: 1 }) + const client = await pool.connect() + await client.query('BEGIN') + expect(client._txStatus).to.be('T') + + client.release() + expect(pool.totalCount).to.be(0) + expect(pool.idleCount).to.be(0) + + // pool should still work by creating a fresh connection + const { rows } = await pool.query('SELECT 1 as num') + expect(rows[0].num).to.be(1) + await pool.end() + }) + + it('removes a client in a failed transaction state on release', async function () { + const pool = new Pool({ max: 1 }) + const client = await pool.connect() + await client.query('BEGIN') + try { + await client.query('SELECT invalid_column FROM nonexistent_table') + } catch (e) { + // swallow the error to avoid pool close the connection + } + // The ReadyForQuery message with status 'E' may arrive on a separate I/O event. + // Issue a follow-up query to ensure it has been processed — this will also fail + // (since the transaction is aborted) but guarantees _txStatus is updated. + try { + await client.query('SELECT 1') + } catch (e) { + // expected — "current transaction is aborted" + } + expect(client._txStatus).to.be('E') + + client.release() + expect(pool.totalCount).to.be(0) + expect(pool.idleCount).to.be(0) + + // pool should still work + const { rows } = await pool.query('SELECT 1 as num') + expect(rows[0].num).to.be(1) + await pool.end() + }) + + it('only removes connections with open transactions, keeps idle ones', async function () { + const pool = new Pool({ max: 3 }) + const clientA = await pool.connect() + const clientB = await pool.connect() + const clientC = await pool.connect() + + // Client A: open transaction (poisoned) + await clientA.query('BEGIN') + expect(clientA._txStatus).to.be('T') + + // Client B: normal query (idle) + await clientB.query('SELECT 1') + expect(clientB._txStatus).to.be('I') + + // Client C: committed transaction (idle) + await clientC.query('BEGIN') + await clientC.query('COMMIT') + expect(clientC._txStatus).to.be('I') + + clientA.release() + clientB.release() + clientC.release() + + // A was removed, B and C kept + expect(pool.totalCount).to.be(2) + expect(pool.idleCount).to.be(2) + await pool.end() + }) +}) diff --git a/packages/pg/lib/client.js b/packages/pg/lib/client.js index 459439037..49a5abec5 100644 --- a/packages/pg/lib/client.js +++ b/packages/pg/lib/client.js @@ -68,6 +68,7 @@ class Client extends EventEmitter { this._connectionError = false this._queryable = true this._activeQuery = null + this._txStatus = null this.enableChannelBinding = Boolean(c.enableChannelBinding) // set true to use SCRAM-SHA-256-PLUS when offered this.connection = @@ -356,6 +357,7 @@ class Client extends EventEmitter { } const activeQuery = this._getActiveQuery() this._activeQuery = null + this._txStatus = msg.status this.readyForQuery = true if (activeQuery) { activeQuery.handleReadyForQuery(this.connection) From eceec5560ec64264545583fae23053f07cd562aa Mon Sep 17 00:00:00 2001 From: Leonardo Zanivan Date: Mon, 9 Feb 2026 15:39:55 -0300 Subject: [PATCH 2/7] fix: handle empty message --- packages/pg/lib/client.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/pg/lib/client.js b/packages/pg/lib/client.js index 49a5abec5..0c72d2e7e 100644 --- a/packages/pg/lib/client.js +++ b/packages/pg/lib/client.js @@ -357,7 +357,7 @@ class Client extends EventEmitter { } const activeQuery = this._getActiveQuery() this._activeQuery = null - this._txStatus = msg.status + this._txStatus = msg?.status ?? null this.readyForQuery = true if (activeQuery) { activeQuery.handleReadyForQuery(this.connection) From f72010f5505ae9a536c85d8b0590c763a29fc3be Mon Sep 17 00:00:00 2001 From: Leonardo Zanivan Date: Tue, 10 Feb 2026 09:23:05 -0300 Subject: [PATCH 3/7] feat: add log and more tests --- packages/pg-pool/index.js | 10 ++- .../test/{poison-pool.js => leaked-pool.js} | 67 ++++++++++++++-- .../test/integration/client/txstatus-tests.js | 76 +++++++++++++++++++ 3 files changed, 146 insertions(+), 7 deletions(-) rename packages/pg-pool/test/{poison-pool.js => leaked-pool.js} (54%) create mode 100644 packages/pg/test/integration/client/txstatus-tests.js diff --git a/packages/pg-pool/index.js b/packages/pg-pool/index.js index 7a73ba529..4072d8df6 100644 --- a/packages/pg-pool/index.js +++ b/packages/pg-pool/index.js @@ -116,6 +116,10 @@ class Pool extends EventEmitter { return this._clients.length > this.options.min } + _hasActiveTransaction(client) { + return client && (client._txStatus === 'T' || client._txStatus === 'E') + } + _pulseQueue() { this.log('pulse queue') if (this.ended) { @@ -364,13 +368,15 @@ class Pool extends EventEmitter { this.ending || !client._queryable || client._ending || - client._txStatus === 'T' || - client._txStatus === 'E' || + this._hasActiveTransaction(client) || client._poolUseCount >= this.options.maxUses ) { if (client._poolUseCount >= this.options.maxUses) { this.log('remove expended client') } + if (this._hasActiveTransaction(client)) { + this.log('remove client with leaked transaction') + } return this._remove(client, this._pulseQueue.bind(this)) } diff --git a/packages/pg-pool/test/poison-pool.js b/packages/pg-pool/test/leaked-pool.js similarity index 54% rename from packages/pg-pool/test/poison-pool.js rename to packages/pg-pool/test/leaked-pool.js index 057b4d28f..40641cc47 100644 --- a/packages/pg-pool/test/poison-pool.js +++ b/packages/pg-pool/test/leaked-pool.js @@ -5,9 +5,13 @@ const describe = require('mocha').describe const it = require('mocha').it const Pool = require('..') -describe('poison connection pool defense (_txStatus check)', function () { +describe('leaked connection pool guard', function () { it('removes a client with an open transaction on release', async function () { - const pool = new Pool({ max: 1 }) + const logMessages = [] + const pool = new Pool({ + max: 1, + log: (msg) => logMessages.push(msg), + }) const client = await pool.connect() await client.query('BEGIN') expect(client._txStatus).to.be('T') @@ -15,10 +19,14 @@ describe('poison connection pool defense (_txStatus check)', function () { client.release() expect(pool.totalCount).to.be(0) expect(pool.idleCount).to.be(0) + expect(logMessages).to.contain('remove client with leaked transaction') - // pool should still work by creating a fresh connection + // pool recovers by creating a fresh connection const { rows } = await pool.query('SELECT 1 as num') expect(rows[0].num).to.be(1) + expect(pool.totalCount).to.be(1) + expect(pool.idleCount).to.be(1) + await pool.end() }) @@ -45,9 +53,12 @@ describe('poison connection pool defense (_txStatus check)', function () { expect(pool.totalCount).to.be(0) expect(pool.idleCount).to.be(0) - // pool should still work + // pool recovers by creating a fresh connection const { rows } = await pool.query('SELECT 1 as num') expect(rows[0].num).to.be(1) + expect(pool.totalCount).to.be(1) + expect(pool.idleCount).to.be(1) + await pool.end() }) @@ -57,7 +68,7 @@ describe('poison connection pool defense (_txStatus check)', function () { const clientB = await pool.connect() const clientC = await pool.connect() - // Client A: open transaction (poisoned) + // Client A: open transaction (leaked) await clientA.query('BEGIN') expect(clientA._txStatus).to.be('T') @@ -79,4 +90,50 @@ describe('poison connection pool defense (_txStatus check)', function () { expect(pool.idleCount).to.be(2) await pool.end() }) + + describe('pool.query', function () { + it('removes a client after pool.query leaks transaction via BEGIN', async function () { + const logMessages = [] + const pool = new Pool({ max: 1, log: (msg) => logMessages.push(msg) }) + + await pool.query('BEGIN') + + // Client auto-released with txStatus='T', should be removed + expect(pool.totalCount).to.be(0) + expect(pool.idleCount).to.be(0) + expect(logMessages).to.contain('remove client with leaked transaction') + + // Verify pool recovers + const { rows } = await pool.query('SELECT 1 as num') + expect(rows[0].num).to.be(1) + expect(pool.totalCount).to.be(1) + expect(pool.idleCount).to.be(1) + + await pool.end() + }) + + it('removes a client after pool.query in failed transaction state', async function () { + const pool = new Pool({ max: 1 }) + + await pool.query('BEGIN') + + try { + await pool.query('SELECT invalid_column FROM nonexistent_table') + } catch (e) { + // Expected error + } + + // Client with txStatus='E' should be removed + expect(pool.totalCount).to.be(0) + expect(pool.idleCount).to.be(0) + + // Pool recovers + const { rows } = await pool.query('SELECT 1 as num') + expect(rows[0].num).to.be(1) + expect(pool.totalCount).to.be(1) + expect(pool.idleCount).to.be(1) + + await pool.end() + }) + }) }) diff --git a/packages/pg/test/integration/client/txstatus-tests.js b/packages/pg/test/integration/client/txstatus-tests.js new file mode 100644 index 000000000..9d197c915 --- /dev/null +++ b/packages/pg/test/integration/client/txstatus-tests.js @@ -0,0 +1,76 @@ +'use strict' +const helper = require('./test-helper') +const suite = new helper.Suite() +const pg = helper.pg +const assert = require('assert') + +suite.test('txStatus tracking', function (done) { + const client = new pg.Client() + client.connect( + assert.success(function () { + // Run a simple query to initialize txStatus + client.query( + 'SELECT 1', + assert.success(function () { + // Test 1: Initial state after query (should be idle) + assert.equal(client._txStatus, 'I', 'should start in idle state') + + // Test 2: BEGIN transaction + client.query( + 'BEGIN', + assert.success(function () { + assert.equal(client._txStatus, 'T', 'should be in transaction state') + + // Test 3: COMMIT + client.query( + 'COMMIT', + assert.success(function () { + assert.equal(client._txStatus, 'I', 'should return to idle after commit') + + client.end(done) + }) + ) + }) + ) + }) + ) + }) + ) +}) + +suite.test('txStatus error state', function (done) { + const client = new pg.Client() + client.connect( + assert.success(function () { + // Run a simple query to initialize txStatus + client.query( + 'SELECT 1', + assert.success(function () { + client.query( + 'BEGIN', + assert.success(function () { + // Execute invalid SQL to trigger error state + client.query('INVALID SQL SYNTAX', function (err) { + assert(err, 'should receive error from invalid query') + + // Use setImmediate to allow ReadyForQuery message to be processed + setImmediate(function () { + assert.equal(client._txStatus, 'E', 'should be in error state') + + // Rollback to recover + client.query( + 'ROLLBACK', + assert.success(function () { + assert.equal(client._txStatus, 'I', 'should return to idle after rollback from error') + client.end(done) + }) + ) + }) + }) + }) + ) + }) + ) + }) + ) +}) From 287728b6fa6e78fd7adfaa9e3337458a9f7ceee7 Mon Sep 17 00:00:00 2001 From: Leonardo Zanivan Date: Tue, 10 Feb 2026 09:59:33 -0300 Subject: [PATCH 4/7] fix: flakly integration test --- packages/pg/test/integration/client/txstatus-tests.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/pg/test/integration/client/txstatus-tests.js b/packages/pg/test/integration/client/txstatus-tests.js index 9d197c915..b3a0b9d4a 100644 --- a/packages/pg/test/integration/client/txstatus-tests.js +++ b/packages/pg/test/integration/client/txstatus-tests.js @@ -53,8 +53,10 @@ suite.test('txStatus error state', function (done) { client.query('INVALID SQL SYNTAX', function (err) { assert(err, 'should receive error from invalid query') - // Use setImmediate to allow ReadyForQuery message to be processed - setImmediate(function () { + // Issue a sync query to ensure ReadyForQuery has been processed + // This guarantees _txStatus has been updated + client.query('SELECT 1', function () { + // This callback fires after ReadyForQuery is processed assert.equal(client._txStatus, 'E', 'should be in error state') // Rollback to recover From 74f433654640b66a9b7fc5839c18981831581243 Mon Sep 17 00:00:00 2001 From: Leonardo Zanivan Date: Tue, 10 Feb 2026 10:07:50 -0300 Subject: [PATCH 5/7] fix: ignore txStatus tests in native mode --- .../test/integration/client/txstatus-tests.js | 131 +++++++++--------- 1 file changed, 67 insertions(+), 64 deletions(-) diff --git a/packages/pg/test/integration/client/txstatus-tests.js b/packages/pg/test/integration/client/txstatus-tests.js index b3a0b9d4a..3906bc87f 100644 --- a/packages/pg/test/integration/client/txstatus-tests.js +++ b/packages/pg/test/integration/client/txstatus-tests.js @@ -4,75 +4,78 @@ const suite = new helper.Suite() const pg = helper.pg const assert = require('assert') -suite.test('txStatus tracking', function (done) { - const client = new pg.Client() - client.connect( - assert.success(function () { - // Run a simple query to initialize txStatus - client.query( - 'SELECT 1', - assert.success(function () { - // Test 1: Initial state after query (should be idle) - assert.equal(client._txStatus, 'I', 'should start in idle state') +// txStatus tracking is not implemented in native client +if (!helper.args.native) { + suite.test('txStatus tracking', function (done) { + const client = new pg.Client() + client.connect( + assert.success(function () { + // Run a simple query to initialize txStatus + client.query( + 'SELECT 1', + assert.success(function () { + // Test 1: Initial state after query (should be idle) + assert.equal(client._txStatus, 'I', 'should start in idle state') - // Test 2: BEGIN transaction - client.query( - 'BEGIN', - assert.success(function () { - assert.equal(client._txStatus, 'T', 'should be in transaction state') + // Test 2: BEGIN transaction + client.query( + 'BEGIN', + assert.success(function () { + assert.equal(client._txStatus, 'T', 'should be in transaction state') - // Test 3: COMMIT - client.query( - 'COMMIT', - assert.success(function () { - assert.equal(client._txStatus, 'I', 'should return to idle after commit') + // Test 3: COMMIT + client.query( + 'COMMIT', + assert.success(function () { + assert.equal(client._txStatus, 'I', 'should return to idle after commit') - client.end(done) - }) - ) - }) - ) - }) - ) - }) - ) -}) + client.end(done) + }) + ) + }) + ) + }) + ) + }) + ) + }) -suite.test('txStatus error state', function (done) { - const client = new pg.Client() - client.connect( - assert.success(function () { - // Run a simple query to initialize txStatus - client.query( - 'SELECT 1', - assert.success(function () { - client.query( - 'BEGIN', - assert.success(function () { - // Execute invalid SQL to trigger error state - client.query('INVALID SQL SYNTAX', function (err) { - assert(err, 'should receive error from invalid query') + suite.test('txStatus error state', function (done) { + const client = new pg.Client() + client.connect( + assert.success(function () { + // Run a simple query to initialize txStatus + client.query( + 'SELECT 1', + assert.success(function () { + client.query( + 'BEGIN', + assert.success(function () { + // Execute invalid SQL to trigger error state + client.query('INVALID SQL SYNTAX', function (err) { + assert(err, 'should receive error from invalid query') - // Issue a sync query to ensure ReadyForQuery has been processed - // This guarantees _txStatus has been updated - client.query('SELECT 1', function () { - // This callback fires after ReadyForQuery is processed - assert.equal(client._txStatus, 'E', 'should be in error state') + // Issue a sync query to ensure ReadyForQuery has been processed + // This guarantees _txStatus has been updated + client.query('SELECT 1', function () { + // This callback fires after ReadyForQuery is processed + assert.equal(client._txStatus, 'E', 'should be in error state') - // Rollback to recover - client.query( - 'ROLLBACK', - assert.success(function () { - assert.equal(client._txStatus, 'I', 'should return to idle after rollback from error') - client.end(done) - }) - ) + // Rollback to recover + client.query( + 'ROLLBACK', + assert.success(function () { + assert.equal(client._txStatus, 'I', 'should return to idle after rollback from error') + client.end(done) + }) + ) + }) }) }) - }) - ) - }) - ) - }) - ) -}) + ) + }) + ) + }) + ) + }) +} From 1d664373454c2523c43e2d9bce780a8f7be6069b Mon Sep 17 00:00:00 2001 From: Leonardo Zanivan Date: Wed, 11 Feb 2026 16:30:49 -0300 Subject: [PATCH 6/7] feat: add new pool option evictOnOpenTransaction --- docs/pages/apis/pool.mdx | 8 +- packages/pg-pool/index.js | 17 +- packages/pg-pool/test/leaked-pool.js | 397 ++++++++++++++++++++------- 3 files changed, 308 insertions(+), 114 deletions(-) diff --git a/docs/pages/apis/pool.mdx b/docs/pages/apis/pool.mdx index fbe0279e1..370bc708c 100644 --- a/docs/pages/apis/pool.mdx +++ b/docs/pages/apis/pool.mdx @@ -56,6 +56,11 @@ type Config = { // regardless of whether they are idle. It's useful to force rotation of connection pools through // middleware so that you can rotate the underlying servers. The default is disabled (value of zero) maxLifetimeSeconds?: number + + // When true, automatically removes connections with open or failed transactions from the pool + // when they are released. This provides protection against "connection poisoning" where + // uncommitted transactions leak across different requests. Default is false. + evictOnOpenTransaction?: boolean } ``` @@ -70,7 +75,8 @@ const pool = new Pool({ max: 20, idleTimeoutMillis: 30000, connectionTimeoutMillis: 2000, - maxLifetimeSeconds: 60 + maxLifetimeSeconds: 60, + evictOnOpenTransaction: true }) ``` diff --git a/packages/pg-pool/index.js b/packages/pg-pool/index.js index 4072d8df6..ec5312e91 100644 --- a/packages/pg-pool/index.js +++ b/packages/pg-pool/index.js @@ -91,6 +91,7 @@ class Pool extends EventEmitter { this.options.maxUses = this.options.maxUses || Infinity this.options.allowExitOnIdle = this.options.allowExitOnIdle || false this.options.maxLifetimeSeconds = this.options.maxLifetimeSeconds || 0 + this.options.evictOnOpenTransaction = this.options.evictOnOpenTransaction || false this.log = this.options.log || function () {} this.Client = this.options.Client || Client || require('pg').Client this.Promise = this.options.Promise || global.Promise @@ -363,21 +364,15 @@ class Pool extends EventEmitter { this.emit('release', err, client) // TODO(bmc): expose a proper, public interface _queryable and _ending - if ( - err || - this.ending || - !client._queryable || - client._ending || - this._hasActiveTransaction(client) || - client._poolUseCount >= this.options.maxUses - ) { + if (err || this.ending || !client._queryable || client._ending || client._poolUseCount >= this.options.maxUses) { if (client._poolUseCount >= this.options.maxUses) { this.log('remove expended client') } - if (this._hasActiveTransaction(client)) { - this.log('remove client with leaked transaction') - } + return this._remove(client, this._pulseQueue.bind(this)) + } + if (this.options.evictOnOpenTransaction && this._hasActiveTransaction(client)) { + this.log('remove client due to open transaction') return this._remove(client, this._pulseQueue.bind(this)) } diff --git a/packages/pg-pool/test/leaked-pool.js b/packages/pg-pool/test/leaked-pool.js index 40641cc47..53900cda1 100644 --- a/packages/pg-pool/test/leaked-pool.js +++ b/packages/pg-pool/test/leaked-pool.js @@ -5,105 +5,57 @@ const describe = require('mocha').describe const it = require('mocha').it const Pool = require('..') -describe('leaked connection pool guard', function () { - it('removes a client with an open transaction on release', async function () { - const logMessages = [] - const pool = new Pool({ - max: 1, - log: (msg) => logMessages.push(msg), - }) - const client = await pool.connect() - await client.query('BEGIN') - expect(client._txStatus).to.be('T') - - client.release() - expect(pool.totalCount).to.be(0) - expect(pool.idleCount).to.be(0) - expect(logMessages).to.contain('remove client with leaked transaction') - - // pool recovers by creating a fresh connection - const { rows } = await pool.query('SELECT 1 as num') - expect(rows[0].num).to.be(1) - expect(pool.totalCount).to.be(1) - expect(pool.idleCount).to.be(1) - - await pool.end() - }) +describe('leaked connection pool', function () { + describe('when evictOnOpenTransaction is true', function () { + it('removes a client with an open transaction on release', async function () { + const logMessages = [] + const pool = new Pool({ + max: 1, + log: (msg) => logMessages.push(msg), + evictOnOpenTransaction: true, + }) + const client = await pool.connect() + await client.query('BEGIN') + expect(client._txStatus).to.be('T') - it('removes a client in a failed transaction state on release', async function () { - const pool = new Pool({ max: 1 }) - const client = await pool.connect() - await client.query('BEGIN') - try { - await client.query('SELECT invalid_column FROM nonexistent_table') - } catch (e) { - // swallow the error to avoid pool close the connection - } - // The ReadyForQuery message with status 'E' may arrive on a separate I/O event. - // Issue a follow-up query to ensure it has been processed — this will also fail - // (since the transaction is aborted) but guarantees _txStatus is updated. - try { - await client.query('SELECT 1') - } catch (e) { - // expected — "current transaction is aborted" - } - expect(client._txStatus).to.be('E') - - client.release() - expect(pool.totalCount).to.be(0) - expect(pool.idleCount).to.be(0) - - // pool recovers by creating a fresh connection - const { rows } = await pool.query('SELECT 1 as num') - expect(rows[0].num).to.be(1) - expect(pool.totalCount).to.be(1) - expect(pool.idleCount).to.be(1) - - await pool.end() - }) + client.release() + expect(pool.totalCount).to.be(0) + expect(pool.idleCount).to.be(0) + expect(logMessages).to.contain('remove client due to open transaction') - it('only removes connections with open transactions, keeps idle ones', async function () { - const pool = new Pool({ max: 3 }) - const clientA = await pool.connect() - const clientB = await pool.connect() - const clientC = await pool.connect() - - // Client A: open transaction (leaked) - await clientA.query('BEGIN') - expect(clientA._txStatus).to.be('T') - - // Client B: normal query (idle) - await clientB.query('SELECT 1') - expect(clientB._txStatus).to.be('I') - - // Client C: committed transaction (idle) - await clientC.query('BEGIN') - await clientC.query('COMMIT') - expect(clientC._txStatus).to.be('I') - - clientA.release() - clientB.release() - clientC.release() - - // A was removed, B and C kept - expect(pool.totalCount).to.be(2) - expect(pool.idleCount).to.be(2) - await pool.end() - }) + // pool recovers by creating a fresh connection + const { rows } = await pool.query('SELECT 1 as num') + expect(rows[0].num).to.be(1) + expect(pool.totalCount).to.be(1) + expect(pool.idleCount).to.be(1) - describe('pool.query', function () { - it('removes a client after pool.query leaks transaction via BEGIN', async function () { - const logMessages = [] - const pool = new Pool({ max: 1, log: (msg) => logMessages.push(msg) }) + await pool.end() + }) - await pool.query('BEGIN') + it('removes a client in a failed transaction state on release', async function () { + const pool = new Pool({ max: 1, evictOnOpenTransaction: true }) + const client = await pool.connect() + await client.query('BEGIN') + try { + await client.query('SELECT invalid_column FROM nonexistent_table') + } catch (e) { + // swallow the error to avoid pool close the connection + } + // The ReadyForQuery message with status 'E' may arrive on a separate I/O event. + // Issue a follow-up query to ensure it has been processed — this will also fail + // (since the transaction is aborted) but guarantees _txStatus is updated. + try { + await client.query('SELECT 1') + } catch (e) { + // expected — "current transaction is aborted" + } + expect(client._txStatus).to.be('E') - // Client auto-released with txStatus='T', should be removed + client.release() expect(pool.totalCount).to.be(0) expect(pool.idleCount).to.be(0) - expect(logMessages).to.contain('remove client with leaked transaction') - // Verify pool recovers + // pool recovers by creating a fresh connection const { rows } = await pool.query('SELECT 1 as num') expect(rows[0].num).to.be(1) expect(pool.totalCount).to.be(1) @@ -112,28 +64,269 @@ describe('leaked connection pool guard', function () { await pool.end() }) - it('removes a client after pool.query in failed transaction state', async function () { - const pool = new Pool({ max: 1 }) + it('only removes connections with open transactions, keeps idle ones', async function () { + const pool = new Pool({ max: 3, evictOnOpenTransaction: true }) + const clientA = await pool.connect() + const clientB = await pool.connect() + const clientC = await pool.connect() + + // Client A: open transaction (leaked) + await clientA.query('BEGIN') + expect(clientA._txStatus).to.be('T') + + // Client B: normal query (idle) + await clientB.query('SELECT 1') + expect(clientB._txStatus).to.be('I') + + // Client C: committed transaction (idle) + await clientC.query('BEGIN') + await clientC.query('COMMIT') + expect(clientC._txStatus).to.be('I') + + clientA.release() + clientB.release() + clientC.release() + + // A was removed, B and C kept + expect(pool.totalCount).to.be(2) + expect(pool.idleCount).to.be(2) + await pool.end() + }) + + describe('pool.query', function () { + it('removes a client after pool.query leaks transaction via BEGIN', async function () { + const logMessages = [] + const pool = new Pool({ max: 1, log: (msg) => logMessages.push(msg), evictOnOpenTransaction: true }) + + await pool.query('BEGIN') + + // Client auto-released with txStatus='T', should be removed + expect(pool.totalCount).to.be(0) + expect(pool.idleCount).to.be(0) + expect(logMessages).to.contain('remove client due to open transaction') + + // Verify pool recovers + const { rows } = await pool.query('SELECT 1 as num') + expect(rows[0].num).to.be(1) + expect(pool.totalCount).to.be(1) + expect(pool.idleCount).to.be(1) + + await pool.end() + }) + + it('removes a client after pool.query in failed transaction state', async function () { + const pool = new Pool({ max: 1 }) + + await pool.query('BEGIN') + + try { + await pool.query('SELECT invalid_column FROM nonexistent_table') + } catch (e) { + // Expected error + } + + // Client with txStatus='E' should be removed + expect(pool.totalCount).to.be(0) + expect(pool.idleCount).to.be(0) + + // Pool recovers + const { rows } = await pool.query('SELECT 1 as num') + expect(rows[0].num).to.be(1) + expect(pool.totalCount).to.be(1) + expect(pool.idleCount).to.be(1) + + await pool.end() + }) + }) + }) + + describe('when evictOnOpenTransaction is false or default', function () { + it('keeps client with open transaction when explicitly false', async function () { + const logMessages = [] + const pool = new Pool({ + max: 1, + log: (msg) => logMessages.push(msg), + evictOnOpenTransaction: false, + }) + const client = await pool.connect() + await client.query('BEGIN') + expect(client._txStatus).to.be('T') + + client.release() + expect(pool.totalCount).to.be(1) // NOT removed + expect(pool.idleCount).to.be(1) + expect(logMessages).to.not.contain('remove client due to open transaction') + + // Verify pool can still execute queries (connection was reused) + const { rows } = await pool.query('SELECT 1 as num') + expect(rows[0].num).to.be(1) + + await pool.end() + }) + + it('keeps client with open transaction when option not specified (default)', async function () { + const logMessages = [] + const pool = new Pool({ + max: 1, + log: (msg) => logMessages.push(msg), + }) + const client = await pool.connect() + await client.query('BEGIN') + expect(client._txStatus).to.be('T') - await pool.query('BEGIN') + client.release() + expect(pool.totalCount).to.be(1) // NOT removed + expect(pool.idleCount).to.be(1) + expect(logMessages).to.not.contain('remove client due to open transaction') + // Verify pool can still execute queries (connection was reused) + const { rows } = await pool.query('SELECT 1 as num') + expect(rows[0].num).to.be(1) + + await pool.end() + }) + + it('keeps client in failed transaction state when explicitly false', async function () { + const pool = new Pool({ max: 1, evictOnOpenTransaction: false }) + const client = await pool.connect() + await client.query('BEGIN') + try { + await client.query('SELECT invalid_column FROM nonexistent_table') + } catch (e) { + // swallow the error + } + // Issue a follow-up query to ensure _txStatus is updated to 'E' try { - await pool.query('SELECT invalid_column FROM nonexistent_table') + await client.query('SELECT 1') } catch (e) { - // Expected error + // expected — "current transaction is aborted" } + expect(client._txStatus).to.be('E') - // Client with txStatus='E' should be removed - expect(pool.totalCount).to.be(0) - expect(pool.idleCount).to.be(0) + client.release() + expect(pool.totalCount).to.be(1) // NOT removed + expect(pool.idleCount).to.be(1) - // Pool recovers - const { rows } = await pool.query('SELECT 1 as num') + // Get a new client and manually ROLLBACK the failed transaction + const client2 = await pool.connect() + await client2.query('ROLLBACK') + const { rows } = await client2.query('SELECT 1 as num') expect(rows[0].num).to.be(1) - expect(pool.totalCount).to.be(1) + client2.release() + + await pool.end() + }) + + it('keeps client in failed transaction state when option not specified (default)', async function () { + const pool = new Pool({ max: 1 }) + const client = await pool.connect() + await client.query('BEGIN') + try { + await client.query('SELECT invalid_column FROM nonexistent_table') + } catch (e) { + // swallow the error + } + // Issue a follow-up query to ensure _txStatus is updated to 'E' + try { + await client.query('SELECT 1') + } catch (e) { + // expected — "current transaction is aborted" + } + expect(client._txStatus).to.be('E') + + client.release() + expect(pool.totalCount).to.be(1) // NOT removed expect(pool.idleCount).to.be(1) + // Get a new client and manually ROLLBACK the failed transaction + const client2 = await pool.connect() + await client2.query('ROLLBACK') + const { rows } = await client2.query('SELECT 1 as num') + expect(rows[0].num).to.be(1) + client2.release() + + await pool.end() + }) + + it('keeps all clients with mixed transaction states', async function () { + const logMessages = [] + const pool = new Pool({ + max: 3, + evictOnOpenTransaction: false, + log: (msg) => logMessages.push(msg), + }) + const clientA = await pool.connect() + const clientB = await pool.connect() + const clientC = await pool.connect() + + // Client A: open transaction (leaked) + await clientA.query('BEGIN') + expect(clientA._txStatus).to.be('T') + + // Client B: normal query (idle) + await clientB.query('SELECT 1') + expect(clientB._txStatus).to.be('I') + + // Client C: committed transaction (idle) + await clientC.query('BEGIN') + await clientC.query('COMMIT') + expect(clientC._txStatus).to.be('I') + + clientA.release() + clientB.release() + clientC.release() + + // All clients kept in pool + expect(pool.totalCount).to.be(3) + expect(pool.idleCount).to.be(3) + expect(logMessages).to.not.contain('remove client due to open transaction') + await pool.end() }) + + describe('pool.query', function () { + it('keeps client after pool.query leaks transaction via BEGIN (default)', async function () { + const logMessages = [] + const pool = new Pool({ max: 1, log: (msg) => logMessages.push(msg) }) + + await pool.query('BEGIN') + + // Client auto-released with txStatus='T', should be kept + expect(pool.totalCount).to.be(1) // NOT removed + expect(pool.idleCount).to.be(1) + expect(logMessages).to.not.contain('remove client due to open transaction') + + // Verify pool still works + const { rows } = await pool.query('SELECT 1 as num') + expect(rows[0].num).to.be(1) + + await pool.end() + }) + + it('removes client on pool.query error even when evictOnOpenTransaction is false', async function () { + const pool = new Pool({ max: 1, evictOnOpenTransaction: false }) + + await pool.query('BEGIN') + + try { + await pool.query('SELECT invalid_column FROM nonexistent_table') + } catch (e) { + // Expected error - pool.query calls client.release(err) which removes the client + } + + // Client is removed because pool.query releases with error argument + // This is independent of evictOnOpenTransaction setting + expect(pool.totalCount).to.be(0) + expect(pool.idleCount).to.be(0) + + // Pool recovers with a fresh connection + const { rows } = await pool.query('SELECT 1 as num') + expect(rows[0].num).to.be(1) + expect(pool.totalCount).to.be(1) + expect(pool.idleCount).to.be(1) + + await pool.end() + }) + }) }) }) From 60fabaab3b76394612d909b8bfc2d45bf21c6883 Mon Sep 17 00:00:00 2001 From: Leonardo Zanivan Date: Wed, 11 Feb 2026 16:43:36 -0300 Subject: [PATCH 7/7] feat: add new client.getTransactionStatus() method --- docs/pages/apis/client.mdx | 57 +++++++++++++++++++ packages/pg-pool/index.js | 2 +- packages/pg-pool/test/leaked-pool.js | 30 +++++----- packages/pg/lib/client.js | 4 ++ packages/pg/lib/native/client.js | 5 ++ .../test/integration/client/txstatus-tests.js | 18 +++--- 6 files changed, 93 insertions(+), 23 deletions(-) diff --git a/docs/pages/apis/client.mdx b/docs/pages/apis/client.mdx index f68542672..ad8aadf4d 100644 --- a/docs/pages/apis/client.mdx +++ b/docs/pages/apis/client.mdx @@ -173,6 +173,63 @@ await client.end() console.log('client has disconnected') ``` +## client.getTransactionStatus + +`client.getTransactionStatus() => string | null` + +Returns the current transaction status of the client connection. This can be useful for debugging transaction state issues or implementing custom transaction management logic. + +**Return values:** + +- `'I'` - Idle (not in a transaction) +- `'T'` - Transaction active (BEGIN has been issued) +- `'E'` - Error (transaction aborted, requires ROLLBACK) +- `null` - Initial state or not supported (native client) + +The transaction status is updated after each query completes based on the PostgreSQL backend's `ReadyForQuery` message. + +**Example: Checking transaction state** + +```js +import { Client } from 'pg' +const client = new Client() +await client.connect() + +await client.query('BEGIN') +console.log(client.getTransactionStatus()) // 'T' - in transaction + +await client.query('SELECT * FROM users') +console.log(client.getTransactionStatus()) // 'T' - still in transaction + +await client.query('COMMIT') +console.log(client.getTransactionStatus()) // 'I' - idle + +await client.end() +``` + +**Example: Handling transaction errors** + +```js +import { Client } from 'pg' +const client = new Client() +await client.connect() + +await client.query('BEGIN') +try { + await client.query('INVALID SQL') +} catch (err) { + console.log(client.getTransactionStatus()) // 'E' - error state + + // Must rollback to recover + await client.query('ROLLBACK') + console.log(client.getTransactionStatus()) // 'I' - idle again +} + +await client.end() +``` + +**Note:** This method is not supported in the native client and will always return `null`. + ## events ### error diff --git a/packages/pg-pool/index.js b/packages/pg-pool/index.js index ec5312e91..e33090dfd 100644 --- a/packages/pg-pool/index.js +++ b/packages/pg-pool/index.js @@ -118,7 +118,7 @@ class Pool extends EventEmitter { } _hasActiveTransaction(client) { - return client && (client._txStatus === 'T' || client._txStatus === 'E') + return client && (client.getTransactionStatus() === 'T' || client.getTransactionStatus() === 'E') } _pulseQueue() { diff --git a/packages/pg-pool/test/leaked-pool.js b/packages/pg-pool/test/leaked-pool.js index 53900cda1..c10f4e9f1 100644 --- a/packages/pg-pool/test/leaked-pool.js +++ b/packages/pg-pool/test/leaked-pool.js @@ -16,7 +16,7 @@ describe('leaked connection pool', function () { }) const client = await pool.connect() await client.query('BEGIN') - expect(client._txStatus).to.be('T') + expect(client.getTransactionStatus()).to.be('T') client.release() expect(pool.totalCount).to.be(0) @@ -43,13 +43,13 @@ describe('leaked connection pool', function () { } // The ReadyForQuery message with status 'E' may arrive on a separate I/O event. // Issue a follow-up query to ensure it has been processed — this will also fail - // (since the transaction is aborted) but guarantees _txStatus is updated. + // (since the transaction is aborted) but guarantees transaction status is updated. try { await client.query('SELECT 1') } catch (e) { // expected — "current transaction is aborted" } - expect(client._txStatus).to.be('E') + expect(client.getTransactionStatus()).to.be('E') client.release() expect(pool.totalCount).to.be(0) @@ -72,16 +72,16 @@ describe('leaked connection pool', function () { // Client A: open transaction (leaked) await clientA.query('BEGIN') - expect(clientA._txStatus).to.be('T') + expect(clientA.getTransactionStatus()).to.be('T') // Client B: normal query (idle) await clientB.query('SELECT 1') - expect(clientB._txStatus).to.be('I') + expect(clientB.getTransactionStatus()).to.be('I') // Client C: committed transaction (idle) await clientC.query('BEGIN') await clientC.query('COMMIT') - expect(clientC._txStatus).to.be('I') + expect(clientC.getTransactionStatus()).to.be('I') clientA.release() clientB.release() @@ -150,7 +150,7 @@ describe('leaked connection pool', function () { }) const client = await pool.connect() await client.query('BEGIN') - expect(client._txStatus).to.be('T') + expect(client.getTransactionStatus()).to.be('T') client.release() expect(pool.totalCount).to.be(1) // NOT removed @@ -172,7 +172,7 @@ describe('leaked connection pool', function () { }) const client = await pool.connect() await client.query('BEGIN') - expect(client._txStatus).to.be('T') + expect(client.getTransactionStatus()).to.be('T') client.release() expect(pool.totalCount).to.be(1) // NOT removed @@ -195,13 +195,13 @@ describe('leaked connection pool', function () { } catch (e) { // swallow the error } - // Issue a follow-up query to ensure _txStatus is updated to 'E' + // Issue a follow-up query to ensure transaction status is updated to 'E' try { await client.query('SELECT 1') } catch (e) { // expected — "current transaction is aborted" } - expect(client._txStatus).to.be('E') + expect(client.getTransactionStatus()).to.be('E') client.release() expect(pool.totalCount).to.be(1) // NOT removed @@ -226,13 +226,13 @@ describe('leaked connection pool', function () { } catch (e) { // swallow the error } - // Issue a follow-up query to ensure _txStatus is updated to 'E' + // Issue a follow-up query to ensure transaction status is updated to 'E' try { await client.query('SELECT 1') } catch (e) { // expected — "current transaction is aborted" } - expect(client._txStatus).to.be('E') + expect(client.getTransactionStatus()).to.be('E') client.release() expect(pool.totalCount).to.be(1) // NOT removed @@ -261,16 +261,16 @@ describe('leaked connection pool', function () { // Client A: open transaction (leaked) await clientA.query('BEGIN') - expect(clientA._txStatus).to.be('T') + expect(clientA.getTransactionStatus()).to.be('T') // Client B: normal query (idle) await clientB.query('SELECT 1') - expect(clientB._txStatus).to.be('I') + expect(clientB.getTransactionStatus()).to.be('I') // Client C: committed transaction (idle) await clientC.query('BEGIN') await clientC.query('COMMIT') - expect(clientC._txStatus).to.be('I') + expect(clientC.getTransactionStatus()).to.be('I') clientA.release() clientB.release() diff --git a/packages/pg/lib/client.js b/packages/pg/lib/client.js index 0c72d2e7e..865109edb 100644 --- a/packages/pg/lib/client.js +++ b/packages/pg/lib/client.js @@ -695,6 +695,10 @@ class Client extends EventEmitter { this.connection.unref() } + getTransactionStatus() { + return this._txStatus + } + end(cb) { this._ending = true diff --git a/packages/pg/lib/native/client.js b/packages/pg/lib/native/client.js index 44d5b5c64..5aa2a79a2 100644 --- a/packages/pg/lib/native/client.js +++ b/packages/pg/lib/native/client.js @@ -313,3 +313,8 @@ Client.prototype.getTypeParser = function (oid, format) { Client.prototype.isConnected = function () { return this._connected } + +Client.prototype.getTransactionStatus = function () { + // not supported in native client + return null +} diff --git a/packages/pg/test/integration/client/txstatus-tests.js b/packages/pg/test/integration/client/txstatus-tests.js index 3906bc87f..3529ce0fe 100644 --- a/packages/pg/test/integration/client/txstatus-tests.js +++ b/packages/pg/test/integration/client/txstatus-tests.js @@ -4,7 +4,7 @@ const suite = new helper.Suite() const pg = helper.pg const assert = require('assert') -// txStatus tracking is not implemented in native client +// txStatus tracking is not supported in native client if (!helper.args.native) { suite.test('txStatus tracking', function (done) { const client = new pg.Client() @@ -15,19 +15,19 @@ if (!helper.args.native) { 'SELECT 1', assert.success(function () { // Test 1: Initial state after query (should be idle) - assert.equal(client._txStatus, 'I', 'should start in idle state') + assert.equal(client.getTransactionStatus(), 'I', 'should start in idle state') // Test 2: BEGIN transaction client.query( 'BEGIN', assert.success(function () { - assert.equal(client._txStatus, 'T', 'should be in transaction state') + assert.equal(client.getTransactionStatus(), 'T', 'should be in transaction state') // Test 3: COMMIT client.query( 'COMMIT', assert.success(function () { - assert.equal(client._txStatus, 'I', 'should return to idle after commit') + assert.equal(client.getTransactionStatus(), 'I', 'should return to idle after commit') client.end(done) }) @@ -56,16 +56,20 @@ if (!helper.args.native) { assert(err, 'should receive error from invalid query') // Issue a sync query to ensure ReadyForQuery has been processed - // This guarantees _txStatus has been updated + // This guarantees transaction status has been updated client.query('SELECT 1', function () { // This callback fires after ReadyForQuery is processed - assert.equal(client._txStatus, 'E', 'should be in error state') + assert.equal(client.getTransactionStatus(), 'E', 'should be in error state') // Rollback to recover client.query( 'ROLLBACK', assert.success(function () { - assert.equal(client._txStatus, 'I', 'should return to idle after rollback from error') + assert.equal( + client.getTransactionStatus(), + 'I', + 'should return to idle after rollback from error' + ) client.end(done) }) )