OXYGENHUB
# 6e2b12dfe6ec oxygen / crates / oxy-core / src / durable.rs
5.0 KiB · Rust · last changed Failed file writes name the file; the guide shows the feature flow 1 month ago source blame history raw
1
//! Durable file updates (spec §13.6): every write that must survive a
2
//! crash goes through here.
3
//!
4
//! A file is replaced by writing a uniquely named temporary file in the
5
//! same directory, syncing it, renaming it over the target, and syncing
6
//! the directory so the rename itself is on disk. A crash at any point
7
//! leaves either the old file or the new one, never a torn one; at worst
8
//! a stray temporary file, which nothing reads.
9
//!
10
//! In tests, `crash_after(n)` makes the n-th durable step on the current
11
//! thread fail as if the process had died there, so a test can stop an
12
//! operation at every step and check what is left behind.
13
14
use rand::RngCore;
15
use std::fs;
16
use std::io::{self, Write};
17
use std::path::Path;
18
19
/// Replace `path` with `data`, atomically and durably.
20
pub fn write_atomic(path: &Path, data: &[u8]) -> io::Result<()> {
21
    let dir = path.parent().unwrap_or(Path::new("."));
22
    let name = path
23
        .file_name()
24
        .map(|n| n.to_string_lossy().into_owned())
25
        .unwrap_or_default();
26
    let mut nonce = [0u8; 6];
27
    rand::rngs::OsRng.fill_bytes(&mut nonce);
28
    let tmp = dir.join(format!(".{name}.tmp-{}", hex::encode(nonce)));
29
    step("create temporary file")?;
30
    let written = (|| {
31
        let mut f = fs::File::create(&tmp)?;
32
        f.write_all(data)?;
33
        f.sync_all()?;
34
        step("rename into place")?;
35
        fs::rename(&tmp, path)
36
    })();
37
    if let Err(e) = written {
38
        if !is_injected(&e) {
39
            let _ = fs::remove_file(&tmp);
40
        }
41
        return Err(at("write", path, e));
42
    }
43
    sync_dir(dir).map_err(|e| at("sync the directory of", path, e))
44
}
45
46
/// `e`, naming what failed and on which file, so a failure deep in a
47
/// command says where it happened (an injected test crash stays as is).
48
/// The kind is kept for callers that test it.
49
fn at(what: &str, path: &Path, e: io::Error) -> io::Error {
50
    if is_injected(&e) {
51
        return e;
52
    }
53
    io::Error::new(e.kind(), format!("{what} {}: {e}", path.display()))
54
}
55
56
/// Rename `from` to `to` and make the rename durable.
57
pub fn rename(from: &Path, to: &Path) -> io::Result<()> {
58
    step("rename")?;
59
    fs::rename(from, to).map_err(|e| at(&format!("rename {} to", from.display()), to, e))?;
60
    sync_dir(to.parent().unwrap_or(Path::new("."))).map_err(|e| at("sync the directory of", to, e))
61
}
62
63
/// Flush a directory's entries (new, renamed or removed files) to disk.
64
/// A no-op where directories cannot be opened for syncing.
65
pub fn sync_dir(dir: &Path) -> io::Result<()> {
66
    #[cfg(unix)]
67
    {
68
        fs::File::open(dir)?.sync_all()
69
    }
70
    #[cfg(not(unix))]
71
    {
72
        let _ = dir;
73
        Ok(())
74
    }
75
}
76
77
/// A point where a crash may happen. Outside tests it does nothing.
78
pub fn step(what: &str) -> io::Result<()> {
79
    #[cfg(test)]
80
    {
81
        failpoint::hit(what)
82
    }
83
    #[cfg(not(test))]
84
    {
85
        let _ = what;
86
        Ok(())
87
    }
88
}
89
90
const INJECTED: &str = "injected crash";
91
92
fn is_injected(e: &io::Error) -> bool {
93
    e.to_string().starts_with(INJECTED)
94
}
95
96
#[cfg(test)]
97
pub(crate) mod failpoint {
98
    use std::cell::Cell;
99
    use std::io;
100
101
    thread_local! {
102
        static COUNTDOWN: Cell<Option<usize>> = const { Cell::new(None) };
103
        static FIRED: Cell<bool> = const { Cell::new(false) };
104
    }
105
106
    /// Fail the `n`-th durable step from now (1-based) on this thread,
107
    /// and every step after it, as a dead process would.
108
    pub fn crash_after(n: usize) {
109
        COUNTDOWN.with(|c| c.set(Some(n)));
110
        FIRED.with(|f| f.set(false));
111
    }
112
113
    /// Stop injecting; returns whether a crash was injected.
114
    pub fn disarm() -> bool {
115
        COUNTDOWN.with(|c| c.set(None));
116
        FIRED.with(|f| f.replace(false))
117
    }
118
119
    pub(super) fn hit(what: &str) -> io::Result<()> {
120
        if FIRED.with(|f| f.get()) {
121
            return Err(io::Error::other(format!(
122
                "{} (after) at {what}",
123
                super::INJECTED
124
            )));
125
        }
126
        COUNTDOWN.with(|c| match c.get() {
127
            Some(1) => {
128
                c.set(None);
129
                FIRED.with(|f| f.set(true));
130
                Err(io::Error::other(format!("{} at {what}", super::INJECTED)))
131
            }
132
            Some(n) => {
133
                c.set(Some(n - 1));
134
                Ok(())
135
            }
136
            None => Ok(()),
137
        })
138
    }
139
}
140
141
#[cfg(test)]
142
mod tests {
143
    use super::*;
144
145
    #[test]
146
    fn a_failed_write_names_the_file_and_keeps_its_kind() {
147
        let dir = tempfile::tempdir().unwrap();
148
        let path = dir.path().join("attest").join("feature").join("x");
149
        let e = write_atomic(&path, b"state").unwrap_err();
150
        assert_eq!(e.kind(), io::ErrorKind::NotFound);
151
        let msg = e.to_string();
152
        assert!(
153
            msg.starts_with("write ") && msg.contains("attest/feature/x"),
154
            "{msg}"
155
        );
156
        write_atomic(&dir.path().join("ok"), b"state").unwrap();
157
        let e = rename(&dir.path().join("missing"), &dir.path().join("to")).unwrap_err();
158
        assert!(
159
            e.to_string().contains("missing") && e.to_string().contains("to"),
160
            "{e}"
161
        );
162
    }
163
}