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
43    /// Return the on-disk path of the WAL file, if any.
44    ///
45    /// `Some` for `RealFs`. `None` for in-memory implementations.
46    /// Used by [`GraphDb::wal_size_bytes`] to read WAL file metadata.
47    fn wal_path(&self) -> Option<std::path::PathBuf> {
48        None
49    }
50    /// Read at most `n` bytes from the beginning of `file` without loading
51    /// the full contents.
52    ///
53    /// Used by the open path to sniff the 6-byte magic+version header before
54    /// deciding whether to mmap (V8) or full-read (legacy V5-V7).
55    ///
56    /// The default implementation calls `read()` and truncates; override in
57    /// `RealFs` for a true partial read.
58    fn read_prefix(&self, file: FileId, n: usize) -> std::io::Result<Vec<u8>> {
59        let mut bytes = self.read(file)?;
60        bytes.truncate(n);
61        Ok(bytes)
62    }
63
64    // ── Cross-process write lock ──────────────────────────────────────────────
65
66    /// Try to take the store's advisory exclusive write lock without blocking.
67    ///
68    /// Returns `Ok(true)` when the lock is now held by this handle, `Ok(false)`
69    /// when another handle holds it.  Taking a lock this handle already holds
70    /// is a successful no-op, so callers may re-acquire freely.
71    ///
72    /// The lock is advisory: it coordinates cooperating mushroomdb processes
73    /// and does not stop an unrelated program from writing the files.
74    ///
75    /// Takes `&self`, not `&mut self`, on purpose: a writer must be able to
76    /// poll for the lock without holding the in-process write guard, or a busy
77    /// peer in another process would stall every reader in this one.
78    ///
79    /// Default: `Ok(true)` — an in-memory store has no other process to
80    /// coordinate with.
81    fn try_lock_exclusive(&self) -> std::io::Result<bool> {
82        Ok(true)
83    }
84
85    /// Release the advisory write lock.  No-op when it is not held.
86    ///
87    /// Default: no-op.
88    fn unlock(&self) -> std::io::Result<()> {
89        Ok(())
90    }
91
92    // ── WAL tailing (refresh) ─────────────────────────────────────────────────
93
94    /// Current length of the WAL in bytes.
95    ///
96    /// Must not read file contents: this is the hot half of a staleness check
97    /// and runs on the read path.  The default implementation reads the WAL
98    /// because a generic `Fs` has no cheaper option; `RealFs` overrides it with
99    /// a metadata-only stat.
100    fn wal_len(&self) -> std::io::Result<u64> {
101        Ok(self.read(FileId::Wal)?.len() as u64)
102    }
103
104    /// Read `file` from byte offset `from` to the end.
105    ///
106    /// Returns an empty `Vec` when `from` is at or past the end of the file.
107    /// Used to decode the WAL tail another process appended since this handle
108    /// last consumed it.
109    ///
110    /// The default implementation reads the whole file and slices; `RealFs`
111    /// overrides it with a seek + read of the tail only.
112    fn read_range(&self, file: FileId, from: u64) -> std::io::Result<Vec<u8>> {
113        let bytes = self.read(file)?;
114        let from = from.min(bytes.len() as u64) as usize;
115        Ok(bytes[from..].to_vec())
116    }
117
118    /// Identity of the current snapshot file as `(len, mtime_nanos)`, or `None`
119    /// when no snapshot exists.
120    ///
121    /// A change in this pair means some process replaced the snapshot, so the
122    /// WAL this handle was tailing no longer continues its in-memory state and
123    /// a full reload is required.  Like [`wal_len`](Fs::wal_len) this must not
124    /// read file contents.
125    ///
126    /// Default: length-only identity derived from the snapshot bytes (in-memory
127    /// stores have no mtime); `RealFs` overrides it with a metadata-only stat.
128    fn snapshot_ident(&self) -> std::io::Result<Option<(u64, u64)>> {
129        let len = self.read(FileId::Snapshot)?.len() as u64;
130        Ok(if len == 0 { None } else { Some((len, 0)) })
131    }
132
133    // ── WAL archive methods (Task 4: history-preserving snapshots) ─────────────
134
135    /// List all WAL archive identifiers, sorted ascending (oldest first).
136    ///
137    /// Each archive created by [`archive_wal`] with commit-seq `N` appears as `N`.
138    /// Returns an empty list when no archives exist.
139    ///
140    /// Default: no archive support — returns empty.
141    fn list_archives(&self) -> std::io::Result<Vec<u64>> {
142        Ok(vec![])
143    }
144
145    /// Read the byte contents of WAL archive `n`.
146    ///
147    /// Returns an empty `Vec` when the archive does not exist.
148    ///
149    /// Default: no archive support — returns empty.
150    fn read_archive(&self, _n: u64) -> std::io::Result<Vec<u8>> {
151        Ok(vec![])
152    }
153
154    /// Atomically rename the current WAL to `wal.<n>.archive` (same directory,
155    /// same filesystem — the rename is guaranteed atomic at the OS level).
156    ///
157    /// The caller ensures the snapshot has been durably written before calling
158    /// this method.  After a successful rename the old WAL no longer exists as
159    /// `wal.bin`; a subsequent [`write_atomic`] on `FileId::Wal` creates a new
160    /// empty WAL.
161    ///
162    /// Returns `Err` if the operation is not supported or fails.
163    fn archive_wal(&mut self, _n: u64) -> std::io::Result<()> {
164        Err(std::io::Error::other(
165            "archive_wal not supported by this Fs implementation",
166        ))
167    }
168
169    /// Delete archive `n`.  No-op if it does not exist.
170    ///
171    /// Retention pruning (inside `snapshot_with`) is the only call site.
172    ///
173    /// Default: no-op.
174    fn delete_archive(&mut self, _n: u64) -> std::io::Result<()> {
175        Ok(())
176    }
177
178    /// Return the persisted horizon floor — the global frame index of the
179    /// first commit that is still reachable through surviving archives.
180    ///
181    /// Defaults to `0` (all history reachable / no pruning ever performed).
182    fn read_horizon_floor(&self) -> std::io::Result<u64> {
183        Ok(0)
184    }
185
186    /// Atomically persist `floor` so that a subsequent [`read_horizon_floor`]
187    /// after reopen returns the same value.
188    ///
189    /// Default: no-op (in-memory only; override in durable implementations).
190    fn write_horizon_floor(&mut self, _floor: u64) -> std::io::Result<()> {
191        Ok(())
192    }
193
194    /// Return `true` when the `wal.genesis` marker file is present.
195    ///
196    /// The marker signals that the surviving archive chain forms a complete,
197    /// uninterrupted WAL history starting from the store's first-ever commit
198    /// (the genesis chain).  When absent, archive-resident commits are not
199    /// safe to replay from empty state and `open_at` must refuse them.
200    ///
201    /// Default: `false` (no genesis chain / no archive support).
202    fn has_genesis_marker(&self) -> bool {
203        false
204    }
205
206    /// Durably create the `wal.genesis` marker file.
207    ///
208    /// Written exactly once, when the first WAL archive is taken from a store
209    /// that has never undergone a WAL-truncating snapshot.
210    ///
211    /// Default: no-op.
212    fn write_genesis_marker(&mut self) -> std::io::Result<()> {
213        Ok(())
214    }
215
216    /// Remove the `wal.genesis` marker file.
217    ///
218    /// Called when the genesis chain is broken: either by archive pruning
219    /// (floor advances past 0) or by a WAL-truncating snapshot taken after
220    /// archives already exist.  No-op if the marker is absent.
221    ///
222    /// Default: no-op.
223    fn delete_genesis_marker(&mut self) -> std::io::Result<()> {
224        Ok(())
225    }
226}
227
228pub trait FsIntrospect {
229    fn total_appended(&self) -> usize;
230    fn sync_count(&self) -> usize {
231        0
232    }
233}
234
235/// Name of the advisory cross-process write lock file.
236///
237/// Always empty: the file exists only to carry the OS lock. It is created on
238/// the first lock attempt and never removed, so the same inode backs the lock
239/// for every process that opens the store.
240pub const LOCK_FILE: &str = "LOCK";
241
242/// This handle's one open description of the store's `LOCK` file, plus whether
243/// it currently owns the lock.
244///
245/// Exactly one per `RealFs` on purpose: the OS lock is held per open file
246/// description, so two descriptions of `LOCK` inside one process would contend
247/// with each other. Opened lazily — a `RealFs` created for a one-shot file
248/// operation never touches the lock file at all.
249#[derive(Debug, Default)]
250struct LockState {
251    file: Option<File>,
252    held: bool,
253}
254
255#[derive(Debug)]
256pub struct RealFs {
257    dir: PathBuf,
258    /// Behind a `Mutex` so the lock can be taken and released through `&self`.
259    /// A writer polls for the cross-process lock *before* it takes the
260    /// in-process write guard, so that a peer holding the lock cannot stall
261    /// this process's readers.
262    lock: std::sync::Mutex<LockState>,
263}
264
265impl RealFs {
266    pub fn new(dir: &std::path::Path) -> std::io::Result<Self> {
267        std::fs::create_dir_all(dir)?;
268        Ok(Self {
269            dir: dir.to_path_buf(),
270            lock: std::sync::Mutex::new(LockState::default()),
271        })
272    }
273
274    /// The database directory this filesystem is rooted at.
275    pub fn dir(&self) -> &std::path::Path {
276        &self.dir
277    }
278
279    fn path(&self, file: FileId) -> PathBuf {
280        self.dir.join(file.name())
281    }
282}
283
284impl Fs for RealFs {
285    fn append(&mut self, file: FileId, data: &[u8]) -> std::io::Result<()> {
286        let mut f = OpenOptions::new()
287            .create(true)
288            .append(true)
289            .open(self.path(file))?;
290        f.write_all(data)
291    }
292
293    fn sync(&mut self, file: FileId) -> std::io::Result<()> {
294        let f = File::open(self.path(file))?;
295        full_sync(&f)
296    }
297
298    fn read(&self, file: FileId) -> std::io::Result<Vec<u8>> {
299        match File::open(self.path(file)) {
300            Ok(mut f) => {
301                let mut buf = Vec::new();
302                f.read_to_end(&mut buf)?;
303                Ok(buf)
304            }
305            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
306            Err(e) => Err(e),
307        }
308    }
309
310    fn write_atomic(&mut self, file: FileId, data: &[u8]) -> std::io::Result<()> {
311        let tmp = self.dir.join(format!("{}.tmp", file.name()));
312        {
313            let mut f = File::create(&tmp)?;
314            f.write_all(data)?;
315            full_sync(&f)?;
316        }
317        std::fs::rename(&tmp, self.path(file))?;
318        sync_dir(&self.dir)
319    }
320
321    fn snapshot_path(&self) -> Option<std::path::PathBuf> {
322        Some(self.path(FileId::Snapshot))
323    }
324
325    fn wal_path(&self) -> Option<std::path::PathBuf> {
326        Some(self.path(FileId::Wal))
327    }
328
329    fn read_prefix(&self, file: FileId, n: usize) -> std::io::Result<Vec<u8>> {
330        use std::io::Read as _;
331        match File::open(self.path(file)) {
332            Ok(mut f) => {
333                let mut buf = vec![0u8; n];
334                let read = f.read(&mut buf)?;
335                buf.truncate(read);
336                Ok(buf)
337            }
338            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
339            Err(e) => Err(e),
340        }
341    }
342
343    fn try_lock_exclusive(&self) -> std::io::Result<bool> {
344        let mut state = self.lock.lock().unwrap_or_else(|e| e.into_inner());
345        if state.held {
346            return Ok(true);
347        }
348        if state.file.is_none() {
349            state.file = Some(
350                OpenOptions::new()
351                    .create(true)
352                    .read(true)
353                    .write(true)
354                    .truncate(false)
355                    .open(self.dir.join(LOCK_FILE))?,
356            );
357        }
358        let f = state.file.as_ref().expect("lock file just opened");
359        match f.try_lock() {
360            Ok(()) => {
361                state.held = true;
362                Ok(true)
363            }
364            Err(std::fs::TryLockError::WouldBlock) => Ok(false),
365            Err(std::fs::TryLockError::Error(e)) => Err(e),
366        }
367    }
368
369    fn unlock(&self) -> std::io::Result<()> {
370        let mut state = self.lock.lock().unwrap_or_else(|e| e.into_inner());
371        if !state.held {
372            return Ok(());
373        }
374        // Clear the flag first: a failed unlock must not leave the handle
375        // believing it still owns a lock it may have lost.
376        state.held = false;
377        match state.file.as_ref() {
378            Some(f) => f.unlock(),
379            None => Ok(()),
380        }
381    }
382
383    fn wal_len(&self) -> std::io::Result<u64> {
384        match std::fs::metadata(self.path(FileId::Wal)) {
385            Ok(m) => Ok(m.len()),
386            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(0),
387            Err(e) => Err(e),
388        }
389    }
390
391    fn read_range(&self, file: FileId, from: u64) -> std::io::Result<Vec<u8>> {
392        use std::io::{Read as _, Seek as _, SeekFrom};
393        let mut f = match File::open(self.path(file)) {
394            Ok(f) => f,
395            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
396            Err(e) => return Err(e),
397        };
398        let len = f.metadata()?.len();
399        if from >= len {
400            return Ok(Vec::new());
401        }
402        f.seek(SeekFrom::Start(from))?;
403        let mut buf = Vec::with_capacity((len - from) as usize);
404        f.read_to_end(&mut buf)?;
405        Ok(buf)
406    }
407
408    fn snapshot_ident(&self) -> std::io::Result<Option<(u64, u64)>> {
409        let m = match std::fs::metadata(self.path(FileId::Snapshot)) {
410            Ok(m) => m,
411            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
412            Err(e) => return Err(e),
413        };
414        // mtime is a change hint, not a clock: an unreadable or pre-epoch
415        // timestamp degrades to 0, leaving length alone to detect the change.
416        let mtime_nanos = m
417            .modified()
418            .ok()
419            .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
420            .map(|d| d.as_nanos() as u64)
421            .unwrap_or(0);
422        Ok(Some((m.len(), mtime_nanos)))
423    }
424
425    fn list_archives(&self) -> std::io::Result<Vec<u64>> {
426        let mut ns = Vec::new();
427        for entry in std::fs::read_dir(&self.dir)? {
428            let entry = entry?;
429            let name = entry.file_name();
430            let s = name.to_string_lossy();
431            if let Some(mid) = s
432                .strip_prefix("wal.")
433                .and_then(|r| r.strip_suffix(".archive"))
434            {
435                if let Ok(n) = mid.parse::<u64>() {
436                    ns.push(n);
437                }
438            }
439        }
440        ns.sort_unstable();
441        Ok(ns)
442    }
443
444    fn read_archive(&self, n: u64) -> std::io::Result<Vec<u8>> {
445        let path = self.dir.join(format!("wal.{n}.archive"));
446        match std::fs::read(&path) {
447            Ok(b) => Ok(b),
448            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(vec![]),
449            Err(e) => Err(e),
450        }
451    }
452
453    fn archive_wal(&mut self, n: u64) -> std::io::Result<()> {
454        let wal_path = self.path(FileId::Wal);
455        let archive_path = self.dir.join(format!("wal.{n}.archive"));
456        std::fs::rename(&wal_path, &archive_path)?;
457        sync_dir(&self.dir)
458    }
459
460    fn delete_archive(&mut self, n: u64) -> std::io::Result<()> {
461        let path = self.dir.join(format!("wal.{n}.archive"));
462        match std::fs::remove_file(&path) {
463            Ok(()) => sync_dir(&self.dir),
464            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
465            Err(e) => Err(e),
466        }
467    }
468
469    fn read_horizon_floor(&self) -> std::io::Result<u64> {
470        let path = self.dir.join("wal.floor");
471        match std::fs::read(&path) {
472            Ok(b) if b.len() >= 8 => Ok(u64::from_le_bytes([
473                b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7],
474            ])),
475            Ok(_) => Ok(0),
476            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(0),
477            Err(e) => Err(e),
478        }
479    }
480
481    fn write_horizon_floor(&mut self, floor: u64) -> std::io::Result<()> {
482        let tmp = self.dir.join("wal.floor.tmp");
483        {
484            let mut f = File::create(&tmp)?;
485            f.write_all(&floor.to_le_bytes())?;
486            full_sync(&f)?;
487        }
488        std::fs::rename(&tmp, self.dir.join("wal.floor"))?;
489        sync_dir(&self.dir)
490    }
491
492    fn has_genesis_marker(&self) -> bool {
493        self.dir.join("wal.genesis").exists()
494    }
495
496    fn write_genesis_marker(&mut self) -> std::io::Result<()> {
497        let path = self.dir.join("wal.genesis");
498        {
499            let mut f = File::create(&path)?;
500            f.write_all(b"")?;
501            full_sync(&f)?;
502        }
503        sync_dir(&self.dir)
504    }
505
506    fn delete_genesis_marker(&mut self) -> std::io::Result<()> {
507        match std::fs::remove_file(self.dir.join("wal.genesis")) {
508            Ok(()) => sync_dir(&self.dir),
509            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
510            Err(e) => Err(e),
511        }
512    }
513}
514
515fn full_sync(file: &File) -> std::io::Result<()> {
516    #[cfg(target_os = "macos")]
517    {
518        use std::os::unix::io::AsRawFd;
519        let fd = file.as_raw_fd();
520        let rc = unsafe { libc::fcntl(fd, libc::F_FULLFSYNC) };
521        if rc == -1 {
522            return Err(std::io::Error::last_os_error());
523        }
524        Ok(())
525    }
526    #[cfg(not(target_os = "macos"))]
527    {
528        file.sync_all()
529    }
530}
531
532/// Sync the WAL file at `dir/wal.bin` to persistent storage without
533/// requiring a `&mut Fs`.  Used by the group-commit drain thread to fsync
534/// outside the exclusive write-lock window (reducing reader-visible latency).
535///
536/// On macOS, uses `F_FULLFSYNC` for true durability.  On other platforms,
537/// falls back to `fdatasync` / `fsync`.  Returns `Ok(())` if the WAL file
538/// does not exist (nothing to sync).
539pub fn sync_wal_at(dir: &std::path::Path) -> std::io::Result<()> {
540    let path = dir.join(FileId::Wal.name());
541    let f = match std::fs::File::open(&path) {
542        Ok(f) => f,
543        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
544        Err(e) => return Err(e),
545    };
546    full_sync(&f)
547}
548
549/// Truncate the WAL file at `dir/wal.bin` to exactly `len` bytes and fsync
550/// the truncation to persistent storage.
551///
552/// Used by the group-commit drain thread when a group fsync fails: truncating
553/// the WAL back to the last known-good synced offset removes the unsynced
554/// frames, ensuring a crash-then-replay cannot silently make the failed group
555/// durable via a later successful fsync flushing the whole inode.
556///
557/// Returns `Ok(())` if the file does not exist (nothing to truncate).
558pub fn truncate_wal_at(dir: &std::path::Path, len: u64) -> std::io::Result<()> {
559    let path = dir.join(FileId::Wal.name());
560    let f = match OpenOptions::new().write(true).open(&path) {
561        Ok(f) => f,
562        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
563        Err(e) => return Err(e),
564    };
565    f.set_len(len)?;
566    f.sync_all() // plain sync_all is sufficient for a truncation barrier
567}
568
569fn sync_dir(dir: &std::path::Path) -> std::io::Result<()> {
570    let d = File::open(dir)?;
571    d.sync_all()
572}
573
574#[cfg(test)]
575mod tests {
576    use super::*;
577
578    fn tmp() -> std::path::PathBuf {
579        let d = std::env::temp_dir().join(format!("graphdb-fs-{}", std::process::id()));
580        let _ = std::fs::remove_dir_all(&d);
581        d
582    }
583
584    #[test]
585    fn append_read_and_atomic_write() {
586        let mut fs = RealFs::new(&tmp()).unwrap();
587        assert_eq!(fs.read(FileId::Wal).unwrap(), Vec::<u8>::new()); // absent = empty
588        fs.append(FileId::Wal, b"ab").unwrap();
589        fs.append(FileId::Wal, b"cd").unwrap();
590        fs.sync(FileId::Wal).unwrap();
591        assert_eq!(fs.read(FileId::Wal).unwrap(), b"abcd");
592        fs.write_atomic(FileId::Snapshot, b"snap1").unwrap();
593        fs.write_atomic(FileId::Snapshot, b"snap2").unwrap(); // replaces
594        assert_eq!(fs.read(FileId::Snapshot).unwrap(), b"snap2");
595        fs.write_atomic(FileId::Wal, b"").unwrap(); // truncation path
596        assert_eq!(fs.read(FileId::Wal).unwrap(), Vec::<u8>::new());
597    }
598
599    #[test]
600    fn write_atomic_replaces_and_still_readable() {
601        // existing append_read_and_atomic_write already covers replace;
602        // keep it; dir-sync is best-effort observable only via crash tests.
603        // Do not fake F_FULLFSYNC in SimFs.
604        let d = std::env::temp_dir().join(format!("graphdb-fs-atomic-{}", std::process::id()));
605        let _ = std::fs::remove_dir_all(&d);
606        let mut fs = RealFs::new(&d).unwrap();
607        fs.write_atomic(FileId::Snapshot, b"snap1").unwrap();
608        fs.write_atomic(FileId::Snapshot, b"snap2").unwrap();
609        assert_eq!(fs.read(FileId::Snapshot).unwrap(), b"snap2");
610    }
611}