Skip to main content

plugmem_host/
storage.rs

1//! `FileStorage`: the engine's `Storage` trait over a **versioned** on-disk
2//! layout — immutable snapshot generations named by a tiny manifest (
3//! ). This is what lets a reader map a stable snapshot while a writer
4//! keeps working: the writer never overwrites a live file, it publishes a new
5//! generation and repoints the manifest.
6//!
7//! Layout for `base = "agent.plugmem"`:
8//!
9//! | file | role |
10//! |---|---|
11//! | `agent.plugmem` | the **manifest** — a tiny record naming the current snapshot generation |
12//! | `agent.plugmem.snap.<N>` | **generation N** — an immutable full snapshot image; never rewritten |
13//! | `agent.plugmem.journal` | the append-only journal since the current generation |
14//! | `agent.plugmem.lock` | the advisory-lock file (writer-vs-writer) |
15//! | `agent.plugmem.snap.<N>.tmp`, `agent.plugmem.manifest.tmp` | staging for the atomic writes |
16//!
17//! A checkpoint streams the fresh image to `…snap.<N+1>.tmp`, fsyncs it,
18//! renames it to `…snap.<N+1>` (an immutable file, never overwritten), then
19//! atomically repoints the manifest (tmp + fsync + rename + directory fsync).
20//! The old generation is reclaimed once nothing maps it. A reader always
21//! observes a manifest pointing at a generation that already exists on disk.
22//! The lock is held from `open` until drop; the OS releases it even on
23//! abnormal termination.
24
25use std::fs::{File, OpenOptions};
26use std::io::{BufWriter, Seek, SeekFrom, Write as _};
27use std::path::{Path, PathBuf};
28
29use memmap2::Mmap;
30use plugmem_core::snapshot::SnapshotSink;
31use plugmem_core::{Error, Scratch, Storage};
32
33use crate::error::HostError;
34
35/// Maps a filesystem error into the engine's storage-error variant so it can
36/// cross the [`SnapshotSink`] boundary (which speaks [`plugmem_core::Error`]).
37fn sink_io(e: std::io::Error) -> Error {
38    Error::Storage(format!("{e}"))
39}
40
41/// When journal appends reach the disk.
42#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
43#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
44pub enum FsyncPolicy {
45    /// Fsync after every appended journal record — every acknowledged
46    /// mutation survives a power cut. The default: durability is worth
47    /// microseconds at this write volume.
48    #[default]
49    EachOp,
50    /// Fsync only at snapshot boundaries. Faster; an OS crash may lose
51    /// the journal tail written since the last snapshot.
52    OnSnapshot,
53}
54
55/// File-backed [`Storage`] holding an advisory lock on its database.
56#[derive(Debug)]
57pub struct FileStorage {
58    /// The manifest path (what the caller points at).
59    base: PathBuf,
60    journal_path: PathBuf,
61    /// Staging path for the atomic manifest publish.
62    manifest_tmp: PathBuf,
63    /// The generation the manifest currently names; `0` = no snapshot yet.
64    current_gen: u64,
65    /// Keeps the advisory lock alive; the handle itself is never read.
66    _lock: File,
67    /// The journal in append mode, kept open across appends.
68    journal: File,
69    fsync: FsyncPolicy,
70    /// While `true`, `append_journal` skips its per-record fsync so a bulk write
71    /// amortizes durability into one [`sync_journal`](Self::sync_journal) at the
72    /// end (see [`set_batch`](Self::set_batch)). Only meaningful under `EachOp`.
73    batch: bool,
74}
75
76impl FileStorage {
77    /// Opens (creating as needed) the database at `base` and takes the
78    /// **exclusive writer lock** — one writer at a time. Readers do *not* take
79    /// this lock (they pin a generation file via
80    /// `pin_current_generation` instead), so a writer and any number of
81    /// readers coexist across threads or processes (the versioned
82    /// MVCC layout).
83    ///
84    /// # Errors
85    ///
86    /// [`HostError::Locked`] when another **writer** owns the lock;
87    /// [`HostError::Io`] for filesystem failures.
88    pub fn open(base: impl Into<PathBuf>, fsync: FsyncPolicy) -> Result<Self, HostError> {
89        let base = base.into();
90        let lock_path = suffixed(&base, "lock");
91        let journal_path = suffixed(&base, "journal");
92        let manifest_tmp = suffixed(&base, "manifest.tmp");
93
94        let lock = OpenOptions::new()
95            .create(true)
96            .write(true)
97            .truncate(false)
98            .open(&lock_path)
99            .map_err(|e| HostError::io(&lock_path, e))?;
100        match lock.try_lock() {
101            Ok(()) => {}
102            Err(std::fs::TryLockError::WouldBlock) => {
103                return Err(HostError::Locked { path: base });
104            }
105            Err(std::fs::TryLockError::Error(e)) => {
106                return Err(HostError::io(&lock_path, e));
107            }
108        }
109
110        let current_gen = read_manifest(&base)?.unwrap_or(0);
111
112        // A crash during explicit reembed may leave its private frozen source.
113        // It is never published and the exclusive writer lock proves nobody
114        // can still be using it.
115        let _ = std::fs::remove_file(suffixed(&base, "reembed-source.tmp"));
116
117        // Crash recovery + GC: drop unpublished orphan generations and staging
118        // tmps, and reclaim any unpinned superseded generation. Safe with live
119        // readers — reclaim is pin-aware (a reader's shared lock keeps its
120        // generation). Only the writer sweeps.
121        sweep_generations(&base, current_gen)?;
122
123        let journal = OpenOptions::new()
124            .create(true)
125            .append(true)
126            .open(&journal_path)
127            .map_err(|e| HostError::io(&journal_path, e))?;
128
129        Ok(Self {
130            base,
131            journal_path,
132            manifest_tmp,
133            current_gen,
134            _lock: lock,
135            journal,
136            fsync,
137            batch: false,
138        })
139    }
140
141    /// Enters (`true`) or leaves (`false`) **batch mode**. While on,
142    /// [`append_journal`](plugmem_core::Storage::append_journal) writes each
143    /// record **without** its per-record fsync, so a bulk write (`remember_many`)
144    /// amortizes durability into a single [`sync_journal`](Self::sync_journal) at
145    /// the end. Only affects the `EachOp` policy — `OnSnapshot` never fsyncs per
146    /// record anyway. The caller must pair `set_batch(true)` with a final
147    /// `set_batch(false)` + `sync_journal`, even on error, so a later single
148    /// write is durable again.
149    pub(crate) fn set_batch(&mut self, on: bool) {
150        self.batch = on;
151    }
152
153    /// Fsyncs the journal now — the durability point for records appended in
154    /// batch mode. Idempotent (a plain `sync_data`), so calling it after a
155    /// partially-written batch makes exactly the records that reached the file
156    /// durable.
157    pub(crate) fn sync_journal(&mut self) -> Result<(), HostError> {
158        self.journal
159            .sync_data()
160            .map_err(|e| HostError::io(&self.journal_path, e))
161    }
162
163    /// The database base path.
164    pub fn path(&self) -> &Path {
165        &self.base
166    }
167
168    /// Current journal size in bytes (drives the snapshot policy).
169    pub fn journal_bytes(&self) -> u64 {
170        self.journal.metadata().map(|m| m.len()).unwrap_or(0)
171    }
172
173    /// The path of the snapshot file the manifest currently names, or `None`
174    /// for a fresh database with no published snapshot. Callers that map the
175    /// snapshot (`open_engine`, `ReadOnlyDatabase`, `Scrub`, `recover`) resolve
176    /// through this instead of mapping `base` (which is now the manifest).
177    pub(crate) fn current_snapshot_path(&self) -> Result<Option<PathBuf>, HostError> {
178        Ok(read_manifest(&self.base)?.map(|g| gen_path(&self.base, g)))
179    }
180
181    /// The next generation number this storage will publish.
182    fn next_gen(&self) -> u64 {
183        self.current_gen + 1
184    }
185
186    /// Streams a snapshot into the next generation's tmp file and fsyncs it,
187    /// **without** publishing — the durable-but-not-yet-visible half of a
188    /// checkpoint. `write` drives the engine's streaming snapshot
189    /// writer against a buffered file sink, so the image never all lives in RAM
190    /// at once. Split from [`FileStorage::commit_snapshot`] because the caller
191    /// must drop the mmap of the *old* generation **between** the two: staging
192    /// reads through that map, and the reclaim in `commit` deletes it. A staged
193    /// tmp is cleaned up on the next exclusive open.
194    pub(crate) fn stage_snapshot(
195        &mut self,
196        write: impl FnOnce(&mut FileSink) -> Result<(), HostError>,
197    ) -> Result<(), HostError> {
198        let tmp = gen_tmp_path(&self.base, self.next_gen());
199        let file = File::create(&tmp).map_err(|e| HostError::io(&tmp, e))?;
200        let mut sink = FileSink::new(file, tmp.clone());
201        write(&mut sink)?;
202        let file = sink.finish()?;
203        file.sync_all().map_err(|e| HostError::io(&tmp, e))?;
204        Ok(())
205    }
206
207    /// Opens a next-generation sink that may be filled without borrowing this
208    /// storage. Explicit reembed uses it while the database state lock is free:
209    /// model/network latency must not hold an engine lock.
210    pub(crate) fn begin_detached_snapshot(&self) -> Result<DetachedSnapshot, HostError> {
211        let generation = self.next_gen();
212        let tmp = gen_tmp_path(&self.base, generation);
213        let file = File::create(&tmp).map_err(|e| HostError::io(&tmp, e))?;
214        Ok(DetachedSnapshot {
215            generation,
216            sink: FileSink::new(file, tmp),
217        })
218    }
219
220    /// Writes an unpublished private source image for explicit reembed. It is
221    /// not a generation and can never be named by the manifest; dropping the
222    /// returned guard removes it.
223    pub(crate) fn stage_reembed_source(
224        &self,
225        write: impl FnOnce(&mut FileSink) -> Result<(), HostError>,
226    ) -> Result<PreparedSource, HostError> {
227        let path = suffixed(&self.base, "reembed-source.tmp");
228        let file = File::create(&path).map_err(|e| HostError::io(&path, e))?;
229        let mut sink = FileSink::new(file, path.clone());
230        write(&mut sink)?;
231        let file = sink.finish()?;
232        file.sync_all().map_err(|e| HostError::io(&path, e))?;
233        Ok(PreparedSource { path: Some(path) })
234    }
235
236    /// Publishes a detached snapshot prepared for this storage's still-current
237    /// next generation. A mismatch means another publisher bypassed the
238    /// reembed barrier and is refused rather than overwriting its generation.
239    pub(crate) fn commit_detached_snapshot(
240        &mut self,
241        mut prepared: PreparedSnapshot,
242    ) -> Result<(), HostError> {
243        if prepared.generation != self.next_gen() {
244            return Err(HostError::Engine(Error::Invalid(
245                "staged snapshot generation became stale",
246            )));
247        }
248        let tmp = prepared.tmp.take().ok_or(HostError::Engine(Error::Invalid(
249            "prepared snapshot has already been published",
250        )))?;
251        let genp = gen_path(&self.base, prepared.generation);
252        std::fs::rename(&tmp, &genp).map_err(|e| HostError::io(&genp, e))?;
253        sync_dir(&self.base)?;
254        publish_manifest(&self.base, &self.manifest_tmp, prepared.generation)?;
255        self.current_gen = prepared.generation;
256        let _ = sweep_generations(&self.base, self.current_gen);
257        Ok(())
258    }
259
260    /// Publishes the staged generation: rename its tmp to the immutable
261    /// `snap.<N+1>`, repoint the manifest, then GC superseded generations
262    /// (pin-aware). Call only after [`FileStorage::stage_snapshot`] and after
263    /// dropping any mmap of the old generation.
264    pub(crate) fn commit_snapshot(&mut self) -> Result<(), HostError> {
265        let next = self.next_gen();
266        let tmp = gen_tmp_path(&self.base, next);
267        let genp = gen_path(&self.base, next);
268        std::fs::rename(&tmp, &genp).map_err(|e| HostError::io(&genp, e))?;
269        sync_dir(&self.base)?;
270        publish_manifest(&self.base, &self.manifest_tmp, next)?;
271        self.current_gen = next;
272        // Reclaim every unpinned superseded generation (a reader on an old one
273        // keeps it until it drops). Best-effort — leftovers go on the next pass.
274        let _ = sweep_generations(&self.base, self.current_gen);
275        Ok(())
276    }
277}
278
279/// Streaming next-generation file detached from [`FileStorage`].
280pub(crate) struct DetachedSnapshot {
281    generation: u64,
282    sink: FileSink,
283}
284
285impl DetachedSnapshot {
286    /// Flushes and fsyncs the completed image, making it ready for the short
287    /// atomic publication step under the database lock.
288    pub(crate) fn prepare(self) -> Result<PreparedSnapshot, HostError> {
289        let generation = self.generation;
290        let tmp = self.sink.path.clone();
291        let file = self.sink.finish()?;
292        file.sync_all().map_err(|e| HostError::io(&tmp, e))?;
293        Ok(PreparedSnapshot {
294            generation,
295            tmp: Some(tmp),
296        })
297    }
298}
299
300impl SnapshotSink for &mut DetachedSnapshot {
301    fn write(&mut self, bytes: &[u8]) -> Result<(), Error> {
302        self.sink.buf.write_all(bytes).map_err(sink_io)
303    }
304
305    fn patch(&mut self, at: u64, bytes: &[u8]) -> Result<(), Error> {
306        self.sink.buf.flush().map_err(sink_io)?;
307        let file = self.sink.buf.get_mut();
308        file.seek(SeekFrom::Start(at)).map_err(sink_io)?;
309        file.write_all(bytes).map_err(sink_io)?;
310        file.seek(SeekFrom::End(0)).map_err(sink_io)?;
311        Ok(())
312    }
313}
314
315/// Durable staged snapshot not yet named by the manifest. Dropping an
316/// unpublished one cleans its temp file after any reembed failure.
317pub(crate) struct PreparedSnapshot {
318    generation: u64,
319    tmp: Option<PathBuf>,
320}
321
322/// A frozen source image that is never published.
323pub(crate) struct PreparedSource {
324    path: Option<PathBuf>,
325}
326
327impl PreparedSource {
328    pub(crate) fn path(&self) -> Result<&Path, HostError> {
329        self.path.as_deref().ok_or(HostError::Engine(Error::Invalid(
330            "reembed source has already been discarded",
331        )))
332    }
333}
334
335impl Drop for PreparedSource {
336    fn drop(&mut self) {
337        if let Some(path) = self.path.take() {
338            let _ = std::fs::remove_file(path);
339        }
340    }
341}
342
343impl Drop for PreparedSnapshot {
344    fn drop(&mut self) {
345        if let Some(path) = self.tmp.take() {
346            let _ = std::fs::remove_file(path);
347        }
348    }
349}
350
351/// A streaming [`SnapshotSink`] over a buffered file: sequential section
352/// writes are buffered, and the single `patch` (the header file-hash, once
353/// the running hash is known) flushes and seeks. Lets a snapshot stream to
354/// disk without a full-image buffer.
355pub(crate) struct FileSink {
356    buf: BufWriter<File>,
357    path: PathBuf,
358}
359
360impl FileSink {
361    fn new(file: File, path: PathBuf) -> Self {
362        Self {
363            buf: BufWriter::new(file),
364            path,
365        }
366    }
367
368    /// Flushes the buffer and returns the underlying file for fsync.
369    fn finish(self) -> Result<File, HostError> {
370        self.buf
371            .into_inner()
372            .map_err(|e| HostError::io(&self.path, e.into_error()))
373    }
374}
375
376impl SnapshotSink for &mut FileSink {
377    fn write(&mut self, bytes: &[u8]) -> Result<(), Error> {
378        self.buf.write_all(bytes).map_err(sink_io)
379    }
380
381    fn patch(&mut self, at: u64, bytes: &[u8]) -> Result<(), Error> {
382        // The one non-sequential write: flush buffered bytes, seek to the
383        // header field, patch it, then restore the position to the end.
384        self.buf.flush().map_err(sink_io)?;
385        let file = self.buf.get_mut();
386        file.seek(SeekFrom::Start(at)).map_err(sink_io)?;
387        file.write_all(bytes).map_err(sink_io)?;
388        file.seek(SeekFrom::End(0)).map_err(sink_io)?;
389        Ok(())
390    }
391}
392
393/// Whether a database exists at `base`.
394///
395/// Not `base.exists()`: the base path holds the published snapshot, and that is
396/// written by the *first checkpoint*. A database created a moment ago and
397/// written to is a journal and a lock with no base file at all, and it is
398/// unmistakably a database — reopening it replays the journal and the facts are
399/// there. Anything asking "is there a database here?" (a workspace listing a
400/// directory, a caller deciding whether to create one) must ask this rather
401/// than stat the base path, or it will overwrite live data.
402///
403/// The lock file alone does not count: it can outlive a database that was
404/// deleted, and a bare lock has no content to lose.
405pub fn database_exists(base: &Path) -> bool {
406    base.exists() || suffixed(base, "journal").exists()
407}
408
409/// `"a.plugmem"` + `"lock"` → `"a.plugmem.lock"`.
410fn suffixed(base: &Path, ext: &str) -> PathBuf {
411    let mut s = base.as_os_str().to_os_string();
412    s.push(".");
413    s.push(ext);
414    PathBuf::from(s)
415}
416
417/// Manifest magic ("PMGL" — distinct from the snapshot's own `MAGIC`).
418const MANIFEST_MAGIC: u32 = 0x504D_474C;
419/// On-disk manifest version (the layout, not the snapshot format).
420const MANIFEST_VERSION: u16 = 1;
421/// Manifest length: magic(4) + version(2) + pad(2) + gen(8) + checksum(8).
422const MANIFEST_LEN: usize = 24;
423
424/// How many times a reader retries an open across the Windows manifest-swap
425/// window. Publishing the manifest is a `rename(tmp, base)`, which on Windows
426/// briefly leaves the old `base` *delete-pending*; a reader opening it in that
427/// window gets a transient `ERROR_ACCESS_DENIED` (5) / `ERROR_SHARING_VIOLATION`
428/// (32). POSIX renames are atomic with respect to a concurrent open, so this
429/// never happens there. `100 * 1ms` dwarfs the microsecond swap while adding a
430/// negligible delay to a genuine failure.
431#[cfg(windows)]
432const SHARE_RETRIES: u32 = 100;
433
434/// True for the transient Windows sharing errors a rename-replace throws at a
435/// concurrent open. Always `false` off Windows (codes 5/32 mean unrelated things
436/// on POSIX), so the retry paths below collapse to the original single shot.
437fn is_transient_share_error(e: &std::io::Error) -> bool {
438    #[cfg(windows)]
439    {
440        matches!(e.raw_os_error(), Some(5) | Some(32))
441    }
442    #[cfg(not(windows))]
443    {
444        let _ = e;
445        false
446    }
447}
448
449/// `std::fs::read`, retried across the transient Windows rename-swap window so a
450/// reader rides over a concurrent manifest publish instead of spuriously failing
451/// with "Access is denied". On non-Windows it is a plain `std::fs::read`.
452fn read_across_rename(path: &Path) -> std::io::Result<Vec<u8>> {
453    #[cfg(windows)]
454    {
455        let mut attempts = 0u32;
456        loop {
457            match std::fs::read(path) {
458                Err(e) if is_transient_share_error(&e) && attempts < SHARE_RETRIES => {
459                    attempts += 1;
460                    std::thread::sleep(std::time::Duration::from_millis(1));
461                }
462                other => return other,
463            }
464        }
465    }
466    #[cfg(not(windows))]
467    {
468        std::fs::read(path)
469    }
470}
471
472/// 64-bit FNV-1a — a dependency-free integrity check for the manifest. The
473/// manifest is written atomically (tmp + rename), so it can never be torn; this
474/// only catches external garbage / bit-rot in the tiny fixed record.
475fn fnv1a(bytes: &[u8]) -> u64 {
476    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
477    for &b in bytes {
478        h ^= u64::from(b);
479        h = h.wrapping_mul(0x0000_0100_0000_01b3);
480    }
481    h
482}
483
484/// The snapshot file for generation `n`: `base` + `.snap.<n>`.
485fn gen_path(base: &Path, n: u64) -> PathBuf {
486    suffixed(base, &format!("snap.{n}"))
487}
488
489/// The staging path for generation `n`: `base` + `.snap.<n>.tmp`.
490fn gen_tmp_path(base: &Path, n: u64) -> PathBuf {
491    suffixed(base, &format!("snap.{n}.tmp"))
492}
493
494/// Reads and validates the manifest at `base`. `Ok(None)` when it is absent (a
495/// fresh database); `Err(Corrupt)` when it is present but malformed; `Err(Io)`
496/// on a real filesystem failure. The returned generation is always ≥ 1.
497pub(crate) fn read_manifest(base: &Path) -> Result<Option<u64>, HostError> {
498    let bytes = match read_across_rename(base) {
499        Ok(b) => b,
500        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
501        Err(e) => return Err(HostError::io(base, e)),
502    };
503    let ok = bytes.len() == MANIFEST_LEN
504        && u32::from_le_bytes(bytes[0..4].try_into().unwrap()) == MANIFEST_MAGIC
505        && u16::from_le_bytes(bytes[4..6].try_into().unwrap()) == MANIFEST_VERSION
506        && fnv1a(&bytes[0..16]) == u64::from_le_bytes(bytes[16..24].try_into().unwrap());
507    if !ok {
508        return Err(HostError::Engine(Error::Corrupt("manifest is corrupt")));
509    }
510    Ok(Some(u64::from_le_bytes(bytes[8..16].try_into().unwrap())))
511}
512
513/// Atomically publishes `gen` as the current generation: write a fresh manifest
514/// to `manifest_tmp`, fsync, rename over `base`, fsync the directory.
515fn publish_manifest(base: &Path, manifest_tmp: &Path, generation: u64) -> Result<(), HostError> {
516    let mut buf = [0u8; MANIFEST_LEN];
517    buf[0..4].copy_from_slice(&MANIFEST_MAGIC.to_le_bytes());
518    buf[4..6].copy_from_slice(&MANIFEST_VERSION.to_le_bytes());
519    buf[8..16].copy_from_slice(&generation.to_le_bytes());
520    let sum = fnv1a(&buf[0..16]);
521    buf[16..24].copy_from_slice(&sum.to_le_bytes());
522    let mut f = File::create(manifest_tmp).map_err(|e| HostError::io(manifest_tmp, e))?;
523    f.write_all(&buf)
524        .and_then(|()| f.sync_all())
525        .map_err(|e| HostError::io(manifest_tmp, e))?;
526    drop(f);
527    std::fs::rename(manifest_tmp, base).map_err(|e| HostError::io(base, e))?;
528    sync_dir(base)
529}
530
531/// Fsyncs the directory holding the database (unix only — the rename's
532/// durability point).
533fn sync_dir(base: &Path) -> Result<(), HostError> {
534    #[cfg(unix)]
535    {
536        let dir = base.parent().filter(|p| !p.as_os_str().is_empty());
537        let dir = dir.unwrap_or_else(|| Path::new("."));
538        File::open(dir)
539            .and_then(|d| d.sync_all())
540            .map_err(|e| HostError::io(dir, e))?;
541    }
542    #[cfg(not(unix))]
543    let _ = base;
544    Ok(())
545}
546
547/// Reclaims one superseded generation file, but only if nothing pins it. A
548/// reader holds a **shared** lock on the generation file for as long as it maps
549/// it (see `pin_current_generation`), so a successful **exclusive** try-lock
550/// proves no reader is using it, and the delete under that lock cannot race a
551/// new pin. Best-effort: a pinned (or, on Windows, an open-mapped) generation is
552/// simply left for a later pass. Never deletes a live reader's snapshot.
553fn try_reclaim_generation(genp: &Path) {
554    if let Ok(f) = File::open(genp)
555        && f.try_lock().is_ok()
556    {
557        let _ = std::fs::remove_file(genp);
558    }
559}
560
561/// Sweeps the generation files around `current`: the current one stays; a
562/// higher number is crash debris from a checkpoint that never published its
563/// manifest (never pinned — delete it); a lower number is a superseded
564/// generation reclaimed only if unpinned (a reader may still map it). Staging
565/// `.tmp`s and the manifest tmp go unconditionally. Called by the exclusive
566/// writer — on open (crash recovery) and after each checkpoint (GC).
567fn sweep_generations(base: &Path, current: u64) -> Result<(), HostError> {
568    let dir = base
569        .parent()
570        .filter(|p| !p.as_os_str().is_empty())
571        .unwrap_or_else(|| Path::new("."));
572    let name = base
573        .file_name()
574        .and_then(|n| n.to_str())
575        .unwrap_or_default();
576    let prefix = format!("{name}.snap.");
577    let entries = match std::fs::read_dir(dir) {
578        Ok(e) => e,
579        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
580        Err(e) => return Err(HostError::io(dir, e)),
581    };
582    for entry in entries {
583        let entry = entry.map_err(|e| HostError::io(dir, e))?;
584        let fname = entry.file_name();
585        let Some(fname) = fname.to_str() else {
586            continue;
587        };
588        let Some(rest) = fname.strip_prefix(&prefix) else {
589            continue;
590        };
591        if rest.ends_with(".tmp") {
592            let _ = std::fs::remove_file(entry.path()); // staging scrap
593            continue;
594        }
595        match rest.parse::<u64>() {
596            Ok(n) if n == current => {} // the live generation
597            Ok(n) if n > current => {
598                let _ = std::fs::remove_file(entry.path()); // unpublished orphan
599            }
600            Ok(_) => try_reclaim_generation(&entry.path()), // superseded — pin-aware
601            Err(_) => {}
602        }
603    }
604    let _ = std::fs::remove_file(suffixed(base, "manifest.tmp"));
605    Ok(())
606}
607
608/// Opens and **shared-locks** the current snapshot generation, pinning it
609/// against the writer's GC for as long as the returned [`File`] is held; returns
610/// it with the generation path and the generation number it pinned. `Ok(None)`
611/// when there is no published generation (a fresh database). Readers do not take
612/// the writer lock, so a reader and the writer coexist — this is what makes the
613/// cross-process MVCC work.
614///
615/// Retries the open→lock race with the collector: if the manifest names a
616/// generation that GC reclaims in the window between resolving and locking it,
617/// the open (or the post-lock existence recheck) fails and we retry against the
618/// fresh manifest. Once the shared lock is held and the file still exists, GC's
619/// exclusive try-lock must fail, so the pin is stable. The returned number is the
620/// generation actually pinned, which a caller can compare against a stale one to
621/// tell whether the writer has published a newer snapshot (see
622/// [`ReadOnlyDatabase::refresh`](crate::ReadOnlyDatabase::refresh)).
623pub(crate) fn pin_current_generation(
624    base: &Path,
625) -> Result<Option<(File, PathBuf, u64)>, HostError> {
626    loop {
627        let Some(generation) = read_manifest(base)? else {
628            return Ok(None);
629        };
630        let genp = gen_path(base, generation);
631        let file = match File::open(&genp) {
632            Ok(f) => f,
633            // GC reclaimed it between the manifest read and the open — retry.
634            Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
635            // Windows: a gen file being GC-reclaimed can surface as a transient
636            // delete-pending share error rather than NotFound — also a retry.
637            Err(e) if is_transient_share_error(&e) => {
638                std::thread::sleep(std::time::Duration::from_millis(1));
639                continue;
640            }
641            Err(e) => return Err(HostError::io(&genp, e)),
642        };
643        match file.try_lock_shared() {
644            Ok(()) => {}
645            // A transient exclusive holder (GC probing) — retry.
646            Err(std::fs::TryLockError::WouldBlock) => continue,
647            Err(std::fs::TryLockError::Error(e)) => return Err(HostError::io(&genp, e)),
648        }
649        // Confirm the generation survived the open→lock window. If GC reclaimed
650        // it just before we locked, the path is gone; drop the lock and retry
651        // for the fresh generation. If it exists, our shared lock now blocks
652        // GC's exclusive try-lock, so the pin holds.
653        if genp.exists() {
654            return Ok(Some((file, genp, generation)));
655        }
656    }
657}
658
659impl Storage for FileStorage {
660    type Error = HostError;
661
662    fn read_snapshot(&mut self) -> Result<Option<Vec<u8>>, HostError> {
663        match self.current_snapshot_path()? {
664            Some(p) => Ok(Some(std::fs::read(&p).map_err(|e| HostError::io(&p, e))?)),
665            None => Ok(None),
666        }
667    }
668
669    fn write_snapshot(&mut self, bytes: &[u8]) -> Result<(), HostError> {
670        // Publish a new immutable generation (the non-streaming path): stage
671        // its tmp, rename to snap.<N+1>, repoint the manifest, reclaim the old.
672        let next = self.next_gen();
673        let tmp = gen_tmp_path(&self.base, next);
674        let mut f = File::create(&tmp).map_err(|e| HostError::io(&tmp, e))?;
675        f.write_all(bytes)
676            .and_then(|()| f.sync_all())
677            .map_err(|e| HostError::io(&tmp, e))?;
678        drop(f);
679        let genp = gen_path(&self.base, next);
680        std::fs::rename(&tmp, &genp).map_err(|e| HostError::io(&genp, e))?;
681        sync_dir(&self.base)?;
682        publish_manifest(&self.base, &self.manifest_tmp, next)?;
683        self.current_gen = next;
684        let _ = sweep_generations(&self.base, self.current_gen);
685        Ok(())
686    }
687
688    fn read_journal(&mut self) -> Result<Vec<u8>, HostError> {
689        std::fs::read(&self.journal_path).map_err(|e| HostError::io(&self.journal_path, e))
690    }
691
692    fn append_journal(&mut self, entry: &[u8]) -> Result<(), HostError> {
693        self.journal
694            .write_all(entry)
695            .map_err(|e| HostError::io(&self.journal_path, e))?;
696        // In batch mode the fsync is deferred to one `sync_journal` at the end
697        // of the batch (durability amortized across the whole bulk write).
698        if self.fsync == FsyncPolicy::EachOp && !self.batch {
699            self.journal
700                .sync_data()
701                .map_err(|e| HostError::io(&self.journal_path, e))?;
702        }
703        Ok(())
704    }
705
706    fn clear_journal(&mut self) -> Result<(), HostError> {
707        // Truncate through a dedicated write handle, not `set_len` on the
708        // append handle: Rust opens append handles with FILE_WRITE_DATA
709        // masked off (append can only extend, never overwrite), so on
710        // Windows `SetEndOfFile` is denied with ERROR_ACCESS_DENIED. A
711        // `write + truncate` open empties the file portably; then the
712        // append handle is re-established so later appends target the
713        // fresh, empty journal.
714        let truncated = OpenOptions::new()
715            .create(true)
716            .write(true)
717            .truncate(true)
718            .open(&self.journal_path)
719            .map_err(|e| HostError::io(&self.journal_path, e))?;
720        truncated
721            .sync_data()
722            .map_err(|e| HostError::io(&self.journal_path, e))?;
723        drop(truncated);
724        self.journal = OpenOptions::new()
725            .create(true)
726            .append(true)
727            .open(&self.journal_path)
728            .map_err(|e| HostError::io(&self.journal_path, e))?;
729        Ok(())
730    }
731}
732
733/// A host [`Scratch`] over a temp file (milestone H): sequential
734/// appends go through a buffered writer; [`freeze`](Scratch::freeze) flushes
735/// and memory-maps the file, so the staged pool is read (randomly and
736/// sequentially) straight from the map instead of RAM. Dropping it unmaps and
737/// deletes the temp file.
738pub struct FileScratch {
739    path: PathBuf,
740    /// `Some` while writing, taken by the first `freeze`.
741    writer: Option<BufWriter<File>>,
742    /// `Some` after `freeze` — the read-back mapping the borrow points into.
743    map: Option<Mmap>,
744    len: u64,
745}
746
747impl FileScratch {
748    /// Creates (truncating) a staging file at `path`, ready for appends.
749    ///
750    /// # Errors
751    ///
752    /// [`HostError::Io`] if the file cannot be created.
753    pub fn create(path: impl Into<PathBuf>) -> Result<Self, HostError> {
754        let path = path.into();
755        let file = OpenOptions::new()
756            .write(true)
757            .create(true)
758            .truncate(true)
759            .open(&path)
760            .map_err(|e| HostError::io(&path, e))?;
761        Ok(Self {
762            path,
763            writer: Some(BufWriter::new(file)),
764            map: None,
765            len: 0,
766        })
767    }
768}
769
770impl Scratch for FileScratch {
771    type Error = HostError;
772
773    fn write(&mut self, bytes: &[u8]) -> Result<(), HostError> {
774        let Self {
775            writer, path, len, ..
776        } = self;
777        let w = writer.as_mut().ok_or(HostError::Engine(Error::Invalid(
778            "scratch write after freeze",
779        )))?;
780        w.write_all(bytes).map_err(|e| HostError::io(path, e))?;
781        *len += bytes.len() as u64;
782        Ok(())
783    }
784
785    fn len(&self) -> u64 {
786        self.len
787    }
788
789    fn freeze(&mut self) -> Result<&[u8], HostError> {
790        if self.len == 0 {
791            if let Some(writer) = self.writer.take() {
792                let file = writer
793                    .into_inner()
794                    .map_err(|e| HostError::io(&self.path, e.into_error()))?;
795                file.sync_all().map_err(|e| HostError::io(&self.path, e))?;
796            }
797            return Ok(&[]);
798        }
799        if self.map.is_none() {
800            // Flush and fsync the staged bytes, then map the file fresh.
801            let writer = self
802                .writer
803                .take()
804                .ok_or(HostError::Engine(Error::Invalid("scratch frozen twice")))?;
805            let file = writer
806                .into_inner()
807                .map_err(|e| HostError::io(&self.path, e.into_error()))?;
808            file.sync_all().map_err(|e| HostError::io(&self.path, e))?;
809            drop(file);
810            let file = File::open(&self.path).map_err(|e| HostError::io(&self.path, e))?;
811            // SAFETY: this is our private temp file — created by `create`,
812            // owned by this `FileScratch` for its whole life, deleted on drop —
813            // so no other process writes or truncates it under the map (the
814            // same argument as the read-only snapshot map).
815            let map = unsafe { Mmap::map(&file) }.map_err(|e| HostError::io(&self.path, e))?;
816            self.map = Some(map);
817        }
818        Ok(&self.map.as_ref().expect("just set")[..])
819    }
820}
821
822impl Drop for FileScratch {
823    fn drop(&mut self) {
824        // Unmap before delete: Windows refuses to remove a mapped file (the
825        // same constraint as renaming over one).
826        self.map = None;
827        self.writer = None;
828        let _ = std::fs::remove_file(&self.path);
829    }
830}