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(§ions, 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}