Mobile Computing · Mobile Storage

Atomic File Replacement: rename, fsync, and the Directory Entry

Why a save operation that overwrites in place is not crash-safe, what the rename trick guarantees, and which flushes must bracket it for the guarantee to hold.

Saving a document, a settings file, or a database snapshot by opening the existing path for writing and truncating it is the most common way to lose data. The operation is not atomic: between the truncation and the last write, the file on disk is a partial copy of the new content, and any interruption leaves it that way. On a phone the interruption is routine, the platform kills backgrounded processes under memory pressure, the battery dies, the user swipes the app away, so a save path that is correct on a desktop is not correct here.

The standard remedy is to write a new file and give it the old name. That single operation is atomic with respect to other processes and, on a crash-consistent filesystem, with respect to power loss. Making it survive a crash, however, requires more than the rename, and the details are where implementations go wrong.

Why overwriting in place cannot be made safe

Truncating a file to zero and writing the new content destroys the old content before the new content exists. There is no ordering of writes that avoids this, because the old content occupies the same blocks the new content will occupy. A copy-on-write filesystem can arrange otherwise internally, but an application cannot rely on that: it must work on whatever filesystem the device ships.

Writing the new content at an offset without truncating is worse, not better. It leaves a file whose first half is new and whose second half is old, and there is no marker in the file to say which is which. Appending a checksum or a length field at the end helps only if the reader treats a mismatch as “use the previous version”, which requires the previous version to still exist.

The rename approach separates the two concerns. The new content is written to a temporary file, which is never visible under the real name, so a crash at any point during the write leaves the old file untouched and a stray temporary file behind. Only once the temporary is complete and durable does the name move.

What rename guarantees

POSIX specifies that rename atomically replaces the destination: at every instant, the destination name refers either to the old file or to the new file, never to neither and never to a mixture. If the destination is a symbolic link, the link itself is replaced, not its target. If the two names are on the same filesystem, the operation is a directory entry update; if they are on different filesystems, rename fails with EXDEV rather than silently copying.

The same-filesystem requirement is the first constraint the temporary file’s location must satisfy. A temporary created in the system temporary directory and renamed into an app container will fail, because those are different mounts on most mobile platforms. The temporary must be created in the same directory as the destination, which also means it consumes the destination’s storage budget.

The second constraint is that rename is atomic for the directory entry but says nothing about the file’s data. If the new file’s contents are still in the page cache when the rename commits, a crash can leave the name pointing at a file whose blocks were never written. The result is a file of the correct length full of zeroes or stale blocks, which is worse than a missing file, because it looks valid.

The third constraint is that the rename itself is a modification of the parent directory, and that modification is also cached. If it is not flushed, a crash can leave the directory in a state where neither name exists, or where both do. The “both” case is benign; the “neither” case is data loss.

The flush sequence

A correct sequence has four steps, and each answers one of the constraints above.

#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>

int save_atomic(const char *dir, const char *name, const void *buf, size_t len)
{
    char tmp[512], dst[512];
    snprintf(tmp, sizeof tmp, "%s/.%s.tmp", dir, name);   /* same directory */
    snprintf(dst, sizeof dst, "%s/%s", dir, name);

    int fd = open(tmp, O_WRONLY | O_CREAT | O_TRUNC, 0600);
    if (fd < 0) return -1;
    if (write(fd, buf, len) != (ssize_t)len) { close(fd); return -1; }
    if (fsync(fd) != 0) { close(fd); return -1; }          /* data durable */
    close(fd);

    if (rename(tmp, dst) != 0) return -1;                  /* entry swapped */

    int dfd = open(dir, O_RDONLY);                          /* flush the directory */
    if (dfd >= 0) { fsync(dfd); close(dfd); }
    return 0;
}

The first flush makes the new file’s bytes durable while it still has a temporary name, so a crash after it leaves either a complete temporary or nothing. The rename swaps the entry. The second flush makes the directory’s new content durable, so a crash after it leaves the new name in place. Between the rename and the directory flush, a crash leaves an indeterminate state: the rename may or may not have reached the medium, and recovery may find either name. That window is why the directory flush is not optional.

Whether fsync on a directory is permitted is a portability question. POSIX defines fsync for file descriptors generally, and Linux and most Unix systems accept a directory descriptor for the purpose of flushing its entries. Some systems reject it with EINVAL; the code above tolerates that but does not distinguish it from success, which is a compromise; on a platform where directory flushing is unavailable, the rename’s durability rests on whatever the filesystem does at unmount.

Costs and the small-file problem

The sequence costs two flushes per save. On flash, as covered in fsync cost and journal modes, the cost is dominated by the barriers rather than the bytes, so a 200-byte settings file pays nearly the same as a 2 MB document. Saving on every keystroke is therefore not viable; saving on a debounce or on a lifecycle transition is.

There is also a space cost. During the save, the old file and the new file both exist, plus the temporary’s directory entry. On a device with a full container, the save fails at write rather than at rename, and the failure is not atomic: it leaves a truncated temporary that must be cleaned up. Handling ENOSPC by keeping the old file and discarding the temporary is the only correct response, and it requires checking the write’s return value rather than assuming success.

A third cost is inode churn. Every save allocates a new inode and frees the old one, and on some filesystems the freed blocks are not immediately reusable. A file saved once per minute for an hour allocates sixty inodes; the freelist absorbs them, but a filesystem with a fixed inode count can run out while space remains.

The alternative to a new inode is to write in place with a journal, which is what a database does. That is the right choice when the content is a structured store with its own recovery logic, and the wrong choice for an opaque blob whose format has no recovery. The two mechanisms can coexist: a database’s file is written by the database, and the export of that database to a portable format uses rename.

Interaction with the platform’s own copies

On mobile platforms, the app container is not the only party with an interest in the file. The system may copy it for backup, index it for search, or sync it to another device. Each of these can observe the temporary file if it is named predictably, and can observe the file mid-sequence if it reads between the write and the rename.

Naming the temporary with a leading dot helps on Unix-derived systems because directory listings and backup tools commonly skip such entries, but it is a convention rather than a guarantee. The reliable approach is to place temporaries in a directory that is explicitly excluded from backup, which is also where container layout and backup exclusion becomes relevant: the same rules that keep caches out of backups keep half-written temporaries out of them.

The backup interaction has a second edge. A backup tool that copies the directory while a save is in progress may capture the old file under the real name and the new temporary under the dot name. Restoring that snapshot yields the old version, which is consistent. A tool that copies files individually and does not respect the directory as a unit may capture neither. There is no application-level fix for that; it is a property of the backup mechanism.

When the guarantee does not hold

Three conditions break the sequence, and all three are common.

If the filesystem does not honour flush requests, the two flushes are no-ops and the sequence reduces to “write, rename, hope”. Removable storage and some emulated or network-backed filesystems on mobile are in this category. The sequence is still better than overwriting in place, because the rename is atomic in the kernel’s view, but it is not crash-safe.

If the rename crosses a mount boundary, it fails outright. This is detectable and must be handled, but it is easy to miss when the temporary directory is chosen by a helper that does not know the destination’s directory.

If the file is memory-mapped by another part of the process, the rename does not affect the mapping. The old inode remains mapped and writable, and writes through it go to a file with no name. A component that reads the file by mapping it must be told to reopen after a replacement, and the sequencing of that notification is application logic, not filesystem behaviour.

The final and least obvious failure is a reader that opens the file, reads part of it, and then reads again after a replacement. The descriptor refers to the old inode throughout, so the second read returns old content while the path now names new content. This is the intended semantics; a reader gets a consistent view of one version, but code that caches a descriptor and expects to see updates will not, and code that reopens mid-read will see a mixture of two versions. Consistent-version reads require holding one descriptor for the duration.

References

  1. POSIX.1-2017 — System Interfaces: rename — The Open Group Base Specifications Issue 7
  2. Linux Kernel Documentation — ext4 Filesystem Overview — Linux kernel project
  3. SQLite Documentation — How To Corrupt An SQLite Database File — SQLite project
  4. Soft Updates: A Solution to the Metadata Update Problem in File Systems — McKusick, Ganger, and Patt, ACM TOCS 2000