Skip to main content

velesdb_memory/migration/
rebuild.rs

1//! The rebuild pass (#1762, PR C2b): drive the proven parts over a real store.
2//!
3//! Everything this module calls was proven separately — facts round-trip
4//! ([`super::enumeration`]), edges round-trip ([`super::edges`]), the journal
5//! can only advance ([`super::state`]). What it adds is the loop, and the loop
6//! is where a migration actually dies: between a batch landing and its
7//! checkpoint, between the last fact and the first edge, between one
8//! collection and the next. Every one of those gaps is covered by the journal
9//! contract — a crash replays at most one batch, and the replay is tolerated
10//! by construction because [`super::reinsert_batch`] refuses to overwrite and
11//! reports collisions instead.
12//!
13//! # Checkpoint order: destination first, journal second
14//!
15//! Every batch is written to the destination BEFORE its cursor reaches the
16//! journal. The other order would be quietly catastrophic: a journal that runs
17//! ahead of the destination makes a resume SKIP facts that never landed, and
18//! nothing downstream can tell a skipped fact from a fact the source never
19//! had. With this order the crash window replays work instead of losing it,
20//! and replays are visible (counted as collisions) rather than silent.
21//!
22//! # What this module deliberately does not do
23//!
24//! It does not choose the vector policy — [`super::resolve`] does, one place,
25//! tested as one rule. It does not create the destination, acquire the lock,
26//! or write the first journal entry — the caller stages those, because each is
27//! refused differently and a monolithic "prepare everything" would blur whose
28//! refusal the operator is reading. And it does not touch the phase: the pass
29//! runs strictly inside [`Phase::Prepared`], and leaving that phase is the
30//! validation-and-switch work of a later PR.
31
32use super::query_error;
33use std::path::Path;
34
35use velesdb_core::agent::AgentMemory;
36use velesdb_core::Database;
37
38use super::edges::{export_edges_verified, reinsert_edges, same_edge_tuples};
39use super::enumeration::{reinsert_batch, scroll_page, RawFact, AGENT_COLLECTIONS};
40use super::state::{CollectionProgress, MigrationLock, MigrationState, Phase};
41use crate::embedder::Embedder;
42
43/// The source store, opened read-only in spirit: nothing here writes to it.
44pub struct RebuildSource<'a> {
45    /// The database the fact walk scrolls.
46    pub db: &'a Database,
47    /// The agent view the edge export reads through.
48    pub memory: &'a AgentMemory,
49}
50
51/// The destination store, created and sized by the caller.
52pub struct RebuildDestination<'a> {
53    /// The database facts are reinserted into.
54    pub db: &'a Database,
55    /// The agent view edges are reinserted through.
56    pub memory: &'a AgentMemory,
57}
58
59/// Where the journal lives and the proof we may write it.
60pub struct RebuildJournal<'a> {
61    /// The workspace holding `migration-state.json` — never the source store.
62    pub workspace: &'a Path,
63    /// The exclusive lock the caller acquired on that workspace.
64    pub lock: &'a MigrationLock,
65}
66
67/// Which vector each reinserted fact carries.
68///
69/// `Reuse` copies the source vector verbatim and never reads the fact's text;
70/// `Reembed` reads the fact's `content` and asks the target embedder. This is
71/// an enum rather than a closure so the pass cannot be handed a policy the
72/// regime resolution did not produce.
73pub enum VectorPolicy<'a> {
74    /// The compatibility-proven regime: the source vectors ARE the target's.
75    Reuse,
76    /// Every other regime: the target embedder produces every vector.
77    Reembed(&'a dyn Embedder),
78}
79
80/// What a completed pass did — counts, not verdicts. The verdict is the
81/// destination re-reads performed along the way.
82#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
83pub struct RebuildOutcome {
84    /// Facts written to the destination by this run.
85    pub facts: u64,
86    /// Ids that were already occupied — nonzero exactly when this run replayed
87    /// a batch an interrupted predecessor had landed but not journalled.
88    pub collisions: u64,
89    /// Edges reinserted (idempotent replays included).
90    pub edges: u64,
91}
92
93/// Run the rebuild to completion, resuming from whatever `state` records.
94///
95/// Collections already `Complete` are skipped; a collection at `Edges` re-runs
96/// its (idempotent) edge pass; a collection at `Facts` resumes strictly after
97/// its journalled cursor.
98///
99/// # Errors
100/// Returns [`crate::MemoryError`] if `state` is not in [`Phase::Prepared`], if
101/// any read, embed, write, or journal step fails, or if a destination re-read
102/// disagrees with the source export.
103pub fn rebuild(
104    source: &RebuildSource<'_>,
105    destination: &RebuildDestination<'_>,
106    state: &mut MigrationState,
107    journal: &RebuildJournal<'_>,
108    policy: &VectorPolicy<'_>,
109    batch: usize,
110) -> Result<RebuildOutcome, crate::MemoryError> {
111    rebuild_inner(source, destination, state, journal, policy, batch, None)
112}
113
114/// [`rebuild`], with an injected stop after `stop_after_batches` batches.
115///
116/// The stop fires after a batch is reinserted and BEFORE its checkpoint is
117/// journalled — the widest window a real crash can hit. This is the seam the
118/// interruption tests drive; production callers go through [`rebuild`], which
119/// never stops early — hence the `cfg(test)`: no production build carries it.
120#[cfg(test)]
121pub(crate) fn rebuild_with_stop(
122    source: &RebuildSource<'_>,
123    destination: &RebuildDestination<'_>,
124    state: &mut MigrationState,
125    journal: &RebuildJournal<'_>,
126    policy: &VectorPolicy<'_>,
127    batch: usize,
128    stop_after_batches: Option<u64>,
129) -> Result<RebuildOutcome, crate::MemoryError> {
130    rebuild_inner(
131        source,
132        destination,
133        state,
134        journal,
135        policy,
136        batch,
137        stop_after_batches,
138    )
139}
140
141/// Counters shared across collections, plus the run-wide stop seam.
142#[derive(Default)]
143struct Run {
144    facts: u64,
145    collisions: u64,
146    edges: u64,
147    batches: u64,
148    stop_after_batches: Option<u64>,
149}
150
151fn rebuild_inner(
152    source: &RebuildSource<'_>,
153    destination: &RebuildDestination<'_>,
154    state: &mut MigrationState,
155    journal: &RebuildJournal<'_>,
156    policy: &VectorPolicy<'_>,
157    batch: usize,
158    stop_after_batches: Option<u64>,
159) -> Result<RebuildOutcome, crate::MemoryError> {
160    if state.phase != Phase::Prepared {
161        return Err(query_error(format!(
162            "the rebuild runs strictly inside {:?}, and this journal stands at \
163             {:?}; a pass that ran after validation would invalidate what was \
164             validated",
165            Phase::Prepared,
166            state.phase
167        )));
168    }
169    let mut run = Run {
170        stop_after_batches,
171        ..Run::default()
172    };
173    for name in AGENT_COLLECTIONS {
174        let step = Step {
175            collection: name,
176            policy,
177            batch,
178        };
179        rebuild_collection(source, destination, state, journal, &step, &mut run)?;
180    }
181    Ok(RebuildOutcome {
182        facts: run.facts,
183        collisions: run.collisions,
184        edges: run.edges,
185    })
186}
187
188/// The immutable context of one collection's pass.
189struct Step<'a> {
190    collection: &'a str,
191    policy: &'a VectorPolicy<'a>,
192    batch: usize,
193}
194
195fn rebuild_collection(
196    source: &RebuildSource<'_>,
197    destination: &RebuildDestination<'_>,
198    state: &mut MigrationState,
199    journal: &RebuildJournal<'_>,
200    step: &Step<'_>,
201    run: &mut Run,
202) -> Result<(), crate::MemoryError> {
203    let current = *state.progress.get(step.collection).ok_or_else(|| {
204        query_error(format!(
205            "the journal carries no progress entry for '{}'; refusing to \
206             invent one mid-pass",
207            step.collection
208        ))
209    })?;
210    match current {
211        CollectionProgress::Complete => return Ok(()),
212        CollectionProgress::Edges => {}
213        CollectionProgress::Facts { cursor } => {
214            walk_facts(source, destination, state, journal, step, run, cursor)?;
215            journal_progress(state, journal, step.collection, CollectionProgress::Edges)?;
216        }
217    }
218    run.edges += edge_pass(source, destination, step)?;
219    journal_progress(
220        state,
221        journal,
222        step.collection,
223        CollectionProgress::Complete,
224    )
225}
226
227fn walk_facts(
228    source: &RebuildSource<'_>,
229    destination: &RebuildDestination<'_>,
230    state: &mut MigrationState,
231    journal: &RebuildJournal<'_>,
232    step: &Step<'_>,
233    run: &mut Run,
234    mut cursor: Option<u64>,
235) -> Result<(), crate::MemoryError> {
236    loop {
237        let (facts, next) = scroll_page(source.db, step.collection, cursor, step.batch)?;
238        if facts.is_empty() {
239            return Ok(());
240        }
241        let mut pairs: Vec<(RawFact, Vec<f32>)> = Vec::with_capacity(facts.len());
242        for fact in facts {
243            let vector = vector_for(step.policy, &fact)?;
244            pairs.push((fact, vector));
245        }
246        let outcome = reinsert_batch(destination.db, step.collection, &pairs)?;
247        run.facts += outcome.inserted;
248        run.collisions += outcome.collisions.len() as u64;
249        run.batches += 1;
250        if run.stop_after_batches == Some(run.batches) {
251            return Err(query_error(format!(
252                "rebuild interrupted by the injected stop after {} batches; the \
253                 destination holds this batch and the journal does not — the \
254                 exact window a crash leaves, and what a resume replays",
255                run.batches
256            )));
257        }
258        let Some(next) = next else {
259            return Ok(());
260        };
261        cursor = Some(next);
262        journal_progress(
263            state,
264            journal,
265            step.collection,
266            CollectionProgress::Facts { cursor: Some(next) },
267        )?;
268    }
269}
270
271/// The vector a fact carries at the destination, per the resolved regime.
272fn vector_for(policy: &VectorPolicy<'_>, fact: &RawFact) -> Result<Vec<f32>, crate::MemoryError> {
273    match policy {
274        VectorPolicy::Reuse => Ok(fact.source_vector.clone()),
275        VectorPolicy::Reembed(embedder) => {
276            let payload: serde_json::Value =
277                serde_json::from_str(&fact.payload).map_err(|err| {
278                    query_error(format!(
279                        "fact {} carries unreadable payload: {err}",
280                        fact.id
281                    ))
282                })?;
283            let Some(content) = payload.get("content").and_then(serde_json::Value::as_str) else {
284                return Err(query_error(format!(
285                    "fact {} carries no `content` text, so `reembed` cannot \
286                     produce its vector; skipping it would silently drop the \
287                     fact and re-using its old vector would mix models, so the \
288                     pass stops here",
289                    fact.id
290                )));
291            };
292            embedder
293                .embed(content)
294                .map_err(|err| query_error(format!("embedding fact {} failed: {err}", fact.id)))
295        }
296    }
297}
298
299/// Export the source's edges, put them back, and re-read the destination.
300fn edge_pass(
301    source: &RebuildSource<'_>,
302    destination: &RebuildDestination<'_>,
303    step: &Step<'_>,
304) -> Result<u64, crate::MemoryError> {
305    let exported = export_edges_verified(source.memory, source.db, step.collection, step.batch)?;
306    let outcome = reinsert_edges(destination.memory, step.collection, &exported)?;
307    let back = export_edges_verified(
308        destination.memory,
309        destination.db,
310        step.collection,
311        step.batch,
312    )?;
313    same_edge_tuples(&exported, &back).map_err(|difference| {
314        // Honesty about what this mismatch can mean: the export, the
315        // reinsertion and the re-read are three separate clock reads, and a
316        // fact whose ABSOLUTE expiry falls between them shrinks one side
317        // without anything being lost — the C2a lesson (two walks must share
318        // one snapshot) cannot apply here because a write sits between the
319        // walks. Distinguishing that transient from real loss mechanically is
320        // the validation pass's job (C3); until then the pass stops, says
321        // both readings, and stays resumable.
322        query_error(format!(
323            "after reinsertion the destination's edges do not match the export \
324             for '{}': {difference}. Either an edge was lost, or an endpoint's \
325             absolute expiry passed between the export and the re-read. The \
326             pass is resumable: re-run it, and a mismatch that PERSISTS across \
327             runs is real loss",
328            step.collection
329        ))
330    })?;
331    Ok(outcome.inserted)
332}
333
334fn journal_progress(
335    state: &mut MigrationState,
336    journal: &RebuildJournal<'_>,
337    collection: &str,
338    progress: CollectionProgress,
339) -> Result<(), crate::MemoryError> {
340    state.progress.insert(collection.to_owned(), progress);
341    state
342        .write(journal.workspace, journal.lock)
343        .map_err(query_error)
344}