Filesystems · Journaling and Copy-on-Write

Transaction Groups, Checkpointing, and Reclaiming Journal Space

Why a committed transaction cannot free its journal space until its blocks are in place, and how checkpointing turns the log into a fixed-size ring.

A journal is a bounded region, so a committed transaction does not end the work; it begins a second phase. The transaction’s blocks are durable in the log and the filesystem’s in-memory state reflects them, but the log cannot be reused until those blocks have been written to their permanent homes and are known to be durable there. The process of making that happen is checkpointing, and the interaction between checkpointing and the log’s fixed size is what determines when a journaling filesystem stalls.

The two meanings of committed

After the commit record is durable, the transaction is committed in the sense that recovery will replay it. It is not yet committed in the sense that the filesystem’s on-disk home locations reflect it. Both properties are needed before the log space can be reused, because overwriting a transaction’s blocks with a new transaction destroys the only copy of the intent.

There is an important asymmetry here that makes the design work. Once the transaction’s payload has been written to its home location and that write is durable, the log copy is redundant, since replaying it would produce the same bytes. So checkpointing is not a delicate operation: it can be done in any order, at any time, by any thread, and a crash in the middle leaves some transactions applied and some not, which recovery handles because the log still contains every unapplied one. The only requirement is that the log’s record of which transactions are checkpointed does not advance past a transaction whose home writes are not yet durable. That record is usually just a pointer, the log’s tail, and the ordering constraint is to advance the tail only after the corresponding home writes have been flushed.

Batching and the commit interval

Committing a transaction costs a flush, and a flush on a device with a write cache costs a round trip to the device’s controller plus the time to persist its cache. If every metadata operation committed separately, a filesystem would spend nearly all of its time waiting. The standard answer is to accumulate operations into a transaction group and commit on a timer, or when the accumulated group reaches a size threshold, or when a caller explicitly requests synchronization.

The timer is a deliberate trade. A longer interval means fewer flushes and higher throughput, at the cost of a larger window of lost work after a crash and a larger group that must be held in the log. The size threshold is what prevents the group from outgrowing the log. When either bound trips, the group is closed: no new operations join it, its blocks are written, a barrier is issued, and the commit record goes down. Operations arriving during that window join the next group, which is why a filesystem under sustained load has several transactions in flight, one being committed, one accumulating, and possibly one being checkpointed.

The concurrency model follows from the batching. A thread that modifies metadata must be able to join the current group, and must not be blocked by a commit that is writing a group it has already left. This is the reason journaling implementations use a two-phase commit lock: operations take a shared lock to join the running transaction, and the committer takes an exclusive lock only briefly to close it, then releases before doing the actual I/O. Without that split, every commit would stall all writers for the duration of the flush.

What forces checkpointing to run

Three conditions drive checkpointing, and they differ in urgency.

The first is log space. As the log fills, the oldest transactions must be checkpointed to make room. When the tail reaches the head, there is no room for a new transaction, and the filesystem must stall new writers until checkpointing catches up. This is the direct cause of the write stalls that appear under metadata-heavy load on a volume with an undersized journal. The stall is not a bug; it is the log’s fixed capacity being enforced. Sizing the journal large enough to absorb the burst is the direct remedy, and the required size scales with the peak rate at which metadata is dirtied, times the checkpointing latency.

The second is memory. The dirty metadata blocks held in the page cache for uncheckpointed transactions consume memory that could hold file data. A filesystem under memory pressure checkpoints aggressively to release those pages, even when the log has room. This is the interaction that makes a memory-constrained system behave differently from a memory-rich one at the same journal size, and it is why the writeback of metadata is tied into the kernel’s general page reclaim rather than being a filesystem-private concern.

The third is time. A long-lived transaction group holds references to blocks that cannot be reused, which pins space in a copy-on-write filesystem and prevents reuse in an in-place one. Periodic checkpointing bounds how long a block stays pinned, which bounds the space amplification.

Checkpointing order and the free-space dependency

Writing the home blocks in arbitrary order is safe for replay but not necessarily safe for the filesystem’s in-memory state, because freeing a block requires the allocation structures to be updated in the right sequence. A transaction that allocates a block records the allocation in the bitmap and the pointer in the inode. Checkpointing the bitmap before the inode leaves the block allocated with no owner; checkpointing the inode before the bitmap leaves it owned but marked free. Both are recoverable, since the log still holds the other write, but only if the log entry is retained until both are done.

The standard resolution is to checkpoint per-transaction rather than per-block: a transaction’s blocks are all written before its tail pointer advances. Within the transaction the order does not matter because the transaction is atomic as a unit; the log retains all of it until the last block is durable. This is why the tail pointer advances per transaction and not per block, and why a single slow write can hold up the reclamation of an entire transaction’s worth of log space.

Copy-on-write filesystems have the same problem in a different shape. Their equivalent of the tail pointer is the oldest transaction group that any live root still references. A snapshot’s root pins every block reachable from it, so the free-space structures must retain those blocks regardless of how many newer groups have committed. The reference counting that makes this tractable is the mechanism, and the consequence is that a copy-on-write filesystem’s space usage is bounded below by the oldest live snapshot’s contents, not by the current tree.

Reclaiming the space the log occupies

The journal’s own blocks are not a resource the filesystem frees; they are a fixed region reused in a ring. What changes is which part of the ring is valid. The head pointer advances as new transactions are appended, the tail advances as old ones are checkpointed, and the distance between them is the amount of uncheckpointed intent. When the ring is empty the journal is clean and a mount can skip replay entirely, because the superblock records a clean state and recovery’s scan finds no valid transaction at the head.

A journal that is never emptied is not corrupt, but it is a sign that checkpointing is not keeping up with the commit rate. The condition is observable: the tail lags the head by a large fraction of the ring, and commits begin to wait. Diagnosing it requires distinguishing it from the other reason a commit can be slow, which is the device’s flush latency. A fast device with a lagging tail points at checkpointing, a slow flush points at the device.

There is a further interaction worth naming: the journal’s contents and the filesystem’s home blocks compete for the same device. Checkpointing writes compete with the journal’s own writes and with application data, so a busy log increases the queue depth at the device and the flush latency, which in turn lengthens the commit. The feedback is why journal performance is not simply journal size divided by write bandwidth: the steady state is set by the point at which commit latency and checkpoint throughput balance.

The checksums that validate each transaction add a read pass to recovery that is proportional to the amount of valid log at mount, so a volume that was unmounted uncleanly with a full log takes longer to mount than one with a nearly empty log. That is a bounded, one-time cost, unlike the full-filesystem scan that predates journaling, which is proportional to volume size regardless of how much work was in flight.

References

  1. ext4 Documentation — Journal (jbd2) — Linux kernel documentation
  2. The Design and Implementation of a Log-Structured File System — Mendel Rosenblum and John K. Ousterhout, ACM TOCS, 1992
  3. XFS Delayed Logging Design — Linux kernel documentation