Crash Consistency: fsync(), rename(), and Durable Writes You Can Trust
If you write a file and your program prints “saved!”, you still might lose data after a crash.
Crash consistency is about making sure that after an unexpected reboot/power loss, the disk contains either:
- the old valid state, or
- the new valid state
…not something half-written.
1) The main idea: separate "write" from "durable"
On modern OSes, write() typically copies into the page cache. It does not mean the data reached stable storage.
Durability usually requires:
fsync(fd)(orfdatasync(fd)) to force data to disk- sometimes also syncing the directory that contains your file
2) Why rename() is special
On POSIX filesystems, rename("tmp", "final") is typically atomic:
- after crash, name points to either old file or new file
- you won't see a filename that is half-updated
But atomic name update is not the same as durable data. You still must fsync correctly.
3) The standard pattern: write temp + fsync + rename + fsync dir
Crash-consistent update (high-level):
- Create
file.tmpin same directory - Write full content to temp
fsync(temp_fd)rename(file.tmp, file)fsync(dir_fd)to persist directory entry update
4) C: crash-consistent save
#define _GNU_SOURCE
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
static int write_all(int fd, const void *buf, size_t n) {
const unsigned char *p = (const unsigned char*)buf;
size_t off = 0;
while (off < n) {
ssize_t w = write(fd, p + off, n - off);
if (w < 0) {
if (errno == EINTR) continue;
return -1;
}
off += (size_t)w;
}
return 0;
}
int atomic_save(const char *path, const void *data, size_t len) {
// temp file path (simple approach; robust code should avoid collisions)
char tmp[4096];
snprintf(tmp, sizeof(tmp), "%s.tmp", path);
int fd = open(tmp, O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC, 0644);
if (fd < 0) return -1;
if (write_all(fd, data, len) != 0) { close(fd); return -1; }
if (fsync(fd) != 0) { close(fd); return -1; }
if (close(fd) != 0) return -1;
if (rename(tmp, path) != 0) return -1;
// fsync directory to persist rename
int dfd = open(".", O_RDONLY | O_DIRECTORY | O_CLOEXEC);
if (dfd >= 0) {
(void)fsync(dfd);
close(dfd);
}
return 0;
}
Notes:
- The directory fsync should target the directory of
path, not necessarily.. - For a real implementation, open the directory fd and use
openat/renameat.
5) Zig: atomic save using std.fs
const std = @import("std");
pub fn atomicSave(path: []const u8, data: []const u8) !void {
const cwd = std.fs.cwd();
var tmp_name_buf: [512]u8 = undefined;
const tmp = try std.fmt.bufPrint(&tmp_name_buf, "{s}.tmp", .{path});
var f = try cwd.createFile(tmp, .{ .truncate = true });
defer f.close();
try f.writeAll(data);
try f.sync();
try cwd.rename(tmp, path);
// Best effort: sync directory entry updates.
var dir = try cwd.openDir(".", .{});
defer dir.close();
try dir.sync();
}
6) Rust: atomic save pattern
use std::fs::{self, File, OpenOptions};
use std::io::{self, Write};
use std::path::Path;
fn atomic_save(path: &Path, data: &[u8]) -> io::Result<()> {
let tmp = path.with_extension("tmp");
{
let mut f = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(&tmp)?;
f.write_all(data)?;
f.sync_all()?;
}
fs::rename(&tmp, path)?;
// Sync the containing directory
if let Some(parent) = path.parent() {
let dir = File::open(parent)?;
dir.sync_all()?;
}
Ok(())
}
7) fsync vs fdatasync
fsync: sync data + metadatafdatasync: sync data + minimal metadata needed for data
If you update only file contents, fdatasync can be enough (but be careful: changing file size typically involves metadata too).
References
fsync(2): https://man7.org/linux/man-pages/man2/fsync.2.htmlfdatasync(2): https://man7.org/linux/man-pages/man2/fdatasync.2.htmlrename(2): https://man7.org/linux/man-pages/man2/rename.2.html- SQLite atomic commit / durability notes (excellent): https://www.sqlite.org/atomiccommit.html
- PostgreSQL WAL / durability concepts: https://www.postgresql.org/docs/current/wal-intro.html