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}