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    fn path(&self, file: FileId) -> PathBuf {
172        self.dir.join(file.name())
173    }
174}
175
176impl Fs for RealFs {
177    fn append(&mut self, file: FileId, data: &[u8]) -> std::io::Result<()> {
178        let mut f = OpenOptions::new()
179            .create(true)
180            .append(true)
181            .open(self.path(file))?;
182        f.write_all(data)
183    }
184
185    fn sync(&mut self, file: FileId) -> std::io::Result<()> {
186        let f = File::open(self.path(file))?;
187        full_sync(&f)
188    }
189
190    fn read(&self, file: FileId) -> std::io::Result<Vec<u8>> {
191        match File::open(self.path(file)) {
192            Ok(mut f) => {
193                let mut buf = Vec::new();
194                f.read_to_end(&mut buf)?;
195                Ok(buf)
196            }
197            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
198            Err(e) => Err(e),
199        }
200    }
201
202    fn write_atomic(&mut self, file: FileId, data: &[u8]) -> std::io::Result<()> {
203        let tmp = self.dir.join(format!("{}.tmp", file.name()));
204        {
205            let mut f = File::create(&tmp)?;
206            f.write_all(data)?;
207            full_sync(&f)?;
208        }
209        std::fs::rename(&tmp, self.path(file))?;
210        sync_dir(&self.dir)
211    }
212
213    fn snapshot_path(&self) -> Option<std::path::PathBuf> {
214        Some(self.path(FileId::Snapshot))
215    }
216
217    fn read_prefix(&self, file: FileId, n: usize) -> std::io::Result<Vec<u8>> {
218        use std::io::Read as _;
219        match File::open(self.path(file)) {
220            Ok(mut f) => {
221                let mut buf = vec![0u8; n];
222                let read = f.read(&mut buf)?;
223                buf.truncate(read);
224                Ok(buf)
225            }
226            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
227            Err(e) => Err(e),
228        }
229    }
230
231    fn list_archives(&self) -> std::io::Result<Vec<u64>> {
232        let mut ns = Vec::new();
233        for entry in std::fs::read_dir(&self.dir)? {
234            let entry = entry?;
235            let name = entry.file_name();
236            let s = name.to_string_lossy();
237            if let Some(mid) = s
238                .strip_prefix("wal.")
239                .and_then(|r| r.strip_suffix(".archive"))
240            {
241                if let Ok(n) = mid.parse::<u64>() {
242                    ns.push(n);
243                }
244            }
245        }
246        ns.sort_unstable();
247        Ok(ns)
248    }
249
250    fn read_archive(&self, n: u64) -> std::io::Result<Vec<u8>> {
251        let path = self.dir.join(format!("wal.{n}.archive"));
252        match std::fs::read(&path) {
253            Ok(b) => Ok(b),
254            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(vec![]),
255            Err(e) => Err(e),
256        }
257    }
258
259    fn archive_wal(&mut self, n: u64) -> std::io::Result<()> {
260        let wal_path = self.path(FileId::Wal);
261        let archive_path = self.dir.join(format!("wal.{n}.archive"));
262        std::fs::rename(&wal_path, &archive_path)?;
263        sync_dir(&self.dir)
264    }
265
266    fn delete_archive(&mut self, n: u64) -> std::io::Result<()> {
267        let path = self.dir.join(format!("wal.{n}.archive"));
268        match std::fs::remove_file(&path) {
269            Ok(()) => sync_dir(&self.dir),
270            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
271            Err(e) => Err(e),
272        }
273    }
274
275    fn read_horizon_floor(&self) -> std::io::Result<u64> {
276        let path = self.dir.join("wal.floor");
277        match std::fs::read(&path) {
278            Ok(b) if b.len() >= 8 => Ok(u64::from_le_bytes([
279                b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7],
280            ])),
281            Ok(_) => Ok(0),
282            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(0),
283            Err(e) => Err(e),
284        }
285    }
286
287    fn write_horizon_floor(&mut self, floor: u64) -> std::io::Result<()> {
288        let tmp = self.dir.join("wal.floor.tmp");
289        {
290            let mut f = File::create(&tmp)?;
291            f.write_all(&floor.to_le_bytes())?;
292            full_sync(&f)?;
293        }
294        std::fs::rename(&tmp, self.dir.join("wal.floor"))?;
295        sync_dir(&self.dir)
296    }
297
298    fn has_genesis_marker(&self) -> bool {
299        self.dir.join("wal.genesis").exists()
300    }
301
302    fn write_genesis_marker(&mut self) -> std::io::Result<()> {
303        let path = self.dir.join("wal.genesis");
304        {
305            let mut f = File::create(&path)?;
306            f.write_all(b"")?;
307            full_sync(&f)?;
308        }
309        sync_dir(&self.dir)
310    }
311
312    fn delete_genesis_marker(&mut self) -> std::io::Result<()> {
313        match std::fs::remove_file(self.dir.join("wal.genesis")) {
314            Ok(()) => sync_dir(&self.dir),
315            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
316            Err(e) => Err(e),
317        }
318    }
319}
320
321fn full_sync(file: &File) -> std::io::Result<()> {
322    #[cfg(target_os = "macos")]
323    {
324        use std::os::unix::io::AsRawFd;
325        let fd = file.as_raw_fd();
326        let rc = unsafe { libc::fcntl(fd, libc::F_FULLFSYNC) };
327        if rc == -1 {
328            return Err(std::io::Error::last_os_error());
329        }
330        Ok(())
331    }
332    #[cfg(not(target_os = "macos"))]
333    {
334        file.sync_all()
335    }
336}
337
338/// Sync the WAL file at `dir/wal.bin` to persistent storage without
339/// requiring a `&mut Fs`.  Used by the group-commit drain thread to fsync
340/// outside the exclusive write-lock window (reducing reader-visible latency).
341///
342/// On macOS, uses `F_FULLFSYNC` for true durability.  On other platforms,
343/// falls back to `fdatasync` / `fsync`.  Returns `Ok(())` if the WAL file
344/// does not exist (nothing to sync).
345pub fn sync_wal_at(dir: &std::path::Path) -> std::io::Result<()> {
346    let path = dir.join(FileId::Wal.name());
347    let f = match std::fs::File::open(&path) {
348        Ok(f) => f,
349        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
350        Err(e) => return Err(e),
351    };
352    full_sync(&f)
353}
354
355/// Truncate the WAL file at `dir/wal.bin` to exactly `len` bytes and fsync
356/// the truncation to persistent storage.
357///
358/// Used by the group-commit drain thread when a group fsync fails: truncating
359/// the WAL back to the last known-good synced offset removes the unsynced
360/// frames, ensuring a crash-then-replay cannot silently make the failed group
361/// durable via a later successful fsync flushing the whole inode.
362///
363/// Returns `Ok(())` if the file does not exist (nothing to truncate).
364pub fn truncate_wal_at(dir: &std::path::Path, len: u64) -> std::io::Result<()> {
365    let path = dir.join(FileId::Wal.name());
366    let f = match OpenOptions::new().write(true).open(&path) {
367        Ok(f) => f,
368        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
369        Err(e) => return Err(e),
370    };
371    f.set_len(len)?;
372    f.sync_all() // plain sync_all is sufficient for a truncation barrier
373}
374
375fn sync_dir(dir: &std::path::Path) -> std::io::Result<()> {
376    let d = File::open(dir)?;
377    d.sync_all()
378}
379
380#[cfg(test)]
381mod tests {
382    use super::*;
383
384    fn tmp() -> std::path::PathBuf {
385        let d = std::env::temp_dir().join(format!("graphdb-fs-{}", std::process::id()));
386        let _ = std::fs::remove_dir_all(&d);
387        d
388    }
389
390    #[test]
391    fn append_read_and_atomic_write() {
392        let mut fs = RealFs::new(&tmp()).unwrap();
393        assert_eq!(fs.read(FileId::Wal).unwrap(), Vec::<u8>::new()); // absent = empty
394        fs.append(FileId::Wal, b"ab").unwrap();
395        fs.append(FileId::Wal, b"cd").unwrap();
396        fs.sync(FileId::Wal).unwrap();
397        assert_eq!(fs.read(FileId::Wal).unwrap(), b"abcd");
398        fs.write_atomic(FileId::Snapshot, b"snap1").unwrap();
399        fs.write_atomic(FileId::Snapshot, b"snap2").unwrap(); // replaces
400        assert_eq!(fs.read(FileId::Snapshot).unwrap(), b"snap2");
401        fs.write_atomic(FileId::Wal, b"").unwrap(); // truncation path
402        assert_eq!(fs.read(FileId::Wal).unwrap(), Vec::<u8>::new());
403    }
404
405    #[test]
406    fn write_atomic_replaces_and_still_readable() {
407        // existing append_read_and_atomic_write already covers replace;
408        // keep it; dir-sync is best-effort observable only via crash tests.
409        // Do not fake F_FULLFSYNC in SimFs.
410        let d = std::env::temp_dir().join(format!("graphdb-fs-atomic-{}", std::process::id()));
411        let _ = std::fs::remove_dir_all(&d);
412        let mut fs = RealFs::new(&d).unwrap();
413        fs.write_atomic(FileId::Snapshot, b"snap1").unwrap();
414        fs.write_atomic(FileId::Snapshot, b"snap2").unwrap();
415        assert_eq!(fs.read(FileId::Snapshot).unwrap(), b"snap2");
416    }
417}