Skip to main content

plugmem_core/memory/
maintain.rs

1//! Maintenance: tombstone purge and satellite compaction (
2//! B).
3//!
4//! `maintain` is the one O(base) verb — everything else is microseconds.
5//! It reclaims the space held by forgotten facts without ever renumbering
6//! ids: `FactId`/`EntityId`/`TermId` are stable forever, so external
7//! references, revision chains and edges stay valid across a compaction.
8//!
9//! Tombstoned facts are purged **physically**: their `FactRecord` and
10//! `FactAux` are simply not carried into the rebuilt arenas. The id itself
11//! is *burned*, never reissued — id allocation runs on the persisted
12//! `next_fact` counter, not on record presence, so replay determinism and
13//! the "ids are never reused" invariant survive removal (
14//! allows numbering holes explicitly). A burned id behaves
15//! exactly like a tombstoned one did: `get` returns `None`, verbs return
16//! `NotFound`. References *to* a purged fact (a successor's `revises`, an
17//! edge's provenance) keep the burned id rather than being rewritten:
18//! resolving it yields `None` either way, which is what makes a maintained
19//! and an unmaintained run observation-equivalent.
20//!
21//! Every satellite structure (the blob heap, the tag pool, the three
22//! posting stores, the temporal arena, the vector pool) is rebuilt from
23//! the live facts alone. The interner is not rebuilt (term ids are
24//! stable; leaked terms are a documented v2 concern), and edges and the
25//! by-name index carry only stable ids, so they ride through untouched.
26//!
27//! Determinism is the load-bearing property: the rebuild walks entities
28//! and facts in id order and re-derives each index the same way every
29//! time, so a snapshot taken after a live `maintain` is byte-identical to
30//! one taken after replaying the journal (which re-executes the `Maintain`
31//! marker). The commit order is check-first: the whole new state is built
32//! (fallible) and the journal marker is appended (fallible) before
33//! anything is swapped in (infallible).
34
35use alloc::format;
36use alloc::vec::Vec;
37
38use plugmem_arena::{
39    Arena, ArenaCfg, BlobHeap, BlobHeapBuilder, BlobHeapCfg, BlobId, ChunkPool, ChunkPoolCfg,
40    ListHandle, ShardMode,
41};
42
43use crate::error::Error;
44use crate::id::{FactId, NONE_U32};
45use crate::index::IdListIndex;
46use crate::index::bm25::Bm25Index;
47use crate::index::hnsw::{HnswGraph, HnswScratch};
48use crate::index::vecpool::VecPool;
49use crate::journal::Op;
50use crate::memory::persist::Sections;
51use crate::model::{EntityRecord, FactAux, FactRecord, TemporalSlot};
52use crate::snapshot::SnapshotSink;
53use crate::storage::{Scratch, Storage};
54use crate::tokenizer::Tokenizer;
55
56use super::Memory;
57
58/// Maps a [`Scratch`] error into the engine's storage-error variant.
59fn scratch_err<E: core::fmt::Debug>(e: E) -> Error {
60    Error::Storage(format!("{e:?}"))
61}
62
63/// Sink for the two dominant pools (text, vectors) during a rebuild. The in-RAM
64/// path ([`OwnedPools`]) builds them owned; the disk-first path
65/// ([`StreamPools`]) streams them into a [`Scratch`] and never holds them
66/// Everything else a rebuild produces is metadata — small enough
67/// (∝ record count) to build in RAM on either path.
68trait PoolSink {
69    /// Records a text blob, returning its new dense id.
70    fn push_text(&mut self, bytes: &[u8]) -> Result<BlobId, Error>;
71    /// Copies vector slot `slot` of `src`, returning its new dense slot.
72    fn push_vector(&mut self, src: &VecPool<'_>, slot: u32) -> Result<u32, Error>;
73}
74
75/// In-RAM pools: the classic owned rebuild.
76struct OwnedPools {
77    texts: BlobHeap<'static>,
78    vecs: VecPool<'static>,
79}
80
81impl PoolSink for OwnedPools {
82    fn push_text(&mut self, bytes: &[u8]) -> Result<BlobId, Error> {
83        Ok(self.texts.push(bytes)?)
84    }
85
86    fn push_vector(&mut self, src: &VecPool<'_>, slot: u32) -> Result<u32, Error> {
87        Ok(self.vecs.copy_slot(src, slot))
88    }
89}
90
91/// Disk-first pools: text bytes and vector slots stream into two `Scratch`es;
92/// only the flat text index ([`BlobHeapBuilder`]) and the slot counter stay in
93/// RAM (both ∝ record count).
94struct StreamPools<'s, T: Scratch, V: Scratch> {
95    text_scratch: &'s mut T,
96    text_index: BlobHeapBuilder,
97    vec_scratch: &'s mut V,
98    vec_count: u32,
99}
100
101impl<T: Scratch, V: Scratch> PoolSink for StreamPools<'_, T, V> {
102    fn push_text(&mut self, bytes: &[u8]) -> Result<BlobId, Error> {
103        self.text_scratch.write(bytes).map_err(scratch_err)?;
104        Ok(self.text_index.push_len(bytes.len())?)
105    }
106
107    fn push_vector(&mut self, src: &VecPool<'_>, slot: u32) -> Result<u32, Error> {
108        self.vec_scratch
109            .write(src.slot_bytes(slot as usize))
110            .map_err(scratch_err)?;
111        let new = self.vec_count;
112        self.vec_count += 1;
113        Ok(new)
114    }
115}
116
117/// The rebuildable metadata (everything but the two big pools and the graph):
118/// produced by [`Memory::rebuild_parts`] and shared by the in-RAM and
119/// disk-first paths.
120struct RebuildMeta {
121    facts: Arena<'static, FactRecord>,
122    fact_aux: Arena<'static, FactAux>,
123    entities: Arena<'static, EntityRecord>,
124    temporal: Arena<'static, TemporalSlot>,
125    tag_lists: ChunkPool<'static>,
126    /// Compacted metadata blobs of the live facts (built owned in RAM on both
127    /// paths — metadata is pointers/attributes, ∝ record count, not a big pool).
128    metas: BlobHeap<'static>,
129    bm25: Bm25Index<'static>,
130    tags_idx: IdListIndex<'static>,
131    entity_facts: IdListIndex<'static>,
132}
133
134/// Report of a `maintain` pass.
135#[derive(Clone, Debug, Default, PartialEq, Eq)]
136#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
137pub struct MaintainReport {
138    /// Tombstoned facts physically removed by this pass (their ids stay
139    /// burned; a second pass over the same state purges nothing).
140    pub purged: usize,
141    /// Bytes across the rebuilt pools before the pass.
142    pub bytes_before: usize,
143    /// Bytes across the rebuilt pools after the pass.
144    pub bytes_after: usize,
145}
146
147/// The freshly rebuilt structures, swapped in atomically once the journal
148/// marker is durable.
149struct Rebuilt {
150    facts: Arena<'static, FactRecord>,
151    entities: Arena<'static, EntityRecord>,
152    fact_aux: Arena<'static, FactAux>,
153    texts: BlobHeap<'static>,
154    metas: BlobHeap<'static>,
155    tag_lists: ChunkPool<'static>,
156    bm25: Bm25Index<'static>,
157    tags_idx: IdListIndex<'static>,
158    entity_facts: IdListIndex<'static>,
159    temporal: Arena<'static, TemporalSlot>,
160    vecs: VecPool<'static>,
161    hnsw: HnswGraph<'static>,
162}
163
164impl Memory<'_> {
165    /// Physically purges tombstoned facts and compacts every satellite
166    /// structure. Ids of living facts are preserved; purged ids
167    /// are burned (never reissued); observable state is unchanged; only
168    /// bytes shrink. Journaled as a `Maintain` marker so replay reproduces
169    /// the compaction exactly.
170    ///
171    /// # Errors
172    ///
173    /// [`Error::CapacityExceeded`] if a rebuilt pool hits its ceiling (it
174    /// cannot, being a subset of the live data, but the path is honest),
175    /// or an [`Error::Storage`] from the journal append — in either case
176    /// nothing is swapped in and the engine is unchanged.
177    pub fn maintain<S: Storage>(
178        &mut self,
179        store: &mut S,
180        now: u64,
181    ) -> Result<MaintainReport, Error> {
182        let bytes_before = self.satellite_bytes();
183        let (rebuilt, purged) = self.rebuild()?;
184        // Commit point: the marker becomes durable before the swap, so a
185        // replay of this journal reproduces the compacted image exactly.
186        let mut entry = Vec::new();
187        Op::Maintain { now }.encode(&mut entry);
188        store
189            .append_journal(&entry)
190            .map_err(|e| Error::Storage(format!("{e:?}")))?;
191        self.install(rebuilt);
192        Ok(MaintainReport {
193            purged,
194            bytes_before,
195            bytes_after: self.satellite_bytes(),
196        })
197    }
198
199    /// Replay entry point: re-execute the compaction without journaling it
200    /// again (the marker being replayed *is* the record of it).
201    pub(super) fn replay_maintain(&mut self) -> Result<(), Error> {
202        let (rebuilt, _) = self.rebuild()?;
203        self.install(rebuilt);
204        Ok(())
205    }
206
207    /// Bytes across the pools the rebuild replaces (everything except the
208    /// interner, the by-name index and the edges — those ride through).
209    fn satellite_bytes(&self) -> usize {
210        self.facts.pool_bytes()
211            + self.fact_aux.pool_bytes()
212            + self.entities.pool_bytes()
213            + self.hnsw.pool_bytes()
214            + self.texts.pool_bytes()
215            + self.metas.pool_bytes()
216            + self.tag_lists.pool_bytes()
217            + self.bm25.pool_bytes()
218            + self.tags_idx.pool_bytes()
219            + self.entity_facts.pool_bytes()
220            + self.temporal.pool_bytes()
221            + self.vecs.pool_bytes()
222    }
223
224    /// Builds the compacted state without touching `self` (so a failure
225    /// leaves the engine intact). Returns the new structures and the count
226    /// of purged tombstones.
227    fn rebuild(&self) -> Result<(Rebuilt, usize), Error> {
228        let cfg = &self.cfg;
229        let blob = BlobHeapCfg::new()
230            .with_max_bytes(cfg.max_bytes)
231            .with_max_blob(cfg.max_blob);
232        let mut pools = OwnedPools {
233            texts: BlobHeap::new(blob),
234            vecs: VecPool::new(cfg.dim, cfg.max_bytes),
235        };
236        let (m, vec_map, purged) = self.rebuild_parts(&mut pools)?;
237        let hnsw = self.rebuild_graph(&vec_map, &pools.vecs)?;
238        Ok((
239            Rebuilt {
240                facts: m.facts,
241                entities: m.entities,
242                fact_aux: m.fact_aux,
243                texts: pools.texts,
244                metas: m.metas,
245                tag_lists: m.tag_lists,
246                bm25: m.bm25,
247                tags_idx: m.tags_idx,
248                entity_facts: m.entity_facts,
249                temporal: m.temporal,
250                vecs: pools.vecs,
251                hnsw,
252            },
253            purged,
254        ))
255    }
256
257    /// Builds the compacted metadata and pushes the two big pools through
258    /// `pools` — the walk shared by the in-RAM rebuild ([`OwnedPools`]) and the
259    /// disk-first one ([`StreamPools`]). Ids are **not** renumbered;
260    /// only text-blob ids and vector slots are re-densified, in fact-id order,
261    /// so both paths produce byte-identical output. Returns the metadata, the
262    /// old→new vector-slot map (for the graph) and the purge count.
263    fn rebuild_parts<P: PoolSink>(
264        &self,
265        pools: &mut P,
266    ) -> Result<(RebuildMeta, alloc::vec::Vec<u32>, usize), Error> {
267        let cfg = &self.cfg;
268        let uni =
269            |shards: usize| ArenaCfg::new(shards, ShardMode::Uniform).with_max_bytes(cfg.max_bytes);
270        let ord =
271            |shards: usize| ArenaCfg::new(shards, ShardMode::Ordered).with_max_bytes(cfg.max_bytes);
272
273        let mut entities = Arena::new(uni(cfg.shards_entities))?;
274        let mut facts = Arena::new(uni(cfg.shards_facts))?;
275        let mut fact_aux = Arena::new(uni(cfg.shards_facts))?;
276        let mut tag_lists = ChunkPool::new(ChunkPoolCfg::new().with_max_bytes(cfg.max_bytes));
277        let mut bm25 = Bm25Index::new(cfg.shards_postings, cfg.max_bytes)?;
278        let mut tags_idx = IdListIndex::new(cfg.shards_postings, cfg.max_bytes)?;
279        let mut entity_facts = IdListIndex::new(cfg.shards_entities, cfg.max_bytes)?;
280        let mut temporal = Arena::new(ord(cfg.shards_temporal))?;
281        let mut metas = BlobHeap::new(
282            BlobHeapCfg::new()
283                .with_max_bytes(cfg.max_bytes)
284                .with_max_blob(cfg.max_blob),
285        );
286
287        // Entities first (id order), each with its name pushed into the new
288        // text pool. Entities are never purged, so a gap is corruption.
289        for eid in 0..self.next_entity {
290            let rec = self
291                .entities
292                .get(&eid.to_be_bytes())
293                .ok_or(Error::Corrupt("maintain: entity id gap"))?;
294            let name_id = pools.push_text(self.texts.get(rec.name))?;
295            entities.insert(&EntityRecord {
296                name: name_id,
297                ..rec
298            })?;
299        }
300
301        // Re-tokenization reuses the (unchanged) interner via read-only
302        // lookup — every live token was interned at creation, so it
303        // resolves; the tokenizer is a scratch, taken to satisfy borrows.
304        // Constraint: this only holds while the tokenizer matches the one
305        // the texts were indexed with. A future tokenizer change must not
306        // ship through this lookup path (new tokens would silently drop
307        // from BM25) — a reindex migration has to intern, not look up.
308        let mut tokenizer = Tokenizer::new();
309        let mut tf: Vec<(u32, u8)> = Vec::new();
310
311        // old vector-slot id → new slot id (NONE for purged vectors);
312        // carries the HNSW graph across the compaction.
313        let mut vec_map = alloc::vec![NONE_U32; self.vecs.len()];
314
315        let mut purged = 0usize;
316        for fid in 0..self.next_fact {
317            let id = FactId(fid);
318            // A missing record is an id burned by an earlier pass — legal
319            // (: numbering holes after a purge are the norm).
320            let Some(rec) = self.facts.get(&fid.to_be_bytes()) else {
321                continue;
322            };
323
324            if rec.is_tombstone() {
325                // Physical purge: neither the record nor its aux is carried
326                // over. The id stays burned via the untouched `next_fact`.
327                purged += 1;
328                continue;
329            }
330
331            // Live fact: push its text and re-derive every index.
332            let text_bytes = self.texts.get(rec.text);
333            let text_id = pools.push_text(text_bytes)?;
334            let text = core::str::from_utf8(text_bytes)
335                .map_err(|_| Error::Corrupt("maintain: fact text is not UTF-8"))?;
336
337            tf.clear();
338            let terms = &self.terms;
339            let tf_ref = &mut tf;
340            tokenizer.tokenize(text, &mut |token| {
341                if let Some(term) = terms.lookup(token) {
342                    match tf_ref.iter_mut().find(|(t, _)| *t == term.0) {
343                        Some((_, c)) => *c = c.saturating_add(1),
344                        None => tf_ref.push((term.0, 1)),
345                    }
346                }
347            });
348            bm25.index_doc(id, &tf)?;
349
350            // Tags: re-read the old list, rebuild the fact's handle and the
351            // inverted index. Every fact gets an aux record at creation, so
352            // a gap here is corruption — same strictness as the fact gap
353            // above, not a silent "no tags".
354            let aux = self
355                .fact_aux
356                .get(&fid.to_be_bytes())
357                .ok_or(Error::Corrupt("maintain: fact aux gap"))?;
358            let mut tags = ListHandle::EMPTY;
359            for chunk in self.tag_lists.iter(&aux.tags) {
360                for raw in chunk.chunks_exact(4) {
361                    let term = u32::from_be_bytes(raw.try_into().unwrap());
362                    tag_lists.push(&mut tags, &term.to_be_bytes())?;
363                    tags_idx.push(term, id, 0)?;
364                }
365            }
366            // Metadata rides across the compaction verbatim: the stored blob is
367            // already canonical, so it is copied byte for byte into the new heap.
368            let meta = if aux.meta.0 == NONE_U32 {
369                BlobId(NONE_U32)
370            } else {
371                metas.push(self.metas.get(aux.meta))?
372            };
373            fact_aux.insert(&FactAux { id, tags, meta })?;
374
375            // Entity index and temporal index.
376            if let Some(entity) = rec.entity.some() {
377                entity_facts.push(entity.0, id, 0)?;
378            }
379            temporal.insert(&TemporalSlot {
380                recorded_at: rec.recorded_at,
381                fact: id,
382            })?;
383
384            // Vector: push the already-quantized slot verbatim.
385            let vector = if rec.has_vector() {
386                let new_slot = pools.push_vector(&self.vecs, rec.vector)?;
387                vec_map[rec.vector as usize] = new_slot;
388                new_slot
389            } else {
390                NONE_U32
391            };
392            facts.insert(&FactRecord {
393                text: text_id,
394                vector,
395                ..rec
396            })?;
397        }
398
399        Ok((
400            RebuildMeta {
401                facts,
402                fact_aux,
403                entities,
404                temporal,
405                tag_lists,
406                metas,
407                bm25,
408                tags_idx,
409                entity_facts,
410            },
411            vec_map,
412            purged,
413        ))
414    }
415
416    /// Disk-first compaction (milestone H): rebuilds the compacted
417    /// image and writes it to `sink`, streaming the two big pools (text,
418    /// vectors) through `text_scratch`/`vec_scratch` so peak RAM stays ∝ the
419    /// record count (metadata + graph), never ∝ the content size. Byte-identical
420    /// to a snapshot taken after an in-RAM [`Memory::maintain`] — it drives the
421    /// same walk (`rebuild_parts`) and the same emit (`write_snapshot_with`),
422    /// the pools merely borrowing the frozen scratch instead of RAM. Returns the
423    /// purge count.
424    ///
425    /// # Errors
426    ///
427    /// [`Error::Corrupt`] for a malformed source, [`Error::Storage`] from a
428    /// scratch or the sink, or a pool ceiling error (a subset never exceeds it).
429    pub fn snapshot_disk_first<T: Scratch, V: Scratch, Sk: SnapshotSink>(
430        &self,
431        created_at: u64,
432        text_scratch: &mut T,
433        vec_scratch: &mut V,
434        sink: Sk,
435    ) -> Result<usize, Error> {
436        let cfg = &self.cfg;
437        let blob = BlobHeapCfg::new()
438            .with_max_bytes(cfg.max_bytes)
439            .with_max_blob(cfg.max_blob);
440        let mut pools = StreamPools {
441            text_scratch,
442            text_index: BlobHeapBuilder::new(blob),
443            vec_scratch,
444            vec_count: 0,
445        };
446        let (m, vec_map, purged) = self.rebuild_parts(&mut pools)?;
447
448        // Freeze the staged pools and borrow them as the two big sections; the
449        // metadata and graph are the only things in RAM.
450        let StreamPools {
451            text_scratch,
452            text_index,
453            vec_scratch,
454            ..
455        } = pools;
456        let mut text_index_bytes = Vec::new();
457        text_index.dump_index(&mut text_index_bytes);
458        let text_pool = text_scratch.freeze().map_err(scratch_err)?;
459        let vec_pool = vec_scratch.freeze().map_err(scratch_err)?;
460        let texts = BlobHeap::load_borrowed(blob, &text_index_bytes, text_pool)?;
461        let vecs = VecPool::from_parts_borrowed(cfg.dim, cfg.max_bytes, vec_pool)?;
462        let hnsw = self.rebuild_graph(&vec_map, &vecs)?;
463
464        let sections = Sections {
465            facts: &m.facts,
466            fact_aux: &m.fact_aux,
467            entities: &m.entities,
468            temporal: &m.temporal,
469            texts: &texts,
470            metas: &m.metas,
471            tag_lists: &m.tag_lists,
472            bm25: &m.bm25,
473            tags_idx: &m.tags_idx,
474            entity_facts: &m.entity_facts,
475            vecs: &vecs,
476            hnsw: &hnsw,
477        };
478        self.write_snapshot_with(&sections, created_at, sink)?;
479        Ok(purged)
480    }
481
482    /// The vector index's maintenance policy (phase 2), all
483    /// deterministic:
484    ///
485    /// - below `flat_to_hnsw` the graph is empty (flat regime);
486    /// - the first crossing (or > 10% of the graph's nodes dead) builds
487    ///   the graph from scratch over the compacted pool;
488    /// - otherwise the existing graph is *carried over*: neighbor lists
489    ///   are remapped through the compaction map (dead nodes drop out)
490    ///   and the flat tail is bulk-inserted — the cheap steady-state
491    ///   path that keeps `maintain` inside its budget.
492    fn rebuild_graph(
493        &self,
494        vec_map: &[u32],
495        pool: &VecPool<'_>,
496    ) -> Result<HnswGraph<'static>, Error> {
497        let cfg = &self.cfg;
498        let mut graph: HnswGraph<'static> = HnswGraph::new(cfg.hnsw_m, cfg.hnsw_m0, cfg.max_bytes)?;
499        let total = pool.len() as u32;
500        if cfg.dim == 0 || (total as usize) < cfg.flat_to_hnsw {
501            return Ok(graph);
502        }
503        let old_indexed = self.hnsw.indexed() as usize;
504        let dead = vec_map[..old_indexed]
505            .iter()
506            .filter(|&&m| m == NONE_U32)
507            .count();
508        let mut scratch = HnswScratch::default();
509        if old_indexed > 0 && dead * 10 <= old_indexed {
510            graph = self.hnsw.remapped(vec_map, pool, cfg.max_bytes)?;
511        }
512        graph.insert_bulk(pool, total, cfg.hnsw_ef_construction, &mut scratch)?;
513        Ok(graph)
514    }
515
516    /// Swaps the rebuilt structures in (infallible). The interner, by-name
517    /// index, edges and id counters are unchanged by design.
518    fn install(&mut self, r: Rebuilt) {
519        self.facts = r.facts;
520        self.entities = r.entities;
521        self.fact_aux = r.fact_aux;
522        self.texts = r.texts;
523        self.metas = r.metas;
524        self.tag_lists = r.tag_lists;
525        self.bm25 = r.bm25;
526        self.tags_idx = r.tags_idx;
527        self.entity_facts = r.entity_facts;
528        self.temporal = r.temporal;
529        self.vecs = r.vecs;
530        self.hnsw = r.hnsw;
531    }
532}