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) (or fdatasync(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):

  1. Create file.tmp in same directory
  2. Write full content to temp
  3. fsync(temp_fd)
  4. rename(file.tmp, file)
  5. 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 + metadata
  • fdatasync: 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