velesdb_memory/migration/
rebuild.rs1use 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
43pub struct RebuildSource<'a> {
45 pub db: &'a Database,
47 pub memory: &'a AgentMemory,
49}
50
51pub struct RebuildDestination<'a> {
53 pub db: &'a Database,
55 pub memory: &'a AgentMemory,
57}
58
59pub struct RebuildJournal<'a> {
61 pub workspace: &'a Path,
63 pub lock: &'a MigrationLock,
65}
66
67pub enum VectorPolicy<'a> {
74 Reuse,
76 Reembed(&'a dyn Embedder),
78}
79
80#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
83pub struct RebuildOutcome {
84 pub facts: u64,
86 pub collisions: u64,
89 pub edges: u64,
91}
92
93pub 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#[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#[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
188struct 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
271fn 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
299fn 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 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}