“Readers don't block writers” is true. Readers block the collector — and one idle reader that touched a single key pins every version of a key it never read. A study guide for mvcc_store.py.
Python 3.15.0a8 (main, Apr 14 2026, 14:20:41) [Clang 22.1.3], stdlib only.
cd mvcc-store
python3 demo.py # the aha (§6)
python3 test_mvcc_store.py # pins every number this page claims
This toy is a key-value store in which nothing is ever overwritten. Writing b a second time does not replace the old value; it pushes a new version onto a chain and stamps the old one with the commit id at which it died. A transaction takes a snapshot — one integer — when it begins, and every read it performs is decided by two comparisons against that integer. There are no locks anywhere in the file.
That buys the property MVCC is famous for, and PostgreSQL states it in one sentence: "reading never blocks writing and writing never blocks reading." The demo confirms it. Fifty writers run to completion while a reader sits with an open snapshot, and not one of them waits.
The toy exists for what happens next. Those fifty writers left 51 versions of b behind, and 49 of them are visible to nobody at all — not to the reader, not to anyone who could start now. A real garbage collector will not touch them. Readers don't block writers; they block the collector, and they do it through a key they never read.
By the end you should be able to:
(xmin, xmax) stamps and the transaction's snapshot integer;BEGIN in a psql window does to bloat on a table that session never opened;Two transactions touch the same row at the same time. One is a 40-minute analytics scan; the other is a checkout that must commit in 3 milliseconds. Both must see a consistent database — not a half-applied transfer, not a row that changes underneath a running query.
The obvious mechanism is locking: a reader takes a shared lock, a writer takes an exclusive one, and the two are made to take turns. It is correct, and it is what pre-MVCC systems did. It has one fatal operational property: the 40-minute scan stops the 3-millisecond checkout for 40 minutes. Read and write throughput are coupled, so one analyst can take down the transactional workload.
Multi-version concurrency control breaks the coupling by refusing to destroy information. A writer never modifies the value a reader is reading, because it never modifies anything — it appends a new version and marks the old one as superseded from a certain moment on. The reader keeps reading the old version, legitimately, because "the database as of moment S" is a well-defined object as long as the bytes are still there.
That last clause is where the competing goals live, and it is the whole reason more than one design is defensible:
None of this is deep, but everything below leans on it.
| Concept | Where it's used here | One source |
|---|---|---|
| Snapshot isolation | Txn.snapshot is the entire implementation: one integer, frozen at begin(), never refreshed |
Jepsen: Snapshot Isolation |
xmin / xmax version stamps |
Version.__slots__ — a value plus the commit-id interval it was current for |
PostgreSQL: System Columns |
| The transaction horizon | Store.horizon() — one min() over live snapshots, and the only thing the real GC rule consults |
PostgreSQL: pg_stat_activity.backend_xmin |
| Half-open intervals | Why visible reads xmin <= snap but xmax > snap, so adjacent versions tile the timeline with no gap and no overlap |
Dijkstra, EWD831 |
| REPEATABLE READ | The snapshot is taken once, at begin(). Postgres's READ COMMITTED re-takes it per statement; this toy has no such mode |
PostgreSQL: Transaction Isolation |
The two that carry the result are the third and the fourth. The horizon is the whole aha: it is a single number, it is set by the oldest live snapshot, and it does not care which keys that transaction touched. The half-open interval is what makes the arithmetic work at all — get one of the two comparisons wrong and, as §5.1 shows by running it, either nothing is ever visible to anyone or two versions are visible at once.
Before any code. Two pictures: what a key looks like, and what "visible" means.
A key is a chain, newest version first, and each version knows the commit id that created it and the commit id that killed it:
"Visible" is not a flag stored anywhere. It is an arithmetic test against the reader's snapshot, and it is easiest to see as intervals on a number line — where the numbers are commit ids, not seconds:
Three things fall out of this picture, and they are the whole toy:
xmax of one version is exactly xmin of the next, and the interval is closed at the bottom and open at the top, so every snapshot lands inside exactly one bar per key.The file is 150 lines. Read it in this order: the two free functions carry all of the semantics, and the class is bookkeeping around them.
visible — the entire read path, in one linedef visible(version, snap):
"""The entire visibility rule. One line, two comparisons, no locks."""
return version.xmin <= snap and (version.xmax is None or version.xmax > snap)
This is the line that replaces a lock manager. It reads no shared state, it takes no latch, it cannot block, and two transactions evaluating it simultaneously cannot interfere — because a Version's fields are only written once, at the commit that created it and the commit that superseded it.
The two comparators are not symmetric, and they are not interchangeable. I ran both variants against the demo scenario rather than reasoning about them.
xmin <= snap, not <. Your snapshot is "the newest commit id that exists", so the transaction that committed at your snapshot is one you must see. Tighten it and the store goes completely blind:
Not "sees an older value" — sees nothing at all, and pays a full 51-hop scan to discover it. The reason is worth holding onto: versions tile the number line with no gaps, so xmax of one equals xmin of the next. Require xmin < S < xmax and you are asking for an integer strictly inside an interval of width 1. There isn't one. Every read on every key returns None.
xmax > snap, not >=. A version dies at the commit that replaced it, so a snapshot equal to that commit id must not see it. Loosen it and one extra version stays visible in every chain:
The read results look unharmed — but they are only unharmed by luck. Both B49 [50,51) and B50 [51,∞) are now visible to snapshot 51, and this toy scans the chain newest-first, so it happens to hit B50 first and return the right answer. Reverse the chain order and the same one-character change starts returning stale data:
That is the most useful thing the counterfactuals turned up: a genuine correctness bug in visible is invisible in this toy because of an unrelated choice about list order. The number that does detect it is the GC count — 48 instead of 49 — because the collector asks the question directly instead of stopping at the first True.
retain — where the two collectors disagreedef retain(version, rule, horizon, snaps):
"""Should a sweep keep this version? The two collectors differ only here."""
if rule == HORIZON:
# Half of `visible`: the death test only. A version whose xmin is still
# in the future has to be kept regardless -- some later snapshot will
# want it -- so xmin is never consulted. One comparison per version,
# however many transactions are running.
return version.xmax is None or version.xmax > horizon
# The exact test: keep it only if somebody can still see it. Costs one
# `visible` call per live snapshot per version.
return any(visible(version, snap) for snap in snaps)
Two rules, five lines, and the entire toy is the gap between them.
The horizon rule is literally half of visible. It checks the death side (xmax) and never looks at xmin. That asymmetry looks like an oversight and is the opposite: it is the one thing keeping the rule correct. A version whose xmin is in the future is invisible to every currently live snapshot — but a transaction beginning one millisecond from now will want it. Death before the horizon is permanent; birth after the horizon is not.
Restore the symmetry and the database eats itself. I replaced the branch with the full visible(version, horizon):
It looks like a triumph — the sweep that used to free nothing now frees 50 — and it has silently deleted B50, the current value of b. The only surviving version of the key is one that died 49 commits ago. Every new reader gets None. This is the single most load-bearing line in the file, and its correctness lives entirely in an omission.
The precise rule pays for exactness by the snapshot. any(visible(...)) is a loop over the live snapshot set — the cost of the sweep now scales with your concurrency, not just your data. §7.2 has the measured numbers.
Note also that snaps includes the snapshot a transaction beginning right now would receive, not only the ones currently held. Drop that and the precise rule commits exactly the same murder as C4, from the other direction:
"Visible to nobody" has to mean nobody who could ever exist, not nobody who happens to be running.
Version — a value and the interval it was true forclass Version:
"""One immutable value, alive for the commit-id interval [xmin, xmax)."""
__slots__ = ("value", "xmin", "xmax")
__slots__ is here to make a point about cost, not to be clever. Version chains are the thing MVCC produces in bulk: one per update, per row, retained until a collector proves nobody wants them. Whatever a version costs, you pay it a very large number of times, which is why real systems put the two stamps in a fixed-size tuple header rather than anywhere flexible. Fifty updates to one row in this toy produced 51 live objects for one logical key.
begin and commit — time is a counter def begin(self):
txn = Txn(self.next_xid, self.clock)
self.next_xid += 1
self.live[txn.xid] = txn
return txn
def commit(self, txn):
"""Publish the buffered writes under one fresh commit id."""
del self.live[txn.xid]
if not txn.writes:
return self.clock # read-only: no commit id is burned
self.clock += 1
for key, value in txn.writes.items():
chain = self.chains.setdefault(key, [])
if chain and chain[0].xmax is None:
chain[0].xmax = self.clock # the previous head dies here
chain.insert(0, Version(value, self.clock))
return self.clock
There is no clock in this file and no RNG. self.clock is a plain integer that only commit may advance, and a snapshot is a copy of it. That is not a determinism hack retrofitted for the demo — it is what MVCC actually is. A transactional store cannot order events by wall time (clocks skew, NTP steps backwards, two machines disagree), so it invents a total order by counting commits and defines "before" against that counter. Determinism is a free side effect: python3 demo.py is byte-identical on every run because the toy contains nothing that could vary, and test_demo_output_is_byte_identical_across_runs (test_mvcc_store.py lines 136–148) captures stdout twice and asserts the strings match.
Two decisions inside commit are worth stopping on.
chain[0].xmax = self.clock is what makes garbage garbage. Delete that one line and reads keep working perfectly — and both collectors stop collecting:
Zero, zero, zero, forever, on a store that reads correctly. The reason reads survive is again the chain order: scanning newest-first and stopping at the first version with xmin <= snap finds the right answer without ever consulting xmax. Under the opposite order it breaks immediately —
— so in this toy xmax is not a read-path field at all. It exists to tell the collector when the version stopped mattering. That reframes the stamp: it is not bookkeeping about the past, it is a message to the garbage collector, written 51 commits before anyone reads it.
A read-only transaction burns no commit id. if not txn.writes: return self.clock. This mirrors PostgreSQL, where a real transaction ID is assigned only "if a permanent ID is assigned to the transaction (which normally happens only if the transaction changes the state of the database)" — otherwise a session runs on a virtual transaction ID. In this toy it is also what keeps the headline arithmetic clean: R commits at the end of the demo without moving the clock, so the horizon lands on 51 and not 52.
It is, however, not load-bearing for the result. I made read-only transactions burn a commit id anyway:
Same 0, same 49, same 50. Worth knowing before writing a paragraph claiming otherwise: this line buys tidiness and real-world fidelity, not behaviour.
probe — the read path, and what a long chain costs def probe(self, txn, key):
"""read(), but also reporting how many versions had to be skipped."""
if key in txn.writes:
return txn.writes[key], 0 # a transaction sees its own writes
chain = self.chains.get(key, ())
for hops, version in enumerate(chain, start=1):
if visible(version, txn.snapshot):
return version.value, hops
return None, len(chain)
A linear scan that stops at the first visible version. hops is not part of the mechanism; it is instrumentation, and it exists because chain length is the second bill MVCC sends you after memory. Reading a key means walking versions until one of them matches your snapshot.
The txn.writes check is read-your-own-writes. A transaction's uncommitted values live only in its own buffer, so without this line a transaction cannot see what it just wrote:
horizon, snapshots, vacuum — computing the thresholds once def horizon(self):
"""The oldest snapshot anyone still holds; the clock if nobody is running."""
return min((t.snapshot for t in self.live.values()), default=self.clock)
def snapshots(self):
"""Every snapshot that can still be used: the living ones, plus the one
a transaction beginning right now would get."""
return sorted({t.snapshot for t in self.live.values()} | {self.clock})
min, not max, and not an average. The horizon must be safe for the worst case — the oldest snapshot still out there — because that transaction is entitled to the versions it named. Swap it for max and the collector starts deleting versions out from under live readers:
One min() over the set of running transactions is the entire safety argument, and it is also the entire performance problem: the single oldest transaction in the system sets the threshold for everything.
def vacuum(self, rule, dry_run=False):
"""Sweep every chain under `rule`; return how many versions it frees."""
# Both thresholds are read once, before the sweep. That is what makes
# the horizon rule one comparison per version rather than one scan of
# the live transactions per version.
horizon, snaps = self.horizon(), self.snapshots()
reclaimed = 0
for key, chain in self.chains.items():
keep = [v for v in chain if retain(v, rule, horizon, snaps)]
reclaimed += len(chain) - len(keep)
if not dry_run:
self.chains[key] = keep
return reclaimed
Hoisting self.horizon() out of the loop is the only reason the "one comparison per version" claim is true — an earlier draft of this toy called it from inside retain and quietly made the cheap rule O(live × versions), which is the exact cost it exists to avoid. dry_run is there so the demo can ask "what would the other rule free, right now?" without disturbing the state the next sweep is measured against.
demo.py runs one scenario. T1 seeds a and b. A reader R begins, reads only a, and then does nothing at all for the rest of the program. Fifty writers each update b and commit.
# --- 50 writers each update `b` and commit. ---------------------------
for i in range(1, WRITERS + 1):
w = store.begin()
store.write(w, "b", f"B{i}")
store.commit(w)
python3 demo.py
The first half is the famous half of MVCC, and it works: the writers never waited for R, R never waited for the writers, R still reads B0 while everyone else reads B50, and an uncommitted value is visible to its own transaction and to nobody else. Now the three numbers that matter.
R is live with snapshot = 1, so horizon() = min(1) = 1. The rule is keep if xmax is None or xmax > 1. Walk the store:
| version | interval | xmax | xmax > 1? |
|---|---|---|---|
A0 | [1, ∞) | None | kept — still current |
B0 | [1, 2) | 2 | 2 > 1 → kept |
B1 | [2, 3) | 3 | 3 > 1 → kept |
| … | … | … | … |
B49 | [50, 51) | 51 | 51 > 1 → kept |
B50 | [51, ∞) | None | kept — still current |
Every version passes. The smallest xmax anywhere in the store is 2, and the threshold is 1, so nothing in the store is dead-before-the-horizon and the sweep frees 0 of 52. One transaction, holding one snapshot, having read one key, is the sole reason.
At the same instant, the live snapshot set is [1, 51] — R's, plus the one a transaction starting now would take. A version is garbage iff visible is false for both:
A0 = [1, ∞): visible at 1. Kept.B0 = [1, 2): 1 <= 1 and 2 > 1. Visible at 1 — this is the version R is actually reading. Kept.Bk = [k+1, k+2) for k = 1…49: at snapshot 1, xmin = k+1 <= 1 is false. At snapshot 51, xmax = k+2 > 51 is false. Invisible to both. Freed. That is exactly 49 versions.B50 = [51, ∞): visible at 51. Kept.A0, B0, B50. So at one single instant, with one idle reader in the system, 49 of 52 versions are provably unreachable and the real rule frees none of them. The gap between 0 and 49 is not a bug in either rule. It is the price of the horizon test being one comparison.
R commits. It wrote nothing, so the clock does not move (§5.4) and no data changes. live is now empty, so horizon() falls through to its default and returns self.clock = 51. The identical sweep now runs keep if xmax is None or xmax > 51:
A0: xmax is None → kept.B0 … B49: xmax runs 2 … 51. None of them exceeds 51. All 50 freed.B50: xmax is None → kept.50 versions vanish because one idle session ended. That is VACUUM unsticking the moment somebody closes a psql window, and PostgreSQL's own documentation describes precisely this failure mode in the entry for idle_in_transaction_session_timeout: "Even when no significant locks are held, an open transaction prevents vacuuming away recently-dead tuples that may be visible only to this transaction; so remaining idle for a long time can contribute to table bloat."
Note it frees 50, one more than the precise rule's 49: B0 goes too, now that the reader who was pinning it has gone. Precision was never the horizon rule's problem. Timing was.
R walks all 51 versions of b to reach the one it can see; a fresh reader finds B50 at the head immediately. This toy prepends new versions, so the straggler pays. That is a real design axis — Wu et al. call the two variants N2O and O2N — and PostgreSQL picks the other one: a HOT chain's "t_ctid field links forward to the newer version", so index scans walk from the oldest tuple forward. Under that ordering the bill lands on the current readers instead, and only the collector can shorten the walk:
Either way the walk is O(chain length) and only a sweep can shorten it — which is the second reason a blocked collector hurts, and the one Wu et al. put first: "This also increases the execution time of queries because the DBMS spends more time traversing long version chains."
python3 test_mvcc_store.py
test_idle_reader_pins_a_key_it_never_read (test_mvcc_store.py lines 37–68) asserts the 52, the 0, the 49 and the 50 in sequence, so this page cannot rot quietly. test_horizon_rule_ignores_xmin_on_purpose (test_mvcc_store.py lines 122–133) pins the asymmetry from §5.2: it asserts that B50 is invisible to R and retained by both rules anyway.
Two boundaries, and the effect disappears at both ends.
50 and 50. If every transaction in your system is short, the cheap rule is not an approximation of the exact rule — it is the exact rule, and none of this matters. That is why the horizon design is the right one: it is exact on the workload databases are tuned for and degrades only under the workload that was going to hurt anyway.
Too much concurrency, no gap either. Precision also stops buying anything once enough snapshots are held, because the union of live snapshots starts covering every interval. Counting the actual visible() calls each sweep makes, on the same 52 versions:
At 51 live snapshots the precise rule frees exactly as much as the horizon rule — nothing — having done 1327 visible() calls to get there. Its advantage is largest at low concurrency, which is exactly where the total amount of garbage is smallest. The place it would pay is the narrow band where one transaction is old and everything else is young.
(The horizon rule's "0 visible() calls" is literal: it never calls the function. It does one bare xmax > horizon per version — 52 comparisons — and that count does not move no matter how many transactions are running.)
Because 0-versus-49 is the only way to see that the real rule is an approximation. A toy with only the horizon rule teaches "GC frees nothing while a reader is open," which reads as a limitation of MVCC. A toy with only the precise rule teaches a system nobody builds. Side by side, at the same instant, on the same 52 versions, the number that matters is the difference.
The measured cost, from §6.6: one comparison per version, always, versus up to one visible() call per live snapshot per version. But the counting understates it, because a real collector runs concurrently with the workload. The horizon is one integer, snapshotted once; the live snapshot set is a shared, contended structure that changes while you sweep, so using it precisely means either holding a lock across the sweep or reasoning about a set that mutates underneath you.
Wu et al. describe exactly this rule and exactly this cost in their survey of in-memory MVCC engines: "the DBMS checks whether a version's end-ts is less than the Tid of all active transactions. The DBMS maintains a centralized data structure to track this information, but this is a scalability bottleneck in a multi-core system." And the consequence, in their words: "The DBMS's performance drops in the presence of long-running transactions. This is because all the versions generated during the lifetime of such a transaction cannot be removed until it completes."
ComputeXidHorizons(), and carries the result in a GlobalVisState struct (defined in src/backend/storage/ipc/procarray.c) whose two fields are named definitely_needed and maybe_needed; removability is then asked per xid through GlobalVisTestIsRemovableXid(). PostgreSQL keeps four such horizons rather than one, split by relation kind (VISHORIZON_SHARED, VISHORIZON_CATALOG, VISHORIZON_DATA, VISHORIZON_TEMP), and GlobalVisState's two-bound design means there is a fast path and a slower recheck rather than a single naked comparison — so it is more refined than this toy's single integer. What I could not find is a verbatim source comment stating outright "we do not test against individual live snapshots," so I will not claim that sentence. What the user-facing documentation does say is the observable consequence, and it is enough: pg_stat_activity.backend_xmin is documented as "The current backend's xmin horizon," and an open transaction "prevents vacuuming away recently-dead tuples that may be visible only to this transaction." "May be", not "are" — that hedge is the approximation, in the manual.
write() puts the value in txn.writes; nothing enters a chain until commit. This makes abort free — txn.writes.clear() and the transaction is gone, with no published version to hunt down — and it makes the uncommitted-visibility demo trivially correct, because an uncommitted value is physically not in the store.
It is also, loudly, not how PostgreSQL works. See §8; this is the biggest lie in the toy.
Reads mostly want the current version, and prepending puts it at hop 1. It is a legitimate choice — Wu et al. name it as one of the two append-only variants (N2O vs. O2N) — but it is the opposite of PostgreSQL's HOT chains, and §5.1 and §5.4 both showed it masking real bugs in other lines. If you want the version of this toy that best matches Postgres, change chain.insert(0, ...) to chain.append(...) and flip chain[0] to chain[-1] in commit; the GC numbers are unchanged and the hop counts swap.
wal-kv.begin(). Postgres's default level re-takes it per statement, which changes what "a long transaction" even means for the horizon.UPDATE writes the new tuple into a heap page immediately, tagged with the still-in-progress transaction's id, and stamps the old tuple's t_xmax at the same moment. The system columns say so directly: xmin is "The identity (transaction ID) of the inserting transaction for this row version," and of xmax the manual notes "It is possible for this column to be nonzero in a visible row version. That usually indicates that the deleting transaction hasn't committed yet, or that an attempted deletion was rolled back."
Three consequences the toy therefore cannot show you:
clog) that this toy has no analogue for.VACUUM must reclaim, so abort is not free the way abort() here is.This toy's abort is a dict.clear(). Postgres's is a promise to a collector.
The rest of the corners cut:
cmin/cmax command identifiers so a transaction can avoid seeing rows its own later statements created. Every write in a Txn here lands at one instant.Store has no locks at all. A real implementation needs the version chain and the "who is running" structure to be safe under concurrent mutation, and — per §7.2 — that structure is a known multicore bottleneck.vacuum is synchronous, total and instantaneous. It sweeps every chain in one pass with the workload stopped. Real vacuuming is a background process working page by page, interleaved with live traffic, which is why it can fall behind and why a table can bloat faster than it is swept.self.clock is a Python int and grows forever. PostgreSQL's transaction ids are 32-bit and wrap around, which turns "vacuum eventually" into "vacuum before two billion transactions elapse or the database shuts down to protect itself" — an entire operational discipline that exists only because the counter is finite.xmax, and the last version of a key could then be reclaimed entirely — which needs the chain-empty case that vacuum here never hits.Answer before expanding. Every answer is derivable from the source, and every one below was verified by running it.
In the demo, R holds snapshot 1 and the horizon sweep frees 0 of 52. Suppose a second idle reader had begun halfway through, at snapshot 26. After R commits, how many does the identical horizon sweep free?
25. With R gone, horizon() is min(26) = 26, so the rule keeps anything with xmax > 26. The versions B0…B24 have xmax running 2…26, which is 25 versions; B25 has xmax = 27 and survives. The horizon does not jump to the clock — it advances to the next oldest snapshot. Killing the worst offender buys you exactly as much as the second-worst offender allows:
Note the precise rule frees 48 rather than 49 with the extra reader live: the version visible to snapshot 26 is now pinned too.
visible tests xmin <= snap and (xmax is None or xmax > snap). The horizon rule tests only xmax is None or xmax > horizon. Why isn't the horizon rule just visible(version, horizon)?
Because it would delete the current value of every key that was recently written. visible is a question about one snapshot; the horizon rule has to be safe for all future snapshots too. A version with xmin above the horizon is invisible to everyone alive right now, but the next transaction to begin will want it. Death before the horizon is permanent; birth after it is not, so only xmax may be consulted.
Running it (C4 in §5.2): the sweep frees 50 instead of 0 and destroys B50. b's only surviving version is B0, which died at commit 2, so a fresh reader gets None. test_horizon_rule_ignores_xmin_on_purpose (test_mvcc_store.py lines 122–133) asserts B50 is invisible to R and retained anyway.
Change xmin <= snap to xmin < snap in visible. Which reads break?
All of them. Not "reads go stale" — every read of every key by every transaction returns None:
Versions tile the commit-id line with no gaps: xmax of one is xmin of the next, so consecutive versions are intervals of width 1. Requiring xmin < S < xmax asks for an integer strictly between k and k+1. There is none. Even R's very first read — store.read(r, "a"), where A0 has xmin = 1 and R's snapshot is 1 — returns None. The <= is what makes "the commit that defined my snapshot" visible to me.
The precise rule frees 49 where the real rule frees 0, at the same instant, with no risk of freeing something live. Why don't real databases just use it?
Cost that scales with concurrency, for a benefit that shrinks with concurrency. Measured on the demo's 52 versions (§6.6): the horizon rule does 52 bare comparisons and zero visible() calls regardless of load, while the precise rule goes from 102 visible() calls at 2 live snapshots to 1327 at 51 — by which point it frees 0, exactly what the cheap rule frees. It is most expensive precisely where it is least useful.
And that undercounts, because the horizon is one integer read once per sweep while the live-snapshot set is shared, contended and mutating during the sweep. Wu et al. call the equivalent structure "a scalability bottleneck in a multi-core system."
A colleague reports growing bloat on a busy orders table. You find a session in pg_stat_activity that has been idle in transaction for six hours; it ran one SELECT against a small lookup table and never committed. Unrelated?
Directly responsible, and the toy is the argument. R touched only a and pinned all 51 versions of b. The horizon is a min() over the snapshots of all live transactions — nothing in horizon() or retain() consults which keys a transaction read, and nothing could, because the transaction may read anything at any moment until it ends.
PostgreSQL says the same in the manual entry for idle_in_transaction_session_timeout: "Even when no significant locks are held, an open transaction prevents vacuuming away recently-dead tuples that may be visible only to this transaction; so remaining idle for a long time can contribute to table bloat." Commit or terminate the session and the sweep that had been freeing nothing frees everything at once — the 0 → 50 step in §6.3.
The demo prints chain hops to find b: R 51, fresh reader 1. In PostgreSQL, which reader would pay the 51, and what does that change about the argument for vacuuming?
The fresh reader pays it. This toy prepends new versions, so distance from the head is staleness; PostgreSQL's HOT chains link the other way — "its t_ctid field links forward to the newer version" — so a scan starts at the oldest tuple and walks toward the newest, and the current readers do the walking. Flipping the toy to match confirms it, and shows what the sweep buys:
It strengthens the argument. Under this toy's ordering a long chain only slows the straggler that caused it; under Postgres's, it taxes every normal query until a sweep shortens it — 51 hops down to 1. So the blocked collector is not only a memory problem, it is a latency problem for the queries you care about, which is the cost Wu et al. list first: "the DBMS spends more time traversing long version chains."
Every link below was fetched and confirmed live when this was written.
idle_in_transaction_session_timeout — the manual admitting the failure mode in one sentence, and the setting that exists solely to stop it. The most operationally useful link on this page.xmin, xmax, cmin, cmax as things you can actually SELECT. The note that xmax may be nonzero on a visible row is the whole of §8's first callout in one line.README.HOT — heap-only tuples, forward t_ctid chains, redirecting line pointers, and page-level pruning. This is the real answer to §6.4's hop count and it is more interesting than the toy's.procarray.c (doxygen) — ComputeXidHorizons(), GlobalVisTestIsRemovableXid() and the four GlobalVisHorizonKind values. Where §7.2's fact-check landed; read the function list first, it is a good map.t_xmin set to an in-progress txid, which is the fact §8 is built on.