cld-toys › Toys › mvcc-store

Commentary: mvcc-store

“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.

mvcc-store/ on GitHub·the source with line numbers, for reading rather than downloading
How to read this This is the only documentation the toy has — read it with mvcc_store.py open beside you. mvcc_store.py is the toy itself (150 lines, two free functions and three classes); demo.py runs the scenario; test_mvcc_store.py locks it down with 8 tests. Every transcript below was captured from a real run on macOS 26.5.2 (Darwin 25.5.0, arm64 Apple Silicon), 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
Contents
  1. Orientation
  2. The problem this mechanism exists to solve
  3. Background you need
  4. The mental model
  5. Reading the source
  6. The demo, and what it proves
  7. Design decisions and roads not taken
  8. What's simplified vs. the real thing
  9. Check yourself
  10. Further reading

1. Orientation

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:


2. The problem this mechanism exists to solve

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:

The goal that gets forgotten The precise answer to "can this version go?" is a question about the set of all live snapshots, and that set changes constantly under you. The cheap answer is a single threshold: one integer, computed once per sweep, compared against each version. Real databases take the cheap answer. It is conservative — it never frees something it shouldn't — but it also refuses to free a great deal that it safely could.

3. Background you need

None of this is deep, but everything below leans on it.

ConceptWhere it's used hereOne 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.


4. The mental model

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:

store.chains["b"] after 50 updates — a list, newest first head | v +----------+ +----------+ +----------+ +----------+ | B50 | -> | B49 | -> | B48 | -> ..->| B0 | | xmin 51 | | xmin 50 | | xmin 49 | | xmin 1 | | xmax - | | xmax 51 | | xmax 50 | | xmax 2 | +----------+ +----------+ +----------+ +----------+ ^ ^ | | a fresh reader stops here R (snapshot 1) stops here after 1 hop after 51 hops

"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:

A version is visible to snapshot S iff xmin <= S < xmax. commit id: 1 2 3 4 ... 50 51 | | | | | | B0 [ 1, 2) ##### B1 [ 2, 3) ##### B2 [ 3, 4) ##### ... . B49 [50,51) ##### B50 [51, ) #############> A0 [ 1, ) ####################################################> ^ ^ R's snapshot a fresh snapshot = 1 = 51 sees B0 and A0 sees B50 and A0

Three things fall out of this picture, and they are the whole toy:


5. Reading the source

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.

5.1 visible — the entire read path, in one line

mvcc_store.py · lines 24–26
def 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:

=== C1: visible(): `xmin <= snap` -> `xmin < snap` === xmin < snap R sees None fresh sees None hops R/fresh 51/51

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:

=== C2: visible(): `xmax > snap` -> `xmax >= snap` === xmax >= snap R sees B0 fresh sees B50 GC: precise 48 (was 49)

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:

=== Is `xmax > snap` masked by the newest-first chain order? === baseline: newest-first, xmax > snap R -> B0 fresh -> B50 newest-first, xmax >= snap R -> B0 fresh -> B50 OLDEST-first, xmax >= snap R -> B0 fresh -> B49

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.

5.2 retain — where the two collectors disagree

mvcc_store.py · lines 29–39
def 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):

=== C4: retain(HORIZON) uses the FULL visibility test (adds the xmin check) === visible(v, horizon) GC: horizon 50 (was 0) after that sweep, a fresh reader (snap 51) reads b as: None surviving chains: {'a': [<A0 [1,inf)>], 'b': [<B0 [1,2)>]}

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:

=== C5: snapshots(): drop the `| {self.clock}` (only live snapshots) === live snapshots only snaps=[1] GC: precise 50 (was 49) after that precise sweep, a fresh reader (snap 51) reads b as: None

"Visible to nobody" has to mean nobody who could ever exist, not nobody who happens to be running.

5.3 Version — a value and the interval it was true for

mvcc_store.py · lines 42–45
class 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.

5.4 begin and commit — time is a counter

mvcc_store.py · lines 79–83
    def begin(self):
        txn = Txn(self.next_xid, self.clock)
        self.next_xid += 1
        self.live[txn.xid] = txn
        return txn
mvcc_store.py · lines 85–96
    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:

=== C10: commit(): drop the `chain[0].xmax = self.clock` stamp === no xmax stamp R sees B0 fresh sees B50 GC: horizon 0, precise 0, after-commit 0

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 —

=== Is the xmax stamp needed for READS, or only for GC? === newest-first, no xmax stamp at all R -> B0 fresh -> B50 OLDEST-first, no xmax stamp at all R -> B0 fresh -> B0

— 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:

=== C7: commit(): read-only transactions DO burn a commit id === read-only bumps clock snaps=[1, 52] GC: horizon 0, precise 49, after-commit 50

Same 0, same 49, same 50. Worth knowing before writing a paragraph claiming otherwise: this line buys tidiness and real-world fidelity, not behaviour.

5.5 probe — the read path, and what a long chain costs

mvcc_store.py · lines 112–120
    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:

=== C8: probe(): remove the read-your-own-writes line === writer writes b=B-uncommitted, then reads b back as: B50 (as written: B-uncommitted)

5.6 horizon, snapshots, vacuum — computing the thresholds once

mvcc_store.py · lines 124–131
    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:

=== C6: horizon(): min() -> max() over live snapshots === with a fresh reader live, max()-horizon sweep leaves 2; R (snap 1) now reads b as: None <- R's version was collected under it

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.

mvcc_store.py · lines 133–145
    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.


6. The demo, and what it proves

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.

demo.py · lines 38–42
    # --- 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
T1 seeds a=A0, b=B0 and commits -> commit id 1 R begins: snapshot = 1; R reads a -> A0 R now sits idle. It never touches b. 50 writers each update b and commit -> clock = 51 versions of b: 51 versions of a: 1 total: 52 R (snapshot 1) reads b as: B0 <- a key it never asked for a fresh reader (snapshot 51) reads b as: B50 an uncommitted writer sets b=B-uncommitted; it reads back B-uncommitted, the fresh reader still sees B50 writer aborts; clock unchanged at 51, total versions still 52 chain hops to find b: R 51 fresh reader 1 R is still live, so the horizon is 1 and the live snapshots are [1, 51] GC horizon=1 reclaims 0, leaving 52 GC precise would reclaim 49, leaving 3 R commits. Nothing else changes. Horizon is now 51. GC horizon=51 reclaims 50, leaving 2 what survived: {'a': [<A0 [1,inf)>], 'b': [<B50 [51,inf)>]} chain hops to find b now: 1

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.

6.1 Deriving the 0

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:

versionintervalxmaxxmax > 1?
A0[1, ∞)Nonekept — still current
B0[1, 2)22 > 1 → kept
B1[2, 3)33 > 1 → kept
B49[50, 51)5151 > 1 → kept
B50[51, ∞)Nonekept — 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.

6.2 Deriving the 49

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:

The headline, arithmetically 49 freed, 3 survive: 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.

6.3 Deriving the 50, and what actually changed

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:

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.

6.4 The other bill: chain hops

chain hops to find b: R 51 fresh reader 1

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:

=== C9: commit(): chain.insert(0,...) -> chain.append(...) (oldest first) === oldest-first chain hops R/fresh 1/51 oldest-first: fresh reader hops BEFORE gc = 51, AFTER gc = 1

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."

6.5 The tests

python3 test_mvcc_store.py
PASS test_idle_reader_pins_a_key_it_never_read PASS test_chain_hops_are_paid_by_the_stale_reader PASS test_visibility_is_two_comparisons PASS test_uncommitted_write_is_visible_only_to_its_own_transaction PASS test_abort_leaves_no_trace PASS test_read_only_transaction_burns_no_commit_id PASS test_horizon_rule_ignores_xmin_on_purpose PASS test_demo_output_is_byte_identical_across_runs All 8 tests PASSED

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.

6.6 The boundary condition — where the effect vanishes

Two boundaries, and the effect disappears at both ends.

Where the effect vanishes, end one No long transaction, no gap. Delete R from the scenario entirely and the two rules agree exactly.
=== C11 (trace): delete the idle reader R entirely === no idle reader: horizon=51, snaps=[51], horizon GC reclaims 50, precise GC reclaims 50

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:

As idle readers pile up (all 52 versions, same 50 writes): 2 live snapshots: HORIZON frees 0 in 0 visible() calls | PRECISE frees 49 in 102 visible() calls 3 live snapshots: HORIZON frees 0 in 0 visible() calls | PRECISE frees 48 in 151 visible() calls 6 live snapshots: HORIZON frees 0 in 0 visible() calls | PRECISE frees 45 in 292 visible() calls 11 live snapshots: HORIZON frees 0 in 0 visible() calls | PRECISE frees 40 in 507 visible() calls 26 live snapshots: HORIZON frees 0 in 0 visible() calls | PRECISE frees 25 in 1002 visible() calls 51 live snapshots: HORIZON frees 0 in 0 visible() calls | PRECISE frees 0 in 1327 visible() calls

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.)


7. Design decisions and roads not taken

7.1 Why ship both collectors?

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.

7.2 Why real databases pick the cruder rule

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."

Fact-check: is modern PostgreSQL still horizon-based? This is the claim I most wanted to check rather than assert. What I could confirm from primary sources: modern PostgreSQL does compute transaction-id horizons by scanning the process array, in 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.

7.3 Why writes buffer until commit

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.

7.4 Why newest-first chains

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.

7.5 What's deliberately absent


8. What's simplified vs. the real thing

The toy's biggest divergence, stated plainly Writes buffer until commit. PostgreSQL's do not. In PostgreSQL, an 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:

  1. Visibility is a two-part question — is this xid committed, and is it visible to me? — which is why Postgres needs the commit log (clog) that this toy has no analogue for.
  2. A rolled-back transaction leaves real dead tuples on real pages that VACUUM must reclaim, so abort is not free the way abort() here is.
  3. An uncommitted transaction's writes consume storage and lengthen version chains for everybody, before it has decided anything.

This toy's abort is a dict.clear(). Postgres's is a promise to a collector.

The rest of the corners cut:


9. Check yourself

Answer before expanding. Every answer is derivable from the source, and every one below was verified by running it.

Question 1

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?

Answer

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:

=== C13 (trace): a SECOND idle reader begins halfway through === snaps=[1, 26, 51], horizon=1 horizon GC reclaims 0, precise GC reclaims 48 (of 52) R commits, mid stays: horizon=26, horizon GC reclaims 25, precise GC reclaims 49

Note the precise rule frees 48 rather than 49 with the extra reader live: the version visible to snapshot 26 is now pinned too.

Question 2

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)?

Answer

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.

Question 3

Change xmin <= snap to xmin < snap in visible. Which reads break?

Answer

All of them. Not "reads go stale" — every read of every key by every transaction returns None:

=== C1: visible(): `xmin <= snap` -> `xmin < snap` === xmin < snap R sees None fresh sees None hops R/fresh 51/51

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.

Question 4

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?

Answer

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."

Question 5

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?

Answer

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.

Question 6

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?

Answer

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:

=== C9: commit(): chain.insert(0,...) -> chain.append(...) (oldest first) === oldest-first chain hops R/fresh 1/51 oldest-first: fresh reader hops BEFORE gc = 51, AFTER gc = 1

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."


10. Further reading

Every link below was fetched and confirmed live when this was written.