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