zfin/src/atomic.zig
Emil Lerch 6ed2ff1f20
All checks were successful
Generic zig build / build (push) Successful in 1m55s
Generic zig build / deploy (push) Successful in 51s
add snapshot command
2026-04-21 21:24:54 -07:00

136 lines
5.1 KiB
Zig

//! Atomic filesystem writes.
//!
//! `writeFileAtomic` writes to `<path>.tmp`, fsyncs, and renames to
//! `<path>`. Crash-safe replacement for `createFile + writeAll + close`:
//! if the process dies mid-write, the destination file is left at its
//! prior contents (or absent) rather than truncated or half-written.
//!
//! Used by the snapshot writer so a ctrl-C or kernel panic mid-run
//! can't produce a corrupt `history/<date>-portfolio.srf`.
const std = @import("std");
/// Suffix appended to the temp file during atomic writes. Exposed so
/// callers that want to sweep orphan temp files (e.g. from a previous
/// crash) know what to look for.
pub const tmp_suffix = ".tmp";
/// Write `bytes` to `path` atomically.
///
/// Strategy:
/// 1. Write to `<path>.tmp` (truncating any previous tmp file).
/// 2. `fsync` the tmp file so the data is durable before we rename.
/// 3. Rename tmp -> path (atomic on POSIX when src/dst are on the same
/// filesystem, which is guaranteed here because both are the literal
/// path plus `.tmp`).
///
/// On any error the tmp file is best-effort removed so we don't leave
/// clutter behind. The caller's `path` is unchanged unless the final
/// rename succeeds.
///
/// The allocator is used for a short-lived temp-path buffer
/// (`path.len + tmp_suffix.len` bytes) and freed before return.
pub fn writeFileAtomic(
allocator: std.mem.Allocator,
path: []const u8,
bytes: []const u8,
) !void {
const tmp_path = try std.fmt.allocPrint(allocator, "{s}{s}", .{ path, tmp_suffix });
defer allocator.free(tmp_path);
{
var tmp_file = try std.fs.cwd().createFile(tmp_path, .{
.truncate = true,
.exclusive = false,
});
errdefer {
tmp_file.close();
std.fs.cwd().deleteFile(tmp_path) catch {};
}
try tmp_file.writeAll(bytes);
// fsync so the kernel flushes data to disk before the rename
// appears. Without this, a crash between rename() and the data
// hitting disk could leave an empty-but-present file at `path`.
try tmp_file.sync();
tmp_file.close();
}
std.fs.cwd().rename(tmp_path, path) catch |err| {
std.fs.cwd().deleteFile(tmp_path) catch {};
return err;
};
}
// ── Tests ────────────────────────────────────────────────────
test "writeFileAtomic creates new file" {
var tmp_dir = std.testing.tmpDir(.{});
defer tmp_dir.cleanup();
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
const dir_path = try tmp_dir.dir.realpath(".", &path_buf);
const file_path = try std.fs.path.join(std.testing.allocator, &.{ dir_path, "atomic_new.txt" });
defer std.testing.allocator.free(file_path);
try writeFileAtomic(std.testing.allocator, file_path, "hello world\n");
const contents = try std.fs.cwd().readFileAlloc(std.testing.allocator, file_path, 4096);
defer std.testing.allocator.free(contents);
try std.testing.expectEqualStrings("hello world\n", contents);
// Tmp file should have been consumed by rename.
const tmp_path = try std.fmt.allocPrint(std.testing.allocator, "{s}{s}", .{ file_path, tmp_suffix });
defer std.testing.allocator.free(tmp_path);
try std.testing.expectError(error.FileNotFound, std.fs.cwd().access(tmp_path, .{}));
// Clean up for the next test run.
std.fs.cwd().deleteFile(file_path) catch {};
}
test "writeFileAtomic overwrites existing file" {
var tmp_dir = std.testing.tmpDir(.{});
defer tmp_dir.cleanup();
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
const dir_path = try tmp_dir.dir.realpath(".", &path_buf);
const file_path = try std.fs.path.join(std.testing.allocator, &.{ dir_path, "atomic_over.txt" });
defer std.testing.allocator.free(file_path);
// Seed with old content.
{
var f = try std.fs.cwd().createFile(file_path, .{});
try f.writeAll("old contents");
f.close();
}
try writeFileAtomic(std.testing.allocator, file_path, "new contents");
const contents = try std.fs.cwd().readFileAlloc(std.testing.allocator, file_path, 4096);
defer std.testing.allocator.free(contents);
try std.testing.expectEqualStrings("new contents", contents);
std.fs.cwd().deleteFile(file_path) catch {};
}
test "writeFileAtomic: missing parent directory surfaces FileNotFound" {
// Point at a path whose parent directory doesn't exist. The tmp dir
// itself exists (so the filesystem is fine), but the "missing"
// subdirectory does not — createFile on the .tmp file must fail
// with FileNotFound regardless of platform.
var tmp_dir = std.testing.tmpDir(.{});
defer tmp_dir.cleanup();
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
const dir_path = try tmp_dir.dir.realpath(".", &path_buf);
const bad_path = try std.fs.path.join(
std.testing.allocator,
&.{ dir_path, "missing", "file.txt" },
);
defer std.testing.allocator.free(bad_path);
try std.testing.expectError(
error.FileNotFound,
writeFileAtomic(std.testing.allocator, bad_path, "x"),
);
}