Skip to main content

plugmem_host/
db.rs

1//! `Database`: the engine + its file-backed storage layout + the maintenance
2//! policy behind one lock (§3).
3//!
4//! The orchestration model in one paragraph: a `Database` handle is
5//! `Clone + Send + Sync` (an `Arc` around an `RwLock`-guarded engine), so
6//! any number of threads or agents in one process share one local database by
7//! cloning the handle — the read verbs (`recall`/`get`/`stats`/…) run
8//! concurrently under a shared guard, the write verbs serialize under an
9//! exclusive one; at microsecond engine calls neither is a bottleneck. A
10//! second *process* (or a second `Database` on the same path) is refused
11//! with [`HostError::Locked`] by the file lock. Different files are fully
12//! independent — open as many `Database`s as you have files.
13//!
14//! Everything expensive and external — computing embeddings over HTTP —
15//! happens **before** the lock is taken: while one agent waits for its
16//! embedding provider, others keep reading and writing.
17//!
18//! (Under the `counters` perf-gate feature the engine's instrumentation
19//! `Cell`s are not `Sync`, so the lock falls back to a `Mutex` and reads
20//! serialize — a single-threaded measurement build; the public API is
21//! unchanged. See `StateLock`.)
22//!
23//! ## Overlay write path
24//!
25//! Opening a database does **not** copy its snapshot into RAM. `open`
26//! memory-maps the snapshot file and the engine *borrows* the mapped pages
27//! (an overlay over the base), replaying the journal into a small owned
28//! overlay; a mutation lands its appends in an owned tail and copies only
29//! the pages it rewrites (per-page copy-on-write in `plugmem-arena`). So a
30//! multi-gigabyte database is opened and written to while resident only in
31//! the pages it actually touches. A snapshot
32//! materializes the base + overlay into a fresh file and **re-maps** it, so
33//! the overlay collapses and a long write session stays bounded. A brand-new
34//! database has no file to map yet: it opens *owned* and empty, and switches
35//! to the mapped overlay at its first snapshot.
36
37use std::cell::RefCell;
38use std::collections::BTreeMap;
39use std::fs::File;
40use std::path::{Path, PathBuf};
41use std::sync::{Arc, RwLock as EmbedderLock};
42#[cfg(feature = "counters")]
43use std::sync::{Mutex, MutexGuard};
44#[cfg(not(feature = "counters"))]
45use std::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard};
46
47use memmap2::Mmap;
48use plugmem_core::{
49    Config, Error, FactFault, FactRecord, GuardedRememberOutcome, LinkInput, MaintainReport,
50    MaintenanceMode, MaintenanceOptions, MemStorage, Memory, OpenReport, RecallQuery, RecallResult,
51    RecallScratch, ReembedError, ReembedReport, RememberInput, RememberOutcome, RemoveTagReport,
52    Stats, Storage, TagPage, TagQuery, UnlinkInput,
53};
54
55thread_local! {
56    /// Per-thread recall scratch. `recall` takes `&self` on the engine, so many
57    /// reader threads recall one [`Database`] at once; each reuses its own
58    /// scratch here (zero re-alloc after warm-up, no lock on the hot path).
59    static RECALL_SCRATCH: RefCell<RecallScratch> = RefCell::new(RecallScratch::new());
60}
61
62use crate::embedder::Embedder;
63use crate::error::HostError;
64use crate::readonly::{ReadOnlyDatabase, Scrub};
65use crate::storage::{FileScratch, FileStorage, FsyncPolicy};
66
67/// Default maximum number of fact texts sent to the embedding provider in one
68/// explicit reembed request.
69pub const DEFAULT_REEMBED_BATCH_SIZE: usize = 128;
70
71/// Automatically produced vectors plus the provider identity that owns them.
72type EmbeddedBatch = (Vec<Vec<f32>>, String);
73
74self_cell::self_cell!(
75    /// Owns the memory map and the overlay [`Memory`] that borrows it — the
76    /// read-write sibling of `readonly::MappedMemory`. `self_cell` keeps the
77    /// self-reference safe: the only `unsafe` on this path is the inherent
78    /// mmap call, not the borrow.
79    struct OverlayMap {
80        owner: Mmap,
81        #[covariant]
82        dependent: OverlayMemory,
83    }
84);
85
86/// The dependent type constructor `self_cell` reborrows per access.
87/// [`Memory`] is covariant in its lifetime (its byte pools are
88/// `Cow<'a, [u8]>`), so borrowing the map is sound.
89type OverlayMemory<'a> = Memory<'a>;
90
91/// The engine backing a live [`Database`]: either an owned in-RAM engine
92/// (a brand-new database with no snapshot file yet) or an overlay over a
93/// memory-mapped snapshot (the common case). Both are mutable; verbs reach
94/// the engine through [`Engine::with`] / [`Engine::read`], which unify the
95/// two lifetimes (`'static` vs the map's) behind one closure.
96enum Engine {
97    /// No snapshot file to map yet — owned and (initially) empty. Switches to
98    /// `Mapped` at the first snapshot, once the file exists. Boxed so the
99    /// common `Mapped` case does not carry the whole owned engine inline.
100    Owned(Box<Memory<'static>>),
101    /// Overlay over a memory-mapped snapshot: the base is borrowed, mutations
102    /// live in the overlay (owned tail + per-page copy-on-write).
103    Mapped(OverlayMap),
104}
105
106impl Engine {
107    /// Reads through an immutable borrow of the engine (owned or mapped).
108    fn read<R>(&self, f: impl for<'a> FnOnce(&Memory<'a>) -> R) -> R {
109        match self {
110            Engine::Owned(mem) => f(mem),
111            Engine::Mapped(map) => f(map.borrow_dependent()),
112        }
113    }
114
115    /// Mutates the engine and its store together (disjoint borrows). The
116    /// closure is higher-ranked over the engine's lifetime so one body serves
117    /// both the `'static` owned engine and the map-bound overlay.
118    fn with<R>(
119        &mut self,
120        store: &mut FileStorage,
121        f: impl for<'a> FnOnce(&mut Memory<'a>, &mut FileStorage) -> R,
122    ) -> R {
123        match self {
124            Engine::Owned(mem) => f(mem, store),
125            Engine::Mapped(map) => map.with_dependent_mut(|_owner, mem| f(mem, store)),
126        }
127    }
128}
129
130/// Opens the engine at `store`'s path: memory-maps the snapshot
131/// and borrows it as an overlay, replaying the journal. A missing snapshot
132/// file (a brand-new database) opens owned and empty — the file appears at the
133/// first snapshot. `store` must already hold the exclusive lock.
134fn open_engine(store: &mut FileStorage, cfg: &Config) -> Result<(Engine, OpenReport), HostError> {
135    let journal = store.read_journal()?;
136    let Some(genp) = store.current_snapshot_path()? else {
137        // No published generation yet. The database is owned until the first
138        // checkpoint publishes one — but a journal may already exist (mutations
139        // before any snapshot), so still replay it into the owned engine.
140        let (mem, report) = Memory::from_bytes(None, &journal, cfg.clone())?;
141        return Ok((Engine::Owned(Box::new(mem)), report));
142    };
143    let file = File::open(&genp).map_err(|e| HostError::io(&genp, e))?;
144    // SAFETY: mapping a file is inherently unsafe — a concurrent truncate or
145    // overwrite of the mapped file would fault the process (SIGBUS/exception)
146    // on the next page access. Our correctness argument: the
147    // generation file is **immutable** (a checkpoint publishes a new one and
148    // never rewrites this), and the `store` holds the exclusive writer lock, so
149    // nothing overwrites it under the map. A foreign `truncate`/`rm` under a
150    // live handle is out of contract — the same caveat as corrupting any
151    // database file under a running engine.
152    let map = unsafe { Mmap::map(&file) }.map_err(|e| HostError::io(&genp, e))?;
153    // The `File` handle is no longer needed: `Mmap` owns the mapping.
154    drop(file);
155    // Replay the journal into the overlay: no whole-arena clone, only the
156    // touched pages copy up. `self_cell` builds the engine borrowing the map;
157    // the replay report is captured out of the constructor closure.
158    let mut report = None;
159    let mapped = OverlayMap::try_new(map, |m| {
160        let (mem, rep) = Memory::from_bytes_overlay(&m[..], &journal, cfg.clone())?;
161        report = Some(rep);
162        Ok::<_, Error>(mem)
163    })?;
164    Ok((Engine::Mapped(mapped), report.unwrap_or_default()))
165}
166
167/// An owned view of one fact — [`Memory::get`] returns borrows that
168/// cannot cross the lock, so the database hands out copies.
169#[derive(Clone, Debug, PartialEq)]
170#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
171pub struct FactSnapshot {
172    /// The raw record (temporality, flags, references).
173    pub record: FactRecord,
174    /// The fact text.
175    pub text: String,
176    /// The fact's metadata as a sorted key→value map (empty when the fact
177    /// carries none). The engine stores it opaquely; this is the decoded view.
178    pub metadata: BTreeMap<String, String>,
179}
180
181/// One exported fact — the human-readable, id-free shape [`Database::export`]
182/// dumps and an importer re-`remember`s. Internal ids and
183/// `recorded_at` are the engine's bookkeeping and are *not* preserved across
184/// a round-trip; the knowledge itself (text, subject name, tags, validity
185/// start) is.
186#[derive(Clone, Debug, PartialEq)]
187#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
188pub struct ExportedFact {
189    /// The fact's id **in the database it came from**.
190    ///
191    /// Informational, and deliberately not restored on import: a fresh database
192    /// assigns its own. It is here because edges reference their provenance
193    /// fact by id, so a dump that carries edges needs something for them to
194    /// point at — an importer translates old id to new as it goes.
195    pub id: u32,
196    /// The fact text.
197    pub text: String,
198    /// Subject entity name, if the fact had one.
199    pub entity: Option<String>,
200    /// Tag strings.
201    pub tags: Vec<String>,
202    /// Metadata as a sorted key→value map (empty when none) — preserved on
203    /// import.
204    pub metadata: BTreeMap<String, String>,
205    /// When the memory learned it (informational; not restorable on import).
206    pub recorded_at: u64,
207    /// Validity start — preserved on import.
208    pub valid_from: u64,
209}
210
211/// One bounded page of currently-open facts.
212///
213/// `next_cursor` is the next fact id to inspect, not an offset into `facts`:
214/// closed, tombstoned, and purged ids are skipped without making the caller
215/// rescan them. `None` means the scan reached the database's current end.
216#[derive(Clone, Debug, PartialEq)]
217#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
218pub struct ExportPage {
219    /// The open facts found in this page, in fact-id order.
220    pub facts: Vec<ExportedFact>,
221    /// Pass this to the next [`Database::export_page`] call.
222    pub next_cursor: Option<u32>,
223}
224
225/// The outcome of a [`Database::recover`] salvage.
226#[derive(Clone, Copy, Debug, PartialEq, Eq)]
227#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
228pub struct RecoverReport {
229    /// Facts written to the destination (the survivors after the purge).
230    pub kept: usize,
231    /// Facts dropped because their stored text was not valid UTF-8.
232    pub dropped_text: usize,
233    /// Facts dropped because their vector slot was out of range or mismatched.
234    pub dropped_vector: usize,
235    /// Facts dropped because their metadata blob did not decode to a
236    /// well-formed key→value map.
237    pub dropped_metadata: usize,
238}
239
240/// Visits the currently-open facts (skipping closed revisions and tombstones),
241/// resolving each subject name and tag string, calling `f` once per fact. The
242/// streaming core of export: a caller that writes each fact out (CLI `export`)
243/// never materializes the whole dump, so a huge database exports without a RAM
244/// spike. Shared by the read-write and read-only handles.
245/// Decodes a fact's metadata into an owned, sorted key→value map (empty when
246/// the fact carries none). Shared by `get` (read-write and read-only) and
247/// `export`; the pairs come back from the engine in canonical order, so the
248/// resulting `BTreeMap` matches the raw core view key-for-key.
249pub(crate) fn metadata_map(mem: &Memory, id: plugmem_core::FactId) -> BTreeMap<String, String> {
250    let mut pairs = Vec::new();
251    mem.metadata_of(id, &mut pairs);
252    pairs
253        .into_iter()
254        .map(|(k, v)| (k.to_string(), v.to_string()))
255        .collect()
256}
257
258fn exported_fact(
259    mem: &Memory,
260    id: plugmem_core::FactId,
261    terms: &mut Vec<plugmem_core::TermId>,
262) -> Option<ExportedFact> {
263    use plugmem_core::{EntityId, VALID_TO_OPEN};
264    let view = mem.get(id)?;
265    if view.record.valid_to != VALID_TO_OPEN {
266        return None; // a closed revision — export the current state only
267    }
268    let entity = (view.record.entity != EntityId::NONE)
269        .then(|| mem.entity_name(view.record.entity))
270        .flatten()
271        .map(str::to_string);
272    terms.clear();
273    mem.tags_of(id, terms);
274    let tags = terms.iter().map(|t| mem.term(*t).to_string()).collect();
275    Some(ExportedFact {
276        id: id.0,
277        text: view.text.to_string(),
278        entity,
279        tags,
280        metadata: metadata_map(mem, id),
281        recorded_at: view.record.recorded_at,
282        valid_from: view.record.valid_from,
283    })
284}
285
286pub(crate) fn export_facts_each(mem: &Memory, mut f: impl FnMut(ExportedFact)) {
287    use plugmem_core::FactId;
288    let next = mem.stats().next_fact;
289    let mut terms = Vec::new();
290    for i in 0..next {
291        if let Some(fact) = exported_fact(mem, FactId(i), &mut terms) {
292            f(fact);
293        }
294    }
295}
296
297pub(crate) fn export_facts_page(mem: &Memory, cursor: u32, limit: usize) -> ExportPage {
298    use plugmem_core::FactId;
299    let end = mem.stats().next_fact;
300    let mut cursor = cursor.min(end);
301    let stop = cursor
302        .saturating_add(limit.min(u32::MAX as usize) as u32)
303        .min(end);
304    let mut terms = Vec::new();
305    let mut facts = Vec::with_capacity((stop - cursor) as usize);
306    while cursor < stop {
307        let id = FactId(cursor);
308        cursor += 1;
309        if let Some(fact) = exported_fact(mem, id, &mut terms) {
310            facts.push(fact);
311        }
312    }
313    ExportPage {
314        facts,
315        next_cursor: (cursor < end).then_some(cursor),
316    }
317}
318
319/// Collects the currently-open facts into a `Vec` (the owning form of
320/// [`export_facts_each`]). Used where the whole dump is wanted in memory.
321pub(crate) fn export_facts(mem: &Memory) -> Vec<ExportedFact> {
322    let mut out = Vec::new();
323    export_facts_each(mem, |e| out.push(e));
324    out
325}
326
327/// Tuning knobs of a [`Database`]. Construct through
328/// [`Database::builder`].
329pub struct DatabaseBuilder {
330    cfg: Config,
331    fsync: FsyncPolicy,
332    snapshot_every_ops: u64,
333    snapshot_journal_bytes: u64,
334    maintain_every_forgets: Option<u64>,
335    embedder: Option<Box<dyn Embedder>>,
336}
337
338impl DatabaseBuilder {
339    /// Journal fsync policy (default: every operation).
340    pub fn fsync(mut self, policy: FsyncPolicy) -> Self {
341        self.fsync = policy;
342        self
343    }
344
345    /// Auto-snapshot after this many mutations (default 1024; `0`
346    /// disables the count trigger).
347    pub fn snapshot_every_ops(mut self, ops: u64) -> Self {
348        self.snapshot_every_ops = ops;
349        self
350    }
351
352    /// Auto-snapshot when the journal outgrows this many bytes (default
353    /// 4 MiB; `0` disables the size trigger).
354    pub fn snapshot_journal_bytes(mut self, bytes: u64) -> Self {
355        self.snapshot_journal_bytes = bytes;
356        self
357    }
358
359    /// Optional auto-`maintain` after this many forgets (default off —
360    /// maintenance is O(database) and the first pass beyond the HNSW
361    /// threshold pays the graph build).
362    pub fn maintain_every_forgets(mut self, forgets: u64) -> Self {
363        self.maintain_every_forgets = Some(forgets);
364        self
365    }
366
367    /// The embedding provider. When set (and its `dim() > 0`),
368    /// `remember` without a vector embeds the fact text and `recall`
369    /// with a text but no vector embeds the query — both outside the
370    /// database lock. `Config::dim` must equal the embedder's dimension.
371    pub fn embedder(mut self, embedder: Box<dyn Embedder>) -> Self {
372        self.embedder = Some(embedder);
373        self
374    }
375
376    /// Opens (or creates) the database at `path`.
377    ///
378    /// # Errors
379    ///
380    /// [`HostError::Locked`] when the file is owned elsewhere;
381    /// [`HostError::Engine`] for config/snapshot/journal problems
382    /// (including an embedder dimension that disagrees with
383    /// `Config::dim`); [`HostError::Io`] for filesystem failures.
384    pub fn open(self, path: impl Into<PathBuf>) -> Result<(Database, OpenReport), HostError> {
385        if let Some(embedder) = &self.embedder {
386            let dim = embedder.dim();
387            if dim != 0 && dim != self.cfg.dim {
388                return Err(HostError::Engine(Error::ConfigMismatch(
389                    "embedder dimension must equal Config::dim",
390                )));
391            }
392        }
393        let mut store = FileStorage::open(path, self.fsync)?;
394        let (engine, report) = open_engine(&mut store, &self.cfg)?;
395        let actual_cfg = engine.read(|mem| mem.cfg().clone());
396        let db = Database {
397            inner: Arc::new(Inner {
398                state: StateLock::new(State {
399                    engine,
400                    store,
401                    cfg: actual_cfg,
402                    ops: 0,
403                    forgets: 0,
404                    reembedding: false,
405                }),
406                embedder: EmbedderLock::new(self.embedder.map(Arc::from)),
407                snapshot_every_ops: self.snapshot_every_ops,
408                snapshot_journal_bytes: self.snapshot_journal_bytes,
409                maintain_every_forgets: self.maintain_every_forgets,
410            }),
411        };
412        Ok((db, report))
413    }
414}
415
416/// The engine lock. Normally an `RwLock` so read-only verbs run concurrently
417/// (the whole point of Variant 1). Under `counters`, `State` embeds the arena's
418/// non-`Sync` instrumentation `Cell`s, and `RwLock<T>` needs `T: Sync` to hand
419/// out shared guards — so there we fall back to a `Mutex`. `counters` is a
420/// single-threaded perf-gate build, so serialized readers cost nothing there,
421/// and the `Mutex` keeps `Database: Send + Sync` so every test still builds.
422#[cfg(not(feature = "counters"))]
423type StateLock = RwLock<State>;
424#[cfg(feature = "counters")]
425type StateLock = Mutex<State>;
426
427struct Inner {
428    state: StateLock,
429    /// Unlocked: [`Embedder::embed`] takes `&self`, so several verbs may be
430    /// inside the provider at once. That is the point — the round trip is the
431    /// slow part of a write, and a lock here would queue every concurrent
432    /// caller behind one HTTP request.
433    embedder: EmbedderLock<Option<Arc<dyn Embedder>>>,
434    snapshot_every_ops: u64,
435    snapshot_journal_bytes: u64,
436    maintain_every_forgets: Option<u64>,
437}
438
439struct State {
440    engine: Engine,
441    store: FileStorage,
442    /// Actual persisted structural configuration. `dim` may differ from the
443    /// target settings while an explicit reembed is pending.
444    cfg: Config,
445    /// Mutations since the last snapshot.
446    ops: u64,
447    /// Forgets since the last maintain.
448    forgets: u64,
449    /// A reembed releases this lock during model calls; the flag makes every
450    /// competing write fail fast instead of changing its frozen source.
451    reembedding: bool,
452}
453
454/// A clonable, thread-safe handle to one local database. See the module
455/// docs for the concurrency model.
456#[derive(Clone)]
457pub struct Database {
458    inner: Arc<Inner>,
459}
460
461impl Database {
462    /// Opens `path` with every knob at its default and no embedder.
463    pub fn open(path: impl Into<PathBuf>, cfg: Config) -> Result<(Self, OpenReport), HostError> {
464        Self::builder(cfg).open(path)
465    }
466
467    /// Opens `path` read-only over a memory-mapped snapshot:
468    /// the engine borrows the mapped pages instead of copying the file
469    /// into RAM, so a large read-mostly database residents only the pages
470    /// `recall`/`get` touch. Requires a **published snapshot generation** —
471    /// checkpoint the database once — and takes a shared lock, so readers run
472    /// alongside the writer rather than excluding it. A journal written since
473    /// that checkpoint is not an obstacle and not visible either: the handle
474    /// answers as of the generation it mapped. See [`ReadOnlyDatabase`].
475    ///
476    /// # Errors
477    ///
478    /// [`HostError::Locked`], [`HostError::NeedsCheckpoint`],
479    /// [`HostError::Io`], [`HostError::Engine`] — see
480    /// `ReadOnlyDatabase::open` semantics.
481    pub fn open_readonly(
482        path: impl Into<PathBuf>,
483        cfg: Config,
484    ) -> Result<ReadOnlyDatabase, HostError> {
485        ReadOnlyDatabase::open(path, cfg)
486    }
487
488    /// Starts a configured open (knobs).
489    pub fn builder(cfg: Config) -> DatabaseBuilder {
490        DatabaseBuilder {
491            cfg,
492            fsync: FsyncPolicy::default(),
493            snapshot_every_ops: 1024,
494            snapshot_journal_bytes: 4 * 1024 * 1024,
495            maintain_every_forgets: None,
496            embedder: None,
497        }
498    }
499
500    /// A shared (read) guard — for the read-only verbs (`recall`/`get`/
501    /// `stats`/`export`/`verify`). Many run at once; they exclude only writers.
502    /// (Under `counters` the lock is a `Mutex`, so reads serialize — see
503    /// [`StateLock`].) A panicked verb cannot leave the engine half-mutated
504    /// (check first, mutate last is the engine's own law), so a poisoned lock
505    /// is recoverable.
506    #[cfg(not(feature = "counters"))]
507    fn read(&self) -> RwLockReadGuard<'_, State> {
508        self.inner.state.read().unwrap_or_else(|e| e.into_inner())
509    }
510
511    /// An exclusive (write) guard — for the mutating verbs. Serializes writers
512    /// against each other and against every concurrent reader.
513    #[cfg(not(feature = "counters"))]
514    fn write(&self) -> RwLockWriteGuard<'_, State> {
515        self.inner.state.write().unwrap_or_else(|e| e.into_inner())
516    }
517
518    /// Under `counters` the engine lock is a `Mutex`: `read` and `write` both
519    /// take the one exclusive guard (readers serialize — acceptable for the
520    /// single-threaded perf-gate build). See [`StateLock`].
521    #[cfg(feature = "counters")]
522    fn read(&self) -> MutexGuard<'_, State> {
523        self.inner.state.lock().unwrap_or_else(|e| e.into_inner())
524    }
525
526    #[cfg(feature = "counters")]
527    fn write(&self) -> MutexGuard<'_, State> {
528        self.inner.state.lock().unwrap_or_else(|e| e.into_inner())
529    }
530
531    /// A short write guard for ordinary mutations. Reembed deliberately drops
532    /// the guard during model calls, so the flag — not lock waiting — protects
533    /// its frozen source generation.
534    fn write_available(&self) -> Result<impl std::ops::DerefMut<Target = State> + '_, HostError> {
535        let guard = self.write();
536        if guard.reembedding {
537            return Err(HostError::ReembedBusy);
538        }
539        Ok(guard)
540    }
541
542    fn embedder(&self) -> Option<Arc<dyn Embedder>> {
543        self.inner
544            .embedder
545            .read()
546            .unwrap_or_else(|e| e.into_inner())
547            .clone()
548    }
549
550    fn check_vector_space(&self, requested: &str) -> Result<(), HostError> {
551        self.read().engine.read(|mem| match mem.vector_space() {
552            Some(stored) if stored == requested => Ok(()),
553            Some(_) if mem.stats().vectors == 0 => Ok(()),
554            Some(stored) => Err(HostError::Engine(Error::VectorSpaceMismatch {
555                stored: stored.into(),
556                requested: requested.into(),
557            })),
558            None if mem.stats().vectors != 0 => Err(HostError::Engine(Error::UntrackedVectorSpace)),
559            None => Ok(()),
560        })
561    }
562
563    /// Replaces the complete vector axis with the currently configured
564    /// embedder, in bounded provider batches.
565    ///
566    /// This operation is deliberately separate from [`Database::maintain`]:
567    /// neither `Auto` nor any other maintenance mode can invoke a model. The
568    /// source generation remains readable while the provider runs. Competing
569    /// writes fail immediately with [`HostError::ReembedBusy`], and the new
570    /// generation becomes visible in one atomic publication step.
571    pub fn reembed(&self, now: u64) -> Result<ReembedReport, HostError> {
572        self.reembed_with_batch(now, DEFAULT_REEMBED_BATCH_SIZE)
573    }
574
575    /// As [`Database::reembed`], with an explicit provider batch bound.
576    pub fn reembed_with_batch(
577        &self,
578        now: u64,
579        batch_size: usize,
580    ) -> Result<ReembedReport, HostError> {
581        let embedder = self.embedder().ok_or_else(|| {
582            HostError::Embed("reembed requires a configured embedding provider".into())
583        })?;
584        self.reembed_arc(now, embedder, batch_size, false)
585    }
586
587    /// Replaces the vector axis with `target`, then installs that embedder on
588    /// this handle after a successful atomic publication.
589    ///
590    /// `batch_size` bounds both the request body and the temporary vectors
591    /// held in RAM. A provider output is validated against [`Embedder::dim`]
592    /// for every fact; callers do not separately supply a database dimension.
593    pub fn reembed_with(
594        &self,
595        now: u64,
596        target: Box<dyn Embedder>,
597        batch_size: usize,
598    ) -> Result<ReembedReport, HostError> {
599        self.reembed_arc(now, Arc::from(target), batch_size, true)
600    }
601
602    fn reembed_arc(
603        &self,
604        now: u64,
605        target: Arc<dyn Embedder>,
606        batch_size: usize,
607        install_target: bool,
608    ) -> Result<ReembedReport, HostError> {
609        if batch_size == 0 {
610            return Err(HostError::Engine(Error::Invalid(
611                "reembed batch size must be nonzero",
612            )));
613        }
614
615        // Freeze every acknowledged mutation into a private, unpublished
616        // source image, reserve the next generation, and then release the
617        // state lock before the first model call.
618        let (source, source_cfg, base_path, mut staged) = {
619            let mut st = self.write_available()?;
620            st.reembedding = true;
621            let setup = (|| {
622                let source = {
623                    let State { engine, store, .. } = &mut *st;
624                    store.stage_reembed_source(|sink| {
625                        engine
626                            .read(|mem| mem.write_snapshot_to(now, &mut *sink))
627                            .map_err(HostError::from)
628                    })?
629                };
630                let source_cfg = st.cfg.clone();
631                let base_path = st.store.path().to_path_buf();
632                let staged = st.store.begin_detached_snapshot()?;
633                Ok::<_, HostError>((source, source_cfg, base_path, staged))
634            })();
635            match setup {
636                Ok(setup) => setup,
637                Err(error) => {
638                    st.reembedding = false;
639                    return Err(error);
640                }
641            }
642        };
643
644        let build = (|| {
645            let source_path = source.path()?;
646            let file = File::open(source_path).map_err(|e| HostError::io(source_path, e))?;
647            // SAFETY: snapshot generations are immutable. The writer lock is
648            // held for this Database's lifetime and the reembed barrier keeps
649            // this generation current until publication.
650            let source_map =
651                unsafe { Mmap::map(&file) }.map_err(|e| HostError::io(source_path, e))?;
652            let source = Memory::from_bytes_borrowed(&source_map, &[], source_cfg.clone())?;
653            let scratch_path = tmp_sibling(&base_path, "reembed-vectors");
654            let mut vec_scratch = FileScratch::create(scratch_path)?;
655            let report = source
656                .write_reembedded_snapshot(
657                    now,
658                    target.dim(),
659                    target.space_id(),
660                    batch_size,
661                    &mut vec_scratch,
662                    &mut staged,
663                    |texts| target.embed(texts),
664                )
665                .map_err(|error| match error {
666                    ReembedError::Engine(error) => HostError::Engine(error),
667                    ReembedError::Embedder(error) => error,
668                })?;
669            Ok::<_, HostError>((report, staged.prepare()?))
670        })();
671
672        let (report, prepared) = match build {
673            Ok(done) => done,
674            Err(error) => {
675                self.write().reembedding = false;
676                return Err(error);
677            }
678        };
679
680        // The expensive work is complete. Publication, journal clear and
681        // re-map are short and serialized with readers/writers.
682        let journal_clear = {
683            let mut st = self.write();
684            if let Err(error) = st.store.commit_detached_snapshot(prepared) {
685                st.reembedding = false;
686                return Err(error);
687            }
688            let journal_clear = st.store.clear_journal();
689            let mut target_cfg = st.cfg.clone();
690            target_cfg.dim = target.dim();
691            let (engine, _) = match open_engine(&mut st.store, &target_cfg) {
692                Ok(opened) => opened,
693                Err(error) => {
694                    // The new generation is already durable. Keep writes
695                    // blocked rather than journal against an older in-memory
696                    // image; reopening the Database recovers normally.
697                    return Err(error);
698                }
699            };
700            st.engine = engine;
701            st.cfg = target_cfg;
702            st.ops = 0;
703            st.forgets = 0;
704            st.reembedding = false;
705            journal_clear
706        };
707        if install_target {
708            *self
709                .inner
710                .embedder
711                .write()
712                .unwrap_or_else(|e| e.into_inner()) = Some(target);
713        }
714        journal_clear?;
715        Ok(report)
716    }
717
718    /// Embeds `text` outside the state lock, when an embedder is
719    /// configured. `None` = leave the input as it was.
720    fn embed_one(&self, text: &str) -> Result<Option<(Vec<f32>, String)>, HostError> {
721        let Some(embedder) = self.embedder() else {
722            return Ok(None);
723        };
724        if embedder.dim() == 0 {
725            return Ok(None);
726        }
727        let space = embedder.space_id().to_owned();
728        self.check_vector_space(&space)?;
729        let mut vs = embedder.embed(&[text])?;
730        if vs.len() != 1 {
731            return Err(HostError::Embed(format!(
732                "expected 1 embedding, got {}",
733                vs.len()
734            )));
735        }
736        Ok(Some((vs.remove(0), space)))
737    }
738
739    /// Embeds a whole batch of texts in a **single** embedder call — outside the
740    /// lock, like [`embed_one`](Self::embed_one). `Ok(None)` when no embedder is
741    /// configured or `dim == 0`; otherwise a vector aligned one-to-one with
742    /// `texts` (the provider contract, checked by [`OpenAiCompatEmbedder`]). An
743    /// empty `texts` yields an empty vector without a round-trip. This is the one
744    /// HTTP that [`remember_many`](Self::remember_many) makes for a bulk write.
745    fn embed_many(&self, texts: &[&str]) -> Result<Option<EmbeddedBatch>, HostError> {
746        let Some(embedder) = self.embedder() else {
747            return Ok(None);
748        };
749        if embedder.dim() == 0 {
750            return Ok(None);
751        }
752        if texts.is_empty() {
753            return Ok(Some((Vec::new(), embedder.space_id().to_owned())));
754        }
755        let space = embedder.space_id().to_owned();
756        self.check_vector_space(&space)?;
757        let vectors = embedder.embed(texts)?;
758        if vectors.len() != texts.len() {
759            return Err(HostError::Embed(format!(
760                "expected {} embeddings, got {}",
761                texts.len(),
762                vectors.len()
763            )));
764        }
765        Ok(Some((vectors, space)))
766    }
767
768    /// Writes a full snapshot and re-maps the fresh file.
769    ///
770    /// Materializes the borrowed base + overlay into an owned buffer, drops
771    /// the current map, writes the buffer (tmp + fsync + rename) and clears
772    /// the journal, then maps the new file into a fresh overlay. The re-map
773    /// collapses the overlay so a long write session stays bounded, and
774    /// dropping the map **before** the rename keeps the write portable
775    /// (a mapped file cannot be renamed over on Windows).
776    fn resnapshot(&self, st: &mut State, now: u64) -> Result<(), HostError> {
777        // Stream the image straight to the tmp file — never a full-image Vec
778        // This reads through the live map, so it happens
779        // **before** the map is dropped.
780        {
781            let State { engine, store, .. } = &mut *st;
782            store.stage_snapshot(|sink| {
783                engine
784                    .read(|mem| mem.write_snapshot_to(now, &mut *sink))
785                    .map_err(HostError::from)
786            })?;
787        }
788        // Drop the current map before the rename: park a cheap empty engine.
789        // It is replaced by the fresh overlay below — or, if the commit fails,
790        // rebuilt from the intact on-disk snapshot + journal.
791        st.engine = Engine::Owned(Box::new(Memory::new(st.cfg.clone())?));
792        let write = st
793            .store
794            .commit_snapshot()
795            .and_then(|()| st.store.clear_journal());
796        // Re-open regardless: on success the fresh file, on failure the
797        // untouched old file + journal (journal replay is idempotent, so a
798        // failed `clear_journal` does not corrupt state). Then surface the
799        // commit error, if any.
800        let cfg = st.cfg.clone();
801        let (engine, _) = open_engine(&mut st.store, &cfg)?;
802        st.engine = engine;
803        write
804    }
805
806    /// The post-mutation policy hook: counts the op, fires auto-maintain
807    /// and auto-snapshot inside the same critical section.
808    fn after_mutation(&self, st: &mut State, now: u64) -> Result<(), HostError> {
809        st.ops += 1;
810        if let Some(threshold) = self.inner.maintain_every_forgets
811            && st.forgets >= threshold
812        {
813            let State { engine, store, .. } = &mut *st;
814            engine.with(store, |mem, store| mem.maintain(store, now))?;
815            st.forgets = 0;
816        }
817        // A database that outgrows its shard layout re-shards itself. This is
818        // on by default, unlike `maintain_every_forgets`, because without it
819        // nothing would ever move a layout: a growing database would keep the
820        // one it was created with until somebody ran `maintain` by hand, and
821        // the cost of that is silent — memory, and a page directory that keeps
822        // lengthening.
823        //
824        // Affordable because both halves are bounded. The question is O(1)
825        // (stored record counts), so asking on every write is free; and the
826        // answer is self-limiting — the thresholds are a doubling up and a
827        // fourfold drop, so it says yes a handful of times over a database's
828        // whole life. `resharding_settles_instead_of_asking_forever` in the
829        // core suite is the test that keeps that true.
830        let State { engine, store, .. } = &mut *st;
831        if engine.with(store, |mem, _| mem.shard_layout_is_stale()) {
832            engine.with(store, |mem, store| mem.maintain(store, now))?;
833        }
834        let by_ops = self.inner.snapshot_every_ops > 0 && st.ops >= self.inner.snapshot_every_ops;
835        let by_bytes = self.inner.snapshot_journal_bytes > 0
836            && st.store.journal_bytes() >= self.inner.snapshot_journal_bytes;
837        if by_ops || by_bytes {
838            self.resnapshot(st, now)?;
839            st.ops = 0;
840        }
841        Ok(())
842    }
843
844    /// Remembers a fact. Without an explicit vector and with an embedder
845    /// configured, the text is embedded first — outside the lock.
846    pub fn remember(&self, input: RememberInput<'_>) -> Result<RememberOutcome, HostError> {
847        let embedded = match input.vector {
848            Some(_) => None,
849            None => self.embed_one(input.text)?,
850        };
851        let input = RememberInput {
852            vector: embedded
853                .as_ref()
854                .map(|(v, _)| v.as_slice())
855                .or(input.vector),
856            ..input
857        };
858        let mut st = self.write_available()?;
859        let State { engine, store, .. } = &mut *st;
860        if let Some((_, space)) = &embedded {
861            engine.with(store, |mem, store| mem.claim_vector_space(store, space))?;
862        }
863        let out = engine.with(store, |mem, store| mem.remember(store, input))?;
864        self.after_mutation(&mut st, input.now)?;
865        Ok(out)
866    }
867
868    /// Checks the ordinary `remember` similarity thresholds and stores only
869    /// when no live same-entity candidate crosses one. Automatic embedding runs
870    /// before the engine lock; no other write can slip between the check and
871    /// possible journaled mutation because both share one exclusive guard.
872    pub fn remember_guarded(
873        &self,
874        input: RememberInput<'_>,
875    ) -> Result<GuardedRememberOutcome, HostError> {
876        let embedded = match input.vector {
877            Some(_) => None,
878            None => self.embed_one(input.text)?,
879        };
880        let input = RememberInput {
881            vector: embedded
882                .as_ref()
883                .map(|(vector, _)| vector.as_slice())
884                .or(input.vector),
885            ..input
886        };
887        let mut st = self.write_available()?;
888        let vector_space = embedded.as_ref().map(|(_, space)| space.as_str());
889        let State { engine, store, .. } = &mut *st;
890        let out = engine.with(store, |mem, store| {
891            mem.remember_guarded_with_vector_space(store, input, vector_space)
892        })?;
893        if matches!(out, GuardedRememberOutcome::Stored { .. }) {
894            self.after_mutation(&mut st, input.now)?;
895        }
896        Ok(out)
897    }
898
899    /// Remembers a **batch** of facts in one shot — the bulk-write path (CLI
900    /// `import`). Equivalent to [`remember`](Self::remember) on each input in
901    /// order, but far cheaper for a batch: the texts that need embedding are
902    /// embedded together in **one** embedder round-trip (outside the lock), and
903    /// all facts are written under **one** write-guard with **one** post-mutation
904    /// policy pass — instead of N HTTP calls and N critical sections.
905    ///
906    /// Inputs that already carry a `vector` are not re-embedded. **Chunking is
907    /// the caller's job**: this writes the whole slice it is given, so a caller
908    /// that needs bounded memory / a bounded HTTP body passes fixed-size batches
909    /// (CLI `import` streams the file in `--batch`-sized slices).
910    ///
911    /// **Fail-fast:** the first engine error returns `Err`; the facts written
912    /// before it stay written (exactly as separate `remember`s — the journal
913    /// replay is idempotent, so a retried bulk load is safe). Returns one
914    /// [`RememberOutcome`] per input, in order.
915    pub fn remember_many(
916        &self,
917        inputs: Vec<RememberInput<'_>>,
918    ) -> Result<Vec<RememberOutcome>, HostError> {
919        if inputs.is_empty() {
920            return Ok(Vec::new());
921        }
922        // One embedder round-trip for every vector-less input's text, outside the
923        // lock. `to_embed` is the vector-less inputs in order, so its result maps
924        // back onto them by a running cursor below.
925        let to_embed: Vec<&str> = inputs
926            .iter()
927            .filter(|i| i.vector.is_none())
928            .map(|i| i.text)
929            .collect();
930        let embedded = if to_embed.is_empty() {
931            None
932        } else {
933            self.embed_many(&to_embed)?
934        };
935
936        let mut st = self.write_available()?;
937        if let Some((_, space)) = &embedded {
938            let State { engine, store, .. } = &mut *st;
939            engine.with(store, |mem, store| mem.claim_vector_space(store, space))?;
940        }
941        // Batch mode: journal appends skip their per-record fsync; one
942        // `sync_journal` at the end makes the whole batch durable at once.
943        st.store.set_batch(true);
944        let mut out = Vec::with_capacity(inputs.len());
945        let mut cursor = 0usize; // into `embedded`, over vector-less inputs in order
946        let mut latest = 0u64;
947        let mut failed = None;
948        for input in inputs {
949            latest = latest.max(input.now);
950            let vector = if input.vector.is_some() {
951                input.vector
952            } else if let Some((vectors, _)) = &embedded {
953                let v = vectors[cursor].as_slice();
954                cursor += 1;
955                Some(v)
956            } else {
957                None // no embedder — lexical/structural only, as single remember
958            };
959            let input = RememberInput { vector, ..input };
960            let State { engine, store, .. } = &mut *st;
961            match engine.with(store, |mem, store| mem.remember(store, input)) {
962                Ok(o) => out.push(o),
963                Err(e) => {
964                    failed = Some(HostError::from(e));
965                    break;
966                }
967            }
968        }
969        // Always leave batch mode and fsync — this is the batch's durability
970        // point. On fail-fast it makes the facts written before the error durable
971        // (they stay, exactly like separate remembers).
972        st.store.set_batch(false);
973        st.store.sync_journal()?;
974        if let Some(e) = failed {
975            return Err(e);
976        }
977        // One policy pass for the whole batch. The op counter advances by one per
978        // batch; the journal-bytes threshold still fires on a large batch, so a
979        // snapshot is not starved.
980        self.after_mutation(&mut st, latest)?;
981        Ok(out)
982    }
983
984    /// Runs a recall. With a text, no vector and an embedder configured,
985    /// the query text is embedded first — outside the lock.
986    pub fn recall(&self, q: RecallQuery<'_>) -> Result<RecallResult, HostError> {
987        let embedded = match (q.vector, q.text) {
988            (None, Some(text)) => self.embed_one(text)?,
989            _ => None,
990        };
991        let q = RecallQuery {
992            vector: embedded.as_ref().map(|(v, _)| v.as_slice()).or(q.vector),
993            ..q
994        };
995        // A shared guard: concurrent recalls run in parallel. `recall_into`
996        // takes `&self` on the engine and a per-thread scratch, so there is no
997        // writer path and no cross-reader contention on the hot path.
998        let st = self.read();
999        RECALL_SCRATCH.with(|scratch| {
1000            let mut scratch = scratch.borrow_mut();
1001            let mut out = RecallResult::default();
1002            st.engine
1003                .read(|mem| mem.recall_into(q, &mut scratch, &mut out))?;
1004            Ok(out)
1005        })
1006    }
1007
1008    /// Revises `target` (same auto-embedding rule as `remember`).
1009    pub fn revise(
1010        &self,
1011        target: plugmem_core::FactId,
1012        input: RememberInput<'_>,
1013    ) -> Result<RememberOutcome, HostError> {
1014        let embedded = match input.vector {
1015            Some(_) => None,
1016            None => self.embed_one(input.text)?,
1017        };
1018        let input = RememberInput {
1019            vector: embedded
1020                .as_ref()
1021                .map(|(v, _)| v.as_slice())
1022                .or(input.vector),
1023            ..input
1024        };
1025        let mut st = self.write_available()?;
1026        let State { engine, store, .. } = &mut *st;
1027        if let Some((_, space)) = &embedded {
1028            engine.with(store, |mem, store| mem.claim_vector_space(store, space))?;
1029        }
1030        let out = engine.with(store, |mem, store| mem.revise(store, target, input))?;
1031        self.after_mutation(&mut st, input.now)?;
1032        Ok(out)
1033    }
1034
1035    /// Tombstones a fact.
1036    pub fn forget(&self, now: u64, id: plugmem_core::FactId) -> Result<bool, HostError> {
1037        let mut st = self.write_available()?;
1038        let State { engine, store, .. } = &mut *st;
1039        let fresh = engine.with(store, |mem, store| mem.forget(store, now, id))?;
1040        st.forgets += 1;
1041        self.after_mutation(&mut st, now)?;
1042        Ok(fresh)
1043    }
1044
1045    /// Upserts a typed edge.
1046    pub fn link(&self, input: LinkInput<'_>) -> Result<(), HostError> {
1047        let mut st = self.write_available()?;
1048        let State { engine, store, .. } = &mut *st;
1049        engine.with(store, |mem, store| mem.link(store, input))?;
1050        self.after_mutation(&mut st, input.now)?;
1051        Ok(())
1052    }
1053
1054    /// Closes a typed edge. Returns `false` when the edge is already absent.
1055    pub fn unlink(&self, input: UnlinkInput<'_>) -> Result<bool, HostError> {
1056        let mut st = self.write_available()?;
1057        let State { engine, store, .. } = &mut *st;
1058        let fresh = engine.with(store, |mem, store| mem.unlink(store, input))?;
1059        self.after_mutation(&mut st, input.now)?;
1060        Ok(fresh)
1061    }
1062
1063    /// An owned copy of one fact, or `None` for unknown/tombstoned ids.
1064    pub fn get(&self, id: plugmem_core::FactId) -> Option<FactSnapshot> {
1065        self.read().engine.read(|mem| {
1066            mem.get(id).map(|v| FactSnapshot {
1067                record: v.record,
1068                text: v.text.to_string(),
1069                metadata: metadata_map(mem, id),
1070            })
1071        })
1072    }
1073
1074    /// One fact's tags, or an empty vector for an unknown or tombstoned id.
1075    ///
1076    /// [`FactSnapshot`] carries text and metadata but not tags, so without this
1077    /// the only way to read one fact's tags is [`Database::export`] — a full
1078    /// scan to answer a question about a single id.
1079    pub fn tags_of(&self, id: plugmem_core::FactId) -> Vec<String> {
1080        self.read().engine.read(|mem| {
1081            let mut terms = Vec::new();
1082            mem.tags_of(id, &mut terms);
1083            terms.iter().map(|t| mem.term(*t).to_string()).collect()
1084        })
1085    }
1086
1087    /// One bounded, cursor-stable page of current tags.
1088    pub fn list_tags(&self, query: TagQuery<'_>) -> Result<TagPage, HostError> {
1089        self.read()
1090            .engine
1091            .read(|mem| mem.list_tags(query))
1092            .map_err(Into::into)
1093    }
1094
1095    /// Removes a tag from every current fact by creating successor revisions.
1096    pub fn remove_tag(&self, now: u64, tag: &str) -> Result<RemoveTagReport, HostError> {
1097        let mut st = self.write_available()?;
1098        let State { engine, store, .. } = &mut *st;
1099        let report = engine.with(store, |mem, store| mem.remove_tag(store, now, tag))?;
1100        if report.affected != 0 {
1101            self.after_mutation(&mut st, now)?;
1102        }
1103        Ok(report)
1104    }
1105
1106    /// Engine size counters.
1107    pub fn stats(&self) -> Stats {
1108        self.read().engine.read(|mem| mem.stats())
1109    }
1110
1111    /// Dumps the currently-open facts for a human-readable backup
1112    /// See [`ExportedFact`]. Collects the whole set; for a large
1113    /// database prefer [`export_each`](Self::export_each), which streams.
1114    pub fn export(&self) -> Vec<ExportedFact> {
1115        self.read().engine.read(export_facts)
1116    }
1117
1118    /// Streams the currently-open facts, calling `f` once per fact under the
1119    /// read guard — the whole dump is never materialized, so a huge database
1120    /// exports without a RAM spike (CLI `export` writes each line straight out).
1121    /// See [`ExportedFact`].
1122    pub fn export_each(&self, f: impl FnMut(ExportedFact)) {
1123        self.read().engine.read(|mem| export_facts_each(mem, f));
1124    }
1125
1126    /// Streams the currently-open **edges**, calling `f` with
1127    /// `(source, relation, destination, provenance fact)` under the read guard.
1128    ///
1129    /// The companion to [`export_each`](Self::export_each): facts alone are not
1130    /// the memory, and a dump without edges silently drops one of the four
1131    /// recall sources. Names are borrowed, so a writer that formats them
1132    /// directly allocates nothing per edge.
1133    ///
1134    /// `provenance` is the fact id **as this database numbers it**. Ids do not
1135    /// survive a re-import, so a file format that wants to keep provenance has
1136    /// to translate it — see the CLI's `export`/`import`, which rewrite it as a
1137    /// position within the same file.
1138    pub fn export_edges_each(&self, mut f: impl FnMut(&str, &str, &str, plugmem_core::FactId)) {
1139        self.read().engine.read(|mem| {
1140            mem.edges_each(|src, rel, dst, fact| {
1141                f(src, rel, dst, fact);
1142                true
1143            });
1144        });
1145    }
1146
1147    /// Inspects at most `limit` fact ids starting at `cursor` and returns the
1148    /// ones that are currently open. A sparse page may therefore contain fewer
1149    /// facts, including zero, while still carrying a `next_cursor`.
1150    ///
1151    /// This is the pull-based counterpart to [`export_each`](Self::export_each):
1152    /// it releases the database read guard before returning, so a boundary
1153    /// caller can process the page, apply backpressure, or write to the database
1154    /// without a callback running under this lock. Mutations between page calls
1155    /// are visible to later pages; use a stable read-only checkpoint when
1156    /// snapshot-consistent paging is required.
1157    pub fn export_page(&self, cursor: u32, limit: std::num::NonZeroUsize) -> ExportPage {
1158        self.read()
1159            .engine
1160            .read(|mem| export_facts_page(mem, cursor, limit.get()))
1161    }
1162
1163    /// Runs a maintenance pass now (cheap no-op, purge/compaction, text
1164    /// reindex, and/or bounded HNSW work — for the cost model).
1165    ///
1166    /// **Disk-first** (milestone H): the compacted image is written by streaming
1167    /// the two big pools (vectors, text) through temp files and then re-mapped,
1168    /// so peak RAM tracks the record count (metadata + graph), not the image
1169    /// size — a database larger than RAM can be maintained. It writes a fresh
1170    /// snapshot and clears the journal (like a checkpoint). The optional
1171    /// auto-maintain policy (`maintain_every_forgets`) still runs in RAM inline
1172    /// — it is for databases that fit.
1173    ///
1174    /// The report's byte counts are the on-disk image size before and after.
1175    pub fn maintain(&self, now: u64) -> Result<MaintainReport, HostError> {
1176        self.maintain_with_options(now, MaintenanceOptions::auto())
1177    }
1178
1179    /// Runs a maintenance pass with explicit policy.
1180    pub fn maintain_with_options(
1181        &self,
1182        now: u64,
1183        options: MaintenanceOptions,
1184    ) -> Result<MaintainReport, HostError> {
1185        let mut st = self.write_available()?;
1186        // The image size is the current snapshot generation's, not the tiny
1187        // manifest at the base path.
1188        let snap_len = |store: &FileStorage| -> usize {
1189            store
1190                .current_snapshot_path()
1191                .ok()
1192                .flatten()
1193                .and_then(|p| std::fs::metadata(&p).ok())
1194                .map(|m| m.len() as usize)
1195                .unwrap_or(0)
1196        };
1197        let bytes_before = snap_len(&st.store);
1198        if !st.engine.read(|mem| mem.maintenance_needed(options)) {
1199            let mut report = st
1200                .engine
1201                .read(|mem| mem.maintenance_preview(options, bytes_before));
1202            report.bytes_after = bytes_before;
1203            return Ok(report);
1204        }
1205        let stats = st.engine.read(|mem| mem.stats());
1206        let needs_disk_first =
1207            stats.tombstones != 0 || matches!(options.mode, MaintenanceMode::Full);
1208        if !needs_disk_first {
1209            let mut report = {
1210                let State { engine, store, .. } = &mut *st;
1211                engine.with(store, |mem, store| {
1212                    mem.maintain_with_options(store, now, options)
1213                })?
1214            };
1215            self.resnapshot(&mut st, now)?;
1216            st.forgets = 0;
1217            st.ops = 0;
1218            report.bytes_before = bytes_before;
1219            report.bytes_after = snap_len(&st.store);
1220            return Ok(report);
1221        }
1222        let text_tmp = tmp_sibling(st.store.path(), "mtext");
1223        let vec_tmp = tmp_sibling(st.store.path(), "mvec");
1224
1225        // Stage a compacted snapshot, streaming the big pools through scratch;
1226        // this reads through the live map, so it happens before the map is
1227        // dropped (as in `resnapshot`).
1228        let mut purged = 0usize;
1229        let mut report = MaintainReport::default();
1230        {
1231            let State { engine, store, .. } = &mut *st;
1232            store.stage_snapshot(|sink| {
1233                engine.read(|mem| {
1234                    let mut text_scratch = FileScratch::create(&text_tmp)?;
1235                    let mut vec_scratch = FileScratch::create(&vec_tmp)?;
1236                    report = mem
1237                        .snapshot_disk_first_with_options(
1238                            now,
1239                            &mut text_scratch,
1240                            &mut vec_scratch,
1241                            &mut *sink,
1242                            options,
1243                        )
1244                        .map_err(HostError::from)?;
1245                    purged = report.purged;
1246                    Ok(())
1247                })
1248            })?;
1249        }
1250        // Drop the current map before the rename (park a cheap empty engine),
1251        // commit, clear the journal, then re-map the compacted file — exactly
1252        // the `resnapshot` dance, so the map is never renamed over on Windows.
1253        st.engine = Engine::Owned(Box::new(Memory::new(st.cfg.clone())?));
1254        st.store
1255            .commit_snapshot()
1256            .and_then(|()| st.store.clear_journal())?;
1257        let cfg = st.cfg.clone();
1258        let (engine, _) = open_engine(&mut st.store, &cfg)?;
1259        st.engine = engine;
1260        st.forgets = 0;
1261        st.ops = 0;
1262        let bytes_after = snap_len(&st.store);
1263        report.purged = purged;
1264        report.bytes_before = bytes_before;
1265        report.bytes_after = bytes_after;
1266        Ok(report)
1267    }
1268
1269    /// Writes a full snapshot and clears the journal now (re-mapping the
1270    /// fresh file — see `Database::resnapshot`).
1271    pub fn checkpoint(&self, now: u64) -> Result<(), HostError> {
1272        let mut st = self.write_available()?;
1273        self.resnapshot(&mut st, now)?;
1274        st.ops = 0;
1275        Ok(())
1276    }
1277
1278    /// Runs the on-demand full content-integrity check. An open validates only
1279    /// the metadata, so the
1280    /// large byte pools stay non-resident on an mmap'd base; this sweeps them
1281    /// (text UTF-8, vector self-consistency and the fact↔slot bijection) and
1282    /// reports any latent corruption. Skipping it is safe — the accessors never
1283    /// panic on bad bytes; `verify` only turns corruption into an explicit
1284    /// error.
1285    ///
1286    /// # Errors
1287    ///
1288    /// [`HostError::Engine`] wrapping [`Error::Corrupt`](plugmem_core::Error)
1289    /// for the first inconsistency found.
1290    pub fn verify(&self) -> Result<(), HostError> {
1291        Ok(self.read().engine.read(|mem| mem.verify())?)
1292    }
1293
1294    /// A resumable byte-level container scrub of the current published
1295    /// generation, with the default slice budget. See
1296    /// [`Database::scrub_with_budget`].
1297    ///
1298    /// # Errors
1299    ///
1300    /// As [`Database::scrub_with_budget`].
1301    pub fn scrub(&self) -> Result<Scrub, HostError> {
1302        self.scrub_with_budget(plugmem_core::snapshot::DEFAULT_SCRUB_BUDGET)
1303    }
1304
1305    /// A resumable container scrub hashing at most `budget` bytes per
1306    /// [`Iterator::next`] — the writer's counterpart to
1307    /// [`ReadOnlyDatabase::scrub_with_budget`], and the same [`Scrub`].
1308    ///
1309    /// It exists because a scrub is an operation on the **file**, not on this
1310    /// handle's view of it: it hashes the published container as it stands, and
1311    /// the journal belongs to the generation the writer has not published yet.
1312    /// A writer could always have reached one by opening a second, read-only
1313    /// handle on the same path — but that maps the whole image again, takes a
1314    /// second lock and reconciles the config, all to hash bytes this handle
1315    /// already knows the path of.
1316    ///
1317    /// The returned [`Scrub`] is independent of this handle: it owns its map
1318    /// and a shared lock on the generation it pins, so it outlives the
1319    /// database, can be moved to its own thread, and keeps this generation
1320    /// safe from the writer's own GC until it is dropped.
1321    ///
1322    /// # Errors
1323    ///
1324    /// [`HostError::NeedsCheckpoint`] when nothing has been published yet;
1325    /// [`HostError::Io`] if the generation cannot be opened or mapped;
1326    /// [`HostError::Engine`] if its container will not parse.
1327    pub fn scrub_with_budget(&self, budget: usize) -> Result<Scrub, HostError> {
1328        Scrub::open(self.read().store.path(), budget)
1329    }
1330
1331    /// Salvages a content-corrupt database (Tier 2): opens `src`,
1332    /// drops the facts that fail the per-fact content checks (`verify`'s
1333    /// predicate), compacts the survivors and their indexes, and writes a clean
1334    /// image to `dst`. `src` on disk is left untouched — the evidence is
1335    /// preserved.
1336    ///
1337    /// It is **disk-first** (milestone H): `src` is opened as an mmap overlay
1338    /// (its pages are reclaimable) and the compacted image is written by
1339    /// streaming the two big pools (vectors, text) through temp files, so peak
1340    /// RAM tracks the record count (metadata + HNSW graph), not the image size.
1341    /// A database far larger than RAM can be recovered, as long as its graph
1342    /// fits.
1343    ///
1344    /// This handles *content* corruption (bad text bytes, a broken fact↔slot
1345    /// vector bijection). *Structural* damage — a snapshot that will not parse
1346    /// — is not salvageable here: `src` fails to open and recover returns the
1347    /// engine's typed error; restore from a backup instead (Tier 0).
1348    ///
1349    /// # Errors
1350    ///
1351    /// [`HostError::Locked`] if `src` or `dst` is owned elsewhere;
1352    /// [`HostError::Engine`] if `src` will not parse (structural corruption) or
1353    /// `dst` equals `src`; [`HostError::Io`] for filesystem failures.
1354    pub fn recover(
1355        src: impl AsRef<Path>,
1356        dst: impl AsRef<Path>,
1357        cfg: Config,
1358        now: u64,
1359    ) -> Result<RecoverReport, HostError> {
1360        let src = src.as_ref();
1361        let dst = dst.as_ref();
1362
1363        // Lock the source exclusively for the salvage's whole life. We never
1364        // write it — the lock only excludes a cooperating writer while we read.
1365        let mut src_store = FileStorage::open(src, FsyncPolicy::OnSnapshot)?;
1366        let src_base = src_store.path().to_path_buf();
1367
1368        // The destination must be a different file: recover preserves the source
1369        // as evidence and writes the clean image elsewhere.
1370        let same = dst == src_base
1371            || matches!(
1372                (std::fs::canonicalize(dst), std::fs::canonicalize(&src_base)),
1373                (Ok(a), Ok(b)) if a == b
1374            );
1375        if same {
1376            return Err(HostError::Engine(Error::Invalid(
1377                "recover destination must differ from the source",
1378            )));
1379        }
1380
1381        // Open the source as an overlay: borrow the mmap base (reclaimable
1382        // pages) and replay its journal into a small owned overlay — never an
1383        // owned copy of the image. A structurally corrupt image fails here —
1384        // that is Tier 0, not salvageable content corruption.
1385        let journal = src_store.read_journal()?;
1386        let Some(genp) = src_store.current_snapshot_path()? else {
1387            return Err(HostError::Engine(Error::Corrupt(
1388                "source database has no published snapshot to recover",
1389            )));
1390        };
1391        let file = File::open(&genp).map_err(|e| HostError::io(&genp, e))?;
1392        // SAFETY: as in `open_engine` — the generation file is immutable and
1393        // `src_store` holds the exclusive lock, so nothing touches it under us.
1394        let map = unsafe { Mmap::map(&file) }.map_err(|e| HostError::io(&genp, e))?;
1395        drop(file);
1396        let (mut mem, _report) = Memory::from_bytes_overlay(&map[..], &journal, cfg.clone())?;
1397
1398        // Drop each content-faulty fact into a throwaway store, so the source
1399        // file is never written. The disk-first rebuild below then physically
1400        // purges them and rebuilds clean indexes + HNSW from the survivors.
1401        let mut scratch = MemStorage::new();
1402        let mut dropped_text = 0usize;
1403        let mut dropped_vector = 0usize;
1404        let mut dropped_metadata = 0usize;
1405        for (id, fault) in mem.faulty_facts() {
1406            mem.forget(&mut scratch, now, id)?;
1407            match fault {
1408                FactFault::Text => dropped_text += 1,
1409                FactFault::Vector => dropped_vector += 1,
1410                FactFault::Metadata => dropped_metadata += 1,
1411            }
1412        }
1413
1414        // Write the compacted image to `dst`, streaming the big pools through
1415        // temp scratch files (metadata + graph are the only things resident).
1416        let mut dst_store = FileStorage::open(dst, FsyncPolicy::OnSnapshot)?;
1417        let text_tmp = tmp_sibling(dst_store.path(), "rectext");
1418        let vec_tmp = tmp_sibling(dst_store.path(), "recvec");
1419        let mut purged = 0usize;
1420        dst_store.stage_snapshot(|sink| {
1421            let mut text_scratch = FileScratch::create(&text_tmp)?;
1422            let mut vec_scratch = FileScratch::create(&vec_tmp)?;
1423            purged = mem
1424                .snapshot_disk_first(now, &mut text_scratch, &mut vec_scratch, &mut *sink)
1425                .map_err(HostError::from)?;
1426            Ok(())
1427        })?;
1428        dst_store.commit_snapshot()?;
1429
1430        let kept = mem.stats().facts.saturating_sub(purged);
1431        Ok(RecoverReport {
1432            kept,
1433            dropped_text,
1434            dropped_vector,
1435            dropped_metadata,
1436        })
1437    }
1438}
1439
1440/// A temp-file path beside `base` with the given tag (for disk-first scratch).
1441fn tmp_sibling(base: &Path, tag: &str) -> PathBuf {
1442    let mut p = base.as_os_str().to_os_string();
1443    p.push(".");
1444    p.push(tag);
1445    p.push(".tmp");
1446    PathBuf::from(p)
1447}
1448
1449impl std::fmt::Debug for Database {
1450    /// Summary only — the contents are the user's memory.
1451    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1452        let stats = self.stats();
1453        f.debug_struct("Database")
1454            .field("facts", &stats.facts)
1455            .field("entities", &stats.entities)
1456            .finish()
1457    }
1458}