plugmem_host/readonly.rs
1//! [`ReadOnlyDatabase`]: a zero-copy read-only open over an mmap'd
2//! snapshot.
3//!
4//! A normal [`Database`](crate::Database) open reads the whole snapshot
5//! into RAM (every byte pool is copied into an arena). For a large,
6//! read-mostly database that is wasteful: `open_readonly` maps the
7//! snapshot file instead and lets the engine's byte pools *borrow* the
8//! mapped pages, so the OS residents only the bytes `recall`/`get`
9//! actually touch. An 8 GiB database opens in milliseconds with a few
10//! pages resident, not 8 GiB.
11//!
12//! The handle is read-only by construction — it exposes `recall`/`get`/
13//! `stats` and nothing that mutates. It requires a *checkpointed*
14//! database (empty journal): replaying a journal would copy whole arenas
15//! up (copy-on-write) and defeat the zero-copy intent, so a non-empty
16//! journal is refused with [`HostError::NeedsCheckpoint`].
17//!
18//! Locking is a **shared** advisory lock held for the handle's whole life
19//! many read-only handles — in this process or others — map
20//! the same file at once, so a read-mostly database serves concurrent
21//! readers. A shared lock still excludes every exclusive (read-write)
22//! owner, so no cooperating process writes or truncates the file while it
23//! is mapped — which is exactly the safety argument for the mmap (see the
24//! `unsafe` block in [`ReadOnlyDatabase::open`]).
25//!
26//! # When you actually need this — [`Database`] vs [`ReadOnlyDatabase`]
27//!
28//! Most callers do **not** need a read-only handle. The distinction is about
29//! **who else has the file open**, where "who else" means a **separate OS
30//! process** — a different running program (a different PID): a second copy of
31//! the CLI, an MCP server, another service — *not* another thread or another
32//! `Database` value inside your own program.
33//!
34//! - **One process reads and writes → just [`Database::open`](crate::Database::open).**
35//! A read-write handle keeps an *overlay* (the mapped snapshot plus the journal
36//! replayed in RAM), so `remember` is visible to the very next `recall` on that
37//! same handle, with no checkpoint and no second open. This is **read-your-writes**:
38//! an agent that stores a fact and immediately recalls it needs one handle and
39//! sees its own write instantly. Opening the same database *twice* from one
40//! process — once read-write, once read-only — is pointless and is **not** how
41//! you get freshness; it only costs you a stale second view.
42//!
43//! - **Another process must read the same file while a writer is live →
44//! [`Database::open_readonly`](crate::Database::open_readonly).** A separate
45//! program cannot share the writer's in-RAM overlay (it is another address
46//! space entirely), so it maps the last *published* generation instead. Such a
47//! handle is a **point-in-time snapshot**: it observes the database "as of the
48//! last checkpoint" and never moves on its own — the writer publishing a newer
49//! generation does not disturb the snapshot you are already reading. To advance
50//! to a freshly published generation, call [`ReadOnlyDatabase::refresh`], which
51//! is a cheap 24-byte manifest read that re-maps only when the writer has
52//! actually published something newer (see its docs).
53//!
54//! In short: `refresh`, `open_readonly`, and snapshot-isolation lag exist **only**
55//! for a reader looking at *another process's* writer. Within a single process,
56//! [`Database`] alone is always fresh.
57
58use std::cell::RefCell;
59use std::fs::File;
60use std::path::{Path, PathBuf};
61#[cfg(feature = "counters")]
62use std::sync::Mutex;
63
64use memmap2::Mmap;
65use plugmem_core::snapshot::{DEFAULT_SCRUB_BUDGET, ScrubCursor, ScrubProgress, Snapshot};
66use plugmem_core::{Config, FactId, Memory, RecallQuery, RecallResult, RecallScratch, Stats};
67
68thread_local! {
69 /// Per-thread recall scratch — the read-only analog of the one in
70 /// [`crate::db`]. `recall` borrows the mapped engine shared (`&Memory`), so
71 /// many threads recall one handle at once, each reusing its own scratch.
72 static RECALL_SCRATCH: RefCell<RecallScratch> = RefCell::new(RecallScratch::new());
73}
74
75use crate::db::FactSnapshot;
76use crate::error::HostError;
77use crate::storage::{pin_current_generation, read_manifest};
78
79self_cell::self_cell!(
80 /// Owns the memory map and the [`Memory`] that borrows it. `self_cell`
81 /// keeps the self-reference safe: the only `unsafe` on this path is
82 /// the inherent mmap call, not the borrow.
83 struct MappedMemory {
84 owner: Mmap,
85 #[covariant]
86 dependent: BorrowedMemory,
87 }
88);
89
90/// The dependent type constructor `self_cell` reborrows per access.
91/// [`Memory`] is covariant in its lifetime (its byte pools are
92/// `Cow<'a, [u8]>`), so borrowing the map is sound.
93type BorrowedMemory<'a> = Memory<'a>;
94
95/// A read-only database handle backed by a memory-mapped snapshot
96/// See the module docs. `Send + Sync` — share it across
97/// threads behind a reference or an `Arc`.
98pub struct ReadOnlyDatabase {
99 /// The map and the engine borrowing it. Normally no lock: every verb
100 /// borrows it shared (`&Memory`) — `recall` keeps its mutable scratch
101 /// per-thread — so many threads read one handle concurrently. Under
102 /// `counters` the engine embeds the arena's non-`Sync` counter `Cells`, so
103 /// it is wrapped in a `Mutex` to stay `Sync` (readers serialize — fine for
104 /// that single-threaded perf build). Purely internal: the public API is the
105 /// same under every feature.
106 #[cfg(not(feature = "counters"))]
107 mapped: MappedMemory,
108 #[cfg(feature = "counters")]
109 mapped: Mutex<MappedMemory>,
110 /// Holds a **shared** lock on the mapped generation file for this handle's
111 /// whole life — never read, but it *pins* the generation against the
112 /// writer's GC (the writer's exclusive try-lock fails while we hold this),
113 /// so the immutable snapshot we borrow can never be reclaimed under us.
114 _pin: File,
115 /// The database base (manifest) path.
116 path: PathBuf,
117 /// The generation number this handle is pinned to — the snapshot it maps.
118 /// Compared against the manifest by [`ReadOnlyDatabase::refresh`] to tell
119 /// whether the writer has published anything newer.
120 generation: u64,
121 /// Kept so [`ReadOnlyDatabase::refresh`] can rebuild the borrowed engine
122 /// over a freshly mapped generation with the same configuration.
123 cfg: Config,
124}
125
126impl ReadOnlyDatabase {
127 /// Opens the database at `path` read-only over an mmap.
128 ///
129 /// # Errors
130 ///
131 /// [`HostError::NeedsCheckpoint`] when the database has no published
132 /// snapshot generation yet (checkpoint it once, then retry); [`HostError::Io`]
133 /// when the generation file cannot be mapped; [`HostError::Engine`] for a
134 /// corrupt image or a config mismatch.
135 pub(crate) fn open(path: impl Into<PathBuf>, cfg: Config) -> Result<Self, HostError> {
136 let base: PathBuf = path.into();
137 // Pin the current generation with a shared lock (no writer lock — a
138 // reader coexists with the writer). The reader maps this immutable
139 // generation and ignores the journal, which belongs to the *next*
140 // generation the writer is building: this is the snapshot-isolation
141 // reader, "as of the last published checkpoint".
142 let Some((pin, genp, generation)) = pin_current_generation(&base)? else {
143 // No published generation yet — checkpoint the database first.
144 return Err(HostError::NeedsCheckpoint { path: base });
145 };
146
147 // SAFETY: mapping a file is inherently unsafe — a concurrent truncate or
148 // overwrite would fault the process on the next page access. Our
149 // argument: a generation file is **immutable** (a
150 // checkpoint publishes a *new* generation, never rewrites this one), and
151 // `pin` holds a shared lock on it for this handle's whole life, so the
152 // writer's GC cannot reclaim it under us. A foreign `truncate`/`rm` is
153 // out of contract — the same caveat as corrupting any live database file.
154 let map = unsafe { Mmap::map(&pin) }.map_err(|e| HostError::io(&genp, e))?;
155 let mapped = MappedMemory::try_new(map, |map| {
156 Memory::from_bytes_borrowed(&map[..], &[], cfg.clone())
157 })?;
158
159 Ok(Self {
160 #[cfg(not(feature = "counters"))]
161 mapped,
162 #[cfg(feature = "counters")]
163 mapped: Mutex::new(mapped),
164 _pin: pin,
165 path: base,
166 generation,
167 cfg,
168 })
169 }
170
171 /// Runs `f` over the mapped engine (`&Memory`). Normally a lock-free shared
172 /// borrow (concurrent readers); under `counters` it takes the `Mutex` first.
173 /// Private — the lock strategy never reaches the public API.
174 #[cfg(not(feature = "counters"))]
175 fn with_mem<R>(&self, f: impl FnOnce(&Memory<'_>) -> R) -> R {
176 f(self.mapped.borrow_dependent())
177 }
178
179 #[cfg(feature = "counters")]
180 fn with_mem<R>(&self, f: impl FnOnce(&Memory<'_>) -> R) -> R {
181 let guard = self.mapped.lock().unwrap_or_else(|e| e.into_inner());
182 f(guard.borrow_dependent())
183 }
184
185 /// Runs a recall. Same semantics as
186 /// [`Database::recall`](crate::Database::recall) minus the embedder:
187 /// a text-only query is not auto-embedded, so pass a vector for the
188 /// vector source.
189 pub fn recall(&self, q: RecallQuery<'_>) -> Result<RecallResult, HostError> {
190 self.with_mem(|mem| {
191 RECALL_SCRATCH.with(|scratch| {
192 let mut scratch = scratch.borrow_mut();
193 let mut out = RecallResult::default();
194 mem.recall_into(q, &mut scratch, &mut out)?;
195 Ok(out)
196 })
197 })
198 }
199
200 /// An owned copy of one fact, or `None` for unknown/tombstoned ids.
201 pub fn get(&self, id: FactId) -> Option<FactSnapshot> {
202 self.with_mem(|mem| {
203 mem.get(id).map(|v| FactSnapshot {
204 record: v.record,
205 text: v.text.to_string(),
206 metadata: crate::db::metadata_map(mem, id),
207 })
208 })
209 }
210
211 /// Engine size counters.
212 pub fn stats(&self) -> Stats {
213 self.with_mem(|mem| mem.stats())
214 }
215
216 /// One fact's tags, or an empty vector for an unknown or tombstoned id.
217 pub fn tags_of(&self, id: FactId) -> Vec<String> {
218 self.with_mem(|mem| {
219 let mut terms = Vec::new();
220 mem.tags_of(id, &mut terms);
221 terms
222 .iter()
223 .map(|term| mem.term(*term).to_string())
224 .collect()
225 })
226 }
227
228 /// Runs the on-demand integrity check — the equivalent of
229 /// SQLite's `integrity_check`. A read-only open validates only the metadata
230 /// (the mapped text and vector pools stay non-resident); this sweeps them
231 /// and reports any latent corruption. Reads the whole image, so it residents
232 /// the pools it checks.
233 ///
234 /// # Errors
235 ///
236 /// [`HostError::Engine`] for the first inconsistency found.
237 pub fn verify(&self) -> Result<(), HostError> {
238 Ok(self.with_mem(|mem| mem.verify())?)
239 }
240
241 /// A resumable byte-level container scrub of the snapshot file, with the
242 /// default slice budget (— the ZFS-scrub model). See
243 /// [`Scrub`] and [`ReadOnlyDatabase::scrub_with_budget`].
244 ///
245 /// # Errors
246 ///
247 /// [`HostError::Locked`]/[`HostError::Io`]/[`HostError::Engine`] if the
248 /// file cannot be locked, mapped, or structurally parsed for the scan.
249 pub fn scrub(&self) -> Result<Scrub, HostError> {
250 self.scrub_with_budget(DEFAULT_SCRUB_BUDGET)
251 }
252
253 /// A resumable container scrub hashing at most `budget` bytes per
254 /// [`Iterator::next`].
255 ///
256 /// The returned [`Scrub`] owns its own map and its own shared advisory
257 /// lock over the same file, so it holds a reader's lock for its whole
258 /// life (a writer is refused with [`HostError::Locked`] while any scrub
259 /// or read-only handle lives) and can be moved to its own thread — the
260 /// caller paces the scan (`next`, pause, resume, cancel) exactly like
261 /// the core [`ScrubCursor`]. Dropping it releases the lock.
262 ///
263 /// It is independent of `self`: the scrub keeps running after this handle
264 /// is dropped. A non-empty journal is not an obstacle — the scrub checks
265 /// the on-disk snapshot container as-is.
266 ///
267 /// # Errors
268 ///
269 /// As [`ReadOnlyDatabase::scrub`].
270 pub fn scrub_with_budget(&self, budget: usize) -> Result<Scrub, HostError> {
271 Scrub::open(&self.path, budget)
272 }
273
274 /// Dumps the currently-open facts for a human-readable backup
275 /// See [`ExportedFact`](crate::ExportedFact). Collects the whole
276 /// set; for a large database prefer [`export_each`](Self::export_each).
277 pub fn export(&self) -> Vec<crate::db::ExportedFact> {
278 self.with_mem(crate::db::export_facts)
279 }
280
281 /// Streams the currently-open facts, calling `f` once per fact under the map
282 /// — the whole dump is never materialized (the zero-copy analog of
283 /// [`Database::export_each`](crate::Database::export_each)).
284 pub fn export_each(&self, f: impl FnMut(crate::db::ExportedFact)) {
285 self.with_mem(|mem| crate::db::export_facts_each(mem, f));
286 }
287
288 /// Streams the currently-open edges — the zero-copy analog of
289 /// [`Database::export_edges_each`](crate::Database::export_edges_each), and
290 /// the path the CLI takes, since `export` runs read-only whenever it can.
291 pub fn export_edges_each(&self, mut f: impl FnMut(&str, &str, &str, plugmem_core::FactId)) {
292 self.with_mem(|mem| {
293 mem.edges_each(|src, rel, dst, fact| {
294 f(src, rel, dst, fact);
295 true
296 });
297 });
298 }
299
300 /// Returns at most `limit` open facts starting at the opaque fact-id
301 /// `cursor`. The mapped generation is immutable, so paging this handle is a
302 /// snapshot-consistent bounded export. Pass the returned `next_cursor` to
303 /// continue; `None` means the scan is complete.
304 pub fn export_page(&self, cursor: u32, limit: std::num::NonZeroUsize) -> crate::db::ExportPage {
305 self.with_mem(|mem| crate::db::export_facts_page(mem, cursor, limit.get()))
306 }
307
308 /// The database base path.
309 pub fn path(&self) -> &Path {
310 &self.path
311 }
312
313 /// The snapshot generation this handle is pinned to — the point in time it
314 /// reads "as of". Monotonic: a writer's checkpoint publishes a strictly
315 /// higher number. Compare it against a later call, or drive your own
316 /// freshness policy around [`refresh`](Self::refresh) with it.
317 pub fn generation(&self) -> u64 {
318 self.generation
319 }
320
321 /// Advances this handle to the writer's latest published generation, if
322 /// there is a newer one. Returns `true` when it re-mapped onto a newer
323 /// snapshot (subsequent reads now observe it), `false` when nothing changed.
324 ///
325 /// This is the **only** way a read-only handle moves forward in time: an
326 /// open handle is a point-in-time snapshot and never advances on its own
327 /// (see the module docs). It exists for a reader watching **another
328 /// process's** writer; a single process that reads and writes uses one
329 /// [`Database`](crate::Database) handle and sees its own writes instantly,
330 /// with no `refresh` at all.
331 ///
332 /// It is cheap to call speculatively — the freshness check is a read of the
333 /// tiny fixed-size manifest (a handful of bytes), and the `mmap` re-map
334 /// happens *only* when the writer has actually published a newer generation.
335 /// In steady state (no new checkpoint) it does no mapping and returns `false`
336 /// for the cost of that manifest read, so calling it before each read is a
337 /// reasonable "always fresh" policy; batching (refresh every N reads, or on a
338 /// timer) trades a bounded staleness for even fewer manifest reads. Re-mapping
339 /// borrows the new generation's pages lazily — no whole-file copy, no journal
340 /// replay, no index rebuild — and drops the old map, so RAM does not grow.
341 ///
342 /// The freshness policy is intentionally left to the caller: an autorefresh
343 /// baked into every read would forfeit snapshot isolation for callers who
344 /// need a *stable* view across a series of queries. Keep the reader stable by
345 /// not calling this; advance it by calling it.
346 ///
347 /// # Errors
348 ///
349 /// [`HostError::Io`] if the newer generation cannot be mapped;
350 /// [`HostError::Engine`] for a corrupt image. On any error the handle is
351 /// left untouched on its current generation (the re-map is built before it
352 /// replaces the live one).
353 pub fn refresh(&mut self) -> Result<bool, HostError> {
354 // Cheap detect: read the fixed-size manifest and bail unless the writer
355 // has published a strictly newer generation.
356 match read_manifest(&self.path)? {
357 Some(latest) if latest > self.generation => {}
358 _ => return Ok(false),
359 }
360 // Pin and map the current published generation. `pin_current_generation`
361 // re-reads the manifest and retries the GC race, so the pinned number is
362 // the freshest one on disk — which may even exceed the value we just
363 // read. If it is not actually newer than ours (a checkpoint raced back,
364 // impossible given monotonicity but cheap to guard), report no change.
365 let Some((pin, genp, generation)) = pin_current_generation(&self.path)? else {
366 return Ok(false);
367 };
368 if generation <= self.generation {
369 return Ok(false);
370 }
371 // SAFETY: identical to `open` — a generation file is immutable (a
372 // checkpoint publishes a *new* generation, never rewrites this one), and
373 // `pin` holds a shared lock on it for as long as we keep it, so the
374 // writer's GC cannot reclaim it under us. Built before we swap it in, so
375 // a failure leaves the live map intact.
376 let map = unsafe { Mmap::map(&pin) }.map_err(|e| HostError::io(&genp, e))?;
377 let cfg = self.cfg.clone();
378 let mapped =
379 MappedMemory::try_new(map, |map| Memory::from_bytes_borrowed(&map[..], &[], cfg))?;
380 // Commit: replace the map (dropping the old one and its pin) and record
381 // the new generation. The old `_pin`'s shared lock releases here, letting
382 // GC reclaim the generation we just left once nothing else pins it.
383 #[cfg(not(feature = "counters"))]
384 {
385 self.mapped = mapped;
386 }
387 #[cfg(feature = "counters")]
388 {
389 self.mapped = Mutex::new(mapped);
390 }
391 self._pin = pin;
392 self.generation = generation;
393 Ok(true)
394 }
395}
396
397impl std::fmt::Debug for ReadOnlyDatabase {
398 /// Summary only — the contents are the user's memory.
399 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
400 let stats = self.stats();
401 f.debug_struct("ReadOnlyDatabase")
402 .field("path", &self.path)
403 .field("facts", &stats.facts)
404 .field("entities", &stats.entities)
405 .finish()
406 }
407}
408
409self_cell::self_cell!(
410 /// Owns the memory map and the [`ScrubCursor`] that borrows it. As with
411 /// [`MappedMemory`], the only `unsafe` is the inherent mmap call, not the
412 /// self-reference.
413 struct MappedScrub {
414 owner: Mmap,
415 #[covariant]
416 dependent: BorrowedScrub,
417 }
418);
419
420/// The dependent type constructor. [`ScrubCursor`] is covariant in its
421/// lifetime (it borrows the mapped bytes as `&'a [u8]` and owns the rest),
422/// so borrowing the map is sound.
423type BorrowedScrub<'a> = ScrubCursor<'a>;
424
425/// A resumable, byte-level container scrub over a memory-mapped snapshot
426/// (— the ZFS-scrub model). Obtained from
427/// [`ReadOnlyDatabase::scrub`].
428///
429/// It implements [`Iterator`]: each [`Iterator::next`] hashes up to the slice
430/// budget and yields `Ok(ScrubProgress)`, verifying each section's stored
431/// xxh3 as its body completes and the whole-file hash at EOF; the first
432/// mismatch yields `Err(HostError::Engine(Error::Corrupt(..)))` and then
433/// `None` (fused). Because it only reads the mapped bytes linearly, the pages
434/// fault in, get hashed and stay reclaimable — a scrub never residents the
435/// whole file.
436///
437/// It pins its generation with a shared lock for its whole life (independent of
438/// the handle it came from), so the writer's GC cannot reclaim it while it runs.
439/// It is [`Send`] — pace it on its own thread. One-shot: obtain a new scrub to
440/// scan again.
441pub struct Scrub {
442 mapped: MappedScrub,
443 /// Holds the shared lock on the scrubbed generation for the scrub's whole
444 /// life (never read — the pin is the point), independent of the handle.
445 _pin: File,
446}
447
448impl Scrub {
449 /// Pins and maps the current generation at `base`, then builds the cursor.
450 /// See [`ReadOnlyDatabase::scrub_with_budget`].
451 fn open(base: &Path, budget: usize) -> Result<Self, HostError> {
452 // Pin the current generation with a shared lock (coexists with other
453 // readers and the writer; blocks only the writer's GC of this one).
454 let Some((pin, genp, _generation)) = pin_current_generation(base)? else {
455 return Err(HostError::NeedsCheckpoint {
456 path: base.to_path_buf(),
457 });
458 };
459
460 // SAFETY: identical to `ReadOnlyDatabase::open` — a generation file is
461 // immutable, and `pin` holds a shared lock on it for this scrub's whole
462 // life, so GC cannot reclaim it under the map.
463 let map = unsafe { Mmap::map(&pin) }.map_err(|e| HostError::io(&genp, e))?;
464
465 let mapped = MappedScrub::try_new(map, |map| {
466 Snapshot::parse(&map[..])
467 .map(|snap| snap.scrub_with_budget(budget))
468 .map_err(HostError::from)
469 })?;
470
471 Ok(Self { mapped, _pin: pin })
472 }
473}
474
475impl Iterator for Scrub {
476 type Item = Result<ScrubProgress, HostError>;
477
478 /// Hashes the next slice, mapping a core [`Error`](plugmem_core::Error)
479 /// mismatch into [`HostError::Engine`]. `None` once complete or fused.
480 fn next(&mut self) -> Option<Self::Item> {
481 self.mapped
482 .with_dependent_mut(|_map, cur| cur.next())
483 .map(|step| step.map_err(HostError::from))
484 }
485}
486
487impl std::fmt::Debug for Scrub {
488 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
489 f.debug_struct("Scrub").finish_non_exhaustive()
490 }
491}