4.2.36 · D3Operating Systems

Worked examples — Journaling — why, how it works

3,950 words18 min readBack to topic

This page is the case-by-case drill room for Journaling. The parent note told you why journaling exists and how the commit block works. Here we take a single simple operation — appending one block to a file — and hit it with every possible crash timing and configuration. By the end you will never meet a crash scenario you haven't already seen solved.

Before we start, a tiny bit of shared vocabulary (all from the parent note):

Recall The five steps of one transaction (from the parent)
  1. Descriptor — a record naming which blocks will change.
  2. Journal data — copies of the new block contents written into the journal.
  3. COMMIT — the last, small, single write; the "go" switch.
  4. Checkpoint — copy the journal blocks to their real in-place homes.
  5. Free — advance the circular log, reclaiming the space. The crash can land in any gap between these steps. That is what we enumerate.

The scenario matrix

Every crash story is one cell of this table. The two axes are when the crash struck and which journaling mode we ran (see the parent for the three modes). We also add degenerate, corruption-of-non-commit-blocks, and word-problem rows. The Cell column uses letters so it never clashes with the "Example N" numbering.

Cell Case class Crash timing Mode Committed? Recovery action
A Happy path no crash ordered yes none needed
B Crash mid-logging after step 2, before COMMIT any no discard
C Crash mid-checkpoint during step 4 any yes replay (idempotent)
D Crash after free after step 5 any (gone) nothing to do
E Torn COMMIT block commit half-written any no (bad checksum) discard
F1 Ordered mode exposure crash after metadata commit, data not landed ordered yes data guaranteed present
F2 Writeback mode exposure crash after metadata commit, data not landed writeback yes can expose garbage
G Degenerate: empty transaction zero blocks changed any trivially no-op
H Durability trap (word problem) crash after commit but before app called fsync ordered yes for what was logged consistent, but recent write lost
I Exam twist: two transactions, second uncommitted crash after TX7 commit, mid TX8 ordered TX7 yes, TX8 no replay TX7, discard TX8
J Full journal (data) mode crash during data journaling / after commit journal as per commit data recoverable from journal itself
K Torn descriptor / torn journal-data crash mid-descriptor or mid-journal-data any no (bad checksum on that record) discard

The nine worked examples below cover all twelve cells (Example 6 covers both F-cells, and Example 8 covers cells G and H).


Worked Example 1 — Cell A: the happy path (no crash)

Forecast: guess first — after everything finishes, how many copies of the new inode exist on disk, and where?

  1. Force the data block to its real home first. Why this step? In ordered mode the rule is data-before-metadata-commit, so the real file bytes are already down before we promise them in metadata. This is what stops the inode ever pointing at garbage.
  2. Write the descriptor "TX#7 touches inode 9, bitmap, block 50" into the journal. Why? Recovery must know the scope — which blocks belong to this transaction.
  3. Write the new inode + new bitmap into the journal. Why? This captures the intended final metadata durably before we touch the real copies.
  4. Write the COMMIT block for TX#7. Why? This single small write flips the transaction from "ignore me" to "replay me." Nothing before it is trusted.
  5. Checkpoint: copy the journalled inode + bitmap to their real in-place locations. Why? Only now do we edit the real filesystem; it was untouched until this point.
  6. Free TX#7's journal space. Why? The circular log is small; reclaim it for the next transaction.

Verify: Count the copies. During the transaction the inode existed in two places (journal copy + real copy). After step 6, the journal copy is freed, leaving exactly 1 real inode. Data block: written once (step 1). No contradiction, no leaked block — the bitmap and inode agree. ✅


Worked Example 2 — Cell B: crash after logging, before COMMIT

Forecast: does the append survive, partially survive, or vanish?

Figure — Journaling — why, how it works

What to see in the figure above: six coloured boxes are the six steps in order, left to right. The magenta dashed line marks the COMMIT. The thick orange bar is where the power died. Notice it stands to the left of the commit line — that single visual fact ("crash before commit") is the whole answer.

  1. Scan the journal. Recovery walks the circular log looking for transactions. Why? Recovery time depends on the journal, not the whole disk (that's the point of Write-Ahead Logging).
  2. Find TX#7 with descriptor + data but no COMMIT block. Why does this matter? No COMMIT means we cannot know that all of TX#7's journal blocks were successfully written — the crash could have cut off mid-write.
  3. Discard TX#7. Drop it, advance past it. Why is this safe? Look at the orange bar in the figure: the crash landed left of the commit line, so step 5 (checkpoint) never ran — the real inode, bitmap, and directory are byte-for-byte what they were before the append began.
  4. Filesystem is consistent. The append simply "did not happen."

Verify: Sanity check the invariant "in-place structures untouched." Steps that write in place are step 1 (data, in ordered mode) and step 5 (metadata). Step 5 never ran, so metadata is old. Step 1 did write the data block — but no inode points at it and no bitmap bit is set, so it is just an unreferenced sector, indistinguishable from free space. Zero corruption. ✅


Worked Example 3 — Cell C: crash during checkpoint (idempotency)

Forecast: the inode is new, the bitmap is old — is the disk corrupt right now, and what fixes it?

Figure — Journaling — why, how it works

What to see in the figure above: the same six step boxes, but now the orange crash bar sits to the right of the magenta commit line, landing inside the checkpoint step. "Crash right of commit" is the visual signature for replay, the mirror image of the previous figure.

  1. Scan the journal, find TX#7 with a COMMIT block. Why does the COMMIT change everything? Its presence proves all of TX#7's journal blocks are safely logged, so we are allowed to force them all into place.
  2. Replay: re-copy every journal block of TX#7 to its real home — the inode and the bitmap. Why re-copy the inode that's already correct? Recovery cannot tell how far step 5 got; the cheapest correct move is to just write all of them again.
  3. Re-writing the same inode value is harmless. Writing value then writing again equals writing once. This is idempotency.

Verify: After replay, inode = new (written again), bitmap = new (finally written). Both agree: the file is one block longer and that block is marked used. Apply a third time in your head — same result. Consistent. ✅


Worked Example 4 — Cell D: crash after the journal was freed

Forecast: is there anything in the journal to replay?

  1. Scan the journal. Why? Same first move as always.
  2. Find no live transaction for the append. Why? Step 6 already advanced the circular log past TX#7, so its records are no longer "live."
  3. Do nothing. The real filesystem already holds the finished append.

Verify: The change was fully applied in place before the crash. There is no half-state to fix and nothing to replay. Recovery is a no-op — which is the ideal outcome. ✅


Worked Example 5 — Cells E & K: torn COMMIT, and torn descriptor / journal-data

Forecast: the commit block is physically present but garbled — replay or discard?

  1. Read the commit record and recompute its checksum over the transaction's blocks. Why a checksum? Because "a commit block is present" is not enough — we need to know it was written completely. A checksum turns "fully written?" into a yes/no test.
  2. Checksum mismatch → treat the transaction as NOT committed. Why? A torn commit means the same danger as no commit: we cannot trust that every block landed.
  3. Discard TX#7, exactly like Cell B.

Verify: This collapses to the Cell B outcome. The in-place structures were never checkpointed (step 5 comes after commit), so discarding leaves the pre-append filesystem intact. Consistent. The checksum guarantees a partial commit is never mistaken for a real one. ✅

Forecast: if the descriptor is garbled, can recovery even tell which blocks the transaction claimed to touch?

  1. Every journal record carries its own checksum, not just the commit. Why? A torn descriptor would otherwise mislead recovery about the transaction's scope, and a torn journal-data block would replay wrong bytes into the real filesystem.
  2. Recompute the checksum of the descriptor and each journal-data block. Why? This is the same "fully written?" test as Cell E, applied to every record type.
  3. If any record's checksum fails, the transaction is treated as NOT committed and discarded. Why is this always safe? A torn descriptor or torn journal-data can only occur before the COMMIT (the commit is written last), so no checkpoint has run — the in-place filesystem is still pre-transaction.

Verify: Both E and K reduce to the same rule: any failed checksum on any record ⇒ not committed ⇒ discard ⇒ pre-transaction state preserved. In every case the crash preceded step 5, so consistency holds. ✅


Worked Example 6 — Cells F1 & F2: ordered vs writeback on the same crash

Forecast: in which mode can the user read someone else's old deleted data out of this file after reboot?

Before the steps, one piece of notation. We will write to compare when two writes finish:

Figure — Journaling — why, how it works

What to see in the figure above: the top row (ordered) forces the orange "data → disk" box to finish before the magenta COMMIT box, so the reader lands on the green "read → REAL bytes." The bottom row (writeback) has COMMIT first and the grey "data NOT landed" box after it; the orange crash line falls before the data lands, so the reader lands on the orange "read → GARBAGE." Same crash column, opposite ending.

  1. Writeback mode path (Cell F2). Metadata is journaled, but data is not ordered before the commit — i.e. writeback does not enforce . Why is that dangerous? The inode (size 8 KB, pointer → block 50) commits and gets replayed, but block 50 was never actually written — it still holds stale/garbage bytes from whatever used it before. Reading the file returns that garbage: an integrity/security leak.
  2. Ordered mode path (Cell F1). The rule enforces : the data block reaches disk before the metadata commit (step 1 of Example 1). Why does that close the hole? If the data isn't down, the commit is not allowed to be written; so a committed inode is guaranteed to point at the real new bytes.
  3. Same crash, opposite safety. Identical timing, different mode → writeback exposes garbage, ordered does not. This is why ordered is the default.

Verify: Trace the ordering constraint symbolically. Ordered mode enforces , which as a logical implication means : if the crash left us a commit, the data is guaranteed present. Writeback drops the constraint, so tells us nothing about — the state " true, false" is possible, exactly the garbage case. The logic checks out. ✅


Worked Example 7 — Cell J: full journal (data) mode

Forecast: with no separate "data-before-commit" flush, where do the correct data bytes come from on reboot?

  1. In journal mode, step 3 wrote the new data block into the journal too, not just metadata. Why? This is the whole point of the mode — the journal holds a complete, durable copy of everything, so it is self-sufficient. Recovery never needs to look anywhere but the journal to rebuild the block.
  2. Recovery finds TX#7 with a valid COMMIT → replay all journalled blocks, including the data block, to their real homes. Why is the data safe? Because it was durably logged before commit; the commit certifies the data copy in the journal is complete (same checksum guarantee as Cell E).
  3. No garbage window exists. Why? Unlike writeback (where data can be missing) and even ordered (where only ordering is guaranteed), here the data itself lives in the journal, so a committed transaction can always be fully reconstructed — at the cost of writing every data block twice.

Verify: Reconstruct the bytes. In journal mode the final data comes from the journal copy, which the commit certifies as complete → the real block gets the correct new bytes on replay, even though it was never separately flushed. Cost check: each data block is written to the journal (once) and then checkpointed in place (once) = 2 writes per data block, matching the parent's "everything written twice." ✅


Worked Example 8 — Cell G (degenerate empty transaction) + Cell H (durability trap)

Forecast: does it still write a descriptor and commit?

  1. The transaction touches no blocks, so the descriptor names an empty block set. Why even open a transaction? Consistency of the code path — but a smart implementation notices there is nothing to log.
  2. No journal writes, no checkpoint. Why safe? (identity): applying "no change" any number of times leaves the state fixed. A crash before or after is indistinguishable.

Verify: Zero blocks changed ⇒ inode, bitmap, data all identical before and after ⇒ trivially consistent. Idempotent for free (identity is idempotent: ). ✅

Forecast: filesystem corrupt? data present? what's the honest answer to the user?

  1. The filesystem is consistent. Why? Whatever was committed replays cleanly; nothing is half-broken. That is journaling's actual guarantee.
  2. But the un-synced writes can be gone. Why? Journaling gives consistency, not durability of un-flushed writes. Buffered bytes that never reached disk are lost — see fsync and Durability.
  3. The fix for the user: call fsync() after critical writes to force durability. Journaling and durability are different guarantees.

Verify: Distinguish the two properties. Consistency = "no contradictory on-disk state" → holds. Durability = "an acknowledged write survives a crash" → only holds for flushed data. The user conflated them; the correct statement is "not corrupt, but recent unsynced work may be lost." ✅ (This is the exact trap in the parent's first [!mistake].)


Worked Example 9 — Cell I (exam twist): two transactions, second uncommitted

Forecast: does the missing commit on TX#8 poison TX#7 too, or are they independent?

Figure — Journaling — why, how it works

What to see in the figure above: two block groups. The left group (TX7) ends in a magenta COMMIT box and is labelled "→ REPLAY" in green. The right group (TX8) ends in a grey "no COMMIT" box and is labelled "→ DISCARD" in orange, with the crash line falling just before its commit would have been. The gap between the groups is the visual reminder that each is judged on its own.

  1. Scan in log order; evaluate each transaction independently by its own COMMIT. Why independent? Each transaction is its own all-or-nothing unit; the commit boundary is per-transaction (this is Atomicity).
  2. TX#7 has a valid COMMIT → replay it (re-copy its blocks to their in-place homes). Why? Committed ⇒ all blocks safely logged ⇒ safe to apply, idempotently.
  3. TX#8 has no COMMIT → discard it and stop scanning there. Why stop? Once you reach a transaction with no valid commit, nothing after it can be trusted either — it is the log's high-water mark.
  4. Final on-disk state: a.txt is one block longer (TX#7 applied); b.txt is exactly as it was before (TX#8 discarded, its in-place blocks never checkpointed).

Verify: Two logically separate operations, two independent outcomes: TX#7 applied = True, TX#8 applied = False. a.txt appended, b.txt unchanged, no cross-contamination — exactly what per-transaction atomicity promises. ✅


Recall

Which single fact does recovery check for each journalled transaction? ::: Whether its COMMIT block is present and passes its checksum. In Cell C (crash mid-checkpoint) why is re-writing the already-updated inode safe? ::: Checkpointing overwrites with a fixed final value, so — idempotent, where is the pre-apply state. In Cells E and K, how are torn commit / descriptor / journal-data blocks detected? ::: Every journal record carries a checksum; any mismatch ⇒ treat the transaction as not committed ⇒ discard. In Cell F, why does ordered mode never expose garbage while writeback can? ::: Ordered enforces data-before-commit (), so a committed inode is guaranteed to point at real bytes. In Cell J (full journal mode), where do the correct data bytes come from on replay? ::: From the journal itself — the data block was copied into the journal before commit, at the cost of writing data twice. In Cell H, was the filesystem corrupt after the crash? ::: No — only consistency is guaranteed; unsynced buffered writes can still be lost. Use fsync(). In Cell I, why doesn't TX#8's missing commit affect TX#7? ::: Each transaction is an independent atomic unit judged by its own commit block.

Prerequisite / neighbour links: File Systems · Write-Ahead Logging · Databases · Atomicity · fsync and Durability · Copy-on-Write Filesystems · fsck