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