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