Skip to main content

concinnity_host/store/
atomic.rs

1//! Replacing a file whole, for the state tree's regenerable containers.
2//!
3//! A cache segment is read by one process while another writes it -- an editor
4//! showing a world while a build cooks it is routine -- so a writer must never
5//! be observed part-way through. The bytes go to a process-unique temp file
6//! beside the target and are renamed over it, which the filesystem publishes
7//! atomically: a reader opens the old file or the new one.
8//!
9//! The write is a closure rather than a byte slice, so a producer holding its
10//! image in memory and one streaming a payload it never materializes both go
11//! through the same publish.
12
13use std::fs::{self, File};
14use std::io::{self, BufWriter, Write};
15use std::path::Path;
16
17/// Replace `path` with whatever `write` emits, creating the directory above it
18/// if needed. Reports whether the file was replaced; a failure anywhere leaves
19/// the existing file untouched and no temp behind.
20pub fn replace(path: &Path, write: impl FnOnce(&mut BufWriter<File>) -> io::Result<()>) -> bool {
21    let Some(dir) = path.parent() else {
22        return false;
23    };
24    if fs::create_dir_all(dir).is_err() {
25        return false;
26    }
27    let tmp = path.with_extension(format!("{}.tmp", std::process::id()));
28    if emit(&tmp, write).is_err() || fs::rename(&tmp, path).is_err() {
29        let _ = fs::remove_file(&tmp);
30        return false;
31    }
32    true
33}
34
35// The temp file's whole life: created, written through a buffer, flushed, and
36// closed before the rename can publish it.
37fn emit(tmp: &Path, write: impl FnOnce(&mut BufWriter<File>) -> io::Result<()>) -> io::Result<()> {
38    let mut out = BufWriter::new(File::create(tmp)?);
39    write(&mut out)?;
40    out.flush()?;
41    Ok(())
42}
43
44#[cfg(test)]
45mod tests {
46    use super::*;
47
48    #[test]
49    fn a_write_replaces_the_file_and_creates_its_directory() {
50        let dir = tempfile::tempdir().unwrap();
51        let path = dir.path().join("nested").join("file");
52
53        assert!(replace(&path, |out| out.write_all(b"first")));
54        assert_eq!(fs::read(&path).unwrap(), b"first");
55
56        assert!(replace(&path, |out| out.write_all(b"second")));
57        assert_eq!(fs::read(&path).unwrap(), b"second");
58
59        let leftovers = fs::read_dir(path.parent().unwrap())
60            .unwrap()
61            .flatten()
62            .filter(|e| e.path().extension().is_some_and(|x| x == "tmp"))
63            .count();
64        assert_eq!(leftovers, 0, "temp files must not survive a write");
65    }
66
67    // A producer that fails part-way leaves the previous file in place: the
68    // reader's guarantee is that it sees one whole version or another.
69    #[test]
70    fn a_failed_write_keeps_the_previous_file_and_no_temp() {
71        let dir = tempfile::tempdir().unwrap();
72        let path = dir.path().join("1");
73        assert!(replace(&path, |out| out.write_all(b"kept")));
74
75        assert!(!replace(&path, |out| {
76            out.write_all(b"partial")?;
77            Err(io::Error::other("producer gave up"))
78        }));
79        assert_eq!(fs::read(&path).unwrap(), b"kept");
80        let leftovers = fs::read_dir(dir.path())
81            .unwrap()
82            .flatten()
83            .filter(|e| e.path().extension().is_some_and(|x| x == "tmp"))
84            .count();
85        assert_eq!(leftovers, 0, "a failed write leaves no temp behind");
86    }
87
88    // Best-effort: a directory that cannot be created drops the write rather
89    // than failing whatever produced the bytes.
90    #[test]
91    fn a_directory_that_cannot_be_created_drops_the_write() {
92        let dir = tempfile::tempdir().unwrap();
93        let blocker = dir.path().join("blocker");
94        fs::write(&blocker, b"a file, not a directory").unwrap();
95
96        let path = blocker.join("nested").join("file");
97        assert!(!replace(&path, |out| out.write_all(b"bytes")));
98        assert!(!path.exists());
99    }
100}