Skip to main content

octl_core/
atomic.rs

1//! Atomic write helpers (create-tempfile-then-rename) for projection files.
2
3use std::fs::{File, OpenOptions};
4use std::io::Write;
5use std::path::Path;
6use std::sync::atomic::{AtomicU64, Ordering};
7
8use crate::error::{Error, Result};
9
10/// Per-process monotonic suffix that disambiguates concurrent in-process
11/// writers of the same projection path (the per-run `flock` only serializes
12/// across processes; in-process writers must self-disambiguate).
13static TMP_COUNTER: AtomicU64 = AtomicU64::new(0);
14
15/// Write `bytes` to `path` atomically: tempfile in the same directory, then
16/// rename, then a parent-directory `fsync`. The tempfile is `fsync`ed before
17/// the rename. Creates the parent directory if absent.
18pub fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> {
19    write_atomic_inner(path, bytes, true)
20}
21
22/// Like [`write_atomic`] but does NOT create the parent directory: if the
23/// parent is absent the write fails with the underlying `NotFound` error
24/// instead of resurrecting it. Used for writes that must never recreate a
25/// directory deleted out from under the writer — e.g. the supervisor's
26/// per-tick state save once its run dir has vanished (otherwise the
27/// `create_dir_all` would rebuild the run dir ghost-file by ghost-file).
28pub fn write_atomic_no_create(path: &Path, bytes: &[u8]) -> Result<()> {
29    write_atomic_inner(path, bytes, false)
30}
31
32fn write_atomic_inner(path: &Path, bytes: &[u8], create_parent: bool) -> Result<()> {
33    let dir = path.parent().ok_or_else(|| {
34        Error::IoBare(std::io::Error::new(
35            std::io::ErrorKind::InvalidInput,
36            format!("path {} has no parent directory", path.display()),
37        ))
38    })?;
39    if create_parent {
40        std::fs::create_dir_all(dir).map_err(|e| Error::io(dir, e))?;
41    }
42    let fname = path
43        .file_name()
44        .ok_or_else(|| {
45            Error::IoBare(std::io::Error::new(
46                std::io::ErrorKind::InvalidInput,
47                format!("path {} has no file name", path.display()),
48            ))
49        })?
50        .to_string_lossy();
51    let seq = TMP_COUNTER.fetch_add(1, Ordering::Relaxed);
52    let tmp = dir.join(format!(".{fname}.tmp.{}.{seq}", std::process::id()));
53    {
54        let mut opts = OpenOptions::new();
55        opts.create_new(true).write(true);
56        // `create_new` (O_CREAT|O_EXCL) already refuses an existing symlink at
57        // the temp path; `O_NOFOLLOW` is belt-and-suspenders on the same open.
58        crate::paths::nofollow(&mut opts);
59        let mut f = opts.open(&tmp).map_err(|e| Error::io(&tmp, e))?;
60        f.write_all(bytes).map_err(|e| Error::io(&tmp, e))?;
61        f.sync_all().map_err(|e| Error::io(&tmp, e))?;
62    }
63    std::fs::rename(&tmp, path).map_err(|e| Error::io(path, e))?;
64    // Best-effort parent-directory fsync so the rename survives a power-loss
65    // event on filesystems that don't journal directory entries automatically
66    // (ext4 without `dirsync`, btrfs without explicit fsync).
67    if let Ok(dir_file) = File::open(dir) {
68        let _ = dir_file.sync_all();
69    }
70    Ok(())
71}
72
73/// JSON-pretty-serialize `value` and atomically write to `path`.
74pub fn write_json_atomic<T: serde::Serialize>(path: &Path, value: &T) -> Result<()> {
75    let bytes = serde_json::to_vec_pretty(value).map_err(|e| Error::json(path, e))?;
76    write_atomic(path, &bytes)
77}
78
79/// Like [`write_json_atomic`] but does NOT create the parent directory.
80/// See [`write_atomic_no_create`].
81pub fn write_json_atomic_no_create<T: serde::Serialize>(path: &Path, value: &T) -> Result<()> {
82    let bytes = serde_json::to_vec_pretty(value).map_err(|e| Error::json(path, e))?;
83    write_atomic_no_create(path, &bytes)
84}
85
86/// Open `events.jsonl` for `O_APPEND` writes (creating it if absent).
87///
88/// `O_NOFOLLOW` so an existing `events.jsonl` that has been replaced by a
89/// symlink fails the open (`ELOOP`) rather than redirecting the highest-leverage
90/// run write through it — the file-level backstop to the caller's
91/// `symlink_metadata` check (see [`crate::paths::nofollow`]).
92pub fn open_events_append(path: &Path) -> Result<File> {
93    if let Some(p) = path.parent() {
94        std::fs::create_dir_all(p).map_err(|e| Error::io(p, e))?;
95    }
96    let mut opts = OpenOptions::new();
97    opts.create(true).append(true);
98    crate::paths::nofollow(&mut opts);
99    opts.open(path).map_err(|e| Error::io(path, e))
100}