The fsync You Didn't Call
A machine lost power during a deploy and came back with a zero-length config file. The deploy tool had written the file, checked the return value of every call, logged success, and moved on. Nothing in that sequence was a bug in the ordinary sense. It was just a misunderstanding about what write() promises.
It promises that the kernel has accepted your bytes. That's all. The data is sitting in the page cache, marked dirty, waiting for writeback to get around to it. If the machine loses power in that window, the bytes were never anywhere but RAM.
The directory entry is a separate problem
Most people learn the first half of this and stop: call fsync() on the file descriptor and you're safe. But the standard recipe for replacing a file atomically involves a rename, and a rename is a change to the directory, not to the file. Fsyncing the file does not persist the directory entry that points at it. After a crash you can end up with a perfectly durable file that nothing links to, or an old name still pointing at the old inode.
The full sequence for “replace this file, atomically, durably” is four steps, and skipping any one of them is a real bug:
fd = open("config.json.tmp", O_WRONLY|O_CREAT|O_TRUNC, 0644);
write(fd, buf, len);
fsync(fd); /* 1. file contents are durable */
close(fd);
rename("config.json.tmp", "config.json"); /* 2. atomic swap */
dfd = open(".", O_RDONLY|O_DIRECTORY);
fsync(dfd); /* 3. the directory entry is durable */
close(dfd);
The temporary file has to live in the same directory as the target, or the rename isn't a rename — it's a copy across filesystems, and it isn't atomic.
A failed fsync is not something you retry
This is the part that surprises people who have been doing the first three steps correctly for years. If fsync() returns an error, the dirty pages that failed to write back may already have been dropped from the page cache. There is nothing left in memory to retry with. Calling fsync() again can return success — not because the data made it, but because there is no longer any dirty data to complain about.
Linux made this at least observable in 4.13, when writeback errors started being tracked per-file with a sequence counter so that every descriptor with the file open sees the error exactly once. Before that, a different process could consume your error and you would never hear about it.1
The operational rule that falls out of this is unglamorous: if fsync fails, stop. Don't retry, don't log a warning and continue. Treat it as data loss, because it probably is, and make the process die loudly enough that someone notices.
And then there is the hardware
Assuming everything above is right, fsync() should turn into a cache flush or a force-unit-access write at the device. Should. Consumer SSDs with a volatile write cache and no power-loss protection will happily acknowledge a flush that hasn't reached NAND yet. If durability actually matters, this is worth testing rather than assuming — a pulled power cord and a loop that writes monotonically increasing numbers will tell you more than any datasheet.
None of this is new. It is just spread across four man pages, one mailing list thread, and a decade of people learning it the same way I did.
Notes
- PostgreSQL reached the same conclusion in 2018 and changed its behaviour so that a failed fsync triggers a PANIC and crash recovery, rather than being retried. ↩