Write-Ahead Logging: Durability Before the Data Itself
Building a Database Engine: Transactions & Concurrency
Chapter 2 · Write-Ahead Logging: Durability Before the Data Itself
Chapter 1 named the gap without closing it: write_page() calls flush() but never os.fsync(), and even if it did, a crash between logging intent and applying it could still leave a table's own pages and indexes disagreeing. A write-ahead log is the real, standard mechanism that turns "I hope this happened" into something verifiable — write down exactly what's about to change, force it to disk, and only then touch the actual data. If a crash happens after the log write, the record survives. If it happens before, nothing was promised yet. There's no third case.
A Real WAL Record Format
Each record is length-prefixed and checksummed, so a reader never has to guess where one record ends and the next begins, or trust bytes it can't verify:
The LSN (log sequence number) is a strictly increasing integer identifying each record — Chapter 3's own recovery routine will need it to know exactly where it left off. This chapter logs the entire new page image, not a small delta — deliberately simple, and it buys something important later: reapplying an already-applied record is harmless, since writing the same complete page twice produces the identical result either time (this matters directly for Exercise 1, below).
The One Non-Negotiable fsync in This Engine
HeapFile.write_page() never calls os.fsync() at all — and this course still doesn't add it there. Calling fsync() on every single data-page write would be correct but needlessly slow, forcing a real disk commit on every write regardless of size. The actual guarantee a WAL needs is narrower: only the log write has to be durable before the operation is considered "logged" at all — the data page itself can be written lazily, exactly as it always was, because the log is now the thing standing behind it if a crash happens first.
Finding 1: A Logged-but-Unapplied Change Survives a Simulated Crash
A "crash" is simulated the same honest way Chapter 1 did it: call wal.append() directly and never call heap_file.write_page() at all — exactly what a process death immediately after the WAL's own fsync() returns, but before the data write runs, actually looks like.
HeapFile pointed at the same path) shows the target page completely unchanged from before — the intended update genuinely never happened at the data layer, exactly as expected; nothing here replays it yet. But reopening the WAL file and calling read_all_records() finds exactly one record, with the correct LSN, correct page number, and a new_page_bytes value that matches the intended update byte-for-byte. The data update was lost. The information needed to redo it wasn't.
That distinction — the update didn't happen, but nothing about it was forgotten — is the entire point of this chapter, and precisely what Chapter 3's own recovery routine will use.
Finding 2: A Torn Trailing Record — Two Very Different Failure Modes
A real crash can interrupt the WAL's own append in the middle, too — the OS commits some of a record's bytes but not all of them before the process dies. This test builds one complete record, then writes only the first half of a second record's real bytes, simulating exactly that.
read_all_records() (bounds-checked and checksum-verified) returns exactly 1 record — the complete first one. It correctly recognizes that the second record's declared length runs past the actual end of the file, stops reading right there, and simply treats everything after that point as if it had never been logged at all — which is honest, since it never finished being written durably in the first place.
new_page_bytes field that is only 118 bytes long, not the required 256. Fed into a real recovery routine, this would silently write a truncated, garbage page into the middle of a real table, under a completely correct LSN and page number that give no outward sign anything is wrong. A crash here would have been the safe outcome; this is worse than a crash.
Finding 2b: Why Bounds-Checking Alone Isn't Enough
A truncated file is one kind of corruption. A single flipped bit somewhere inside an otherwise complete, correctly-sized record is another — and a length check alone has nothing to say about it.
Where This Connects
| This chapter's finding | What it connects to |
|---|---|
| Log-before-apply ordering, and a durable-but-unapplied record | Chapter 1 (this course) — the exact flush()-without-fsync() gap identified there is what this chapter's WAL closes, specifically for the log itself, not every write |
| A torn trailing record correctly discarded | Chapter 3 (this course) — recovery starts by reading exactly this list of valid records and deciding what still needs to be reapplied |
| A naive reader silently fabricating a plausible-looking corrupted record | Building a Web Browser Engine: Layout & Rendering Chapter 8's own bounds-clipping bug, and this site's own recurring "confidently wrong is worse than a crash" theme |
Hands-On Exercises
Perform two writes through write_page_with_wal() to two different pages — let the first complete fully (logged and applied), then simulate a crash right after logging the second (never calling heap_file.write_page() for it). Verify the data file has page 1's update but not page 2's, while the WAL contains records for both. Explain why it has to be safe for a future recovery routine to reapply page 1's own record too, even though it was already applied.
Write two records to a WAL file, close it, then simulate a crash by truncating the file so that only the first record survives (matching Finding 2's own torn-write setup). Open a brand-new WAL object pointed at the same file and confirm next_lsn correctly continues from the surviving first record's own LSN, not from any value that could be parsed out of the torn second record.
Corrupt a single byte inside a record's own 4-byte length field (rather than inside its payload, as Finding 2b did) and confirm how read_all_records() responds. Explain, in your own words, which of the two checks — the bounds check or the checksum — actually catches this specific kind of corruption, and why.
Chapter 2 Quick Reference
- A WAL record: length-prefixed and checksummed —
[4B length][4B CRC32][LSN][page_num][new page bytes] - The rule: log first (durably, via
os.fsync()), apply to the data file second — never the other way around - Verified Finding 1: a "crashed" write leaves the data file honestly untouched, but the WAL's own record of it survives completely intact and byte-exact
- Verified Finding 2: a naive reader with no bounds check or checksum doesn't crash on a torn trailing record — it silently fabricates a plausible-looking, genuinely corrupted one
- Verified Finding 2b: a length/bounds check alone can't catch in-place corruption — only a checksum can
- Next chapter: Crash Recovery — actually replaying a WAL's own valid records to rebuild correct state after a real simulated crash