Skip to main content

arcbox_atomic_file/
lib.rs

1//! Atomic file replacement with a durable rename.
2//!
3//! Write a private temp sibling, `fsync` it, rename it over the
4//! destination, then `fsync` the parent directory so the rename itself
5//! survives a crash. Either the old contents or the new ones are visible
6//! at every instant; a reader never sees a truncated file.
7//!
8//! # Two failures, not one
9//!
10//! The point of this crate is that the two ways it can fail mean
11//! different things, and callers legitimately treat them differently:
12//!
13//! - [`AtomicWriteError::NotCommitted`] — the destination was never
14//!   touched and the temp file is cleaned up. The caller simply failed.
15//! - [`AtomicWriteError::DurabilityUncertain`] — the new contents are
16//!   already visible, but the directory `fsync` confirming the rename did
17//!   not complete, so a power loss could still resurrect the old ones.
18//!
19//! A catalog that must not lie about what it persisted treats the second
20//! as an error; a record store that would rather keep the record and warn
21//! treats it as a warning. So this crate reports which happened and lets
22//! the caller decide, rather than picking one policy for everyone.
23
24#![forbid(unsafe_code)]
25
26use std::fs::{self, File, OpenOptions};
27use std::io::Write;
28use std::os::unix::fs::OpenOptionsExt;
29use std::path::{Path, PathBuf};
30
31use thiserror::Error;
32use uuid::Uuid;
33
34/// How an atomic write failed.
35#[derive(Debug, Error)]
36pub enum AtomicWriteError {
37    /// The destination was never replaced; the temp file is cleaned up.
38    #[error("{} was not replaced: {source}", .path.display())]
39    NotCommitted {
40        /// The destination that was left untouched.
41        path: PathBuf,
42        /// What went wrong before the rename.
43        #[source]
44        source: std::io::Error,
45    },
46
47    /// The rename landed, but the directory `fsync` confirming it did not.
48    /// The new contents are visible; their survival across power loss is
49    /// unconfirmed.
50    #[error("{} was renamed but directory durability is unconfirmed: {source}", .path.display())]
51    DurabilityUncertain {
52        /// The destination that now holds the new contents.
53        path: PathBuf,
54        /// The `fsync` failure.
55        #[source]
56        source: std::io::Error,
57    },
58}
59
60impl AtomicWriteError {
61    /// The underlying I/O failure, whichever stage it came from.
62    #[must_use]
63    pub const fn source_io(&self) -> &std::io::Error {
64        match self {
65            Self::NotCommitted { source, .. } | Self::DurabilityUncertain { source, .. } => source,
66        }
67    }
68}
69
70/// Atomically replaces `destination` with `bytes`, durably.
71///
72/// The temp sibling is created with mode 0600 and a name unique per call,
73/// so concurrent writers to the same destination cannot interleave into
74/// one another's file — they degrade to last-writer-wins with each
75/// writer's own bytes intact.
76///
77/// # Errors
78///
79/// See [`AtomicWriteError`]: the two variants mean materially different
80/// things and are worth matching on rather than collapsing.
81pub fn write(destination: &Path, bytes: &[u8]) -> Result<(), AtomicWriteError> {
82    let not_committed = |source| AtomicWriteError::NotCommitted {
83        path: destination.to_path_buf(),
84        source,
85    };
86
87    let Some(parent) = destination.parent() else {
88        return Err(not_committed(std::io::Error::new(
89            std::io::ErrorKind::InvalidInput,
90            format!("path has no parent: {}", destination.display()),
91        )));
92    };
93    let temp_path = parent.join(format!(
94        ".{}.{}.tmp",
95        destination
96            .file_stem()
97            .and_then(|name| name.to_str())
98            .unwrap_or("atomic"),
99        Uuid::new_v4()
100    ));
101
102    (|| {
103        let mut temp = OpenOptions::new()
104            .write(true)
105            .create_new(true)
106            .mode(0o600)
107            .open(&temp_path)?;
108        temp.write_all(bytes)?;
109        temp.sync_all()?;
110        drop(temp);
111        fs::rename(&temp_path, destination)?;
112        Ok::<(), std::io::Error>(())
113    })()
114    .map_err(|original| {
115        // Leave no temp behind — the names are unique, so a leaked one is
116        // never reused and would accumulate silently.
117        match fs::remove_file(&temp_path) {
118            Ok(()) => original,
119            Err(error) if error.kind() == std::io::ErrorKind::NotFound => original,
120            Err(cleanup) => std::io::Error::new(
121                original.kind(),
122                format!(
123                    "{original}; failed to remove temporary file {}: {cleanup}",
124                    temp_path.display()
125                ),
126            ),
127        }
128    })
129    .map_err(not_committed)?;
130
131    File::open(parent)
132        .and_then(|directory| directory.sync_all())
133        .map_err(|source| AtomicWriteError::DurabilityUncertain {
134            path: destination.to_path_buf(),
135            source,
136        })
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    #[test]
144    fn a_replaced_file_holds_the_new_bytes_and_leaves_no_temp() {
145        let dir = tempfile::tempdir().unwrap();
146        let path = dir.path().join("record.json");
147        write(&path, b"first").unwrap();
148        write(&path, b"second").unwrap();
149
150        assert_eq!(fs::read(&path).unwrap(), b"second");
151        let leftovers: Vec<_> = fs::read_dir(dir.path())
152            .unwrap()
153            .map(|entry| entry.unwrap().file_name())
154            .filter(|name| name != "record.json")
155            .collect();
156        assert!(
157            leftovers.is_empty(),
158            "temp files left behind: {leftovers:?}"
159        );
160    }
161
162    #[test]
163    fn a_failed_write_leaves_the_destination_untouched() {
164        let dir = tempfile::tempdir().unwrap();
165        let path = dir.path().join("nested").join("record.json");
166        // The parent does not exist, so the temp file cannot be created.
167        let error = write(&path, b"payload").unwrap_err();
168
169        assert!(matches!(error, AtomicWriteError::NotCommitted { .. }));
170        assert!(!path.exists());
171    }
172
173    #[test]
174    fn a_pathless_destination_is_reported_rather_than_panicking() {
175        let error = write(Path::new("/"), b"payload").unwrap_err();
176        assert!(matches!(error, AtomicWriteError::NotCommitted { .. }));
177        assert_eq!(error.source_io().kind(), std::io::ErrorKind::InvalidInput);
178    }
179
180    #[test]
181    fn concurrent_writers_each_land_their_own_bytes_whole() {
182        let dir = tempfile::tempdir().unwrap();
183        let path = dir.path().join("contended.json");
184        let long = vec![b'a'; 64 * 1024];
185        let short = vec![b'b'; 64 * 1024];
186
187        std::thread::scope(|scope| {
188            for bytes in [&long, &short] {
189                scope.spawn(|| {
190                    for _ in 0..20 {
191                        write(&path, bytes).unwrap();
192                    }
193                });
194            }
195        });
196
197        // Whoever won, the file is one writer's payload in full — never a
198        // splice of both, which is what a shared temp name would produce.
199        let final_bytes = fs::read(&path).unwrap();
200        assert!(final_bytes == long || final_bytes == short);
201    }
202}