Skip to main content

blockworx/storage/
atomic.rs

1//! Crash-safe file writes.
2//!
3//! Every write of something a user would mind losing goes through
4//! [`write_atomically`]. `std::fs::write` truncates the target and then fills it,
5//! so a crash, a kill, or a power cut partway through leaves a half-written file
6//! where the previous good one used to be — and for the document save that is the
7//! drawing itself.
8
9use std::io::Write as _;
10use std::path::{Path, PathBuf};
11
12use super::Durability;
13
14/// Write `contents` to `path` so that dying partway through leaves the previous
15/// contents intact: the bytes land in a sibling temporary file, are flushed to
16/// the storage device, and only then replace `path` with one atomic rename.
17///
18/// The target is never opened for writing, so there is no window in which it
19/// holds a partial document.
20pub fn write_atomically(path: &Path, contents: &[u8]) -> std::io::Result<()> {
21    write_with(path, contents, Durability::Sync)
22}
23
24/// [`write_atomically`], with the choice of whether to wait for the storage
25/// device. [`Durability::Relaxed`] still moves the file into place — it only
26/// skips the flushes, which is the whole cost (see `todo.md` P0).
27pub fn write_with(path: &Path, contents: &[u8], durability: Durability) -> std::io::Result<()> {
28    let target = resolve(path);
29    let temp = temp_path(&target)?;
30    write_and_replace(&temp, &target, contents, durability).inspect_err(|_| {
31        // A temp file left behind would be overwritten by the next attempt
32        // anyway; removing it just keeps the directory tidy.
33        let _ = std::fs::remove_file(&temp);
34    })
35}
36
37/// Follow symlinks, so writing a symlinked document replaces what it points at —
38/// the way `fs::write` does. Renaming onto the link would instead replace the
39/// link with a regular file and quietly detach it from its target.
40fn resolve(path: &Path) -> PathBuf {
41    std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
42}
43
44/// A dot-prefixed sibling of `target`. It has to share the directory: `rename`
45/// is only atomic within one filesystem.
46fn temp_path(target: &Path) -> std::io::Result<PathBuf> {
47    let name = target.file_name().ok_or_else(|| {
48        std::io::Error::new(
49            std::io::ErrorKind::InvalidInput,
50            format!("{} names no file to write", target.display()),
51        )
52    })?;
53    let mut temp = std::ffi::OsString::from(".");
54    temp.push(name);
55    temp.push(".tmp");
56    Ok(target.with_file_name(temp))
57}
58
59fn write_and_replace(
60    temp: &Path,
61    target: &Path,
62    contents: &[u8],
63    durability: Durability,
64) -> std::io::Result<()> {
65    let mut file = std::fs::File::create(temp)?;
66    file.write_all(contents)?;
67    // Without this the rename can be recorded while the bytes are still in the
68    // page cache, so a power cut yields an intact rename onto empty content.
69    if durability == Durability::Sync {
70        file.sync_all()?;
71    }
72    drop(file);
73    std::fs::rename(temp, target)?;
74    if durability == Durability::Sync {
75        sync_dir(parent_dir(target))?;
76    }
77    Ok(())
78}
79
80/// The directory holding `target`. A bare relative filename has an empty parent,
81/// which names nothing openable.
82fn parent_dir(target: &Path) -> &Path {
83    match target.parent() {
84        Some(dir) if !dir.as_os_str().is_empty() => dir,
85        _ => Path::new("."),
86    }
87}
88
89/// Flush the directory entry itself, so the rename survives a power cut and not
90/// just a process crash.
91#[cfg(unix)]
92fn sync_dir(dir: &Path) -> std::io::Result<()> {
93    std::fs::File::open(dir)?.sync_all()
94}
95
96/// Windows has no directory handle to sync; `MoveFileEx` metadata ordering is
97/// the filesystem's business there.
98#[cfg(not(unix))]
99fn sync_dir(_dir: &Path) -> std::io::Result<()> {
100    Ok(())
101}
102
103#[cfg(test)]
104pub mod tests {
105    use super::*;
106
107    /// A unique directory per test, removed on drop. Named for the test so a
108    /// failure leaves an identifiable directory behind. Shared with the
109    /// `storage::fs` and `storage::container` tests.
110    pub struct TempDir(PathBuf);
111
112    impl TempDir {
113        /// # Panics
114        ///
115        /// If the directory cannot be created — in a test that is the
116        /// assertion, not something to recover from.
117        pub fn new(name: &str) -> Self {
118            let dir = std::env::temp_dir().join(format!("blockworx-{}-{name}", std::process::id()));
119            let _ = std::fs::remove_dir_all(&dir);
120            std::fs::create_dir_all(&dir).unwrap();
121            Self(dir)
122        }
123
124        pub fn join(&self, name: &str) -> PathBuf {
125            self.0.join(name)
126        }
127
128        pub fn path(&self) -> &Path {
129            &self.0
130        }
131    }
132
133    impl Drop for TempDir {
134        fn drop(&mut self) {
135            let _ = std::fs::remove_dir_all(&self.0);
136        }
137    }
138
139    fn read(path: &Path) -> String {
140        std::fs::read_to_string(path).unwrap()
141    }
142
143    #[test]
144    fn it_creates_and_then_overwrites() {
145        let dir = TempDir::new("create-overwrite");
146        let doc = dir.join("doc.kdl");
147
148        write_atomically(&doc, b"first").unwrap();
149        assert_eq!(read(&doc), "first");
150
151        write_atomically(&doc, b"second").unwrap();
152        assert_eq!(read(&doc), "second");
153    }
154
155    #[test]
156    fn a_successful_write_leaves_no_temp_behind() {
157        let dir = TempDir::new("no-temp");
158        let doc = dir.join("doc.kdl");
159        write_atomically(&doc, b"content").unwrap();
160
161        let leftovers: Vec<_> = std::fs::read_dir(&dir.0)
162            .unwrap()
163            .map(|e| e.unwrap().file_name())
164            .filter(|name| name != "doc.kdl")
165            .collect();
166        assert!(leftovers.is_empty(), "left {leftovers:?} behind");
167    }
168
169    /// The property the whole module exists for: a write that fails partway
170    /// cannot have touched the target. The failure is forced by occupying the
171    /// temp path with a directory, so `File::create` fails after the target
172    /// already holds content worth keeping.
173    #[test]
174    fn a_failed_write_leaves_the_previous_contents_intact() {
175        let dir = TempDir::new("failed-write");
176        let doc = dir.join("doc.kdl");
177        write_atomically(&doc, b"the good version").unwrap();
178
179        let blocker = dir.join(".doc.kdl.tmp");
180        std::fs::create_dir(&blocker).unwrap();
181        // Proves the precondition: this is the path the write would have used.
182        assert_eq!(temp_path(&resolve(&doc)).unwrap(), blocker);
183
184        assert!(write_atomically(&doc, b"a doomed longer version").is_err());
185        assert_eq!(read(&doc), "the good version");
186    }
187
188    #[test]
189    fn a_path_naming_no_file_is_an_error() {
190        assert!(write_atomically(Path::new(".."), b"x").is_err());
191    }
192
193    /// A symlinked document is written *through*, not replaced by a regular
194    /// file — the behavior `fs::write` had and a bare rename would lose.
195    #[cfg(unix)]
196    #[test]
197    fn writing_a_symlink_updates_its_target() {
198        let dir = TempDir::new("symlink");
199        let target = dir.join("real.kdl");
200        let link = dir.join("link.kdl");
201        write_atomically(&target, b"before").unwrap();
202        std::os::unix::fs::symlink(&target, &link).unwrap();
203
204        write_atomically(&link, b"after").unwrap();
205
206        assert!(
207            std::fs::symlink_metadata(&link).unwrap().is_symlink(),
208            "the link itself was replaced"
209        );
210        assert_eq!(read(&target), "after");
211    }
212}