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