Skip to main content

plugmem_host/
db.rs

1//! `Database`: the engine + its file + the maintenance policy behind
2//! 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 file 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 — the SQLite model. 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};
41#[cfg(feature = "counters")]
42use std::sync::MutexGuard;
43use std::sync::{Arc, Mutex};
44#[cfg(not(feature = "counters"))]
45use std::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard};
46
47use memmap2::Mmap;
48use plugmem_core::{
49    Config, Error, FactFault, FactRecord, LinkInput, MaintainReport, MaintenanceMode,
50    MaintenanceOptions, MemStorage, Memory, OpenReport, RecallQuery, RecallResult, RecallScratch,
51    RememberInput, RememberOutcome, Stats, Storage, UnlinkInput,
52};
53
54thread_local! {
55    /// Per-thread recall scratch. `recall` takes `&self` on the engine, so many
56    /// reader threads recall one [`Database`] at once; each reuses its own
57    /// scratch here (zero re-alloc after warm-up, no lock on the hot path).
58    static RECALL_SCRATCH: RefCell<RecallScratch> = RefCell::new(RecallScratch::new());
59}
60
61use crate::embedder::Embedder;
62use crate::error::HostError;
63use crate::readonly::ReadOnlyDatabase;
64use crate::storage::{FileScratch, FileStorage, FsyncPolicy};
65
66self_cell::self_cell!(
67    /// Owns the memory map and the overlay [`Memory`] that borrows it — the
68    /// read-write sibling of `readonly::MappedMemory`. `self_cell` keeps the
69    /// self-reference safe: the only `unsafe` on this path is the inherent
70    /// mmap call, not the borrow.
71    struct OverlayMap {
72        owner: Mmap,
73        #[covariant]
74        dependent: OverlayMemory,
75    }
76);
77
78/// The dependent type constructor `self_cell` reborrows per access.
79/// [`Memory`] is covariant in its lifetime (its byte pools are
80/// `Cow<'a, [u8]>`), so borrowing the map is sound.
81type OverlayMemory<'a> = Memory<'a>;
82
83/// The engine backing a live [`Database`]: either an owned in-RAM engine
84/// (a brand-new database with no snapshot file yet) or an overlay over a
85/// memory-mapped snapshot (the common case). Both are mutable; verbs reach
86/// the engine through [`Engine::with`] / [`Engine::read`], which unify the
87/// two lifetimes (`'static` vs the map's) behind one closure.
88enum Engine {
89    /// No snapshot file to map yet — owned and (initially) empty. Switches to
90    /// `Mapped` at the first snapshot, once the file exists. Boxed so the
91    /// common `Mapped` case does not carry the whole owned engine inline.
92    Owned(Box<Memory<'static>>),
93    /// Overlay over a memory-mapped snapshot: the base is borrowed, mutations
94    /// live in the overlay (owned tail + per-page copy-on-write).
95    Mapped(OverlayMap),
96}
97
98impl Engine {
99    /// Reads through an immutable borrow of the engine (owned or mapped).
100    fn read<R>(&self, f: impl for<'a> FnOnce(&Memory<'a>) -> R) -> R {
101        match self {
102            Engine::Owned(mem) => f(mem),
103            Engine::Mapped(map) => f(map.borrow_dependent()),
104        }
105    }
106
107    /// Mutates the engine and its store together (disjoint borrows). The
108    /// closure is higher-ranked over the engine's lifetime so one body serves
109    /// both the `'static` owned engine and the map-bound overlay.
110    fn with<R>(
111        &mut self,
112        store: &mut FileStorage,
113        f: impl for<'a> FnOnce(&mut Memory<'a>, &mut FileStorage) -> R,
114    ) -> R {
115        match self {
116            Engine::Owned(mem) => f(mem, store),
117            Engine::Mapped(map) => map.with_dependent_mut(|_owner, mem| f(mem, store)),
118        }
119    }
120}
121
122/// Opens the engine at `store`'s path: memory-maps the snapshot
123/// and borrows it as an overlay, replaying the journal. A missing snapshot
124/// file (a brand-new database) opens owned and empty — the file appears at the
125/// first snapshot. `store` must already hold the exclusive lock.
126fn open_engine(store: &mut FileStorage, cfg: &Config) -> Result<(Engine, OpenReport), HostError> {
127    let journal = store.read_journal()?;
128    let Some(genp) = store.current_snapshot_path()? else {
129        // No published generation yet. The database is owned until the first
130        // checkpoint publishes one — but a journal may already exist (mutations
131        // before any snapshot), so still replay it into the owned engine.
132        let (mem, report) = Memory::from_bytes(None, &journal, cfg.clone())?;
133        return Ok((Engine::Owned(Box::new(mem)), report));
134    };
135    let file = File::open(&genp).map_err(|e| HostError::io(&genp, e))?;
136    // SAFETY: mapping a file is inherently unsafe — a concurrent truncate or
137    // overwrite of the mapped file would fault the process (SIGBUS/exception)
138    // on the next page access. Our correctness argument: the
139    // generation file is **immutable** (a checkpoint publishes a new one and
140    // never rewrites this), and the `store` holds the exclusive writer lock, so
141    // nothing overwrites it under the map. A foreign `truncate`/`rm` under a
142    // live handle is out of contract — the same caveat as corrupting any
143    // database file under a running engine.
144    let map = unsafe { Mmap::map(&file) }.map_err(|e| HostError::io(&genp, e))?;
145    // The `File` handle is no longer needed: `Mmap` owns the mapping.
146    drop(file);
147    // Replay the journal into the overlay: no whole-arena clone, only the
148    // touched pages copy up. `self_cell` builds the engine borrowing the map;
149    // the replay report is captured out of the constructor closure.
150    let mut report = None;
151    let mapped = OverlayMap::try_new(map, |m| {
152        let (mem, rep) = Memory::from_bytes_overlay(&m[..], &journal, cfg.clone())?;
153        report = Some(rep);
154        Ok::<_, Error>(mem)
155    })?;
156    Ok((Engine::Mapped(mapped), report.unwrap_or_default()))
157}
158
159/// An owned view of one fact — [`Memory::get`] returns borrows that
160/// cannot cross the lock, so the database hands out copies.
161#[derive(Clone, Debug, PartialEq)]
162#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
163pub struct FactSnapshot {
164    /// The raw record (temporality, flags, references).
165    pub record: FactRecord,
166    /// The fact text.
167    pub text: String,
168    /// The fact's metadata as a sorted key→value map (empty when the fact
169    /// carries none). The engine stores it opaquely; this is the decoded view.
170    pub metadata: BTreeMap<String, String>,
171}
172
173/// One exported fact — the human-readable, id-free shape [`Database::export`]
174/// dumps and an importer re-`remember`s. Internal ids and
175/// `recorded_at` are the engine's bookkeeping and are *not* preserved across
176/// a round-trip; the knowledge itself (text, subject name, tags, validity
177/// start) is.
178#[derive(Clone, Debug, PartialEq)]
179#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
180pub struct ExportedFact {
181    /// The fact text.
182    pub text: String,
183    /// Subject entity name, if the fact had one.
184    pub entity: Option<String>,
185    /// Tag strings.
186    pub tags: Vec<String>,
187    /// Metadata as a sorted key→value map (empty when none) — preserved on
188    /// import.
189    pub metadata: BTreeMap<String, String>,
190    /// When the memory learned it (informational; not restorable on import).
191    pub recorded_at: u64,
192    /// Validity start — preserved on import.
193    pub valid_from: u64,
194}
195
196/// One bounded page of currently-open facts.
197///
198/// `next_cursor` is the next fact id to inspect, not an offset into `facts`:
199/// closed, tombstoned, and purged ids are skipped without making the caller
200/// rescan them. `None` means the scan reached the database's current end.
201#[derive(Clone, Debug, PartialEq)]
202#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
203pub struct ExportPage {
204    /// The open facts found in this page, in fact-id order.
205    pub facts: Vec<ExportedFact>,
206    /// Pass this to the next [`Database::export_page`] call.
207    pub next_cursor: Option<u32>,
208}
209
210/// The outcome of a [`Database::recover`] salvage.
211#[derive(Clone, Copy, Debug, PartialEq, Eq)]
212#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
213pub struct RecoverReport {
214    /// Facts written to the destination (the survivors after the purge).
215    pub kept: usize,
216    /// Facts dropped because their stored text was not valid UTF-8.
217    pub dropped_text: usize,
218    /// Facts dropped because their vector slot was out of range or mismatched.
219    pub dropped_vector: usize,
220    /// Facts dropped because their metadata blob did not decode to a
221    /// well-formed key→value map.
222    pub dropped_metadata: usize,
223}
224
225/// Visits the currently-open facts (skipping closed revisions and tombstones),
226/// resolving each subject name and tag string, calling `f` once per fact. The
227/// streaming core of export: a caller that writes each fact out (CLI `export`)
228/// never materializes the whole dump, so a huge database exports without a RAM
229/// spike. Shared by the read-write and read-only handles.
230/// Decodes a fact's metadata into an owned, sorted key→value map (empty when
231/// the fact carries none). Shared by `get` (read-write and read-only) and
232/// `export`; the pairs come back from the engine in canonical order, so the
233/// resulting `BTreeMap` matches the raw core view key-for-key.
234pub(crate) fn metadata_map(mem: &Memory, id: plugmem_core::FactId) -> BTreeMap<String, String> {
235    let mut pairs = Vec::new();
236    mem.metadata_of(id, &mut pairs);
237    pairs
238        .into_iter()
239        .map(|(k, v)| (k.to_string(), v.to_string()))
240        .collect()
241}
242
243fn exported_fact(
244    mem: &Memory,
245    id: plugmem_core::FactId,
246    terms: &mut Vec<plugmem_core::TermId>,
247) -> Option<ExportedFact> {
248    use plugmem_core::{EntityId, VALID_TO_OPEN};
249    let view = mem.get(id)?;
250    if view.record.valid_to != VALID_TO_OPEN {
251        return None; // a closed revision — export the current state only
252    }
253    let entity = (view.record.entity != EntityId::NONE)
254        .then(|| mem.entity_name(view.record.entity))
255        .flatten()
256        .map(str::to_string);
257    terms.clear();
258    mem.tags_of(id, terms);
259    let tags = terms.iter().map(|t| mem.term(*t).to_string()).collect();
260    Some(ExportedFact {
261        text: view.text.to_string(),
262        entity,
263        tags,
264        metadata: metadata_map(mem, id),
265        recorded_at: view.record.recorded_at,
266        valid_from: view.record.valid_from,
267    })
268}
269
270pub(crate) fn export_facts_each(mem: &Memory, mut f: impl FnMut(ExportedFact)) {
271    use plugmem_core::FactId;
272    let next = mem.stats().next_fact;
273    let mut terms = Vec::new();
274    for i in 0..next {
275        if let Some(fact) = exported_fact(mem, FactId(i), &mut terms) {
276            f(fact);
277        }
278    }
279}
280
281pub(crate) fn export_facts_page(mem: &Memory, cursor: u32, limit: usize) -> ExportPage {
282    use plugmem_core::FactId;
283    let end = mem.stats().next_fact;
284    let mut cursor = cursor.min(end);
285    let stop = cursor
286        .saturating_add(limit.min(u32::MAX as usize) as u32)
287        .min(end);
288    let mut terms = Vec::new();
289    let mut facts = Vec::with_capacity((stop - cursor) as usize);
290    while cursor < stop {
291        let id = FactId(cursor);
292        cursor += 1;
293        if let Some(fact) = exported_fact(mem, id, &mut terms) {
294            facts.push(fact);
295        }
296    }
297    ExportPage {
298        facts,
299        next_cursor: (cursor < end).then_some(cursor),
300    }
301}
302
303/// Collects the currently-open facts into a `Vec` (the owning form of
304/// [`export_facts_each`]). Used where the whole dump is wanted in memory.
305pub(crate) fn export_facts(mem: &Memory) -> Vec<ExportedFact> {
306    let mut out = Vec::new();
307    export_facts_each(mem, |e| out.push(e));
308    out
309}
310
311/// Tuning knobs of a [`Database`]. Construct through
312/// [`Database::builder`].
313pub struct DatabaseBuilder {
314    cfg: Config,
315    fsync: FsyncPolicy,
316    snapshot_every_ops: u64,
317    snapshot_journal_bytes: u64,
318    maintain_every_forgets: Option<u64>,
319    embedder: Option<Box<dyn Embedder>>,
320}
321
322impl DatabaseBuilder {
323    /// Journal fsync policy (default: every operation).
324    pub fn fsync(mut self, policy: FsyncPolicy) -> Self {
325        self.fsync = policy;
326        self
327    }
328
329    /// Auto-snapshot after this many mutations (default 1024; `0`
330    /// disables the count trigger).
331    pub fn snapshot_every_ops(mut self, ops: u64) -> Self {
332        self.snapshot_every_ops = ops;
333        self
334    }
335
336    /// Auto-snapshot when the journal outgrows this many bytes (default
337    /// 4 MiB; `0` disables the size trigger).
338    pub fn snapshot_journal_bytes(mut self, bytes: u64) -> Self {
339        self.snapshot_journal_bytes = bytes;
340        self
341    }
342
343    /// Optional auto-`maintain` after this many forgets (default off —
344    /// maintenance is O(database) and the first pass beyond the HNSW
345    /// threshold pays the graph build).
346    pub fn maintain_every_forgets(mut self, forgets: u64) -> Self {
347        self.maintain_every_forgets = Some(forgets);
348        self
349    }
350
351    /// The embedding provider. When set (and its `dim() > 0`),
352    /// `remember` without a vector embeds the fact text and `recall`
353    /// with a text but no vector embeds the query — both outside the
354    /// database lock. `Config::dim` must equal the embedder's dimension.
355    pub fn embedder(mut self, embedder: Box<dyn Embedder>) -> Self {
356        self.embedder = Some(embedder);
357        self
358    }
359
360    /// Opens (or creates) the database at `path`.
361    ///
362    /// # Errors
363    ///
364    /// [`HostError::Locked`] when the file is owned elsewhere;
365    /// [`HostError::Engine`] for config/snapshot/journal problems
366    /// (including an embedder dimension that disagrees with
367    /// `Config::dim`); [`HostError::Io`] for filesystem failures.
368    pub fn open(self, path: impl Into<PathBuf>) -> Result<(Database, OpenReport), HostError> {
369        if let Some(embedder) = &self.embedder {
370            let dim = embedder.dim();
371            if dim != 0 && dim != self.cfg.dim {
372                return Err(HostError::Engine(Error::ConfigMismatch(
373                    "embedder dimension must equal Config::dim",
374                )));
375            }
376        }
377        let mut store = FileStorage::open(path, self.fsync)?;
378        let (engine, report) = open_engine(&mut store, &self.cfg)?;
379        let db = Database {
380            inner: Arc::new(Inner {
381                state: StateLock::new(State {
382                    engine,
383                    store,
384                    ops: 0,
385                    forgets: 0,
386                }),
387                embedder: self.embedder.map(Mutex::new),
388                cfg: self.cfg,
389                snapshot_every_ops: self.snapshot_every_ops,
390                snapshot_journal_bytes: self.snapshot_journal_bytes,
391                maintain_every_forgets: self.maintain_every_forgets,
392            }),
393        };
394        Ok((db, report))
395    }
396}
397
398/// The engine lock. Normally an `RwLock` so read-only verbs run concurrently
399/// (the whole point of Variant 1). Under `counters`, `State` embeds the arena's
400/// non-`Sync` instrumentation `Cell`s, and `RwLock<T>` needs `T: Sync` to hand
401/// out shared guards — so there we fall back to a `Mutex`. `counters` is a
402/// single-threaded perf-gate build, so serialized readers cost nothing there,
403/// and the `Mutex` keeps `Database: Send + Sync` so every test still builds.
404#[cfg(not(feature = "counters"))]
405type StateLock = RwLock<State>;
406#[cfg(feature = "counters")]
407type StateLock = Mutex<State>;
408
409struct Inner {
410    state: StateLock,
411    embedder: Option<Mutex<Box<dyn Embedder>>>,
412    /// Kept to rebuild the overlay engine after a re-map on snapshot.
413    cfg: Config,
414    snapshot_every_ops: u64,
415    snapshot_journal_bytes: u64,
416    maintain_every_forgets: Option<u64>,
417}
418
419struct State {
420    engine: Engine,
421    store: FileStorage,
422    /// Mutations since the last snapshot.
423    ops: u64,
424    /// Forgets since the last maintain.
425    forgets: u64,
426}
427
428/// A clonable, thread-safe handle to one database file. See the module
429/// docs for the concurrency model.
430#[derive(Clone)]
431pub struct Database {
432    inner: Arc<Inner>,
433}
434
435impl Database {
436    /// Opens `path` with every knob at its default and no embedder.
437    pub fn open(path: impl Into<PathBuf>, cfg: Config) -> Result<(Self, OpenReport), HostError> {
438        Self::builder(cfg).open(path)
439    }
440
441    /// Opens `path` read-only over a memory-mapped snapshot:
442    /// the engine borrows the mapped pages instead of copying the file
443    /// into RAM, so a large read-mostly database residents only the pages
444    /// `recall`/`get` touch. Requires a checkpointed database (empty
445    /// journal) and takes a shared lock (N readers or one writer).
446    /// See [`ReadOnlyDatabase`].
447    ///
448    /// # Errors
449    ///
450    /// [`HostError::Locked`], [`HostError::NeedsCheckpoint`],
451    /// [`HostError::Io`], [`HostError::Engine`] — see
452    /// [`ReadOnlyDatabase::open`] semantics.
453    pub fn open_readonly(
454        path: impl Into<PathBuf>,
455        cfg: Config,
456    ) -> Result<ReadOnlyDatabase, HostError> {
457        ReadOnlyDatabase::open(path, cfg)
458    }
459
460    /// Starts a configured open (knobs).
461    pub fn builder(cfg: Config) -> DatabaseBuilder {
462        DatabaseBuilder {
463            cfg,
464            fsync: FsyncPolicy::default(),
465            snapshot_every_ops: 1024,
466            snapshot_journal_bytes: 4 * 1024 * 1024,
467            maintain_every_forgets: None,
468            embedder: None,
469        }
470    }
471
472    /// A shared (read) guard — for the read-only verbs (`recall`/`get`/
473    /// `stats`/`export`/`verify`). Many run at once; they exclude only writers.
474    /// (Under `counters` the lock is a `Mutex`, so reads serialize — see
475    /// [`StateLock`].) A panicked verb cannot leave the engine half-mutated
476    /// (check first, mutate last is the engine's own law), so a poisoned lock
477    /// is recoverable.
478    #[cfg(not(feature = "counters"))]
479    fn read(&self) -> RwLockReadGuard<'_, State> {
480        self.inner.state.read().unwrap_or_else(|e| e.into_inner())
481    }
482
483    /// An exclusive (write) guard — for the mutating verbs. Serializes writers
484    /// against each other and against every concurrent reader.
485    #[cfg(not(feature = "counters"))]
486    fn write(&self) -> RwLockWriteGuard<'_, State> {
487        self.inner.state.write().unwrap_or_else(|e| e.into_inner())
488    }
489
490    /// Under `counters` the engine lock is a `Mutex`: `read` and `write` both
491    /// take the one exclusive guard (readers serialize — acceptable for the
492    /// single-threaded perf-gate build). See [`StateLock`].
493    #[cfg(feature = "counters")]
494    fn read(&self) -> MutexGuard<'_, State> {
495        self.inner.state.lock().unwrap_or_else(|e| e.into_inner())
496    }
497
498    #[cfg(feature = "counters")]
499    fn write(&self) -> MutexGuard<'_, State> {
500        self.inner.state.lock().unwrap_or_else(|e| e.into_inner())
501    }
502
503    /// Embeds `text` outside the state lock, when an embedder is
504    /// configured. `None` = leave the input as it was.
505    fn embed_one(&self, text: &str) -> Result<Option<Vec<f32>>, HostError> {
506        let Some(embedder) = &self.inner.embedder else {
507            return Ok(None);
508        };
509        let mut embedder = embedder.lock().unwrap_or_else(|e| e.into_inner());
510        if embedder.dim() == 0 {
511            return Ok(None);
512        }
513        let mut vs = embedder.embed(&[text])?;
514        Ok(Some(vs.remove(0)))
515    }
516
517    /// Embeds a whole batch of texts in a **single** embedder call — outside the
518    /// lock, like [`embed_one`](Self::embed_one). `Ok(None)` when no embedder is
519    /// configured or `dim == 0`; otherwise a vector aligned one-to-one with
520    /// `texts` (the provider contract, checked by [`OpenAiCompatEmbedder`]). An
521    /// empty `texts` yields an empty vector without a round-trip. This is the one
522    /// HTTP that [`remember_many`](Self::remember_many) makes for a bulk write.
523    fn embed_many(&self, texts: &[&str]) -> Result<Option<Vec<Vec<f32>>>, HostError> {
524        let Some(embedder) = &self.inner.embedder else {
525            return Ok(None);
526        };
527        let mut embedder = embedder.lock().unwrap_or_else(|e| e.into_inner());
528        if embedder.dim() == 0 {
529            return Ok(None);
530        }
531        if texts.is_empty() {
532            return Ok(Some(Vec::new()));
533        }
534        Ok(Some(embedder.embed(texts)?))
535    }
536
537    /// Writes a full snapshot and re-maps the fresh file.
538    ///
539    /// Materializes the borrowed base + overlay into an owned buffer, drops
540    /// the current map, writes the buffer (tmp + fsync + rename) and clears
541    /// the journal, then maps the new file into a fresh overlay. The re-map
542    /// collapses the overlay so a long write session stays bounded, and
543    /// dropping the map **before** the rename keeps the write portable
544    /// (a mapped file cannot be renamed over on Windows).
545    fn resnapshot(&self, st: &mut State, now: u64) -> Result<(), HostError> {
546        // Stream the image straight to the tmp file — never a full-image Vec
547        // This reads through the live map, so it happens
548        // **before** the map is dropped.
549        {
550            let State { engine, store, .. } = &mut *st;
551            store.stage_snapshot(|sink| {
552                engine
553                    .read(|mem| mem.write_snapshot_to(now, &mut *sink))
554                    .map_err(HostError::from)
555            })?;
556        }
557        // Drop the current map before the rename: park a cheap empty engine.
558        // It is replaced by the fresh overlay below — or, if the commit fails,
559        // rebuilt from the intact on-disk snapshot + journal.
560        st.engine = Engine::Owned(Box::new(Memory::new(self.inner.cfg.clone())?));
561        let write = st
562            .store
563            .commit_snapshot()
564            .and_then(|()| st.store.clear_journal());
565        // Re-open regardless: on success the fresh file, on failure the
566        // untouched old file + journal (journal replay is idempotent, so a
567        // failed `clear_journal` does not corrupt state). Then surface the
568        // commit error, if any.
569        let (engine, _) = open_engine(&mut st.store, &self.inner.cfg)?;
570        st.engine = engine;
571        write
572    }
573
574    /// The post-mutation policy hook: counts the op, fires auto-maintain
575    /// and auto-snapshot inside the same critical section.
576    fn after_mutation(&self, st: &mut State, now: u64) -> Result<(), HostError> {
577        st.ops += 1;
578        if let Some(threshold) = self.inner.maintain_every_forgets
579            && st.forgets >= threshold
580        {
581            let State { engine, store, .. } = &mut *st;
582            engine.with(store, |mem, store| mem.maintain(store, now))?;
583            st.forgets = 0;
584        }
585        // A database that outgrows its shard layout re-shards itself. This is
586        // on by default, unlike `maintain_every_forgets`, because without it
587        // nothing would ever move a layout: a growing database would keep the
588        // one it was created with until somebody ran `maintain` by hand, and
589        // the cost of that is silent — memory, and a page directory that keeps
590        // lengthening.
591        //
592        // Affordable because both halves are bounded. The question is O(1)
593        // (stored record counts), so asking on every write is free; and the
594        // answer is self-limiting — the thresholds are a doubling up and a
595        // fourfold drop, so it says yes a handful of times over a database's
596        // whole life. `resharding_settles_instead_of_asking_forever` in the
597        // core suite is the test that keeps that true.
598        let State { engine, store, .. } = &mut *st;
599        if engine.with(store, |mem, _| mem.shard_layout_is_stale()) {
600            engine.with(store, |mem, store| mem.maintain(store, now))?;
601        }
602        let by_ops = self.inner.snapshot_every_ops > 0 && st.ops >= self.inner.snapshot_every_ops;
603        let by_bytes = self.inner.snapshot_journal_bytes > 0
604            && st.store.journal_bytes() >= self.inner.snapshot_journal_bytes;
605        if by_ops || by_bytes {
606            self.resnapshot(st, now)?;
607            st.ops = 0;
608        }
609        Ok(())
610    }
611
612    /// Remembers a fact. Without an explicit vector and with an embedder
613    /// configured, the text is embedded first — outside the lock.
614    pub fn remember(&self, input: RememberInput<'_>) -> Result<RememberOutcome, HostError> {
615        let embedded = match input.vector {
616            Some(_) => None,
617            None => self.embed_one(input.text)?,
618        };
619        let input = RememberInput {
620            vector: embedded.as_deref().or(input.vector),
621            ..input
622        };
623        let mut st = self.write();
624        let State { engine, store, .. } = &mut *st;
625        let out = engine.with(store, |mem, store| mem.remember(store, input))?;
626        self.after_mutation(&mut st, input.now)?;
627        Ok(out)
628    }
629
630    /// Remembers a **batch** of facts in one shot — the bulk-write path (CLI
631    /// `import`). Equivalent to [`remember`](Self::remember) on each input in
632    /// order, but far cheaper for a batch: the texts that need embedding are
633    /// embedded together in **one** embedder round-trip (outside the lock), and
634    /// all facts are written under **one** write-guard with **one** post-mutation
635    /// policy pass — instead of N HTTP calls and N critical sections.
636    ///
637    /// Inputs that already carry a `vector` are not re-embedded. **Chunking is
638    /// the caller's job**: this writes the whole slice it is given, so a caller
639    /// that needs bounded memory / a bounded HTTP body passes fixed-size batches
640    /// (CLI `import` streams the file in `--batch`-sized slices).
641    ///
642    /// **Fail-fast:** the first engine error returns `Err`; the facts written
643    /// before it stay written (exactly as separate `remember`s — the journal
644    /// replay is idempotent, so a retried bulk load is safe). Returns one
645    /// [`RememberOutcome`] per input, in order.
646    pub fn remember_many(
647        &self,
648        inputs: Vec<RememberInput<'_>>,
649    ) -> Result<Vec<RememberOutcome>, HostError> {
650        if inputs.is_empty() {
651            return Ok(Vec::new());
652        }
653        // One embedder round-trip for every vector-less input's text, outside the
654        // lock. `to_embed` is the vector-less inputs in order, so its result maps
655        // back onto them by a running cursor below.
656        let to_embed: Vec<&str> = inputs
657            .iter()
658            .filter(|i| i.vector.is_none())
659            .map(|i| i.text)
660            .collect();
661        let embedded = if to_embed.is_empty() {
662            None
663        } else {
664            self.embed_many(&to_embed)?
665        };
666
667        let mut st = self.write();
668        // Batch mode: journal appends skip their per-record fsync; one
669        // `sync_journal` at the end makes the whole batch durable at once.
670        st.store.set_batch(true);
671        let mut out = Vec::with_capacity(inputs.len());
672        let mut cursor = 0usize; // into `embedded`, over vector-less inputs in order
673        let mut latest = 0u64;
674        let mut failed = None;
675        for input in inputs {
676            latest = latest.max(input.now);
677            let vector = if input.vector.is_some() {
678                input.vector
679            } else if let Some(embedded) = &embedded {
680                let v = embedded[cursor].as_slice();
681                cursor += 1;
682                Some(v)
683            } else {
684                None // no embedder — lexical/structural only, as single remember
685            };
686            let input = RememberInput { vector, ..input };
687            let State { engine, store, .. } = &mut *st;
688            match engine.with(store, |mem, store| mem.remember(store, input)) {
689                Ok(o) => out.push(o),
690                Err(e) => {
691                    failed = Some(HostError::from(e));
692                    break;
693                }
694            }
695        }
696        // Always leave batch mode and fsync — this is the batch's durability
697        // point. On fail-fast it makes the facts written before the error durable
698        // (they stay, exactly like separate remembers).
699        st.store.set_batch(false);
700        st.store.sync_journal()?;
701        if let Some(e) = failed {
702            return Err(e);
703        }
704        // One policy pass for the whole batch. The op counter advances by one per
705        // batch; the journal-bytes threshold still fires on a large batch, so a
706        // snapshot is not starved.
707        self.after_mutation(&mut st, latest)?;
708        Ok(out)
709    }
710
711    /// Runs a recall. With a text, no vector and an embedder configured,
712    /// the query text is embedded first — outside the lock.
713    pub fn recall(&self, q: RecallQuery<'_>) -> Result<RecallResult, HostError> {
714        let embedded = match (q.vector, q.text) {
715            (None, Some(text)) => self.embed_one(text)?,
716            _ => None,
717        };
718        let q = RecallQuery {
719            vector: embedded.as_deref().or(q.vector),
720            ..q
721        };
722        // A shared guard: concurrent recalls run in parallel. `recall_into`
723        // takes `&self` on the engine and a per-thread scratch, so there is no
724        // writer path and no cross-reader contention on the hot path.
725        let st = self.read();
726        RECALL_SCRATCH.with(|scratch| {
727            let mut scratch = scratch.borrow_mut();
728            let mut out = RecallResult::default();
729            st.engine
730                .read(|mem| mem.recall_into(q, &mut scratch, &mut out))?;
731            Ok(out)
732        })
733    }
734
735    /// Revises `target` (same auto-embedding rule as `remember`).
736    pub fn revise(
737        &self,
738        target: plugmem_core::FactId,
739        input: RememberInput<'_>,
740    ) -> Result<RememberOutcome, HostError> {
741        let embedded = match input.vector {
742            Some(_) => None,
743            None => self.embed_one(input.text)?,
744        };
745        let input = RememberInput {
746            vector: embedded.as_deref().or(input.vector),
747            ..input
748        };
749        let mut st = self.write();
750        let State { engine, store, .. } = &mut *st;
751        let out = engine.with(store, |mem, store| mem.revise(store, target, input))?;
752        self.after_mutation(&mut st, input.now)?;
753        Ok(out)
754    }
755
756    /// Tombstones a fact.
757    pub fn forget(&self, now: u64, id: plugmem_core::FactId) -> Result<bool, HostError> {
758        let mut st = self.write();
759        let State { engine, store, .. } = &mut *st;
760        let fresh = engine.with(store, |mem, store| mem.forget(store, now, id))?;
761        st.forgets += 1;
762        self.after_mutation(&mut st, now)?;
763        Ok(fresh)
764    }
765
766    /// Upserts a typed edge.
767    pub fn link(&self, input: LinkInput<'_>) -> Result<(), HostError> {
768        let mut st = self.write();
769        let State { engine, store, .. } = &mut *st;
770        engine.with(store, |mem, store| mem.link(store, input))?;
771        self.after_mutation(&mut st, input.now)?;
772        Ok(())
773    }
774
775    /// Closes a typed edge. Returns `false` when the edge is already absent.
776    pub fn unlink(&self, input: UnlinkInput<'_>) -> Result<bool, HostError> {
777        let mut st = self.write();
778        let State { engine, store, .. } = &mut *st;
779        let fresh = engine.with(store, |mem, store| mem.unlink(store, input))?;
780        self.after_mutation(&mut st, input.now)?;
781        Ok(fresh)
782    }
783
784    /// An owned copy of one fact, or `None` for unknown/tombstoned ids.
785    pub fn get(&self, id: plugmem_core::FactId) -> Option<FactSnapshot> {
786        self.read().engine.read(|mem| {
787            mem.get(id).map(|v| FactSnapshot {
788                record: v.record,
789                text: v.text.to_string(),
790                metadata: metadata_map(mem, id),
791            })
792        })
793    }
794
795    /// One fact's tags, or an empty vector for an unknown or tombstoned id.
796    ///
797    /// [`FactSnapshot`] carries text and metadata but not tags, so without this
798    /// the only way to read one fact's tags is [`Database::export`] — a full
799    /// scan to answer a question about a single id.
800    pub fn tags_of(&self, id: plugmem_core::FactId) -> Vec<String> {
801        self.read().engine.read(|mem| {
802            let mut terms = Vec::new();
803            mem.tags_of(id, &mut terms);
804            terms.iter().map(|t| mem.term(*t).to_string()).collect()
805        })
806    }
807
808    /// Engine size counters.
809    pub fn stats(&self) -> Stats {
810        self.read().engine.read(|mem| mem.stats())
811    }
812
813    /// Dumps the currently-open facts for a human-readable backup
814    /// See [`ExportedFact`]. Collects the whole set; for a large
815    /// database prefer [`export_each`](Self::export_each), which streams.
816    pub fn export(&self) -> Vec<ExportedFact> {
817        self.read().engine.read(export_facts)
818    }
819
820    /// Streams the currently-open facts, calling `f` once per fact under the
821    /// read guard — the whole dump is never materialized, so a huge database
822    /// exports without a RAM spike (CLI `export` writes each line straight out).
823    /// See [`ExportedFact`].
824    pub fn export_each(&self, f: impl FnMut(ExportedFact)) {
825        self.read().engine.read(|mem| export_facts_each(mem, f));
826    }
827
828    /// Inspects at most `limit` fact ids starting at `cursor` and returns the
829    /// ones that are currently open. A sparse page may therefore contain fewer
830    /// facts, including zero, while still carrying a `next_cursor`.
831    ///
832    /// This is the pull-based counterpart to [`export_each`](Self::export_each):
833    /// it releases the database read guard before returning, so a boundary
834    /// caller can process the page, apply backpressure, or write to the database
835    /// without a callback running under this lock. Mutations between page calls
836    /// are visible to later pages; use a stable read-only checkpoint when
837    /// snapshot-consistent paging is required.
838    pub fn export_page(&self, cursor: u32, limit: std::num::NonZeroUsize) -> ExportPage {
839        self.read()
840            .engine
841            .read(|mem| export_facts_page(mem, cursor, limit.get()))
842    }
843
844    /// Runs a maintenance pass now (cheap no-op, purge/compaction, text
845    /// reindex, and/or bounded HNSW work — for the cost model).
846    ///
847    /// **Disk-first** (milestone H): the compacted image is written by streaming
848    /// the two big pools (vectors, text) through temp files and then re-mapped,
849    /// so peak RAM tracks the record count (metadata + graph), not the image
850    /// size — a database larger than RAM can be maintained. It writes a fresh
851    /// snapshot and clears the journal (like a checkpoint). The optional
852    /// auto-maintain policy (`maintain_every_forgets`) still runs in RAM inline
853    /// — it is for databases that fit.
854    ///
855    /// The report's byte counts are the on-disk image size before and after.
856    pub fn maintain(&self, now: u64) -> Result<MaintainReport, HostError> {
857        self.maintain_with_options(now, MaintenanceOptions::auto())
858    }
859
860    /// Runs a maintenance pass with explicit policy.
861    pub fn maintain_with_options(
862        &self,
863        now: u64,
864        options: MaintenanceOptions,
865    ) -> Result<MaintainReport, HostError> {
866        let mut st = self.write();
867        // The image size is the current snapshot generation's, not the tiny
868        // manifest at the base path.
869        let snap_len = |store: &FileStorage| -> usize {
870            store
871                .current_snapshot_path()
872                .ok()
873                .flatten()
874                .and_then(|p| std::fs::metadata(&p).ok())
875                .map(|m| m.len() as usize)
876                .unwrap_or(0)
877        };
878        let bytes_before = snap_len(&st.store);
879        if !st.engine.read(|mem| mem.maintenance_needed(options)) {
880            let mut report = st
881                .engine
882                .read(|mem| mem.maintenance_preview(options, bytes_before));
883            report.bytes_after = bytes_before;
884            return Ok(report);
885        }
886        let stats = st.engine.read(|mem| mem.stats());
887        let needs_disk_first =
888            stats.tombstones != 0 || matches!(options.mode, MaintenanceMode::Full);
889        if !needs_disk_first {
890            let mut report = {
891                let State { engine, store, .. } = &mut *st;
892                engine.with(store, |mem, store| {
893                    mem.maintain_with_options(store, now, options)
894                })?
895            };
896            self.resnapshot(&mut st, now)?;
897            st.forgets = 0;
898            st.ops = 0;
899            report.bytes_before = bytes_before;
900            report.bytes_after = snap_len(&st.store);
901            return Ok(report);
902        }
903        let text_tmp = tmp_sibling(st.store.path(), "mtext");
904        let vec_tmp = tmp_sibling(st.store.path(), "mvec");
905
906        // Stage a compacted snapshot, streaming the big pools through scratch;
907        // this reads through the live map, so it happens before the map is
908        // dropped (as in `resnapshot`).
909        let mut purged = 0usize;
910        let mut report = MaintainReport::default();
911        {
912            let State { engine, store, .. } = &mut *st;
913            store.stage_snapshot(|sink| {
914                engine.read(|mem| {
915                    let mut text_scratch = FileScratch::create(&text_tmp)?;
916                    let mut vec_scratch = FileScratch::create(&vec_tmp)?;
917                    report = mem
918                        .snapshot_disk_first_with_options(
919                            now,
920                            &mut text_scratch,
921                            &mut vec_scratch,
922                            &mut *sink,
923                            options,
924                        )
925                        .map_err(HostError::from)?;
926                    purged = report.purged;
927                    Ok(())
928                })
929            })?;
930        }
931        // Drop the current map before the rename (park a cheap empty engine),
932        // commit, clear the journal, then re-map the compacted file — exactly
933        // the `resnapshot` dance, so the map is never renamed over on Windows.
934        st.engine = Engine::Owned(Box::new(Memory::new(self.inner.cfg.clone())?));
935        st.store
936            .commit_snapshot()
937            .and_then(|()| st.store.clear_journal())?;
938        let (engine, _) = open_engine(&mut st.store, &self.inner.cfg)?;
939        st.engine = engine;
940        st.forgets = 0;
941        st.ops = 0;
942        let bytes_after = snap_len(&st.store);
943        report.purged = purged;
944        report.bytes_before = bytes_before;
945        report.bytes_after = bytes_after;
946        Ok(report)
947    }
948
949    /// Writes a full snapshot and clears the journal now (re-mapping the
950    /// fresh file — see [`Database::resnapshot`]).
951    pub fn checkpoint(&self, now: u64) -> Result<(), HostError> {
952        let mut st = self.write();
953        self.resnapshot(&mut st, now)?;
954        st.ops = 0;
955        Ok(())
956    }
957
958    /// Runs the on-demand integrity check — the equivalent of
959    /// SQLite's `integrity_check`. An open validates only the metadata, so the
960    /// large byte pools stay non-resident on an mmap'd base; this sweeps them
961    /// (text UTF-8, vector self-consistency and the fact↔slot bijection) and
962    /// reports any latent corruption. Skipping it is safe — the accessors never
963    /// panic on bad bytes; `verify` only turns corruption into an explicit
964    /// error.
965    ///
966    /// # Errors
967    ///
968    /// [`HostError::Engine`] wrapping [`Error::Corrupt`](plugmem_core::Error)
969    /// for the first inconsistency found.
970    pub fn verify(&self) -> Result<(), HostError> {
971        Ok(self.read().engine.read(|mem| mem.verify())?)
972    }
973
974    /// Salvages a content-corrupt database (Tier 2): opens `src`,
975    /// drops the facts that fail the per-fact content checks (`verify`'s
976    /// predicate), compacts the survivors and their indexes, and writes a clean
977    /// image to `dst`. `src` on disk is left untouched — the evidence is
978    /// preserved.
979    ///
980    /// It is **disk-first** (milestone H): `src` is opened as an mmap overlay
981    /// (its pages are reclaimable) and the compacted image is written by
982    /// streaming the two big pools (vectors, text) through temp files, so peak
983    /// RAM tracks the record count (metadata + HNSW graph), not the image size.
984    /// A database far larger than RAM can be recovered, as long as its graph
985    /// fits.
986    ///
987    /// This handles *content* corruption (bad text bytes, a broken fact↔slot
988    /// vector bijection). *Structural* damage — a snapshot that will not parse
989    /// — is not salvageable here: `src` fails to open and recover returns the
990    /// engine's typed error; restore from a backup instead (Tier 0).
991    ///
992    /// # Errors
993    ///
994    /// [`HostError::Locked`] if `src` or `dst` is owned elsewhere;
995    /// [`HostError::Engine`] if `src` will not parse (structural corruption) or
996    /// `dst` equals `src`; [`HostError::Io`] for filesystem failures.
997    pub fn recover(
998        src: impl AsRef<Path>,
999        dst: impl AsRef<Path>,
1000        cfg: Config,
1001        now: u64,
1002    ) -> Result<RecoverReport, HostError> {
1003        let src = src.as_ref();
1004        let dst = dst.as_ref();
1005
1006        // Lock the source exclusively for the salvage's whole life. We never
1007        // write it — the lock only excludes a cooperating writer while we read.
1008        let mut src_store = FileStorage::open(src, FsyncPolicy::OnSnapshot)?;
1009        let src_base = src_store.path().to_path_buf();
1010
1011        // The destination must be a different file: recover preserves the source
1012        // as evidence and writes the clean image elsewhere.
1013        let same = dst == src_base
1014            || matches!(
1015                (std::fs::canonicalize(dst), std::fs::canonicalize(&src_base)),
1016                (Ok(a), Ok(b)) if a == b
1017            );
1018        if same {
1019            return Err(HostError::Engine(Error::Invalid(
1020                "recover destination must differ from the source",
1021            )));
1022        }
1023
1024        // Open the source as an overlay: borrow the mmap base (reclaimable
1025        // pages) and replay its journal into a small owned overlay — never an
1026        // owned copy of the image. A structurally corrupt image fails here —
1027        // that is Tier 0, not salvageable content corruption.
1028        let journal = src_store.read_journal()?;
1029        let Some(genp) = src_store.current_snapshot_path()? else {
1030            return Err(HostError::Engine(Error::Corrupt(
1031                "source database has no published snapshot to recover",
1032            )));
1033        };
1034        let file = File::open(&genp).map_err(|e| HostError::io(&genp, e))?;
1035        // SAFETY: as in `open_engine` — the generation file is immutable and
1036        // `src_store` holds the exclusive lock, so nothing touches it under us.
1037        let map = unsafe { Mmap::map(&file) }.map_err(|e| HostError::io(&genp, e))?;
1038        drop(file);
1039        let (mut mem, _report) = Memory::from_bytes_overlay(&map[..], &journal, cfg.clone())?;
1040
1041        // Drop each content-faulty fact into a throwaway store, so the source
1042        // file is never written. The disk-first rebuild below then physically
1043        // purges them and rebuilds clean indexes + HNSW from the survivors.
1044        let mut scratch = MemStorage::new();
1045        let mut dropped_text = 0usize;
1046        let mut dropped_vector = 0usize;
1047        let mut dropped_metadata = 0usize;
1048        for (id, fault) in mem.faulty_facts() {
1049            mem.forget(&mut scratch, now, id)?;
1050            match fault {
1051                FactFault::Text => dropped_text += 1,
1052                FactFault::Vector => dropped_vector += 1,
1053                FactFault::Metadata => dropped_metadata += 1,
1054            }
1055        }
1056
1057        // Write the compacted image to `dst`, streaming the big pools through
1058        // temp scratch files (metadata + graph are the only things resident).
1059        let mut dst_store = FileStorage::open(dst, FsyncPolicy::OnSnapshot)?;
1060        let text_tmp = tmp_sibling(dst_store.path(), "rectext");
1061        let vec_tmp = tmp_sibling(dst_store.path(), "recvec");
1062        let mut purged = 0usize;
1063        dst_store.stage_snapshot(|sink| {
1064            let mut text_scratch = FileScratch::create(&text_tmp)?;
1065            let mut vec_scratch = FileScratch::create(&vec_tmp)?;
1066            purged = mem
1067                .snapshot_disk_first(now, &mut text_scratch, &mut vec_scratch, &mut *sink)
1068                .map_err(HostError::from)?;
1069            Ok(())
1070        })?;
1071        dst_store.commit_snapshot()?;
1072
1073        let kept = mem.stats().facts.saturating_sub(purged);
1074        Ok(RecoverReport {
1075            kept,
1076            dropped_text,
1077            dropped_vector,
1078            dropped_metadata,
1079        })
1080    }
1081}
1082
1083/// A temp-file path beside `base` with the given tag (for disk-first scratch).
1084fn tmp_sibling(base: &Path, tag: &str) -> PathBuf {
1085    let mut p = base.as_os_str().to_os_string();
1086    p.push(".");
1087    p.push(tag);
1088    p.push(".tmp");
1089    PathBuf::from(p)
1090}
1091
1092impl std::fmt::Debug for Database {
1093    /// Summary only — the contents are the user's memory.
1094    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1095        let stats = self.stats();
1096        f.debug_struct("Database")
1097            .field("facts", &stats.facts)
1098            .field("entities", &stats.entities)
1099            .finish()
1100    }
1101}