Skip to main content

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