Skip to content

feat: evict connections with open transactions on pool release#3597

Open
panga wants to merge 7 commits intobrianc:masterfrom
panga:poison-pool
Open

feat: evict connections with open transactions on pool release#3597
panga wants to merge 7 commits intobrianc:masterfrom
panga:poison-pool

Conversation

@panga
Copy link

@panga panga commented Feb 9, 2026

Summary

  • Add _txStatus tracking to pg Client, populated from PostgreSQL's ReadyForQuery message
  • Add a new client.getTransactionStatus() method to return the _txStatus value
  • Add a new pool option evictOnOpenTransaction (disabled by default for backwards compatiblity)
  • Check client.getTransactionStatus() in pg-pool's release() method to evict connections not in idle (I) state instead of returning them to the pool
  • Add a new log indicating that a connection was evicted from the pool due to open transaction
  • Add tests covering the new methods and eviction behavior

Behavior Changes

  • When evictOnOpenTransaction: true, a connection is closed and removed from the pool if it is released with an active transaction (BEGIN without COMMIT/ROLLBACK).

Problem

When a client with an active transaction (BEGIN without COMMIT/ROLLBACK) is released back to the pool, the connection is returned in a non-idle transaction state. The next consumer that checks out this connection inherits the open transaction, which can cause:

  • Row-level lock retention: Rows locked by SELECT ... FOR UPDATE or UPDATE inside the uncommitted transaction remain locked, blocking other connections that try to access the same rows.
  • Connection pool exhaustion: In a pool with max: N connections, poisoned connections that hold locks can cause other connections to block waiting on those locks, eventually starving the pool.
  • Silent data visibility issues: The next consumer operates inside someone else's transaction with a stale MVCC snapshot, potentially reading stale data or having writes silently rolled back.

This is a known as "leaked/poison connection" problem in connection pools. This feature was recently added in Go pgx jackc/pgx#2481

Notes

  • This new behavior is not implemented in pg-native because it works at protocol level which we don't have access in libpq.
  • This change was also tested with PgBouncer in transaction mode to verify it is working as expected.

}
const activeQuery = this._getActiveQuery()
this._activeQuery = null
this._txStatus = msg?.status ?? null
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this check just for tests and pg-native? Both should probably be updated to consistently report a status in their readyForQuery events.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added integration tests and updated PR description. However pg-native is not supported because this is a protocol level feature.

@charmander
Copy link
Collaborator

Related: #391, #724

How about throwing an error when attempting to return a client with an open transaction to the pool, since that indicates an app bug in current pg, and pg doesn’t have the information to make the optimal choice by itself?

@panga
Copy link
Author

panga commented Feb 10, 2026

@charmander thanks for the review. I added a log to align with the behavior in other cases. I couldn’t find a clean way to surface an error at that point since release returns void and usually called on final. Given the trade-offs around pool correctness, I think it’s preferable to prevent an invalid state rather than silently ignoring it.

Do you think this behavior change should be behind a pool option and disabled by default?

@panga panga requested a review from charmander February 10, 2026 13:18
@brianc
Copy link
Owner

brianc commented Feb 10, 2026

Do you think this behavior change should be behind a pool option and disabled by default?

Though I think how the pool behaves now (doesn't have any concept of the client being in a transaction and will happily rent it out to another caller later) is almost always wrong, I think it risks weird behavioral breaking changes if we change the default behavior in the 8.x branch....so putting it behind a config where the default value currently is to do what it does now (which is incorrect) is there, but one can opt into auto-aborting & closing clients which are returned to the pool while a transaction is still pending.

So for [email protected] it would be

// not sure what a good config property name is...but something like this:
const pool = new Pool({ evictOnOpenTransaction: true })

To opt into the behavior. Then in 9.x we can make this behavior the default & actually remove the feature flag entirely since I don't see any reason to allow the old, invalid behavior long term.

How about throwing an error when attempting to return a client with an open transaction to the pool, since that indicates an app bug in current pg, and pg doesn’t have the information to make the optimal choice by itself?

I agree w/ @panga that .release() seems like a disruptive place to throw an error as its usually in a cleanup phase & historically doesn't throw. I think one nice thing though would be to actually put .transactionStatus() as an actuall method (or getter property) on the client itself and make it public. That would allow libraries or users to perform the check if they wanted to and throw on their side. What do you think about that?

@charmander
Copy link
Collaborator

I couldn’t find a clean way to surface an error at that point since release returns void and usually called on final.

My suggestion was to throw an error synchronously from release. Yes, it’s disruptive if it happens, but that’s the point, since it indicates a serious bug (that would not necessarily even be fixed by discarding the client from the pool). I can’t think of a legitimate reason to return a connection with an open transaction to a pool. But for strict backward compatibility, it could be a deprecation warning in pg 8, with a possible option to opt-in to pg 9 behaviour early.

Exposing the status as a public property is a good idea for other reasons, though.

@brianc
Copy link
Owner

brianc commented Feb 11, 2026

My suggestion was to throw an error synchronously from release. Yes, it’s disruptive if it happens, but that’s the point, since it indicates a serious bug (that would not necessarily even be fixed by discarding the client from the pool). I can’t think of a legitimate reason to return a connection with an open transaction to a pool. But for strict backward compatibility, it could be a deprecation warning in pg 8, with a possible option to opt-in to pg 9 behaviour early.

Yeah that makes sense too. I'll work on a PR soon for the 9.x branch. I agree it should be considered an error when you return a client that's in a transaction...and closing the users connection is kinda more of a surprise and "silent error" than just throwing as its almost certainly a mistake in the code w/ a dangling transaction.

@panga
Copy link
Author

panga commented Feb 11, 2026

I implemented the recommended changes for the new pool option evictOnOpenTransaction and for client.getTransactionStatus(), including the corresponding documentation updates.

I'm concerned that throwing errors from release() may be overly disruptive for many Node.js applications, because in a number of codebases this can surface as an uncaught exception and terminate the process. For example, a common implementation pattern I see looks like this:

const client = await pool.connect();
try {
    return await client.query(...);
} catch (err) {
    client.release(err);
} finally {
    client.release();
}

Edit: The above example is not correct, it need to catch the err to avoid double release.
e.g:

try {
...
} catch (err) {
  clientErr = err
} finally {
  client.release(clientErr)
}

@charmander
Copy link
Collaborator

I'm concerned that throwing errors from release() may be overly disruptive for many Node.js applications, because in a number of codebases this can surface as an uncaught exception and terminate the process.

Yes, that’s normal JavaScript behaviour for error conditions. Note that this would not be an uncatchable exception, and would typically not terminate the process in applications that care about surviving unexpected error conditions.

For example, a common implementation pattern I see looks like this: […]

That pattern is broken and should not be used, but it’s not incompatible with the general approach of throwing an exception as long as the second release is a no-op.

@panga
Copy link
Author

panga commented Feb 12, 2026

@charmander you’re right. The simplified example I shared above is not correct because it can result in a double release. In practice, the implementation should explicitly handle the error path and ensure the resource is released exactly once, typically by passing the error object to release(err).

If @brianc is aligned, I can add a throw in this case since it’s behind a feature flag, which should avoid introducing breaking changes for the current version.

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.

3 participants