Skip to main content
RunBook Academy

← All break/fix scenarios in PostgreSQL

advancedpg-pooler~45 min

After the pooler was switched to transaction mode, a scheduled job started blocking unrelated clients on a lock nobody was holding

Reported symptoms

  • PgBouncer was switched from session mode to transaction mode to reduce backend connections, and backend count fell from 400 to 65 as intended
  • Within a day, a scheduled job that takes a session-level advisory lock began blocking unrelated clients for minutes at a time
  • The blocked clients are waiting on an advisory lock that no running query holds
  • A reporting job that sets work_mem at the start of its session now produces plans consistent with the cluster default, intermittently
  • A data-loading job using temporary tables fails with relation "staging_rows" does not exist, but only under concurrency
  • Everything worked correctly in staging, where the job runs alone
  • Reverting the pooler to session mode makes all three problems disappear and the backend count return to 400

Evidence

  • · PgBouncer is configured with pool_mode = transaction, default_pool_size = 10, max_client_conn = 500 and server_reset_query = DISCARD ALL
  • · server_reset_query_always is 0, so DISCARD ALL runs between transactions in transaction mode
  • · Under a single client against an idle pool, SET work_mem = 64MB did persist across two separate transactions, because the same server connection was handed back
  • · The same test under concurrency does not persist, because the pool reassigns connections
  • · A session-scoped advisory lock taken through the pool was still held between two separate statements, with pg_locks showing locks_held = 1
  • · A direct connection then blocked waiting on that advisory lock while the pooled server connection sat idle in the pool, and the attempt had to be abandoned after 120 seconds
  • · At 100 clients the pool served the workload on 10 server connections while a direct connection failed outright against max_connections of 100
  • · At 80 clients, where a direct connection works, direct measured 5124 tps against 2627 through the pool
Diagnosis and resolutionclick to reveal

Root cause

Transaction pooling breaks the identity between a client connection and a server connection, and every session-scoped object in PostgreSQL depends on that identity. In session mode, a client holds one server connection for as long as it stays connected. In transaction mode, the pooler hands a server connection to a client for the duration of one transaction and then takes it back. The next transaction from that client may land on a different server connection, and the connection it just released may go to somebody else. So anything scoped to a session stops belonging to the client that created it: - `SET` outside a transaction, and any `SET` that is meant to persist - Temporary tables - Session-level advisory locks - Prepared statements, unless the driver re-prepares - `LISTEN`/`NOTIFY` registrations - Cursors held outside a transaction PgBouncer mitigates most of this with `server_reset_query = DISCARD ALL`, which runs between transactions and clears settings, temporary tables and prepared statements. That is why the `SET` and the temporary table problems appear as *intermittent failures* rather than as cross-client leakage: the state is discarded, so the job finds it missing. The advisory lock is the more serious case and it does leak. Measured directly: a session-scoped advisory lock taken through the pool was still held after the client's transaction ended, with `pg_locks` reporting it held, while the server connection sat idle in the pool. A direct connection then blocked waiting on it and had to be abandoned after 120 seconds. That is the shape of the reported symptom exactly — clients waiting on a lock that no running query holds, because the holder is an idle pooled connection with no client attached to it. Staging did not reproduce any of this because the job runs alone there. With one client and an idle pool the same server connection is handed back every time, so session state appears to survive. The measurement above shows this directly: `SET work_mem = 64MB` persisted across two transactions through the pool, under no concurrency. **The pool only breaks session state when it has a reason to reassign connections**, which is precisely what does not happen in a test with one client. Reverting to session mode fixed all three because it restores the identity — at the cost of the 400 backends the change was made to avoid.

Remediation

Establish which session-scoped features each application actually uses. This is an application audit, not a database change, and it is the whole work: - `SET` statements outside a transaction - `CREATE TEMP TABLE` - `pg_advisory_lock` — as opposed to `pg_advisory_xact_lock` - `LISTEN` - Server-side prepared statements - `WITH HOLD` cursors Then fix each at the application, because the pooler cannot fix them for you: **Advisory locks.** Use the transaction-scoped variant, which is released automatically at the end of the transaction and cannot leak into a pooled connection: ```sql -- instead of pg_advisory_lock(key), which is session-scoped SELECT pg_advisory_xact_lock(12345); ``` This is a strict improvement even without a pooler, because it cannot be leaked by a client that disconnects awkwardly either. **Settings.** Use `SET LOCAL` inside the transaction, which reverts at commit, or set the value on the role so it applies to every connection: ```sql BEGIN; SET LOCAL work_mem = '256MB'; SELECT ...; COMMIT; ``` ```sql ALTER ROLE reporting SET work_mem = '64MB'; ``` **Temporary tables.** Create and use them inside a single transaction, or use an ordinary table with a session identifier, or `CREATE TEMP TABLE ... ON COMMIT DROP` so their lifetime is explicit. **Prepared statements.** Most drivers can be told not to use server-side prepared statements, or to re-prepare. Check the driver's documentation rather than assuming; this is the most common silent incompatibility. **`LISTEN`/`NOTIFY`.** These cannot work through transaction pooling at all. Give those clients a separate pool in session mode, or connect them directly. If an application genuinely needs session state, give it its own pool in session mode rather than reverting the whole estate: ```ini [databases] orders = host=db1 dbname=orders pool_mode=transaction orders_sessions = host=db1 dbname=orders pool_mode=session ``` Set `server_reset_query_always = 1` if you run session mode anywhere and want the reset to happen there too.

Verification

The scheduled job no longer blocks unrelated clients. Confirm from `pg_locks` rather than from absence of complaints: ```sql SELECT l.pid, a.state, l.locktype, l.objid, l.granted, now() - a.state_change AS in_state_for FROM pg_locks l LEFT JOIN pg_stat_activity a USING (pid) WHERE l.locktype = 'advisory' ORDER BY l.granted, l.pid; ``` A row with `granted = true` on a backend in state `idle` is a leaked session lock. Under the fix there should be none. The reporting job's plans are consistent. Check `work_mem` from inside the job's own transaction rather than from a separate session, since a separate session tells you nothing about which server connection the job got. The loading job succeeds under concurrency. **Test it under concurrency** — the staging environment did not reproduce any of this because the job ran alone, and a single-client test will pass for the same reason. Backend count stays at the pooled level: ```sql SELECT count(*) FROM pg_stat_activity WHERE backend_type = 'client backend'; ``` PgBouncer's own view agrees: ```text SHOW POOLS; SHOW SERVERS; ``` Latency is measured and the trade is acknowledged. At 80 clients, direct measured 5124 tps against 2627 through the pool; at 100 clients, direct failed outright against `max_connections` of 100 while the pool served the workload on 10 server connections. Both numbers matter and neither should surprise anybody afterwards.

Prevention

**Audit for session-scoped features before switching pool mode.** Transaction pooling is a change to application semantics, not a configuration tweak, and the audit is the work. **Prefer `pg_advisory_xact_lock` to `pg_advisory_lock` everywhere.** The transaction-scoped variant cannot leak — not into a pool, and not through an awkward client disconnect. **Prefer `SET LOCAL` to `SET`, and role settings to both.** **Test pooling changes under concurrency.** A single-client test hands the same server connection back every time and will pass. This is the specific reason staging said everything was fine, and it will say so again next time. **Alert on advisory locks held by idle backends.** It is a short query and it is the direct detection of the leak: ```sql SELECT count(*) FROM pg_locks l JOIN pg_stat_activity a USING (pid) WHERE l.locktype = 'advisory' AND l.granted AND a.state = 'idle'; ``` **Give incompatible applications their own pool in session mode**, rather than reverting the whole estate for one job. **Know what a pooler is for.** It is not a performance optimisation — direct was nearly twice as fast at a concurrency where direct works. Its value is that 100 clients work at all, on a cluster whose `max_connections` is 100. Deploy it for capacity and stability, and do not expect speed. **Set `query_wait_timeout` deliberately.** A client waiting for a server connection waits by default up to 120 seconds; whether that is a graceful queue or a hidden outage depends on your application's own timeouts, and the two should be chosen together.

Reported symptoms

PgBouncer was switched from session mode to transaction mode to reduce backend connections. Backend count fell from 400 to 65, as intended.

Within a day:

  • A scheduled job that takes a session-level advisory lock began blocking unrelated clients for minutes at a time. The blocked clients are waiting on a lock that no running query holds.
  • A reporting job that sets work_mem at the start of its session now produces plans consistent with the cluster default — intermittently.
  • A data-loading job using temporary tables fails with relation "staging_rows" does not exist, but only under concurrency.

Everything worked correctly in staging, where the job runs alone.

Reverting to session mode makes all three disappear, and the backend count return to 400.

Evidence provided

Read-only / Safethe pooler's configuration
$ SHOW CONFIG;
 default_pool_size           | 10
max_client_conn             | 500
pool_mode                   | transaction
server_reset_query          | DISCARD ALL
server_reset_query_always   | 0
query_wait_timeout          | 120
server_idle_timeout         | 600
server_lifetime             | 3600
Read-only / Safea SET that survived transaction pooling, which is the confusing part
$ psql "port=6432" -c "SET work_mem = '64MB'" -c "SHOW work_mem"
SET
work_mem 
----------
64MB
(1 row)
Service impact possiblethe advisory lock leak, measured
$ psql "port=6432" -c "SELECT pg_advisory_lock(42)" -c "SELECT count(*) FROM pg_locks WHERE locktype='advisory'"
 pg_advisory_lock 
------------------

(1 row)

locks_held 
------------
        1
(1 row)

-- then, on a DIRECT connection:
-- SELECT pg_advisory_lock(42);   <- blocked; killed after 120 s

At 100 clients the pool served the workload on 10 server connections while a direct connection failed outright against max_connections of 100. At 80 clients, where direct works: 5124 tps direct against 2627 pooled.

Work the evidence before reading on

  1. Clients are waiting on an advisory lock that no running query holds. Who holds it?
  2. The SET persisted through the pool in the test above. Does that mean transaction pooling preserves settings?
  3. Why did staging not reproduce any of this?
  4. The pool is half the speed of a direct connection. Why deploy one?

Root cause

Transaction pooling breaks the identity between client and server connection

The advisory lock does leak, and that is the serious one

Staging could not have found this

Reverting to session mode fixed all three because it restores the identity — at the cost of the 400 backends the change was made to avoid.

Resolution

Audit which session-scoped features each application uses. This is an application audit, not a database change, and it is the whole work: SET outside a transaction, CREATE TEMP TABLE, pg_advisory_lock, LISTEN, server-side prepared statements, WITH HOLD cursors.

Then fix each at the application, because the pooler cannot.

Advisory locks — use the transaction-scoped variant:

-- instead of pg_advisory_lock(key), which is session-scoped
SELECT pg_advisory_xact_lock(12345);

A strict improvement even without a pooler: it cannot be leaked by an awkward client disconnect either.

SettingsSET LOCAL inside the transaction, or on the role:

BEGIN;
SET LOCAL work_mem = '256MB';
SELECT ...;
COMMIT;
ALTER ROLE reporting SET work_mem = '64MB';

Temporary tables — create and use them inside a single transaction, or use an ordinary table with a session identifier, or ON COMMIT DROP so the lifetime is explicit.

Prepared statements — most drivers can be told not to use server-side prepared statements, or to re-prepare. Check the driver’s documentation rather than assuming; this is the most common silent incompatibility.

LISTEN/NOTIFY — cannot work through transaction pooling at all. Give those clients a separate pool in session mode, or connect directly.

If an application genuinely needs session state, give it its own pool rather than reverting the estate:

[databases]
orders          = host=db1 dbname=orders pool_mode=transaction
orders_sessions = host=db1 dbname=orders pool_mode=session

Verification

The scheduled job no longer blocks unrelated clients — confirmed from pg_locks, not from an absence of complaints:

SELECT l.pid, a.state, l.locktype, l.objid, l.granted,
       now() - a.state_change AS in_state_for
FROM pg_locks l LEFT JOIN pg_stat_activity a USING (pid)
WHERE l.locktype = 'advisory'
ORDER BY l.granted, l.pid;

A row with granted = true on a backend in state idle is a leaked session lock. Under the fix there should be none.

The reporting job’s plans are consistent — checked from inside the job’s own transaction, since a separate session tells you nothing about which server connection the job got.

The loading job succeeds under concurrency, tested under concurrency. Staging did not reproduce any of this because the job ran alone, and a single-client test will pass for the same reason.

Backend count stays at the pooled level, and PgBouncer’s own view agrees:

SELECT count(*) FROM pg_stat_activity WHERE backend_type = 'client backend';
SHOW POOLS;
SHOW SERVERS;

Latency is measured and the trade acknowledged: 5124 tps direct against 2627 pooled at 80 clients, and direct failing outright at 100. Both numbers matter, and neither should surprise anybody afterwards.

Prevention

Audit for session-scoped features before switching pool mode. Transaction pooling is a change to application semantics, not a configuration tweak.

Prefer pg_advisory_xact_lock to pg_advisory_lock everywhere.

Prefer SET LOCAL to SET, and role settings to both.

Test pooling changes under concurrency. A single-client test hands the same server connection back every time and will pass.

Alert on advisory locks held by idle backends — the direct detection of the leak:

SELECT count(*) FROM pg_locks l JOIN pg_stat_activity a USING (pid)
WHERE l.locktype = 'advisory' AND l.granted AND a.state = 'idle';

Give incompatible applications their own pool in session mode, rather than reverting the estate for one job.

Know what a pooler is for. Not speed — direct was nearly twice as fast at a concurrency where direct works. Its value is that 100 clients work at all on a cluster whose max_connections is 100.

Set query_wait_timeout deliberately. A client waits up to 120 seconds by default for a server connection; whether that is a graceful queue or a hidden outage depends on your application’s own timeouts, and the two should be chosen together.