fsync, Durability and Write Barriers

Every previous lesson kept the file system consistent. This one is about a different, sharper promise: durability — the guarantee that data you wrote is actually on stable media and will survive a power failure. The two are constantly confused, and the confusion has cost real companies real data. Here is the uncomfortable truth: when your program's \texttt{write()} call returns successfully, your data is almost certainly not on the disk. It is sitting in volatile RAM, and a power cut in the next instant would erase it without a trace — even though \texttt{write()} told you it succeeded.

That gap between "the write call returned" and "the bytes are on the platter" is the subject of this lesson. To close it deliberately you use \texttt{fsync}, and to make \texttt{fsync} actually work the operating system must punch a write barrier through several layers of caching all the way down to the spinning rust or flash. Getting this right is subtle enough that it has produced famous data-loss bugs in ext3, ext4, and countless applications.

The page cache: why write() is a lie of omission

For speed, \texttt{write()} does not go to the disk. It copies your bytes into the page cache — an area of kernel RAM — marks that page dirty, and returns immediately. Your program races on, believing the write is done, while the actual disk I/O happens later, asynchronously, when the kernel's writeback threads get around to it (in Linux, dirty pages can linger up to ~30 seconds by default). This buffering is a huge performance win — it batches writes, lets the elevator reorder them, and absorbs rewrites of the same block — but it means the window between "wrote" and "safe" can be tens of seconds wide.

Two volatile caches, one stable medium

There is not one cache in the way but two. Data leaving the page cache lands in the disk's own write cache — a chunk of RAM on the drive itself — which reports the write "complete" the moment it arrives there, long before the head has actually put it on the platter. So even after the kernel has flushed the page cache, the bytes can still evaporate on power loss. Only a FLUSH CACHE command (or writing with the FUA — Force Unit Access — bit set) drives them through that last volatile layer onto stable media. Trace the whole path:

\texttt{fsync}'s real job is to push a barrier through every stage here — flush the relevant dirty pages out of the page cache and issue the drive FLUSH — so that when it returns, the data has genuinely crossed the volatile/stable line.

Where does a crash bite?

The model below tracks a write as it moves down the stack and asks, at each stage, "if the power failed now, would this survive?" The answer is no until the very last stage — which is exactly why an explicit \texttt{fsync} is the only way to know your data is safe.

// Where does data live as it flows down the write path, and does it survive a crash at each stage? type Stage = "page-cache (kernel RAM)" | "disk-cache (drive RAM)" | "platter (STABLE)"; const survives = (s: Stage): boolean => s === "platter (STABLE)"; function report(label: string, s: Stage): void { console.log(`${label.padEnd(26)} data in ${s.padEnd(24)} survives crash? ${survives(s) ? "YES" : "no"}`); } // App just calls write() and moves on — no fsync. report("after write()", "page-cache (kernel RAM)"); // Background writeback fires seconds later; still only in the drive's volatile cache. report("after writeback", "disk-cache (drive RAM)"); // The app instead calls fsync(): forces writeback AND a FLUSH/FUA barrier to the platter. report("after fsync() + FLUSH", "platter (STABLE)"); console.log("\nLesson: only fsync()+FLUSH crosses the volatile→stable line.");

The atomic-replace pattern — and its classic trap

The safe way to update a config file so a reader never sees a half-written mess is write-temp-then-rename, because \texttt{rename} over an existing name is atomic:

  1. write the new contents to \texttt{file.tmp};
  2. \texttt{fsync(file.tmp)} — make the data durable;
  3. \texttt{rename(file.tmp, file)} — atomically swap it into place;
  4. \texttt{fsync(directory)} — make the rename itself durable.

Miss step 2 and a crash can leave the renamed file full of garbage. Miss step 4 — the one everyone forgets — and a crash can lose the rename, leaving you with the old file or no file, even though the data was safe. Durability of the data and durability of the directory entry are separate fsyncs, because the file and its parent directory are separate on-disk objects.

For years, applications got away with skipping \texttt{fsync} after the rename trick, and nobody noticed. Why? ext3's default ordered mode had an accidental property: because it flushed all pending data on every metadata commit (every few seconds), the data usually hit the disk right around the rename anyway. Programs were relying on a durability guarantee the standard never made. Then ext4 arrived with delayed allocation — it deliberately held data in the page cache longer to pick better block placement — and suddenly the window widened to tens of seconds. Users who crashed after replacing a file found it truncated to zero bytes: the rename had committed but the data had not. The uproar was so loud that ext4 added heuristics to auto-flush data on a rename-over-existing-file, papering over the buggy apps. The real lesson, seared into a generation of systems programmers: if you did not call \texttt{fsync}, you have no durability guarantee — full stop — no matter what the file system happened to do for you yesterday.

Two promises get tangled here. Ordering means "write B never becomes durable before write A" — it constrains the sequence in which things reach stable storage (journaling leans on this: the commit record must not precede the journal blocks). Durability means "this specific write is on stable media now." A write barrier enforces ordering; \texttt{fsync} enforces durability (and, as a side effect, ordering up to that point). Confusing them leads to two opposite mistakes: thinking your data is safe because writes were ordered (they may all still be in volatile cache), or calling \texttt{fsync} after every tiny write and destroying throughput because each one forces a full drive FLUSH — often milliseconds, an eternity. The art is to fsync at the transaction boundaries that actually matter (a committed database row, a saved document), and let everything in between stream through the caches. Databases fsync once per group-commit for exactly this reason.

The module, closed

You now have the full stack. The VFS gives one interface to many file systems; inodes and allocation lay bytes on disk; crash consistency, journaling, log-structured and copy-on-write designs keep the structures coherent across failures; and \texttt{fsync} with write barriers finally lets an application demand that a specific write is durably saved. Consistency keeps the file system alive; durability keeps your data alive — and only you, by calling \texttt{fsync} at the right moment, can ask for the second.