Topic 32

Long Transactions: The Cost You Don't See

Operations

A transaction that stays open holds a snapshot, and that snapshot decides the oldest row version the entire cluster is still obliged to keep. One analytics query, one connection sitting idle inside a transaction, one replication slot left behind after an experiment: each of them can stop dead rows being removed on every table in every database, including tables that session never read.

What makes this the most expensive item in the chapter is the distance between cause and symptom. The thing you notice is a table growing, plans getting slower, and a disk filling. The thing responsible is a connection doing nothing at all, three layers away, and no error is raised anywhere along the path.

The Horizon

Cleanup has one rule: a row version may be removed only when no snapshot could still legitimately need it. The cluster therefore tracks the oldest transaction id any live snapshot depends on, and vacuum treats that value as a floor. Versions expired before it can go. Versions expired after it must stay, however dead they look and however many gigabytes they occupy.

That single number decides whether cleanup works at all, and it is not a per-table or per-database property. It is one boundary for the whole cluster, and any backend can pin it. Chapter 7 is about what vacuum does with the space it is allowed to reclaim; this topic is about the number that decides how much of it there is.

The Four Ways to Hold It Back

A long-running query is the honest case. The nightly reconciliation over 40 million rows in orders genuinely needs the rows its snapshot describes, and it holds the horizon for its full runtime. A session sitting idle in transaction is the dishonest one: it holds exactly the same snapshot and is doing nothing with it, usually because a framework opened a transaction on request entry and an exception path skipped the commit. In pg_stat_activity that session looks idle, and it is not — it is holding the cluster's cleanup boundary at whatever time it started.

The third holder is a replication slot. A slot exists to guarantee that its consumer can still receive everything it has not yet processed, so it retains WAL and, for a logical slot, pins the transaction ids the decoding will need. A slot created for a one-afternoon logical-replication experiment and never dropped goes on doing that job for months, with nothing connected to it.

The fourth is the cruellest, because it appears in no connection list and survives a restart: a prepared transaction, left behind by two-phase commit. PREPARE TRANSACTION writes the transaction to disk and detaches it from any session, and one left in that state interferes with vacuum's ability to reclaim storage and can, in the extreme, force a shutdown to prevent transaction id wraparound. The saving grace is that max_prepared_transactions is zero by default, so the feature has to have been deliberately enabled before this can happen to you. If an external transaction manager is not tracking them, leaving it at zero is the documented recommendation.

The Symptom Chain

The horizon stops moving. Dead tuples accumulate on the tables with real write rates — inventory first, because it is small and hot. The tables and their indexes grow, so the same rows now occupy more pages, so scans read more pages and plans get slower. Autovacuum runs on schedule, works correctly, removes nothing, and reports precisely that.

Autovacuum, working perfectly, achieving nothing
automatic vacuum of table "cartwheel.public.inventory": index scans: 0
  pages: 0 removed, 115200 remain
  tuples: 0 removed, 12000 remain, 4200000 are dead but not yet removable
  removable cutoff: 487355012, which was 1904223 XIDs old when operation ended

That log entry is a diagnosis, not a vacuum problem. Zero tuples removed with four million dead ones present says the work ran and was forbidden to do anything, and the removable cutoff being nearly two million transaction ids old names the reason: something has been holding a snapshot for roughly two million transactions' worth of time. No autovacuum setting will move that cutoff.

The distance between cause and symptom, one step at a time
The horizon stops moving
Dead tuples accumulate
Tables and indexes grow
Plans get slower
Autovacuum removes nothingand reports precisely that

Finding the Holder in One Query

The answer lives in three catalogue views, and the documentation names all three for exactly this purpose: pg_stat_activity for backends, pg_replication_slots for slots, and pg_prepared_xacts for two-phase transactions. Ranking them by the age of the id each one pins puts the culprit on the first row.

Who is holding the horizon, ranked oldest first
SELECT 'backend' AS kind, pid::text AS who, state,
       age(backend_xmin) AS xid_age, xact_start
  FROM pg_stat_activity
 WHERE backend_xmin IS NOT NULL
UNION ALL
SELECT 'slot', slot_name, active::text,
       age(xmin), NULL
  FROM pg_replication_slots
 WHERE xmin IS NOT NULL
UNION ALL
SELECT 'prepared', gid, 'prepared',
       age(transaction), prepared
  FROM pg_prepared_xacts
 ORDER BY xid_age DESC NULLS LAST
 LIMIT 10;

Run it as the first step of every bloat investigation, before looking at a single autovacuum setting. A backend row with a state of idle in transaction and an xact_start from this morning is the whole answer; so is a slot with no consumer attached. Keep the query in the runbook rather than in someone's shell history, because the moment it is needed is not the moment to be composing SQL. For an inactive slot the active column is the tell, and for a prepared transaction the gid is what the external transaction manager knows it by.

The Timeouts That Prevent It

This class of incident becomes a non-event on three settings, and all three ship disabled. idle_in_transaction_session_timeout terminates a session that has been idle inside an open transaction for longer than the limit, and it is arguably the highest-value non-default setting in this book. statement_timeout aborts a statement that runs too long. transaction_timeout, added in 17, terminates a session whose transaction as a whole exceeds the limit, which is the one that catches a transaction that keeps busy with a stream of short statements and never commits.

Limits set per role, strictest on the application
ALTER ROLE cartwheel_app       SET idle_in_transaction_session_timeout = '15s';
ALTER ROLE cartwheel_app       SET statement_timeout                  = '10s';
ALTER ROLE cartwheel_app       SET transaction_timeout                = '60s';

ALTER ROLE cartwheel_analytics SET idle_in_transaction_session_timeout = '5min';
ALTER ROLE cartwheel_analytics SET statement_timeout                   = '30min';

Per-role limits are what make this workable: checkout has no business holding a transaction for fifteen seconds, and the reconciliation job legitimately needs half an hour. There are two details worth knowing before setting them. If transaction_timeout is shorter than or equal to one of the other two, the longer of those is ignored, so the three interact rather than stacking. And prepared transactions are explicitly not subject to transaction_timeout, so the fourth holder is caught by monitoring or not at all.

The four holders, and what catches each one
A statement that runs too longstatement_timeout
A session sitting idle inside an open transactionidle_in_transaction_session_timeout
A transaction kept busy by short statements that never commitstransaction_timeout
A replication slot with no consumer attachedNo timeout — drop the slot
A prepared transaction, in no connection list and surviving a restartMonitoring, not a timeout

The Legitimate Long Transaction

Some long transactions are correct. A nightly reconciliation that must see one consistent instant across nine tables needs its hour-long snapshot, and shortening it would break the thing it exists to do. The answer is to run it where it costs least, which for Cartwheel means pg-replica-a rather than pg-primary, give it its own role with its own limits, and know the number it is costing rather than pretending it is free.

Moving it to the replica moves the tension rather than deleting it. With hot_standby_feedback off, which is the default, the replica reports nothing upstream and a long query there can be cancelled once replay needs to remove rows it is reading — max_standby_streaming_delay allows 30 seconds of that before the cancellation, which happens to be the same 30 seconds the dashboard already tolerates. Turn the feedback on and the query survives, at the cost of the primary keeping those rows: the manual's own framing is that it delays cleanup on the primary and may cause undesirable bloat, but no more than running the query on the primary would have. Chapter 7 picks the story up from here, with the vacuum that finally has room to work.

Common Mistakes
  • Leaving idle_in_transaction_session_timeout at its default of zero because the framework always commits — one exception path that skips the commit, plus a pool that keeps the session alive, is the entire failure.
  • Creating a replication slot for a one-off test and never dropping it — WAL accumulates until the volume fills and the vacuum horizon stops moving for as long as the slot exists.
  • Reading "0 removed, 4,200,000 are dead but not yet removable" as a vacuum tuning problem — that line is the horizon reporting the cause, and no autovacuum setting will change it.
  • Running the nightly report on pg-primary because the replica might be lagging — the report's snapshot then becomes the primary's cleanup boundary for its entire runtime.
  • Enabling hot_standby_feedback on pg-replica-a without telling anyone — long reports stop being cancelled and the primary starts retaining every version they might read, with no log line to say so.
  • Killing the offending session and assuming the tables recover immediately — vacuum still has to do the work it was prevented from doing, and on a badly bloated table that is the next hour.
Best Practices
  • Set idle_in_transaction_session_timeout and statement_timeout per role, with the strictest values on cartwheel_app and generous ones on cartwheel_analytics.
  • Add transaction_timeout for roles whose transactions should never span more than a minute, and remember it does not apply to prepared transactions.
  • Alert on the age of the oldest backend_xmin and on every replication slot's retained WAL, rather than waiting for a disk-usage alert to fire.
  • Keep the "who holds the horizon" query in the runbook and run it first in any bloat investigation, before touching an autovacuum setting.
  • Drop replication slots the moment their consumer is gone, and treat every slot as an object with an owner and a reason to exist.
  • Leave max_prepared_transactions at zero unless an external transaction manager is tracking prepared transactions and closing them out.
Comparable toolsOracle ORA-01555, the same tension resolved by failing the reader insteadInnoDB history list length, the closest analogue and the same pathologypganalyze and pgwatch horizon and slot age as first-class metricscheck_postgres ready-made checks for transaction age and slots

Knowledge Check

A vacuum log line reports 0 tuples removed and 4.2 million dead but not yet removable. What does it tell you?

  • Autovacuum was blocked by a conflicting lock and never reached the table
  • Something is holding an old snapshot, so nothing was allowed to be removed
  • The worker ran out of maintenance_work_mem before it could free the tuples
  • The autovacuum scale factor is too high for a table of this size

Which of these holds the vacuum horizon without appearing in pg_stat_activity at all?

  • A pooled session sitting idle in transaction after a skipped commit
  • A prepared transaction left behind by two-phase commit
  • A nightly reconciliation report that has been running for an hour
  • An autovacuum worker still running on a very large table

Which timeout catches a transaction that stays open for an hour while issuing a short statement every few seconds?

  • statement_timeout, since the session has been busy for a whole hour
  • idle_in_transaction_session_timeout, because the transaction stayed open
  • transaction_timeout, which limits the whole transaction regardless of activity
  • idle_session_timeout, which covers any session left open too long

The nightly reconciliation is moved from pg-primary to pg-replica-a. What changes about the horizon?

  • The primary is free to clean up, and the query risks being cancelled on the replica
  • The replica reports its snapshot upstream by default and pins the primary anyway
  • The replica performs its own vacuum, so cleanup happens independently there
  • Nothing changes, because a query running on a replica holds no snapshot of its own at all

A logical replication slot was created for a test six weeks ago and never dropped. What is the ongoing cost?

  • Nothing beyond a few kilobytes of shared memory held for an inactive slot
  • Retained WAL that fills the volume, and a horizon that stops advancing
  • A background worker still decoding changes and consuming CPU on the primary
  • None, because an inactive slot is dropped automatically after a week

You got correct