Chapter 4 — MVCC, Tuple Versions, and Snapshots¶
Multi-version concurrency control separates physical history from logical visibility. An UPDATE does not need to destroy the row another transaction is reading. Instead, MiniPostgres stores creator/deleter transaction IDs and links versions; each statement evaluates those versions against a snapshot.
Learning objectives¶
After this chapter, you can:
- interpret
xmin,xmax, andnext_tidinTupleVersion; - apply
is_visible()in creator-then-deleter order; - explain the
Snapshot(xmax, active_xids)boundary; - distinguish Read Committed snapshot refresh from Repeatable Read pinning; and
- identify what this model omits from PostgreSQL MVCC.
Physical history¶
TupleVersion in src/minipostgres/storage/tuple.py has four fields:
TupleVersion(
xmin: int,
xmax: int,
next_tid: TID | None,
values: tuple[Scalar, ...],
)
xmin names the transaction that created the version. xmax == 0 means no
transaction has deleted or superseded it; otherwise xmax names that
transaction. next_tid links to a successor version. The values remain with
the physical version so an older snapshot can still read them.
HeapTable.insert_version() in src/minipostgres/storage/heap.py encodes a
new version with the current transaction's XID, zero xmax, and no successor.
HeapTable.replace_version() inserts replacement bytes, then rewrites the old
version with xmax=current_xid and next_tid=replacement. Deletion uses
delete_version() to set xmax without creating a successor.
These changes are protected by the table lock at this layer and are published as WAL-backed page images. Writer coordination and post-wait conflict policy are covered later; this chapter focuses on the version representation and visibility decision.
Transaction state and status¶
TransactionManager.begin() in
src/minipostgres/transaction/manager.py assigns the next monotonically
increasing XID and records an active Transaction. A transaction has an
IsolationLevel, state, optional repeatable snapshot, write flag, and owned
resources.
TransactionStatusTable in transaction/status.py maps XIDs to
IN_PROGRESS, COMMITTED, or ABORTED. Unknown XIDs default to in progress.
Committed and aborted states are terminal. This is an in-process teaching
analogue of PostgreSQL's transaction-status responsibility, not its pg_xact
storage format.
The special SYSTEM_XID marks versions inserted through the storage reference
path outside ordinary transactional DML. is_visible() treats it as committed.
Normal engine queries use transaction-owned versions.
What a snapshot means¶
Snapshot in src/minipostgres/transaction/snapshot.py is immutable:
xmaxis the next XID at snapshot creation; any XID greater than or equal to it is in the snapshot's future;active_xidsis the set of other transactions that were active; even if they commit later, this snapshot remembers that they were not complete.
The validation requires active XIDs to be positive and below xmax.
oldest_active_xid is the minimum active XID or xmax when none exists. A
later VACUUM chapter uses such horizons to avoid reclaiming history still
visible to old snapshots.
TransactionManager.statement_snapshot() constructs snapshots. It excludes
the current transaction from active_xids because own changes receive special
visibility treatment. Under Read Committed it creates a new object for every
statement. Under Repeatable Read it saves the first snapshot in
transaction.repeatable_snapshot and returns that same object thereafter.
Snapshot lifetime, not a different row format, creates the isolation difference.
The visibility algorithm¶
is_visible() in src/minipostgres/transaction/visibility.py mirrors the
important decision order of HeapTupleSatisfiesMVCC: decide whether the
creator is visible, then decide whether a visible deletion hides the tuple.
First, own versions:
if xmin == current_xid:
visible unless xmax == current_xid
This gives a transaction read-your-writes behavior and hides its own deletion.
For another creator, the version is invisible if any condition holds:
- creator status is not COMMITTED;
xmin >= snapshot.xmax; orxminappears insnapshot.active_xids.
A transaction that commits after the snapshot still fails condition 3. A transaction allocated after the snapshot fails condition 2.
If the creator is visible and xmax == 0, the version is visible. If the
current transaction is the deleter, it is invisible. For another deleter, the
old version remains visible when the deletion is not committed, belongs to the
snapshot's future, or was active at snapshot creation. Only a deletion visible
to this snapshot hides it.
This is a two-sided rule: committed creation is insufficient; deletion visibility must also be evaluated.
Resolving a version chain¶
HeapTable.resolve_visible() starts from a TID and follows next_tid, checking
cycles and missing targets. It records every version for which is_visible()
returns true and finally returns the newest visible pair. A chain may contain
an aborted or future version that the reader skips while retaining an older
visible one.
scan_visible() first materializes physical versions and computes all
continuation TIDs. It begins resolution only at chain roots, preventing one
logical row from being emitted once per physical member. This is simple and
observable, though it is not a production traversal design.
root_tid() reveals an important simplification. It scans every physical
version to build a predecessor map and walks backward, an O(N) teaching
operation. PostgreSQL HOT metadata and line-pointer states provide much richer
direct navigation. The repository calls this difference out in its
mapping.
Statement execution and isolation¶
Database.execute_for_session() asks the manager for a snapshot after binding
and before dispatch. It creates an ExecutionContext containing the
transaction, snapshot, status table, and lock manager. Heap access receives
that context when scanning or fetching.
Implicit transactions live for one statement, so each statement naturally has
a fresh snapshot. Explicit sessions are created by
Database.session(isolation=...); BEGIN, COMMIT, and ROLLBACK are parsed
and bound like other statements but handled at the engine coordination layer.
Read Committed permits a later statement in the same transaction to see rows committed since its previous statement. Repeatable Read fixes its data view at the first statement snapshot. It does not mean read-only: the transaction can see its own new versions through the current-XID branch.
Experiment: one update, two views¶
Run:
uv run python - <<'PY'
from tempfile import TemporaryDirectory
from minipostgres import Database
from minipostgres.transaction.model import IsolationLevel
with TemporaryDirectory() as root, Database.open(root) as db:
db.execute("CREATE TABLE counters (id INT PRIMARY KEY, value INT)")
db.execute("INSERT INTO counters VALUES (1, 10)")
rc = db.session(isolation=IsolationLevel.READ_COMMITTED)
rr = db.session(isolation=IsolationLevel.REPEATABLE_READ)
rc.execute("BEGIN"); rr.execute("BEGIN")
print("before", rc.execute("SELECT value FROM counters").rows,
rr.execute("SELECT value FROM counters").rows)
db.execute("UPDATE counters SET value = 11 WHERE id = 1")
print("after ", rc.execute("SELECT value FROM counters").rows,
rr.execute("SELECT value FROM counters").rows)
rc.execute("ROLLBACK"); rr.execute("ROLLBACK")
PY
Observed output:
before ((10,),) ((10,),)
after ((11,),) ((10,),)
Both explicit transactions saw 10 before the default session committed its UPDATE. The next Read Committed statement received a fresh snapshot and saw 11. Repeatable Read reused its first snapshot and followed the chain to the older visible version.
Run the focused model tests:
uv run pytest -q tests/unit/transaction/test_visibility.py \
tests/concurrency/test_isolation_snapshots.py
Observed output:
....... [100%]
7 passed in 0.15s
These experiments use direct in-process sessions, not sockets, and were runtime-verified.
Compared with PostgreSQL¶
PostgreSQL heap tuples also carry transaction visibility metadata, snapshots
track transaction boundaries, and Read Committed versus Repeatable Read
differs primarily in snapshot lifetime. The nearby owners include
heapam_visibility.c, snapmgr.c, and transaction status in pg_xact.
MiniPostgres omits command IDs, subtransactions, exported snapshots,
Serializable Snapshot Isolation, speculative insertion, XID wraparound,
freezing, hint bits, multixacts, and the production HOT/line-pointer layout.
Its status table is in process, its version chains are explicit TID links, and
its concurrency surface is bounded. See the transactions section of
Differences, stops 5 and 8 of the
mapping, and the mvcc row in the
behavior matrix.
Exercises¶
1. Understanding: a late commit¶
Transaction 8 is active when a snapshot is taken with xmax=10 and
active_xids={8}. It later commits. Is a version with xmin=8 visible to that
snapshot?
Reference answer
No. Although the status is now COMMITTED and 8 < 10, XID 8 remains in
the snapshot's active set. The snapshot preserves what was incomplete at
creation time.
2. Understanding: deletion in the future¶
A visible version has xmax=12, committed status, and the reader's snapshot
has xmax=11. Is the old version visible?
Reference answer
Yes. The deleting transaction is in the snapshot's future because
12 >= 11. Its later commit cannot retroactively hide the version from
this snapshot.
3. Hands-on: test read-your-writes¶
Create one explicit Repeatable Read session, insert a row, select it before
commit, roll back, then prove the default session cannot see it. Do not change
src/.
Acceptance:
- the explicit session returns the inserted row before rollback;
ROLLBACKreturns its command tag; and- the default session returns no rows afterward.
Reference answer
Begin the Repeatable Read transaction, execute CREATE TABLE outside it
(DDL is rejected inside explicit transactions), insert inside it, and
select. The xmin == current_xid branch makes the row visible. After
rollback, status is ABORTED, so another transaction rejects the creator.
4. Hands-on design: visibility truth table¶
Propose a parameterized unit test covering committed, active, future, and aborted creators plus committed and active deleters.
Acceptance:
- construct
TupleVersion,Snapshot, andTransactionStatusTabledirectly; - include own insertion and own deletion; and
- give each case a descriptive pytest ID.
Reference answer
Use rows of (name, xmin, xmax, current, xmax_boundary, active, statuses,
expected) and call is_visible() directly. Include IDs such as
committed_creator, creator_active_at_snapshot, future_creator,
aborted_creator, active_deleter_keeps_old, committed_deleter_hides,
own_insert, and own_delete.
Summary¶
MVCC is a visibility calculation over durable physical history.
TupleVersion records creator, deleter, and successor; Snapshot records the
future boundary and transactions active at capture; is_visible() evaluates
creator then deleter. Read Committed refreshes that evidence, while Repeatable
Read retains it. Chapter 5 shows how a B+Tree points into this heap history
without becoming the authority on visibility.