skip to content

Why can a single session that has held one transaction open for hours stop a database's background version cleanup from reclaiming dead rows across every table, not just the tables that session touched?

level: middleimportance: must knowfreq 62%

answer

  1. oldest snapshot = global horizon
  2. idle-in-transaction is the classic culprit
  3. 'dead but not yet removable'
  4. prepared txns and replication slots pin too
  5. standby feedback moves the problem, not solves it

basics

~20 s

Cleanup may only remove versions that no live snapshot can see. The oldest active snapshot sets a single global horizon; anything that died after it must be kept. One ancient transaction holds that horizon back, so dead rows everywhere become unreclaimable.

solid answer

~50 s

Version cleanup is only safe for row versions that died **before the oldest snapshot still in use**. That oldest snapshot is a single instance-wide watermark, so it does not matter which tables the long transaction reads: while it lives, every version that died after it started must be preserved, everywhere. The usual culprits are a session left idle inside an open transaction, an hours-long analytical query, an abandoned two-phase prepared transaction, a stale replication or change-data-capture slot, and a standby whose feedback pushes its own oldest reader onto the primary. The symptom is deceptive: cleanup appears to run and reports doing nothing useful while relations keep growing, so people wrongly blame cleanup throughput. Diagnosis is to find the oldest transaction or retention holder and its age. Mitigations: cap transaction and idle-in-transaction lifetimes, chunk long batch jobs into short transactions, run long reads on a replica with its own retention, and monitor retained bytes per slot.

go deeper

for a junior

Know the rule: cleanup can only remove versions older than the oldest snapshot still in use, so an old transaction blocks it.

for a middle

Explain why the horizon is instance-wide rather than per-table, and list the common holders: idle-in-transaction sessions, long reports, prepared transactions, replication slots.

for a senior

Show the diagnosis path (oldest transaction age, slots, standby feedback), distinguish 'cannot reclaim' from 'cannot keep up', and name the timeouts you would enforce.

for a principal

Treat retention as a shared global resource with one dial and decide policy: maximum tolerated history depth, whether slots may break rather than bloat the primary, and where analytics runs.

## The rule cleanup must obey A row version can be reclaimed only when it is invisible to every snapshot that exists now or could be taken later. New snapshots always see the newest committed state, so "later" is not the problem. The binding constraint is the **oldest snapshot currently in use**. Concretely, the engine computes a horizon, the oldest transaction identifier that any live snapshot still cares about, and cleanup may reclaim only versions that became dead strictly before it. ## Why the effect is global, not per-table The horizon is a property of the *instance*, not of a table, because the engine cannot know in advance which rows a still-running transaction will look at next. A transaction that opened at 09:00 and has so far read only `orders` is legally entitled to issue `SELECT * FROM audit_log` at any moment, and it must see that table as of 09:00. The only safe rule is to preserve everything that died after 09:00 across the whole database. That is why one forgotten session in a client window can bloat tables it has never named. Engines differ in a detail worth knowing: where READ COMMITTED takes a fresh snapshot per statement, a read-only transaction sitting idle between statements may release its snapshot and stop pinning the horizon; but any transaction that has written holds an assigned transaction identifier and still constrains cleanup and freezing. Under REPEATABLE READ or SERIALIZABLE, the single transaction-scoped snapshot pins the horizon for the whole transaction, idle or not. ## Everything that can hold the horizon back - **Idle-in-transaction sessions.** An application that opened a transaction, ran one query and then went off to call an HTTP API, or a connection pool handing out connections with autocommit off, is the single most common cause. - **Genuinely long queries.** An hour-long analytical scan or a nightly export is a legitimate reader with an hour-old snapshot. - **Prepared (two-phase) transactions never resolved.** These survive disconnection and even restart, pinning the horizon indefinitely and silently. - **Replication and change-data-capture slots.** A slot that a consumer has stopped reading holds retention so the stream stays complete, which on some engines also holds the cleanup horizon. - **Standby feedback.** When a replica reports its oldest reader back to the primary to avoid having its own queries cancelled, a long report on the replica pins the primary's horizon. - **Long-lived cursors held across statements** inside a transaction, which is the same thing as a long transaction. ## What it looks like in production Disk usage climbs steadily. Cleanup runs on schedule and finishes quickly, reporting that many dead rows were found but could not be removed; that phrasing is the giveaway. Scans get slower over days. Nothing about the write rate has changed. Because cleanup *appears* to be running, teams often respond by making it more aggressive, which changes nothing: the constraint is correctness, not throughput. ## Diagnosing it Ask one question: what is the age of the oldest open transaction, and who holds it? Every engine exposes running sessions with their transaction start time, plus the state of prepared transactions and replication slots. Sort by age descending; the answer is usually at the top and usually measured in hours. Also check standby feedback if replicas exist. The healthy shape of this metric is flat and small, seconds to a couple of minutes. ## Fixing and preventing it - **Cap lifetimes mechanically.** Set a maximum statement duration, a maximum transaction duration, and an idle-in-transaction timeout that terminates offenders. Choose the numbers from your longest *legitimate* transaction, then enforce them; do not rely on developers remembering. - **Chunk batch work.** A job that deletes ten million rows should run as many short transactions with a bounded key range each, not one that stays open for an hour, because the single transaction pins the horizon for exactly as long as it runs and prevents cleanup of the very rows it is generating. - **Move long reads off the writer.** Give analytics a dedicated replica; if it uses feedback to the primary, you have merely relocated the problem, so decide explicitly whether you prefer replica query cancellations or primary bloat. - **Monitor retention holders.** Alert on oldest-transaction age and on bytes retained per replication slot, and treat an abandoned slot as an incident, because a dead consumer should not be allowed to fill the primary's disk. - **Audit for prepared transactions** if your stack uses distributed transactions; an orphaned one can pin history for weeks and survives restarts. ## The mental model Retention is a shared global resource with a single dial, and the dial is set by whoever is furthest behind. Any design that lets an arbitrary client hold that dial indefinitely has handed a client the ability to fill your disk.

  • Cleanup logs say it found millions of dead rows but could not remove them. What do you check first?
    That message means the horizon, not throughput, is the constraint, so tuning cleanup harder will not help. Look for the oldest open transaction and its age, then any prepared transactions, replication or change-data-capture slots, and standby feedback. One of those will be old enough to explain the retained versions, and ending it lets the next cleanup pass reclaim the space.
  • Does a purely read-only transaction pin history?
    Yes, if it holds a snapshot. Visibility does not care whether the transaction writes; the snapshot is what obliges the engine to keep older versions readable. Under REPEATABLE READ or SERIALIZABLE, one transaction-wide snapshot pins the horizon for the entire transaction. Under READ COMMITTED, where each statement takes a fresh snapshot, an idle read-only transaction may release its pin between statements, but a running long query still pins for its whole duration.

saying these in an interview costs you the question

  • Claiming only the tables the long transaction touched are affected
  • Saying read-only transactions are harmless because they change nothing
  • Responding by making cleanup more aggressive when the blocker is the snapshot horizon
  • Forgetting replication or CDC slots and prepared transactions as retention holders
  • Assuming a long query on a replica can never affect the primary

context