Skip to main content

nodedb_wal/segment/
atomic_io.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Durable atomic file / directory operations for checkpoint-class writes.
4//!
5//! The tmp-file + rename pattern is atomic only if both the file data and
6//! the containing directory entry reach stable storage in the correct order.
7//! On ext4 / XFS the rename metadata op can reach disk before the data pages
8//! backing the tmp file — a power loss between the write and the next
9//! checkpoint then leaves a correctly-named file containing zeros.
10//!
11//! [`atomic_write_fsync`] is the single helper all checkpoint-class writers
12//! go through so the ordering (`write → sync_data → rename → fsync_dir`) is
13//! enforced in one place. [`atomic_swap_dirs_fsync`] does the same for
14//! directory-level swaps (rename old-dir → backup, rename new-dir → old-dir).
15//!
16//! Path safety lives here, and a caller cannot opt out of it. Both helpers take
17//! the base directory and the plain names separately and do every join
18//! themselves, so no caller hands this module a path it assembled. Each name
19//! passes [`nodedb_types::is_plain_path_component`], which rejects a separator,
20//! a drive letter, a NUL, `.`, `..`, a leading or trailing dot or space, and a
21//! control character. A name that fails is `InvalidInput` naming the argument,
22//! and nothing is written.
23//!
24//! The tmp name is derived from the destination name as `{name}.tmp`, inside
25//! this module, so the five naming conventions the call sites used are gone.
26//! The suffix is APPENDED rather than replacing the extension: `a.col` and
27//! `a.idx` keep distinct tmp names, where `set_extension("tmp")` collapses both
28//! onto `a.tmp` and lets two concurrent writers destroy each other's staging
29//! file. The tmp lands in the same directory as the destination, so one parent
30//! fsync covers both entries and the rename stays within one filesystem
31//! directory.
32//!
33//! [`read_checkpoint_dontneed`] pairs with the write helper on the read side:
34//! checkpoint bytes are consumed once (deserialized into the in-memory index)
35//! and then superseded. Leaving them in the page cache wastes memory needed
36//! by hot workloads.
37
38use std::fs;
39use std::io::Write;
40use std::path::Path;
41
42use crate::error::{Result, WalError};
43
44/// Fsync a directory to ensure file creation/deletion metadata is durable.
45///
46/// On ext4/XFS, creating or deleting a file writes the file data to disk
47/// but the directory entry may only be in the page cache. A power loss
48/// before the directory entry is persisted causes the file to "disappear"
49/// on reboot. Calling fsync on the directory fd ensures the metadata
50/// (filename, inode pointer) is on stable storage.
51pub fn fsync_directory(dir: &Path) -> Result<()> {
52    // Crash injection: the directory entry never reaches stable storage.
53    // Every caller must treat this as a durability failure, not a warning.
54    nodedb_types::fail_point_err!("wal::fsync_directory", |detail: String| WalError::Io(
55        std::io::Error::other(format!("failpoint wal::fsync_directory: {detail}"))
56    ));
57
58    let dir_file = fs::File::open(dir).map_err(WalError::Io)?;
59    dir_file.sync_all().map_err(WalError::Io)?;
60    Ok(())
61}
62
63fn invalid_input(detail: String) -> WalError {
64    WalError::Io(std::io::Error::new(
65        std::io::ErrorKind::InvalidInput,
66        detail,
67    ))
68}
69
70/// Reject a name that is not one ordinary path component.
71///
72/// A name carrying a separator, a `..`, or a drive letter resolves outside the
73/// directory this module fsyncs, so the durability ordering no longer covers
74/// the entry that was created, and a name taken from catalog, wire, or user
75/// input can address a directory the caller never intended to write. Rejecting
76/// the name is what makes the join below safe, which is why the join happens
77/// here and not at the call site.
78fn checked_name(op: &str, label: &str, name: &str) -> Result<()> {
79    if nodedb_types::is_plain_path_component(name) {
80        return Ok(());
81    }
82    Err(invalid_input(format!(
83        "{op}: {label} must be one plain name — no separator, no '..', no drive \
84         letter, no leading or trailing dot or space: {name:?}"
85    )))
86}
87
88/// Derive the staging name for a destination name.
89///
90/// The suffix is appended, never substituted for the extension, so two
91/// destinations in one directory can never share a tmp name.
92fn tmp_name(name: &str) -> String {
93    format!("{name}.tmp")
94}
95
96/// Atomically write `bytes` to `dir/name` via a `dir/name.tmp` staging file
97/// with full durability.
98///
99/// Order of operations (must not change):
100/// 1. Create / truncate the tmp file and write `bytes`.
101/// 2. `sync_data()` on the tmp file — forces file data pages to stable storage.
102/// 3. `rename(tmp, dst)` — atomic on POSIX filesystems.
103/// 4. `fsync_directory(dir)` — forces the directory entry durable so the new
104///    name survives power loss.
105///
106/// `name` must be one plain path component; anything else returns
107/// `InvalidInput` before a byte is written. Both paths are built here from
108/// `dir`, so the rename stays inside one filesystem directory and the single
109/// parent fsync covers both entries.
110pub fn atomic_write_fsync(dir: &Path, name: &str, bytes: &[u8]) -> Result<()> {
111    checked_name("atomic_write_fsync", "name", name)?;
112
113    let dst = dir.join(name);
114    let tmp = dir.join(tmp_name(name));
115
116    {
117        let mut f = fs::File::create(&tmp).map_err(WalError::Io)?;
118        f.write_all(bytes).map_err(WalError::Io)?;
119        f.sync_data().map_err(WalError::Io)?;
120    }
121
122    fs::rename(&tmp, &dst).map_err(WalError::Io)?;
123    fsync_directory(dir)?;
124    Ok(())
125}
126
127/// Atomically swap a directory under `parent`:
128/// `rename(live, backup); rename(staged, live)`, fsyncing `parent` once both
129/// renames have completed.
130///
131/// `live`, `backup`, and `staged` are plain names under one `parent`, so the
132/// renames are same-directory by construction. A name that is not one plain
133/// component returns `InvalidInput` before either rename runs. The caller
134/// removes the backup directory once the new state is proven good — this helper
135/// deletes nothing.
136pub fn atomic_swap_dirs_fsync(parent: &Path, live: &str, backup: &str, staged: &str) -> Result<()> {
137    checked_name("atomic_swap_dirs_fsync", "live", live)?;
138    checked_name("atomic_swap_dirs_fsync", "backup", backup)?;
139    checked_name("atomic_swap_dirs_fsync", "staged", staged)?;
140
141    let live = parent.join(live);
142    let backup = parent.join(backup);
143    let staged = parent.join(staged);
144
145    fs::rename(&live, &backup).map_err(WalError::Io)?;
146    fs::rename(&staged, &live).map_err(WalError::Io)?;
147    fsync_directory(parent)?;
148    Ok(())
149}
150
151/// Read a checkpoint file and advise the kernel to drop its pages from the
152/// page cache.
153///
154/// Checkpoint files are consumed exactly once per process lifetime (loaded
155/// into the in-memory index and then superseded). `posix_fadvise(DONTNEED)`
156/// after read frees the page-cache memory for hot workloads.
157///
158/// On non-Unix targets the advise call is skipped and this degrades to a
159/// plain read.
160pub fn read_checkpoint_dontneed(path: &Path) -> Result<Vec<u8>> {
161    let file = fs::File::open(path).map_err(WalError::Io)?;
162    let len = file.metadata().map_err(WalError::Io)?.len();
163    let bytes = fs::read(path).map_err(WalError::Io)?;
164
165    #[cfg(target_os = "linux")]
166    {
167        use std::os::unix::io::AsRawFd as _;
168        // Safe: `file` owns the fd for the duration of the call; len fits in
169        // off_t on all supported platforms (checkpoint files are << i64::MAX).
170        let ret = unsafe {
171            libc::posix_fadvise(
172                file.as_raw_fd(),
173                0,
174                len as libc::off_t,
175                libc::POSIX_FADV_DONTNEED,
176            )
177        };
178        if ret != 0 {
179            tracing::debug!(
180                path = %path.display(),
181                ret,
182                "posix_fadvise(DONTNEED) returned nonzero — checkpoint bytes may stay in page cache"
183            );
184        }
185    }
186    #[cfg(not(target_os = "linux"))]
187    {
188        let _ = (file, len);
189    }
190
191    Ok(bytes)
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197
198    fn is_invalid_input(err: &WalError) -> bool {
199        matches!(err, WalError::Io(e) if e.kind() == std::io::ErrorKind::InvalidInput)
200    }
201
202    /// Names that `is_plain_path_component` refuses, one per rejection reason.
203    const BAD_NAMES: [&str; 5] = ["sub/escaped.ckpt", "..", "/etc/passwd", "C:evil", "."];
204
205    #[test]
206    fn atomic_write_fsync_roundtrip() {
207        let dir = tempfile::tempdir().unwrap();
208
209        atomic_write_fsync(dir.path(), "payload.ckpt", b"hello world").unwrap();
210
211        assert!(
212            !dir.path().join("payload.ckpt.tmp").exists(),
213            "tmp must be renamed away"
214        );
215        assert_eq!(
216            fs::read(dir.path().join("payload.ckpt")).unwrap(),
217            b"hello world"
218        );
219    }
220
221    #[test]
222    fn atomic_write_fsync_overwrites() {
223        let dir = tempfile::tempdir().unwrap();
224
225        atomic_write_fsync(dir.path(), "payload.ckpt", b"v1").unwrap();
226        atomic_write_fsync(dir.path(), "payload.ckpt", b"v2").unwrap();
227
228        assert_eq!(fs::read(dir.path().join("payload.ckpt")).unwrap(), b"v2");
229    }
230
231    /// The tmp suffix is appended, so two destinations sharing a stem stage
232    /// under different names and cannot clobber each other.
233    #[test]
234    fn tmp_name_keeps_the_full_destination_name() {
235        assert_eq!(tmp_name("a.col"), "a.col.tmp");
236        assert_ne!(tmp_name("a.col"), tmp_name("a.idx"));
237    }
238
239    #[test]
240    fn atomic_swap_dirs_fsync_swaps() {
241        let dir = tempfile::tempdir().unwrap();
242        let live = dir.path().join("live");
243        let backup = dir.path().join("backup");
244        let staged = dir.path().join("staged");
245
246        fs::create_dir(&live).unwrap();
247        fs::write(live.join("marker"), b"old").unwrap();
248        fs::create_dir(&staged).unwrap();
249        fs::write(staged.join("marker"), b"new").unwrap();
250
251        atomic_swap_dirs_fsync(dir.path(), "live", "backup", "staged").unwrap();
252
253        assert_eq!(fs::read(live.join("marker")).unwrap(), b"new");
254        assert_eq!(fs::read(backup.join("marker")).unwrap(), b"old");
255        assert!(!staged.exists());
256    }
257
258    /// Every rejection reason, checked on the one name argument the write
259    /// helper takes. Without the check each of these escapes `dir`.
260    #[test]
261    fn atomic_write_fsync_rejects_a_name_that_is_not_one_component() {
262        let dir = tempfile::tempdir().unwrap();
263        fs::create_dir(dir.path().join("sub")).unwrap();
264
265        for name in BAD_NAMES {
266            let err = atomic_write_fsync(dir.path(), name, b"x").unwrap_err();
267            assert!(
268                is_invalid_input(&err),
269                "{name:?} must be InvalidInput, got {err:?}"
270            );
271        }
272
273        assert!(
274            fs::read_dir(dir.path().join("sub"))
275                .unwrap()
276                .next()
277                .is_none(),
278            "nothing may be written before the check"
279        );
280    }
281
282    /// The same rejection, exercised through each of the three name arguments
283    /// of the swap helper: a check present on only one of them leaves the other
284    /// two able to rename outside the fsynced directory.
285    #[test]
286    fn atomic_swap_dirs_fsync_rejects_a_bad_name_in_each_position() {
287        for position in 0..3 {
288            for name in BAD_NAMES {
289                let dir = tempfile::tempdir().unwrap();
290                let live = dir.path().join("live");
291                fs::create_dir(&live).unwrap();
292                fs::write(live.join("marker"), b"old").unwrap();
293                fs::create_dir(dir.path().join("staged")).unwrap();
294
295                let mut names = ["live", "backup", "staged"];
296                names[position] = name;
297                let err =
298                    atomic_swap_dirs_fsync(dir.path(), names[0], names[1], names[2]).unwrap_err();
299
300                assert!(
301                    is_invalid_input(&err),
302                    "{name:?} at position {position} must be InvalidInput, got {err:?}"
303                );
304                assert_eq!(
305                    fs::read(live.join("marker")).unwrap(),
306                    b"old",
307                    "live must be untouched when the check fails"
308                );
309            }
310        }
311    }
312
313    #[test]
314    fn read_checkpoint_dontneed_returns_bytes() {
315        let dir = tempfile::tempdir().unwrap();
316        let path = dir.path().join("ckpt");
317        fs::write(&path, b"checkpoint bytes").unwrap();
318
319        let bytes = read_checkpoint_dontneed(&path).unwrap();
320        assert_eq!(bytes, b"checkpoint bytes");
321    }
322}