Skip to main content

dbmd_core/
fsx.rs

1//! `fsx` — the one atomic, durable file write for db.md's primary data.
2//!
3//! Every store-state file that holds **primary** data — content records
4//! ([`crate::parser::write_file`]), `log.md` and its archives ([`crate::log`]),
5//! and in-place link rewrites — is replaced through [`write_atomic`]:
6//!
7//! 1. write the bytes to a uniquely-named sibling temp file in the *same*
8//!    directory (`create_new`, so a predictable temp name can never be
9//!    clobbered — closing the temp-clobber race);
10//! 2. `fsync` the temp file;
11//! 3. `rename` it over the destination (atomic on a single filesystem, so a
12//!    concurrent reader never observes a half-written file);
13//! 4. `fsync` the parent directory so the rename survives a crash.
14//!
15//! This is the single primitive for durable writes — never `std::fs::write`,
16//! which is neither atomic nor crash-durable.
17//!
18//! **Not for the index.** `index.md` / `index.jsonl` are *derived, rebuildable*
19//! artifacts on the O(changed) write-through path; they use their own
20//! atomic-but-not-`fsync`'d writer ([`crate::index`]'s `AtomicTemp`) on purpose
21//! — a crash-lost index write is recovered by `dbmd index rebuild`, so paying an
22//! `fsync` per catalog update on the hot loop would be cost without benefit.
23
24use std::fs::{self, File, OpenOptions};
25use std::io::Write;
26use std::path::{Path, PathBuf};
27use std::sync::atomic::{AtomicU64, Ordering};
28use std::time::{SystemTime, UNIX_EPOCH};
29
30/// Atomically and durably replace `path` with `bytes` (see the module docs for
31/// the write/fsync/rename/fsync sequence). The parent directory is created if
32/// missing. On a rename failure the temp file is cleaned up rather than leaked.
33pub fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
34    let dir = path.parent().unwrap_or_else(|| Path::new("."));
35    fs::create_dir_all(dir)?;
36
37    let file_name = path
38        .file_name()
39        .and_then(|s| s.to_str())
40        .unwrap_or("dbmd-tmp");
41    let (mut f, tmp) = create_temp_file(dir, file_name)?;
42
43    // Scope the handle so it is flushed/closed before the rename.
44    {
45        f.write_all(bytes)?;
46        f.sync_all()?;
47    }
48
49    match fs::rename(&tmp, path) {
50        Ok(()) => {
51            sync_parent_dir(dir);
52            Ok(())
53        }
54        Err(e) => {
55            let _ = fs::remove_file(&tmp);
56            Err(e)
57        }
58    }
59}
60
61/// Create a uniquely-named temp file in `dir` with `create_new` (never clobbers
62/// a predictable name), retrying on the vanishingly-rare collision. The name is
63/// hidden (`.`-prefixed) and tagged with pid + nanos + a process-wide counter so
64/// concurrent writers in the same directory never pick the same path.
65fn create_temp_file(dir: &Path, file_name: &str) -> std::io::Result<(File, PathBuf)> {
66    static TMP_SEQ: AtomicU64 = AtomicU64::new(0);
67    let pid = std::process::id();
68    let nanos = SystemTime::now()
69        .duration_since(UNIX_EPOCH)
70        .map(|d| d.as_nanos())
71        .unwrap_or(0);
72
73    for _ in 0..128 {
74        let seq = TMP_SEQ.fetch_add(1, Ordering::Relaxed);
75        let tmp = dir.join(format!(".{file_name}.tmp.{pid}.{nanos}.{seq}"));
76        match OpenOptions::new().write(true).create_new(true).open(&tmp) {
77            Ok(file) => return Ok((file, tmp)),
78            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
79            Err(e) => return Err(e),
80        }
81    }
82
83    Err(std::io::Error::new(
84        std::io::ErrorKind::AlreadyExists,
85        "could not allocate a unique dbmd temp file",
86    ))
87}
88
89/// Best-effort `fsync` of the directory so a completed `rename` is durable across
90/// a crash. Non-fatal: some filesystems disallow directory `fsync`.
91fn sync_parent_dir(dir: &Path) {
92    if let Ok(d) = File::open(dir) {
93        let _ = d.sync_all();
94    }
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100    use tempfile::TempDir;
101
102    #[test]
103    fn write_atomic_creates_then_replaces_durably() {
104        let tmp = TempDir::new().unwrap();
105        let target = tmp.path().join("sub").join("file.txt"); // parent missing
106
107        write_atomic(&target, b"first").unwrap();
108        assert_eq!(std::fs::read(&target).unwrap(), b"first");
109
110        // Replace in place — content swaps, no temp files left behind.
111        write_atomic(&target, b"second").unwrap();
112        assert_eq!(std::fs::read(&target).unwrap(), b"second");
113
114        let leftovers: Vec<_> = std::fs::read_dir(target.parent().unwrap())
115            .unwrap()
116            .filter_map(|e| e.ok())
117            .filter(|e| e.file_name().to_string_lossy().contains(".tmp."))
118            .collect();
119        assert!(leftovers.is_empty(), "no temp files may be left behind");
120    }
121
122    #[test]
123    fn write_atomic_is_byte_exact_including_empty() {
124        let tmp = TempDir::new().unwrap();
125        let target = tmp.path().join("empty.txt");
126        write_atomic(&target, b"").unwrap();
127        assert_eq!(std::fs::read(&target).unwrap(), b"");
128    }
129}