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}