Skip to main content

core_storage/
fs.rs

1use std::fs::{File, OpenOptions};
2use std::io::{Read, Write};
3use std::path::PathBuf;
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
6pub enum FileId {
7    Wal,
8    Snapshot,
9    /// Backup of the previous snapshot, kept until the next clean open at
10    /// the current format version. Written before any migration to preserve
11    /// the original bytes if the migration step fails.
12    SnapshotBak,
13    /// RBAC role definitions sidecar. Written atomically by `apply_schema`
14    /// when roles change; loaded at open. Never part of WAL/snapshot format.
15    Roles,
16}
17
18impl FileId {
19    fn name(self) -> &'static str {
20        match self {
21            FileId::Wal => "wal.bin",
22            FileId::Snapshot => "snapshot.bin",
23            FileId::SnapshotBak => "snapshot.bin.bak",
24            FileId::Roles => "roles.json",
25        }
26    }
27}
28
29pub trait Fs {
30    fn append(&mut self, file: FileId, data: &[u8]) -> std::io::Result<()>;
31    fn sync(&mut self, file: FileId) -> std::io::Result<()>;
32    fn read(&self, file: FileId) -> std::io::Result<Vec<u8>>;
33    fn write_atomic(&mut self, file: FileId, data: &[u8]) -> std::io::Result<()>;
34    /// Return the on-disk path of the snapshot file, if any.
35    ///
36    /// `Some` for `RealFs` (used by `MappedBase::map` for true file mmap).
37    /// `None` for `SimFs` and other in-memory implementations (falls back to
38    /// `MappedBase::from_bytes`).
39    fn snapshot_path(&self) -> Option<std::path::PathBuf> {
40        None
41    }
42    /// Read at most `n` bytes from the beginning of `file` without loading
43    /// the full contents.
44    ///
45    /// Used by the open path to sniff the 6-byte magic+version header before
46    /// deciding whether to mmap (V8) or full-read (legacy V5-V7).
47    ///
48    /// The default implementation calls `read()` and truncates; override in
49    /// `RealFs` for a true partial read.
50    fn read_prefix(&self, file: FileId, n: usize) -> std::io::Result<Vec<u8>> {
51        let mut bytes = self.read(file)?;
52        bytes.truncate(n);
53        Ok(bytes)
54    }
55
56    // ── WAL archive methods (Task 4: history-preserving snapshots) ─────────────
57
58    /// List all WAL archive identifiers, sorted ascending (oldest first).
59    ///
60    /// Each archive created by [`archive_wal`] with commit-seq `N` appears as `N`.
61    /// Returns an empty list when no archives exist.
62    ///
63    /// Default: no archive support — returns empty.
64    fn list_archives(&self) -> std::io::Result<Vec<u64>> {
65        Ok(vec![])
66    }
67
68    /// Read the byte contents of WAL archive `n`.
69    ///
70    /// Returns an empty `Vec` when the archive does not exist.
71    ///
72    /// Default: no archive support — returns empty.
73    fn read_archive(&self, _n: u64) -> std::io::Result<Vec<u8>> {
74        Ok(vec![])
75    }
76
77    /// Atomically rename the current WAL to `wal.<n>.archive` (same directory,
78    /// same filesystem — the rename is guaranteed atomic at the OS level).
79    ///
80    /// The caller ensures the snapshot has been durably written before calling
81    /// this method.  After a successful rename the old WAL no longer exists as
82    /// `wal.bin`; a subsequent [`write_atomic`] on `FileId::Wal` creates a new
83    /// empty WAL.
84    ///
85    /// Returns `Err` if the operation is not supported or fails.
86    fn archive_wal(&mut self, _n: u64) -> std::io::Result<()> {
87        Err(std::io::Error::other(
88            "archive_wal not supported by this Fs implementation",
89        ))
90    }
91
92    /// Delete archive `n`.  No-op if it does not exist.
93    ///
94    /// Retention pruning (inside `snapshot_with`) is the only call site.
95    ///
96    /// Default: no-op.
97    fn delete_archive(&mut self, _n: u64) -> std::io::Result<()> {
98        Ok(())
99    }
100
101    /// Return the persisted horizon floor — the global frame index of the
102    /// first commit that is still reachable through surviving archives.
103    ///
104    /// Defaults to `0` (all history reachable / no pruning ever performed).
105    fn read_horizon_floor(&self) -> std::io::Result<u64> {
106        Ok(0)
107    }
108
109    /// Atomically persist `floor` so that a subsequent [`read_horizon_floor`]
110    /// after reopen returns the same value.
111    ///
112    /// Default: no-op (in-memory only; override in durable implementations).
113    fn write_horizon_floor(&mut self, _floor: u64) -> std::io::Result<()> {
114        Ok(())
115    }
116
117    /// Return `true` when the `wal.genesis` marker file is present.
118    ///
119    /// The marker signals that the surviving archive chain forms a complete,
120    /// uninterrupted WAL history starting from the store's first-ever commit
121    /// (the genesis chain).  When absent, archive-resident commits are not
122    /// safe to replay from empty state and `open_at` must refuse them.
123    ///
124    /// Default: `false` (no genesis chain / no archive support).
125    fn has_genesis_marker(&self) -> bool {
126        false
127    }
128
129    /// Durably create the `wal.genesis` marker file.
130    ///
131    /// Written exactly once, when the first WAL archive is taken from a store
132    /// that has never undergone a WAL-truncating snapshot.
133    ///
134    /// Default: no-op.
135    fn write_genesis_marker(&mut self) -> std::io::Result<()> {
136        Ok(())
137    }
138
139    /// Remove the `wal.genesis` marker file.
140    ///
141    /// Called when the genesis chain is broken: either by archive pruning
142    /// (floor advances past 0) or by a WAL-truncating snapshot taken after
143    /// archives already exist.  No-op if the marker is absent.
144    ///
145    /// Default: no-op.
146    fn delete_genesis_marker(&mut self) -> std::io::Result<()> {
147        Ok(())
148    }
149}
150
151pub trait FsIntrospect {
152    fn total_appended(&self) -> usize;
153    fn sync_count(&self) -> usize {
154        0
155    }
156}
157
158#[derive(Debug)]
159pub struct RealFs {
160    dir: PathBuf,
161}
162
163impl RealFs {
164    pub fn new(dir: &std::path::Path) -> std::io::Result<Self> {
165        std::fs::create_dir_all(dir)?;
166        Ok(Self {
167            dir: dir.to_path_buf(),
168        })
169    }
170
171    /// The database directory this filesystem is rooted at.
172    pub fn dir(&self) -> &std::path::Path {
173        &self.dir
174    }
175
176    fn path(&self, file: FileId) -> PathBuf {
177        self.dir.join(file.name())
178    }
179}
180
181impl Fs for RealFs {
182    fn append(&mut self, file: FileId, data: &[u8]) -> std::io::Result<()> {
183        let mut f = OpenOptions::new()
184            .create(true)
185            .append(true)
186            .open(self.path(file))?;
187        f.write_all(data)
188    }
189
190    fn sync(&mut self, file: FileId) -> std::io::Result<()> {
191        let f = File::open(self.path(file))?;
192        full_sync(&f)
193    }
194
195    fn read(&self, file: FileId) -> std::io::Result<Vec<u8>> {
196        match File::open(self.path(file)) {
197            Ok(mut f) => {
198                let mut buf = Vec::new();
199                f.read_to_end(&mut buf)?;
200                Ok(buf)
201            }
202            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
203            Err(e) => Err(e),
204        }
205    }
206
207    fn write_atomic(&mut self, file: FileId, data: &[u8]) -> std::io::Result<()> {
208        let tmp = self.dir.join(format!("{}.tmp", file.name()));
209        {
210            let mut f = File::create(&tmp)?;
211            f.write_all(data)?;
212            full_sync(&f)?;
213        }
214        std::fs::rename(&tmp, self.path(file))?;
215        sync_dir(&self.dir)
216    }
217
218    fn snapshot_path(&self) -> Option<std::path::PathBuf> {
219        Some(self.path(FileId::Snapshot))
220    }
221
222    fn read_prefix(&self, file: FileId, n: usize) -> std::io::Result<Vec<u8>> {
223        use std::io::Read as _;
224        match File::open(self.path(file)) {
225            Ok(mut f) => {
226                let mut buf = vec![0u8; n];
227                let read = f.read(&mut buf)?;
228                buf.truncate(read);
229                Ok(buf)
230            }
231            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
232            Err(e) => Err(e),
233        }
234    }
235
236    fn list_archives(&self) -> std::io::Result<Vec<u64>> {
237        let mut ns = Vec::new();
238        for entry in std::fs::read_dir(&self.dir)? {
239            let entry = entry?;
240            let name = entry.file_name();
241            let s = name.to_string_lossy();
242            if let Some(mid) = s
243                .strip_prefix("wal.")
244                .and_then(|r| r.strip_suffix(".archive"))
245            {
246                if let Ok(n) = mid.parse::<u64>() {
247                    ns.push(n);
248                }
249            }
250        }
251        ns.sort_unstable();
252        Ok(ns)
253    }
254
255    fn read_archive(&self, n: u64) -> std::io::Result<Vec<u8>> {
256        let path = self.dir.join(format!("wal.{n}.archive"));
257        match std::fs::read(&path) {
258            Ok(b) => Ok(b),
259            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(vec![]),
260            Err(e) => Err(e),
261        }
262    }
263
264    fn archive_wal(&mut self, n: u64) -> std::io::Result<()> {
265        let wal_path = self.path(FileId::Wal);
266        let archive_path = self.dir.join(format!("wal.{n}.archive"));
267        std::fs::rename(&wal_path, &archive_path)?;
268        sync_dir(&self.dir)
269    }
270
271    fn delete_archive(&mut self, n: u64) -> std::io::Result<()> {
272        let path = self.dir.join(format!("wal.{n}.archive"));
273        match std::fs::remove_file(&path) {
274            Ok(()) => sync_dir(&self.dir),
275            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
276            Err(e) => Err(e),
277        }
278    }
279
280    fn read_horizon_floor(&self) -> std::io::Result<u64> {
281        let path = self.dir.join("wal.floor");
282        match std::fs::read(&path) {
283            Ok(b) if b.len() >= 8 => Ok(u64::from_le_bytes([
284                b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7],
285            ])),
286            Ok(_) => Ok(0),
287            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(0),
288            Err(e) => Err(e),
289        }
290    }
291
292    fn write_horizon_floor(&mut self, floor: u64) -> std::io::Result<()> {
293        let tmp = self.dir.join("wal.floor.tmp");
294        {
295            let mut f = File::create(&tmp)?;
296            f.write_all(&floor.to_le_bytes())?;
297            full_sync(&f)?;
298        }
299        std::fs::rename(&tmp, self.dir.join("wal.floor"))?;
300        sync_dir(&self.dir)
301    }
302
303    fn has_genesis_marker(&self) -> bool {
304        self.dir.join("wal.genesis").exists()
305    }
306
307    fn write_genesis_marker(&mut self) -> std::io::Result<()> {
308        let path = self.dir.join("wal.genesis");
309        {
310            let mut f = File::create(&path)?;
311            f.write_all(b"")?;
312            full_sync(&f)?;
313        }
314        sync_dir(&self.dir)
315    }
316
317    fn delete_genesis_marker(&mut self) -> std::io::Result<()> {
318        match std::fs::remove_file(self.dir.join("wal.genesis")) {
319            Ok(()) => sync_dir(&self.dir),
320            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
321            Err(e) => Err(e),
322        }
323    }
324}
325
326fn full_sync(file: &File) -> std::io::Result<()> {
327    #[cfg(target_os = "macos")]
328    {
329        use std::os::unix::io::AsRawFd;
330        let fd = file.as_raw_fd();
331        let rc = unsafe { libc::fcntl(fd, libc::F_FULLFSYNC) };
332        if rc == -1 {
333            return Err(std::io::Error::last_os_error());
334        }
335        Ok(())
336    }
337    #[cfg(not(target_os = "macos"))]
338    {
339        file.sync_all()
340    }
341}
342
343/// Sync the WAL file at `dir/wal.bin` to persistent storage without
344/// requiring a `&mut Fs`.  Used by the group-commit drain thread to fsync
345/// outside the exclusive write-lock window (reducing reader-visible latency).
346///
347/// On macOS, uses `F_FULLFSYNC` for true durability.  On other platforms,
348/// falls back to `fdatasync` / `fsync`.  Returns `Ok(())` if the WAL file
349/// does not exist (nothing to sync).
350pub fn sync_wal_at(dir: &std::path::Path) -> std::io::Result<()> {
351    let path = dir.join(FileId::Wal.name());
352    let f = match std::fs::File::open(&path) {
353        Ok(f) => f,
354        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
355        Err(e) => return Err(e),
356    };
357    full_sync(&f)
358}
359
360/// Truncate the WAL file at `dir/wal.bin` to exactly `len` bytes and fsync
361/// the truncation to persistent storage.
362///
363/// Used by the group-commit drain thread when a group fsync fails: truncating
364/// the WAL back to the last known-good synced offset removes the unsynced
365/// frames, ensuring a crash-then-replay cannot silently make the failed group
366/// durable via a later successful fsync flushing the whole inode.
367///
368/// Returns `Ok(())` if the file does not exist (nothing to truncate).
369pub fn truncate_wal_at(dir: &std::path::Path, len: u64) -> std::io::Result<()> {
370    let path = dir.join(FileId::Wal.name());
371    let f = match OpenOptions::new().write(true).open(&path) {
372        Ok(f) => f,
373        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
374        Err(e) => return Err(e),
375    };
376    f.set_len(len)?;
377    f.sync_all() // plain sync_all is sufficient for a truncation barrier
378}
379
380fn sync_dir(dir: &std::path::Path) -> std::io::Result<()> {
381    let d = File::open(dir)?;
382    d.sync_all()
383}
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388
389    fn tmp() -> std::path::PathBuf {
390        let d = std::env::temp_dir().join(format!("graphdb-fs-{}", std::process::id()));
391        let _ = std::fs::remove_dir_all(&d);
392        d
393    }
394
395    #[test]
396    fn append_read_and_atomic_write() {
397        let mut fs = RealFs::new(&tmp()).unwrap();
398        assert_eq!(fs.read(FileId::Wal).unwrap(), Vec::<u8>::new()); // absent = empty
399        fs.append(FileId::Wal, b"ab").unwrap();
400        fs.append(FileId::Wal, b"cd").unwrap();
401        fs.sync(FileId::Wal).unwrap();
402        assert_eq!(fs.read(FileId::Wal).unwrap(), b"abcd");
403        fs.write_atomic(FileId::Snapshot, b"snap1").unwrap();
404        fs.write_atomic(FileId::Snapshot, b"snap2").unwrap(); // replaces
405        assert_eq!(fs.read(FileId::Snapshot).unwrap(), b"snap2");
406        fs.write_atomic(FileId::Wal, b"").unwrap(); // truncation path
407        assert_eq!(fs.read(FileId::Wal).unwrap(), Vec::<u8>::new());
408    }
409
410    #[test]
411    fn write_atomic_replaces_and_still_readable() {
412        // existing append_read_and_atomic_write already covers replace;
413        // keep it; dir-sync is best-effort observable only via crash tests.
414        // Do not fake F_FULLFSYNC in SimFs.
415        let d = std::env::temp_dir().join(format!("graphdb-fs-atomic-{}", std::process::id()));
416        let _ = std::fs::remove_dir_all(&d);
417        let mut fs = RealFs::new(&d).unwrap();
418        fs.write_atomic(FileId::Snapshot, b"snap1").unwrap();
419        fs.write_atomic(FileId::Snapshot, b"snap2").unwrap();
420        assert_eq!(fs.read(FileId::Snapshot).unwrap(), b"snap2");
421    }
422}