module 11 · the disk is the new heap

WAL & crash recovery

The 30-second version. A database must survive a crash with every acknowledged commit intact and every half-finished transaction gone. The pages are big, random, torn-prone; the log is small, sequential, append-only. The fix is an inversion: make the log the truth, demote the pages to a rebuildable cache. Changes are logged and made durable first; pages update lazily, under one rule — a page may not reach disk before the log records describing it do: a write barrier (module 10 ch04) in a durability uniform. Recovery replays forward (redo), then erases backward what never committed (undo); group commit — many transactions sharing one fsync — makes it cheap.

The inversion: the log is the truth, the pages are a cache

One transaction touches scattered pages: heap, two B-tree leaves, metadata. Chapter 01's physics: the disk's atomicity unit is the sector (512 B–4 KB), not your page (PostgreSQL 8 KB, InnoDB 16 KB) — a mid-transaction crash leaves a state that never legally existed: row updated, index not. Shadow paging — copy every touched page, flip one root pointer: COW at database scale (module 01 ch03) — lost to ~2x write amplification and shattered locality. Production lineage chose write-ahead logging: record the description of each change in an append-only log, make that durable, let the pages lag.

Derive why the log wins: "Insert row (5, 'bob') into page 47" is tens of bytes; the pages it dirties are 8–16 KB each. The log is sequential — the one place a disk streams (ch01: ~200 MB/s sequential vs 100–200 IOPS random on HDD) — and sector-atomic: a record that fits in one sector cannot half-land the way a 16 KB page over 4 KB sectors can. The cheapest durable artifact is the description, not the data. Hence the insider's mantra:

The log is the truth; the pages are a cache of the log's consequences.

A lost dirty page costs nothing — redo re-applies the log to it. A lost log record breaks a promise already reported to a user. The phrase traces to Gray & Reuter's Transaction Processing (1993); the machinery is Mohan's ARIES (IBM, 1992), below.

LSNs and the WAL rule: a write barrier for durability

Ordering comes from a log sequence number (LSN) — a monotone value stamped on every record: InnoDB (MySQL 8.0) an 8-byte byte-offset into the redo stream; PostgreSQL 16 a 64-bit XLogRecPtr, printed as hex like 0/16B3748. Two more LSNs close the loop:

Then the WAL rule:

A page may be flushed to disk only if page_LSN ≤ flushed_LSN.
  log (durable through LSN 4000)         buffer pool (RAM)           disk (a cache!)
  +------+------ ... -----+------+       +----------------------+
  | 3800 |   ...          | 4000 | <--   | page 12   LSN 3600   |  dirty, evict me
  +------+------ ... -----+------+ last  +----------------------+
                                flushed  | page 47   LSN 5200   |  dirty, evict me
                                         +----------------------+
  WAL rule:  flush(P)  <=>  page_LSN(P) <= flushed_LSN

  page 12:  3600 <= 4000  ->  OK; its whole history is already durable.
  page 47:  5200 >  4000  ->  BLOCKED: first force the log (write + fsync
                              records 4000..5200), THEN flush page 47.
  If page 47 landed first and the machine died: a page whose only
  record is gone -> effect with no explanation, unrecoverable.

Note the shape. Module 10 ch04's card-table write barrier: a pointer store may not become visible to the GC before the card is dirtied, or the collector reasons from a stale world. The WAL rule: a page flush may not become durable before its log records are, or the recoverer faces a page with no explanation. Same concept — a happens-before edge at a store, with durability standing in for visibility, protecting an asynchronous reader from effect-without-record.

Physiological logging: the record format that makes replay possible

What does one record say? Three spots on a spectrum:

The design also buys what recovery leans on: idempotent redo. The guard is the page LSN: applying a redo record with LSN L first checks page_LSN(P) ≥ L; if true, skip. The requirement: every redo operation repeatable — no "increment a counter", no absolute offsets; each record self-contained, landing identically twice. (PostgreSQL 16 logs nearer the physical end and insures torn pages differently, below.)

Redo and undo: ARIES in one page

Crash: the fsync'd log prefix is intact, the buffer pool is gone. ARIES (Algorithms for Recovery and Isolation Exploiting Semantics, Mohan et al., ACM TODS 1992) runs three passes:

  crash!  log timeline (LSNs grow rightward),  * = commit record
  --| T1: BEGIN UPDATE UPDATE COMMIT* | T2: BEGIN UPDATE UPDATE | T3: BEGIN UPDATE |--
    ^checkpoint                         ^last flushed LSN (durable to here)

  1. ANALYSIS  from checkpoint: rebuild who was running (losers = T2, T3)
               and which pages were dirty (the flush list).
  2. REDO      from checkpoint-LSN forward, apply EVERYTHING, including
               T2/T3's uncommitted edits ("repeat history"): physiological
               replay must proceed in exact order to keep pages
               structurally consistent; the page-LSN check keeps it
               idempotent.
  3. UNDO      walk BACKWARD from the log's end: for each loser apply the
               inverse of each update, logging it as a CLR (Compensation
               Log Record) -- redo-only, undoNext-linked, so a crash
               DURING undo resumes where it left off: undo is crash-safe.

Redo repeats history including work that will be undone — the price of in-order, structurally sound replay, and cheap: sequential I/O. Undo is itself logged — without CLRs, a crash during recovery could loop forever. PostgreSQL's twist: no undo — aborted tuples stay on disk, masked by snapshots, collected by VACUUM (chapter 05); InnoDB erases during recovery, PostgreSQL masks and garbage-collects.

The commit point: one fsync, many transactions

A transaction is durably committed when the log flush covering its commit record completes — not at its last page edit, not when the record reaches the in-memory log buffer. Before that flush, "committed" is a rumor; after it, redo resurrects the transaction.

So commit latency ≈ fsync latency: consumer NVMe ~1–2 ms; an enterprise SSD with power-loss-protection capacitors ~50–100 µs (ch01's 10x insider gap). One naive client gets 1/fsync_time commits/s — a 10 ms fsync is a 100 commits/s ceiling, CPU speed irrelevant.

Group commit breaks it: N transactions reaching the commit point while one fsync is in flight all attach to it — one flush, N durable commits.

The implementations are pipelines: a leader takes the log mutex, copies its tail plus waiters' commit records into one write, releases the mutex, then holds only the fsync mutex while the flush runs — writers keep appending and the next batch queues (PostgreSQL's commit-flush queue; InnoDB's log-flush leader, same dance). The knobs, with their loss windows:

Batch geometry + fsync policy is the database tuning lever: it decides commit throughput before any query-level optimization matters.

Checkpoints and torn-page insurance

Checkpoints bound recovery. Without them, redo starts at LSN 0 forever and the log never truncates. A checkpoint asserts: every page dirty as of LSN C is on disk — redo starts at C, log before C is recyclable. Real checkpoints are fuzzy: InnoDB (MySQL 8.0) flushes the dirty list in the background while writes continue and advances the checkpoint when pre-C stragglers drain, force-flushing when redo capacity (innodb_redo_log_capacity, default 100 MB) runs low. PostgreSQL 16 checkpoints every checkpoint_timeout (5 min default) or max_wal_size, spreading writes via checkpoint_completion_target (0.9). Crash-recovery time is proportional to log bytes since the last checkpoint — the "restart took 20 minutes" ticket is a redo-distance question.

Torn pages are the residual hole. The WAL rule guarantees the log explains every page — but physiological redo applies operations to a page whose starting state must be valid. Chapter 01: a 16 KB page over 4 KB sectors can half-land; half-old/half-new never legally existed, its checksum fails, and replaying diffs cannot reconstruct what was computed against a whole page. Two insurance designs:

What insiders know

See it yourself

No PostgreSQL here (no psql/pgbench); the labs use SQLite 3.53.1 (Python 3.12.15) on Linux 5.10. With PG 16 at home: pgbench -c 8 -T 60 bench, kill -9 the postmaster, restart, and the server log reads database system was not properly shut down; automatic recovery in progress, redo starts at 0/..., redo done at 0/....

Lab 1 — kill -9 a database mid-transaction (WAL mode).

cd /tmp/wallab && cat > crasher.py <<'EOF'
import sqlite3, time
db = sqlite3.connect("crash.db")
db.execute("PRAGMA journal_mode=WAL")        # redo-style: log is the truth
db.execute("PRAGMA synchronous=FULL")        # fsync at every commit point
db.execute("BEGIN")                          # committed transaction
db.execute("CREATE TABLE t(i INTEGER PRIMARY KEY, v TEXT)")
db.executemany("INSERT INTO t VALUES(?,?)", [(i, "x"*40) for i in range(500)])
db.commit()
db.execute("BEGIN IMMEDIATE")                # doomed: never commits
db.executemany("INSERT INTO t VALUES(?,?)", [(i, "y"*40) for i in range(500, 800)])
print("300 uncommitted rows; waiting for SIGKILL", flush=True)
time.sleep(30)
EOF
python3 crasher.py & sleep 2; kill -9 %1; ls -la crash.db*
-rw-rw-r--  1 z z   4096  crash.db        <- main file: ONE page. The cache.
-rw-rw-r--  1 z z  41232  crash.db-wal    <- the log: 10x bigger. The truth.
-rw-rw-r--  1 z z  32768  crash.db-shm
python3 -c "import sqlite3; print(sqlite3.connect('file:crash.db?mode=ro', uri=True)
             .execute('SELECT count(*), max(i) FROM t').fetchone())"
# (500, 499)  -- committed rows resurrected; 0 of the 300 doomed ones

The sizes teach the lesson: the main file holds one 4 KB page, the WAL 41 KB. count = 500 on read-only reopen is commit-frame visibility — the 300 doomed rows sit beyond the last commit frame (no commit record, no durability); a later PRAGMA wal_checkpoint(PASSIVE) returns (0, 10, 10), pages catching up.

Lab 2 — the same crash, undo-style (journal_mode=DELETE, 1,000 uncommitted rows, kill -9): a hot rj.db-journal of before-images (8,720 B) is replayed on reopen — the table is empty. WAL replays committed work forward; the rollback journal erases uncommitted work backward.

Lab 3 — commit latency, 1 vs 8 clients. Eight threads, 200 BEGIN IMMEDIATE/insert/COMMIT each, separate connections (SQLite 3.53.1, same sandbox):

sync=FULL   clients=1  mean commit 0.008 ms  p99 0.025 ms  max 0.050 ms
sync=FULL   clients=8  mean commit 0.093 ms  p99 0.082 ms  max 54.318 ms
sync=NORMAL clients=1  mean commit 0.007 ms  p99 0.025 ms  max 0.048 ms
sync=NORMAL clients=8  mean commit 0.069 ms  p99 0.029 ms  max 33.488 ms

Platform honesty: this sandbox's overlay filesystem reports a 0.0003 ms fsync (the host absorbs it), so the absolute numbers are a floor. What survives: concurrency without batching is still a convoy — writer serialization plus real flushes yields the 54 ms tail at 8 clients. On a real 10 ms-fsync disk, naive commits top out at 100/s total regardless of client count; group commit at 8 committers delivers 800/s.

How to use this knowledge

Connections

Quiz

  1. After a crash, all data pages are intact, but the last two log records (still in the OS page cache) were lost — and one transaction had already acknowledged its commit. What category of failure is this, and why is it worse than losing every dirty page?
  2. A teammate proposes redo records meaning "increment the row count in page 47's header by 1." A crash happens mid-recovery and some records replay twice. What property is violated, and what is the standard guard?
  3. Page 12 has page LSN 3,600; page 47 has page LSN 5,200; the flushed LSN is 4,000. Both must be evicted now. What happens, in what order, and why does the order matter?
  4. An fsync takes 10 ms; eight clients commit continuously. (a) Naive one-fsync-per-commit: system-wide throughput ceiling? (b) Perfect group commit: throughput and per-commit latency? (c) What does (a) say about concurrency without batching?
  5. MySQL 8.0 runs with innodb_flush_log_at_trx_commit = 2. The mysqld process is kill -9ed; users report committed transactions vanished. Is the knob a sufficient explanation, or do you keep investigating?
  6. InnoDB with doublewrite active on ext4 (4 KB sectors, 16 KB pages). Power dies during a page flush; after reboot the page's checksum fails. Which write tore, where is the intact copy, and what does recovery do?
  7. Disk A writes atomically in 512 B sectors; disk B in 4 KB sectors (chapter 01). Both host 16 KB pages. Which produces torn pages, which needs doublewrite/full-page-writes, and which makes small log records atomic for free?
  8. Module 10 ch04's card-table barrier forces "dirty the card before the pointer store becomes visible to the collector." Name the two players and the ordering for that barrier and for the WAL rule, then state the one-word difference in what each protects.
Answers
  1. A durability violation — the only unforgivable kind. The promise was "commit record in the durable log"; losing log records breaks a promise already reported to the user. Dirty pages are a cache — redo rebuilds them for free. Likely causes: commit acknowledged before fsync reached stable storage (innodb_flush_log_at_trx_commit = 2 plus an OS crash), or hardware lying about fsync.
  2. Idempotent redo is violated: applying the record twice double-counts. The guard is the page LSN: before applying a record with LSN L, recovery checks page_LSN(P) ≥ L and skips if the page already absorbed it. That works only because physiological operations are repeatable — "set field to value" is; "increment" is not.
  3. Page 12 flushes immediately: 3,600 ≤ 4,000, its history already durable. Page 47 is blocked — first force the log (write + fsync) to ≥ 5,200, then flush. If page 47 hit disk first, a crash would leave a page whose changes have no surviving description: effect without record, exactly what the rule forbids.
  4. (a) 1/10 ms = 100 commits/s total — clients serialize behind the fsync; eight clients are a queue. (b) All eight attach to one flush: 800 commits/s, each ~10 ms. (c) Without batching, concurrency buys nothing on the fsync path; with group commit it converts linearly into throughput — the commit-path lever, not a micro-optimization.
  5. Keep investigating. Value 2 writes the log to the OS cache per commit and fsyncs about once per second; kill -9 doesn't destroy the kernel's copy, so the next startup reads it fine. Vanished commits after a process kill point elsewhere: a different knob in effect, or lying fsync. Power loss — not process loss — is where the ~1 s window appears.
  6. The tear hit the second write — from doublewrite scratch (2 MB, 128 × 16 KB) to the tree. The first write (batch → scratch + fsync) completed, so an intact copy sits in scratch. Recovery copies the clean 16 KB image into place, then continues redo from that page's LSN. The log alone can't fix it: physiological redo needs a valid starting page, and a torn page is a state that never existed.
  7. Both can tear a 16 KB page — any atomicity unit below the page allows it — so both need insurance. Disk B (4 KB sectors) tears in chunkier shapes: 0, 4, 8, or 12 KB of 16 lands, the rest doesn't. Disk A (512 B) offers 32 tear points but makes a small sector-sized log record atomic for free — section 1's sector-atomicity argument. The real escape is a CoW filesystem, where pages are never overwritten in place.
  8. Card-table barrier: the mutating thread and the GC; ordering is card dirtying before pointer-store visibility, protecting the collector's heap view. WAL rule: the page flusher and crash recovery; ordering is log durability before page durability, protecting the recoverer's page explanations. One word: visibility vs durability — the same edge, different plane.