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 // Crash recovery + GC: drop unpublished orphan generations and staging
113 // tmps, and reclaim any unpinned superseded generation. Safe with live
114 // readers — reclaim is pin-aware (a reader's shared lock keeps its
115 // generation). Only the writer sweeps.
116 sweep_generations(&base, current_gen)?;
117
118 let journal = OpenOptions::new()
119 .create(true)
120 .append(true)
121 .open(&journal_path)
122 .map_err(|e| HostError::io(&journal_path, e))?;
123
124 Ok(Self {
125 base,
126 journal_path,
127 manifest_tmp,
128 current_gen,
129 _lock: lock,
130 journal,
131 fsync,
132 batch: false,
133 })
134 }
135
136 /// Enters (`true`) or leaves (`false`) **batch mode**. While on,
137 /// [`append_journal`](plugmem_core::Storage::append_journal) writes each
138 /// record **without** its per-record fsync, so a bulk write (`remember_many`)
139 /// amortizes durability into a single [`sync_journal`](Self::sync_journal) at
140 /// the end. Only affects the `EachOp` policy — `OnSnapshot` never fsyncs per
141 /// record anyway. The caller must pair `set_batch(true)` with a final
142 /// `set_batch(false)` + `sync_journal`, even on error, so a later single
143 /// write is durable again.
144 pub(crate) fn set_batch(&mut self, on: bool) {
145 self.batch = on;
146 }
147
148 /// Fsyncs the journal now — the durability point for records appended in
149 /// batch mode. Idempotent (a plain `sync_data`), so calling it after a
150 /// partially-written batch makes exactly the records that reached the file
151 /// durable.
152 pub(crate) fn sync_journal(&mut self) -> Result<(), HostError> {
153 self.journal
154 .sync_data()
155 .map_err(|e| HostError::io(&self.journal_path, e))
156 }
157
158 /// The database base path.
159 pub fn path(&self) -> &Path {
160 &self.base
161 }
162
163 /// Current journal size in bytes (drives the snapshot policy).
164 pub fn journal_bytes(&self) -> u64 {
165 self.journal.metadata().map(|m| m.len()).unwrap_or(0)
166 }
167
168 /// The path of the snapshot file the manifest currently names, or `None`
169 /// for a fresh database with no published snapshot. Callers that map the
170 /// snapshot (`open_engine`, `ReadOnlyDatabase`, `Scrub`, `recover`) resolve
171 /// through this instead of mapping `base` (which is now the manifest).
172 pub(crate) fn current_snapshot_path(&self) -> Result<Option<PathBuf>, HostError> {
173 Ok(read_manifest(&self.base)?.map(|g| gen_path(&self.base, g)))
174 }
175
176 /// The next generation number this storage will publish.
177 fn next_gen(&self) -> u64 {
178 self.current_gen + 1
179 }
180
181 /// Streams a snapshot into the next generation's tmp file and fsyncs it,
182 /// **without** publishing — the durable-but-not-yet-visible half of a
183 /// checkpoint. `write` drives the engine's streaming snapshot
184 /// writer against a buffered file sink, so the image never all lives in RAM
185 /// at once. Split from [`FileStorage::commit_snapshot`] because the caller
186 /// must drop the mmap of the *old* generation **between** the two: staging
187 /// reads through that map, and the reclaim in `commit` deletes it. A staged
188 /// tmp is cleaned up on the next exclusive open.
189 pub(crate) fn stage_snapshot(
190 &mut self,
191 write: impl FnOnce(&mut FileSink) -> Result<(), HostError>,
192 ) -> Result<(), HostError> {
193 let tmp = gen_tmp_path(&self.base, self.next_gen());
194 let file = File::create(&tmp).map_err(|e| HostError::io(&tmp, e))?;
195 let mut sink = FileSink::new(file, tmp.clone());
196 write(&mut sink)?;
197 let file = sink.finish()?;
198 file.sync_all().map_err(|e| HostError::io(&tmp, e))?;
199 Ok(())
200 }
201
202 /// Publishes the staged generation: rename its tmp to the immutable
203 /// `snap.<N+1>`, repoint the manifest, then GC superseded generations
204 /// (pin-aware). Call only after [`FileStorage::stage_snapshot`] and after
205 /// dropping any mmap of the old generation.
206 pub(crate) fn commit_snapshot(&mut self) -> Result<(), HostError> {
207 let next = self.next_gen();
208 let tmp = gen_tmp_path(&self.base, next);
209 let genp = gen_path(&self.base, next);
210 std::fs::rename(&tmp, &genp).map_err(|e| HostError::io(&genp, e))?;
211 sync_dir(&self.base)?;
212 publish_manifest(&self.base, &self.manifest_tmp, next)?;
213 self.current_gen = next;
214 // Reclaim every unpinned superseded generation (a reader on an old one
215 // keeps it until it drops). Best-effort — leftovers go on the next pass.
216 let _ = sweep_generations(&self.base, self.current_gen);
217 Ok(())
218 }
219}
220
221/// A streaming [`SnapshotSink`] over a buffered file: sequential section
222/// writes are buffered, and the single `patch` (the header file-hash, once
223/// the running hash is known) flushes and seeks. Lets a snapshot stream to
224/// disk without a full-image buffer.
225pub(crate) struct FileSink {
226 buf: BufWriter<File>,
227 path: PathBuf,
228}
229
230impl FileSink {
231 fn new(file: File, path: PathBuf) -> Self {
232 Self {
233 buf: BufWriter::new(file),
234 path,
235 }
236 }
237
238 /// Flushes the buffer and returns the underlying file for fsync.
239 fn finish(self) -> Result<File, HostError> {
240 self.buf
241 .into_inner()
242 .map_err(|e| HostError::io(&self.path, e.into_error()))
243 }
244}
245
246impl SnapshotSink for &mut FileSink {
247 fn write(&mut self, bytes: &[u8]) -> Result<(), Error> {
248 self.buf.write_all(bytes).map_err(sink_io)
249 }
250
251 fn patch(&mut self, at: u64, bytes: &[u8]) -> Result<(), Error> {
252 // The one non-sequential write: flush buffered bytes, seek to the
253 // header field, patch it, then restore the position to the end.
254 self.buf.flush().map_err(sink_io)?;
255 let file = self.buf.get_mut();
256 file.seek(SeekFrom::Start(at)).map_err(sink_io)?;
257 file.write_all(bytes).map_err(sink_io)?;
258 file.seek(SeekFrom::End(0)).map_err(sink_io)?;
259 Ok(())
260 }
261}
262
263/// Whether a database exists at `base`.
264///
265/// Not `base.exists()`: the base path holds the published snapshot, and that is
266/// written by the *first checkpoint*. A database created a moment ago and
267/// written to is a journal and a lock with no base file at all, and it is
268/// unmistakably a database — reopening it replays the journal and the facts are
269/// there. Anything asking "is there a database here?" (a workspace listing a
270/// directory, a caller deciding whether to create one) must ask this rather
271/// than stat the base path, or it will overwrite live data.
272///
273/// The lock file alone does not count: it can outlive a database that was
274/// deleted, and a bare lock has no content to lose.
275pub fn database_exists(base: &Path) -> bool {
276 base.exists() || suffixed(base, "journal").exists()
277}
278
279/// `"a.plugmem"` + `"lock"` → `"a.plugmem.lock"`.
280fn suffixed(base: &Path, ext: &str) -> PathBuf {
281 let mut s = base.as_os_str().to_os_string();
282 s.push(".");
283 s.push(ext);
284 PathBuf::from(s)
285}
286
287/// Manifest magic ("PMGL" — distinct from the snapshot's own `MAGIC`).
288const MANIFEST_MAGIC: u32 = 0x504D_474C;
289/// On-disk manifest version (the layout, not the snapshot format).
290const MANIFEST_VERSION: u16 = 1;
291/// Manifest length: magic(4) + version(2) + pad(2) + gen(8) + checksum(8).
292const MANIFEST_LEN: usize = 24;
293
294/// How many times a reader retries an open across the Windows manifest-swap
295/// window. Publishing the manifest is a `rename(tmp, base)`, which on Windows
296/// briefly leaves the old `base` *delete-pending*; a reader opening it in that
297/// window gets a transient `ERROR_ACCESS_DENIED` (5) / `ERROR_SHARING_VIOLATION`
298/// (32). POSIX renames are atomic with respect to a concurrent open, so this
299/// never happens there. `100 * 1ms` dwarfs the microsecond swap while adding a
300/// negligible delay to a genuine failure.
301#[cfg(windows)]
302const SHARE_RETRIES: u32 = 100;
303
304/// True for the transient Windows sharing errors a rename-replace throws at a
305/// concurrent open. Always `false` off Windows (codes 5/32 mean unrelated things
306/// on POSIX), so the retry paths below collapse to the original single shot.
307fn is_transient_share_error(e: &std::io::Error) -> bool {
308 #[cfg(windows)]
309 {
310 matches!(e.raw_os_error(), Some(5) | Some(32))
311 }
312 #[cfg(not(windows))]
313 {
314 let _ = e;
315 false
316 }
317}
318
319/// `std::fs::read`, retried across the transient Windows rename-swap window so a
320/// reader rides over a concurrent manifest publish instead of spuriously failing
321/// with "Access is denied". On non-Windows it is a plain `std::fs::read`.
322fn read_across_rename(path: &Path) -> std::io::Result<Vec<u8>> {
323 #[cfg(windows)]
324 {
325 let mut attempts = 0u32;
326 loop {
327 match std::fs::read(path) {
328 Err(e) if is_transient_share_error(&e) && attempts < SHARE_RETRIES => {
329 attempts += 1;
330 std::thread::sleep(std::time::Duration::from_millis(1));
331 }
332 other => return other,
333 }
334 }
335 }
336 #[cfg(not(windows))]
337 {
338 std::fs::read(path)
339 }
340}
341
342/// 64-bit FNV-1a — a dependency-free integrity check for the manifest. The
343/// manifest is written atomically (tmp + rename), so it can never be torn; this
344/// only catches external garbage / bit-rot in the tiny fixed record.
345fn fnv1a(bytes: &[u8]) -> u64 {
346 let mut h: u64 = 0xcbf2_9ce4_8422_2325;
347 for &b in bytes {
348 h ^= u64::from(b);
349 h = h.wrapping_mul(0x0000_0100_0000_01b3);
350 }
351 h
352}
353
354/// The snapshot file for generation `n`: `base` + `.snap.<n>`.
355fn gen_path(base: &Path, n: u64) -> PathBuf {
356 suffixed(base, &format!("snap.{n}"))
357}
358
359/// The staging path for generation `n`: `base` + `.snap.<n>.tmp`.
360fn gen_tmp_path(base: &Path, n: u64) -> PathBuf {
361 suffixed(base, &format!("snap.{n}.tmp"))
362}
363
364/// Reads and validates the manifest at `base`. `Ok(None)` when it is absent (a
365/// fresh database); `Err(Corrupt)` when it is present but malformed; `Err(Io)`
366/// on a real filesystem failure. The returned generation is always ≥ 1.
367pub(crate) fn read_manifest(base: &Path) -> Result<Option<u64>, HostError> {
368 let bytes = match read_across_rename(base) {
369 Ok(b) => b,
370 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
371 Err(e) => return Err(HostError::io(base, e)),
372 };
373 let ok = bytes.len() == MANIFEST_LEN
374 && u32::from_le_bytes(bytes[0..4].try_into().unwrap()) == MANIFEST_MAGIC
375 && u16::from_le_bytes(bytes[4..6].try_into().unwrap()) == MANIFEST_VERSION
376 && fnv1a(&bytes[0..16]) == u64::from_le_bytes(bytes[16..24].try_into().unwrap());
377 if !ok {
378 return Err(HostError::Engine(Error::Corrupt("manifest is corrupt")));
379 }
380 Ok(Some(u64::from_le_bytes(bytes[8..16].try_into().unwrap())))
381}
382
383/// Atomically publishes `gen` as the current generation: write a fresh manifest
384/// to `manifest_tmp`, fsync, rename over `base`, fsync the directory.
385fn publish_manifest(base: &Path, manifest_tmp: &Path, generation: u64) -> Result<(), HostError> {
386 let mut buf = [0u8; MANIFEST_LEN];
387 buf[0..4].copy_from_slice(&MANIFEST_MAGIC.to_le_bytes());
388 buf[4..6].copy_from_slice(&MANIFEST_VERSION.to_le_bytes());
389 buf[8..16].copy_from_slice(&generation.to_le_bytes());
390 let sum = fnv1a(&buf[0..16]);
391 buf[16..24].copy_from_slice(&sum.to_le_bytes());
392 let mut f = File::create(manifest_tmp).map_err(|e| HostError::io(manifest_tmp, e))?;
393 f.write_all(&buf)
394 .and_then(|()| f.sync_all())
395 .map_err(|e| HostError::io(manifest_tmp, e))?;
396 drop(f);
397 std::fs::rename(manifest_tmp, base).map_err(|e| HostError::io(base, e))?;
398 sync_dir(base)
399}
400
401/// Fsyncs the directory holding the database (unix only — the rename's
402/// durability point).
403fn sync_dir(base: &Path) -> Result<(), HostError> {
404 #[cfg(unix)]
405 {
406 let dir = base.parent().filter(|p| !p.as_os_str().is_empty());
407 let dir = dir.unwrap_or_else(|| Path::new("."));
408 File::open(dir)
409 .and_then(|d| d.sync_all())
410 .map_err(|e| HostError::io(dir, e))?;
411 }
412 #[cfg(not(unix))]
413 let _ = base;
414 Ok(())
415}
416
417/// Reclaims one superseded generation file, but only if nothing pins it. A
418/// reader holds a **shared** lock on the generation file for as long as it maps
419/// it (see [`pin_current_generation`]), so a successful **exclusive** try-lock
420/// proves no reader is using it, and the delete under that lock cannot race a
421/// new pin. Best-effort: a pinned (or, on Windows, an open-mapped) generation is
422/// simply left for a later pass. Never deletes a live reader's snapshot.
423fn try_reclaim_generation(genp: &Path) {
424 if let Ok(f) = File::open(genp)
425 && f.try_lock().is_ok()
426 {
427 let _ = std::fs::remove_file(genp);
428 }
429}
430
431/// Sweeps the generation files around `current`: the current one stays; a
432/// higher number is crash debris from a checkpoint that never published its
433/// manifest (never pinned — delete it); a lower number is a superseded
434/// generation reclaimed only if unpinned (a reader may still map it). Staging
435/// `.tmp`s and the manifest tmp go unconditionally. Called by the exclusive
436/// writer — on open (crash recovery) and after each checkpoint (GC).
437fn sweep_generations(base: &Path, current: u64) -> Result<(), HostError> {
438 let dir = base
439 .parent()
440 .filter(|p| !p.as_os_str().is_empty())
441 .unwrap_or_else(|| Path::new("."));
442 let name = base
443 .file_name()
444 .and_then(|n| n.to_str())
445 .unwrap_or_default();
446 let prefix = format!("{name}.snap.");
447 let entries = match std::fs::read_dir(dir) {
448 Ok(e) => e,
449 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
450 Err(e) => return Err(HostError::io(dir, e)),
451 };
452 for entry in entries {
453 let entry = entry.map_err(|e| HostError::io(dir, e))?;
454 let fname = entry.file_name();
455 let Some(fname) = fname.to_str() else {
456 continue;
457 };
458 let Some(rest) = fname.strip_prefix(&prefix) else {
459 continue;
460 };
461 if rest.ends_with(".tmp") {
462 let _ = std::fs::remove_file(entry.path()); // staging scrap
463 continue;
464 }
465 match rest.parse::<u64>() {
466 Ok(n) if n == current => {} // the live generation
467 Ok(n) if n > current => {
468 let _ = std::fs::remove_file(entry.path()); // unpublished orphan
469 }
470 Ok(_) => try_reclaim_generation(&entry.path()), // superseded — pin-aware
471 Err(_) => {}
472 }
473 }
474 let _ = std::fs::remove_file(suffixed(base, "manifest.tmp"));
475 Ok(())
476}
477
478/// Opens and **shared-locks** the current snapshot generation, pinning it
479/// against the writer's GC for as long as the returned [`File`] is held; returns
480/// it with the generation path and the generation number it pinned. `Ok(None)`
481/// when there is no published generation (a fresh database). Readers do not take
482/// the writer lock, so a reader and the writer coexist — this is what makes the
483/// cross-process MVCC work.
484///
485/// Retries the open→lock race with the collector: if the manifest names a
486/// generation that GC reclaims in the window between resolving and locking it,
487/// the open (or the post-lock existence recheck) fails and we retry against the
488/// fresh manifest. Once the shared lock is held and the file still exists, GC's
489/// exclusive try-lock must fail, so the pin is stable. The returned number is the
490/// generation actually pinned, which a caller can compare against a stale one to
491/// tell whether the writer has published a newer snapshot (see
492/// [`ReadOnlyDatabase::refresh`](crate::ReadOnlyDatabase::refresh)).
493pub(crate) fn pin_current_generation(
494 base: &Path,
495) -> Result<Option<(File, PathBuf, u64)>, HostError> {
496 loop {
497 let Some(generation) = read_manifest(base)? else {
498 return Ok(None);
499 };
500 let genp = gen_path(base, generation);
501 let file = match File::open(&genp) {
502 Ok(f) => f,
503 // GC reclaimed it between the manifest read and the open — retry.
504 Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
505 // Windows: a gen file being GC-reclaimed can surface as a transient
506 // delete-pending share error rather than NotFound — also a retry.
507 Err(e) if is_transient_share_error(&e) => {
508 std::thread::sleep(std::time::Duration::from_millis(1));
509 continue;
510 }
511 Err(e) => return Err(HostError::io(&genp, e)),
512 };
513 match file.try_lock_shared() {
514 Ok(()) => {}
515 // A transient exclusive holder (GC probing) — retry.
516 Err(std::fs::TryLockError::WouldBlock) => continue,
517 Err(std::fs::TryLockError::Error(e)) => return Err(HostError::io(&genp, e)),
518 }
519 // Confirm the generation survived the open→lock window. If GC reclaimed
520 // it just before we locked, the path is gone; drop the lock and retry
521 // for the fresh generation. If it exists, our shared lock now blocks
522 // GC's exclusive try-lock, so the pin holds.
523 if genp.exists() {
524 return Ok(Some((file, genp, generation)));
525 }
526 }
527}
528
529impl Storage for FileStorage {
530 type Error = HostError;
531
532 fn read_snapshot(&mut self) -> Result<Option<Vec<u8>>, HostError> {
533 match self.current_snapshot_path()? {
534 Some(p) => Ok(Some(std::fs::read(&p).map_err(|e| HostError::io(&p, e))?)),
535 None => Ok(None),
536 }
537 }
538
539 fn write_snapshot(&mut self, bytes: &[u8]) -> Result<(), HostError> {
540 // Publish a new immutable generation (the non-streaming path): stage
541 // its tmp, rename to snap.<N+1>, repoint the manifest, reclaim the old.
542 let next = self.next_gen();
543 let tmp = gen_tmp_path(&self.base, next);
544 let mut f = File::create(&tmp).map_err(|e| HostError::io(&tmp, e))?;
545 f.write_all(bytes)
546 .and_then(|()| f.sync_all())
547 .map_err(|e| HostError::io(&tmp, e))?;
548 drop(f);
549 let genp = gen_path(&self.base, next);
550 std::fs::rename(&tmp, &genp).map_err(|e| HostError::io(&genp, e))?;
551 sync_dir(&self.base)?;
552 publish_manifest(&self.base, &self.manifest_tmp, next)?;
553 self.current_gen = next;
554 let _ = sweep_generations(&self.base, self.current_gen);
555 Ok(())
556 }
557
558 fn read_journal(&mut self) -> Result<Vec<u8>, HostError> {
559 std::fs::read(&self.journal_path).map_err(|e| HostError::io(&self.journal_path, e))
560 }
561
562 fn append_journal(&mut self, entry: &[u8]) -> Result<(), HostError> {
563 self.journal
564 .write_all(entry)
565 .map_err(|e| HostError::io(&self.journal_path, e))?;
566 // In batch mode the fsync is deferred to one `sync_journal` at the end
567 // of the batch (durability amortized across the whole bulk write).
568 if self.fsync == FsyncPolicy::EachOp && !self.batch {
569 self.journal
570 .sync_data()
571 .map_err(|e| HostError::io(&self.journal_path, e))?;
572 }
573 Ok(())
574 }
575
576 fn clear_journal(&mut self) -> Result<(), HostError> {
577 // Truncate through a dedicated write handle, not `set_len` on the
578 // append handle: Rust opens append handles with FILE_WRITE_DATA
579 // masked off (append can only extend, never overwrite), so on
580 // Windows `SetEndOfFile` is denied with ERROR_ACCESS_DENIED. A
581 // `write + truncate` open empties the file portably; then the
582 // append handle is re-established so later appends target the
583 // fresh, empty journal.
584 let truncated = OpenOptions::new()
585 .create(true)
586 .write(true)
587 .truncate(true)
588 .open(&self.journal_path)
589 .map_err(|e| HostError::io(&self.journal_path, e))?;
590 truncated
591 .sync_data()
592 .map_err(|e| HostError::io(&self.journal_path, e))?;
593 drop(truncated);
594 self.journal = OpenOptions::new()
595 .create(true)
596 .append(true)
597 .open(&self.journal_path)
598 .map_err(|e| HostError::io(&self.journal_path, e))?;
599 Ok(())
600 }
601}
602
603/// A host [`Scratch`] over a temp file (milestone H): sequential
604/// appends go through a buffered writer; [`freeze`](Scratch::freeze) flushes
605/// and memory-maps the file, so the staged pool is read (randomly and
606/// sequentially) straight from the map instead of RAM. Dropping it unmaps and
607/// deletes the temp file.
608pub struct FileScratch {
609 path: PathBuf,
610 /// `Some` while writing, taken by the first `freeze`.
611 writer: Option<BufWriter<File>>,
612 /// `Some` after `freeze` — the read-back mapping the borrow points into.
613 map: Option<Mmap>,
614 len: u64,
615}
616
617impl FileScratch {
618 /// Creates (truncating) a staging file at `path`, ready for appends.
619 ///
620 /// # Errors
621 ///
622 /// [`HostError::Io`] if the file cannot be created.
623 pub fn create(path: impl Into<PathBuf>) -> Result<Self, HostError> {
624 let path = path.into();
625 let file = OpenOptions::new()
626 .write(true)
627 .create(true)
628 .truncate(true)
629 .open(&path)
630 .map_err(|e| HostError::io(&path, e))?;
631 Ok(Self {
632 path,
633 writer: Some(BufWriter::new(file)),
634 map: None,
635 len: 0,
636 })
637 }
638}
639
640impl Scratch for FileScratch {
641 type Error = HostError;
642
643 fn write(&mut self, bytes: &[u8]) -> Result<(), HostError> {
644 let Self {
645 writer, path, len, ..
646 } = self;
647 let w = writer.as_mut().ok_or(HostError::Engine(Error::Invalid(
648 "scratch write after freeze",
649 )))?;
650 w.write_all(bytes).map_err(|e| HostError::io(path, e))?;
651 *len += bytes.len() as u64;
652 Ok(())
653 }
654
655 fn len(&self) -> u64 {
656 self.len
657 }
658
659 fn freeze(&mut self) -> Result<&[u8], HostError> {
660 if self.map.is_none() {
661 // Flush and fsync the staged bytes, then map the file fresh.
662 let writer = self
663 .writer
664 .take()
665 .ok_or(HostError::Engine(Error::Invalid("scratch frozen twice")))?;
666 let file = writer
667 .into_inner()
668 .map_err(|e| HostError::io(&self.path, e.into_error()))?;
669 file.sync_all().map_err(|e| HostError::io(&self.path, e))?;
670 drop(file);
671 let file = File::open(&self.path).map_err(|e| HostError::io(&self.path, e))?;
672 // SAFETY: this is our private temp file — created by `create`,
673 // owned by this `FileScratch` for its whole life, deleted on drop —
674 // so no other process writes or truncates it under the map (the
675 // same argument as the read-only snapshot map).
676 let map = unsafe { Mmap::map(&file) }.map_err(|e| HostError::io(&self.path, e))?;
677 self.map = Some(map);
678 }
679 Ok(&self.map.as_ref().expect("just set")[..])
680 }
681}
682
683impl Drop for FileScratch {
684 fn drop(&mut self) {
685 // Unmap before delete: Windows refuses to remove a mapped file (the
686 // same constraint as renaming over one).
687 self.map = None;
688 self.writer = None;
689 let _ = std::fs::remove_file(&self.path);
690 }
691}