Skip to main content

release_kit/
atomic.rs

1//! The temp-plus-rename writer every landing write goes through, and
2//! the staged transaction an apply commits several writes through.
3//!
4//! A plain `fs::write` interrupted mid-call leaves a truncated file that
5//! is valid-looking YAML until a forge parses it. Writing beside the
6//! destination and renaming over it makes each write land whole or not at
7//! all; the rename stays in one directory, which is what keeps it atomic
8//! on POSIX filesystems. A transaction stages every write first and
9//! renames them in order afterwards, so an interruption leaves each
10//! destination holding either its previous bytes or its new ones, and
11//! the caller learns which renames landed.
12
13use std::fs;
14use std::io::Write as _;
15use std::path::{Path, PathBuf};
16
17/// Write `bytes` at `path` through a same-directory temporary file and a
18/// rename, creating the parent directories it needs.
19///
20/// # Errors
21///
22/// Any I/O failure from creating, writing, or renaming; on failure the
23/// temporary file is removed and the destination holds what it held.
24pub fn write(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
25    let parent = path.parent().filter(|p| !p.as_os_str().is_empty());
26    if let Some(parent) = parent {
27        fs::create_dir_all(parent)?;
28    }
29    let name = path
30        .file_name()
31        .ok_or_else(|| std::io::Error::other(format!("no file name in {}", path.display())))?;
32    let mut tmp_name = std::ffi::OsString::from(format!(".{}", std::process::id()));
33    tmp_name.push(".rk-tmp.");
34    tmp_name.push(name);
35    let tmp = path.with_file_name(tmp_name);
36    let written = fs::File::create(&tmp)
37        .and_then(|mut file| file.write_all(bytes).and_then(|()| file.sync_all()))
38        .and_then(|()| fs::rename(&tmp, path));
39    if written.is_err() {
40        let _ = fs::remove_file(&tmp);
41    }
42    written
43}
44
45/// Several writes staged beside their destinations, renamed over them in
46/// order on commit; a staged file that is never committed is removed
47/// when the transaction drops.
48#[derive(Debug, Default)]
49pub struct Transaction {
50    staged: Vec<(PathBuf, PathBuf)>,
51}
52
53/// A commit that stopped part way: what landed, and what did not.
54#[derive(Debug)]
55pub struct Interrupted {
56    /// The destinations renamed over before the failure, in order.
57    pub landed: Vec<PathBuf>,
58    /// The destination whose rename failed.
59    pub failed: PathBuf,
60    /// The failure.
61    pub error: std::io::Error,
62}
63
64impl Transaction {
65    /// An empty transaction.
66    #[must_use]
67    pub fn new() -> Self {
68        Self::default()
69    }
70
71    /// Stage `bytes` for `path`: written whole and synced beside the
72    /// destination, which holds what it held until commit.
73    ///
74    /// # Errors
75    ///
76    /// Any I/O failure from creating or writing the staged file; the
77    /// destination is untouched.
78    pub fn stage(&mut self, path: &Path, bytes: &[u8]) -> std::io::Result<()> {
79        let parent = path.parent().filter(|p| !p.as_os_str().is_empty());
80        if let Some(parent) = parent {
81            fs::create_dir_all(parent)?;
82        }
83        let name = path
84            .file_name()
85            .ok_or_else(|| std::io::Error::other(format!("no file name in {}", path.display())))?;
86        let mut tmp_name = std::ffi::OsString::from(format!(".{}", std::process::id()));
87        tmp_name.push(format!(".rk-txn-{}.", self.staged.len()));
88        tmp_name.push(name);
89        let tmp = path.with_file_name(tmp_name);
90        let written = fs::File::create(&tmp)
91            .and_then(|mut file| file.write_all(bytes).and_then(|()| file.sync_all()));
92        if written.is_err() {
93            let _ = fs::remove_file(&tmp);
94            return written;
95        }
96        self.staged.push((tmp, path.to_path_buf()));
97        Ok(())
98    }
99
100    /// How many writes are staged.
101    #[must_use]
102    pub fn len(&self) -> usize {
103        self.staged.len()
104    }
105
106    /// Whether nothing is staged.
107    #[must_use]
108    pub fn is_empty(&self) -> bool {
109        self.staged.is_empty()
110    }
111
112    /// Rename every staged file over its destination, in staging order.
113    ///
114    /// # Errors
115    ///
116    /// The first rename that fails stops the commit; the result names
117    /// every destination renamed before it. The remaining staged files
118    /// are removed, so no destination is ever half-written.
119    pub fn commit(self) -> Result<Vec<PathBuf>, Interrupted> {
120        self.commit_stopping_at(None)
121    }
122
123    /// [`Self::commit`], stopped on purpose before the rename over
124    /// `stop`, as if that rename had failed. This is the one seam the
125    /// interruption proof needs: a rename cannot be made to fail from
126    /// outside without a read failing first, and the proof is about what
127    /// the tree holds after a commit that stopped part way.
128    ///
129    /// # Errors
130    ///
131    /// As [`Self::commit`], plus the injected stop.
132    pub fn commit_stopping_at(mut self, stop: Option<&Path>) -> Result<Vec<PathBuf>, Interrupted> {
133        let mut landed = Vec::new();
134        let staged = std::mem::take(&mut self.staged);
135        let mut pending = staged.into_iter();
136        for (tmp, dest) in pending.by_ref() {
137            let renamed = if stop.is_some_and(|stop| dest.ends_with(stop)) {
138                Err(std::io::Error::other(
139                    "the commit was stopped here for the proof",
140                ))
141            } else {
142                fs::rename(&tmp, &dest)
143            };
144            if let Err(error) = renamed {
145                let _ = fs::remove_file(&tmp);
146                for (rest, _) in pending {
147                    let _ = fs::remove_file(rest);
148                }
149                return Err(Interrupted {
150                    landed,
151                    failed: dest,
152                    error,
153                });
154            }
155            landed.push(dest);
156        }
157        Ok(landed)
158    }
159}
160
161impl Drop for Transaction {
162    fn drop(&mut self) {
163        for (tmp, _) in self.staged.drain(..) {
164            let _ = fs::remove_file(tmp);
165        }
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use super::{Transaction, write};
172
173    #[test]
174    fn a_transaction_lands_every_write_in_order_and_leaves_no_temp() {
175        let dir = tempfile::tempdir().expect("a scratch dir exists");
176        let one = dir.path().join("a/one.txt");
177        let two = dir.path().join("two.txt");
178        let mut txn = Transaction::new();
179        txn.stage(&one, b"one").expect("stages");
180        txn.stage(&two, b"two").expect("stages");
181        assert!(
182            !one.exists() && !two.exists(),
183            "staging touches no destination"
184        );
185        let landed = txn.commit().expect("commits");
186        assert_eq!(landed, vec![one.clone(), two.clone()]);
187        assert_eq!(std::fs::read(&one).expect("reads"), b"one");
188        assert_eq!(std::fs::read(&two).expect("reads"), b"two");
189        let leftovers: Vec<_> = std::fs::read_dir(dir.path())
190            .expect("the dir reads")
191            .map(|entry| entry.expect("an entry").file_name())
192            .filter(|name| name != "a" && name != "two.txt")
193            .collect();
194        assert!(
195            leftovers.is_empty(),
196            "temp files left behind: {leftovers:?}"
197        );
198    }
199
200    #[test]
201    fn a_dropped_transaction_removes_what_it_staged() {
202        let dir = tempfile::tempdir().expect("a scratch dir exists");
203        let dest = dir.path().join("kept.txt");
204        std::fs::write(&dest, b"before").expect("writes");
205        {
206            let mut txn = Transaction::new();
207            txn.stage(&dest, b"after").expect("stages");
208        }
209        assert_eq!(std::fs::read(&dest).expect("reads"), b"before");
210        assert_eq!(std::fs::read_dir(dir.path()).expect("reads").count(), 1);
211    }
212
213    #[test]
214    fn an_interrupted_commit_names_what_landed_and_leaves_the_rest_whole() {
215        let dir = tempfile::tempdir().expect("a scratch dir exists");
216        let first = dir.path().join("first.txt");
217        let blocked = dir.path().join("blocked");
218        std::fs::create_dir(&blocked).expect("the blocking dir creates");
219        let third = dir.path().join("third.txt");
220        std::fs::write(&third, b"before").expect("writes");
221        let mut txn = Transaction::new();
222        txn.stage(&first, b"first").expect("stages");
223        txn.stage(&blocked, b"bytes")
224            .expect("stages beside a directory");
225        txn.stage(&third, b"after").expect("stages");
226        let interrupted = txn.commit().expect_err("the directory blocks the rename");
227        assert_eq!(interrupted.landed, vec![first.clone()]);
228        assert_eq!(interrupted.failed, blocked);
229        assert_eq!(std::fs::read(&first).expect("reads"), b"first");
230        assert_eq!(std::fs::read(&third).expect("reads"), b"before");
231        assert_eq!(std::fs::read_dir(dir.path()).expect("reads").count(), 3);
232    }
233
234    #[test]
235    fn a_write_creates_parents_lands_whole_and_leaves_no_temp() {
236        let dir = tempfile::tempdir().expect("a scratch dir exists");
237        let path = dir.path().join("deep/nested/file.txt");
238        write(&path, b"first").expect("the write lands");
239        assert_eq!(std::fs::read(&path).expect("the file reads"), b"first");
240        write(&path, b"second").expect("the overwrite lands");
241        assert_eq!(std::fs::read(&path).expect("the file reads"), b"second");
242        let leftovers: Vec<_> = std::fs::read_dir(path.parent().expect("a parent"))
243            .expect("the dir reads")
244            .map(|entry| entry.expect("an entry").file_name())
245            .filter(|name| name != "file.txt")
246            .collect();
247        assert!(
248            leftovers.is_empty(),
249            "temp files left behind: {leftovers:?}"
250        );
251    }
252
253    #[test]
254    fn a_failed_write_leaves_the_destination_alone() {
255        let dir = tempfile::tempdir().expect("a scratch dir exists");
256        // A directory where the file should land: the rename fails.
257        let path = dir.path().join("blocked");
258        std::fs::create_dir(&path).expect("the blocking dir creates");
259        assert!(write(&path, b"bytes").is_err());
260        assert!(path.is_dir(), "the destination must be untouched");
261    }
262}