<!-- category: snippets -->
Atomic File Write
Your program is writing a config file. Power blip. OOM killer. Stray SIGKILL. The process dies mid-write.Now you have half a config file. Your service reads it on restart, chokes, and does something quietly, subtly wrong. The kind of wrong that takes hours to notice and days to fix.
The solution is four lines of Perl and about forty years of filesystem wisdom:
Write to a temp file. Rename it into place. That's the whole trick.open my $fh, '>', "$file.tmp.$$" or die "Can't write $file.tmp.$$: $!"; print $fh $data; close $fh or die "Can't close $file.tmp.$$: $!"; rename("$file.tmp.$$", $file) or die "Can't rename to $file: $!";
Part 1: WHY DIRECT WRITES ARE DANGEROUS
The obvious approach:Thatopen my $fh, '>', $file or die "Can't write $file: $!"; print $fh $data; close $fh;
> truncates the file immediately on open. Before you've written
a single byte, the old content is gone. If your process dies between
open and close, you have either an empty file or a partial one.
Even without crashes, there's a race condition. Another process reads the file while you're writing it. No crash needed. Just bad timing.Timeline of disaster: open '>' $file --> old content: GONE. 0 bytes. print chunk 1 --> partial data print chunk 2 --> still partial *** CRASH *** --> chunks 1-2 of N (service restarts, reads garbage, bad day)
Part 2: THE TEMP FILE PATTERN
Split the operation into two phases. Create the new content in a temp file. Then atomically swap it into place.Themy $tmp = "$file.tmp.$$"; open my $fh, '>', $tmp or die "Can't write $tmp: $!"; print $fh $data; close $fh or die "Can't close $tmp: $!"; rename($tmp, $file) or die "Can't rename $tmp -> $file: $!";
$$ variable is the current process ID. It makes the temp
filename unique per process. Two instances writing the same file
won't clobber each other's temp files.
No partial files. No truncation of the original until the replacement is fully written and ready.Process 1 writes to: config.yml.tmp.4821 Process 2 writes to: config.yml.tmp.4822 Both rename to: config.yml Last rename wins, but both files are always complete.
Part 3: WHY RENAME() IS ATOMIC
Perl'srename() calls the C library's rename(2), which on POSIX
systems is an atomic operation within the same filesystem. It
either fully succeeds or fully fails. There is no intermediate state.
At no point doesBefore rename(): config.yml <-- old, complete content config.yml.tmp.$$ <-- new, complete content After rename(): config.yml <-- new, complete content (temp file is gone)
config.yml contain partial data. Any process that
opens the file gets either the complete old version or the complete
new version. Never a mix. Never empty.
This guarantee comes from the filesystem, not from Perl. Perl just gives you clean access to it.
Part 4: THE CROSS-FILESYSTEM GOTCHA
One critical requirement: the temp file and the target must be on the same filesystem.rename() across filesystem boundaries fails.
The fix is simple. Put the temp file next to the target:# FAILS: /tmp and /etc are different filesystems rename("/tmp/config.tmp.$$", "/etc/myapp/config.yml"); # $! will be "Invalid cross-device link"
This is why the pattern uses# WORKS: same directory, same filesystem my $tmp = "/etc/myapp/config.yml.tmp.$$";
"$file.tmp.$$" instead of
File::Spec->tmpdir(). Same directory guarantees same filesystem.
Part 5: CHECKING CLOSE() FOR ERRORS
Notice theor die on close. This is not paranoia. This is
essential.
Buffered I/O meansclose $fh or die "Can't close $tmp: $!";
print might succeed even if the disk is full.
The data sits in a buffer in memory. The actual write to disk happens
at close when the buffer flushes. If the flush fails, close
returns false and sets $!.
If you skip this check, you might rename a file that wasn't fully written. You've atomically replaced your good file with a bad one. The pattern saved you from crashes but you defeated it by ignoring the write error.
Always check close(). It's the most commonly skipped error check in file I/O and the one that matters most here.
Part 6: PRESERVING PERMISSIONS
rename() doesn't handle permissions. The temp file has your
umask's defaults. If the original had specific permissions, they're
gone.
Fix it before the rename:
Themy $tmp = "$file.tmp.$$"; open my $fh, '>', $tmp or die "Can't write $tmp: $!"; print $fh $data; close $fh or die "Can't close $tmp: $!"; # Copy permissions from the original if (-e $file) { my @stat = stat($file); chmod $stat[2] & 07777, $tmp; } rename($tmp, $file) or die "Can't rename $tmp -> $file: $!";
& 07777 masks off the file type bits, keeping only permissions.
If you're running as root and need ownership preserved too:
chown $stat[4], $stat[5], $tmp; # uid, gid
Part 7: SIGNAL HANDLING AND CLEANUP
What if your process catches a SIGTERM while writing? The temp file sticks around like a ghost.Themy $tmp = "$file.tmp.$$"; my $cleanup = sub { unlink $tmp; exit 1; }; local $SIG{INT} = $cleanup; local $SIG{TERM} = $cleanup; local $SIG{HUP} = $cleanup; open my $fh, '>', $tmp or die "Can't write $tmp: $!"; print $fh $data; close $fh or die "Can't close $tmp: $!"; rename($tmp, $file) or die "Can't rename $tmp -> $file: $!";
local scopes the signal handlers. When the surrounding block
exits, original handlers are restored. Interrupted? Temp file cleaned
up, original untouched.
For belt-and-suspenders safety, wrap it in an eval:
Exception? Temp file removed. Original intact. Clean failure.eval { open my $fh, '>', $tmp or die "write: $!"; print $fh $data; close $fh or die "close: $!"; rename($tmp, $file) or die "rename: $!"; }; if ($@) { unlink $tmp; die "Atomic write failed for $file: $@"; }
Part 8: FILE::TEMP ALTERNATIVE
Perl's coreFile::Temp module generates temp files with random
names, slightly more collision-resistant than $$:
Theuse File::Temp qw(tempfile); my ($fh, $tmp) = tempfile("${file}.XXXX", UNLINK => 0); print $fh $data; close $fh or die "close: $!"; rename($tmp, $file) or die "rename: $!";
XXXX becomes random characters. UNLINK => 0 prevents
auto-deletion (since we're renaming it).
The $$ pattern is simpler for most cases though, and has better
debuggability. "What's config.yml.tmp.4821? Oh, PID 4821 was
writing a new config." Clear provenance.
Part 9: THE PRODUCTION SUBROUTINE
Here's the whole thing wrapped in a reusable function:Usage:sub atomic_write { my ($file, $data) = @_; my $tmp = "$file.tmp.$$"; my $cleanup = sub { unlink $tmp; exit 1; }; local $SIG{INT} = $cleanup; local $SIG{TERM} = $cleanup; eval { open my $fh, '>', $tmp or die "open: $!"; if (ref $data eq 'CODE') { $data->($fh); # callback for large writes } else { print $fh $data; } close $fh or die "close: $!"; # Preserve permissions if (-e $file) { my @st = stat($file); chmod $st[2] & 07777, $tmp; } rename($tmp, $file) or die "rename: $!"; }; if ($@) { unlink $tmp; die "atomic_write($file): $@"; } return 1; }
# Simple string write atomic_write("/etc/myapp/config.yml", $yaml_string); # Callback for large writes (avoids building giant string in memory) atomic_write("/var/data/report.csv", sub { my $fh = shift; for my $row (@rows) { print $fh join(",", @$row), "\n"; } });
Part 10: REAL-WORLD CONFIG UPDATE
Putting it all together. Read a YAML config, modify it, write it back atomically:This pattern is everywhere. Vim uses it. Package managers use it. Every serious daemon that writes state to disk uses temp-plus-rename. It's not clever. It's not flashy. It's the kind of boring code that keeps you from getting paged at 3 AM.#!/usr/bin/env perl use strict; use warnings; use feature 'say'; use YAML::Tiny; my $config_file = '/etc/myapp/config.yml'; # Read current config my $yaml = YAML::Tiny->read($config_file) or die "Can't read $config_file: " . YAML::Tiny->errstr; # Modify it $yaml->[0]{last_updated} = time(); $yaml->[0]{maintenance} = 0; # Write atomically my $tmp = "$config_file.tmp.$$"; eval { $yaml->write($tmp) or die "YAML write: " . YAML::Tiny->errstr; my @st = stat($config_file); chmod $st[2] & 07777, $tmp; rename($tmp, $config_file) or die "rename: $!"; }; if ($@) { unlink $tmp; die $@; } say "Config updated atomically.";
perl.gg+------------------+ +------------------+ | Write to temp | ---> | rename() into | | $file.tmp.$$ | | place | +------------------+ +------------------+ | | (crash here = (atomic = temp file left, no partial original intact) files ever) .--. |o_o | |:_/ | // \ \ (| | ) /'\_ _/`\ \___)=(___/ Write safe. Sleep well.