velesdb_memory/migration/
execute.rs1use super::query_error;
28use std::path::{Path, PathBuf};
29
30use velesdb_core::agent::AgentMemory;
31use velesdb_core::Database;
32
33use super::diagnosis::{diagnose, DiagnosisReport, TargetContract};
34use super::rebuild::{
35 rebuild, RebuildDestination, RebuildJournal, RebuildOutcome, RebuildSource, VectorPolicy,
36};
37use super::state::{CollectionProgress, MigrationLock, MigrationState, Phase};
38use super::strategy::Resolution;
39use crate::embedder::Embedder;
40
41#[derive(Debug)]
43pub struct ExecuteOutcome {
44 pub report: DiagnosisReport,
46 pub rebuild: RebuildOutcome,
48 pub destination: PathBuf,
50 pub workspace: PathBuf,
52}
53
54pub fn execute(
66 store: &Path,
67 scratch_parent: &Path,
68 target: &TargetContract,
69 destination: &Path,
70 embedder: &dyn Embedder,
71 batch: usize,
72) -> Result<ExecuteOutcome, crate::MemoryError> {
73 let report = diagnose(store, scratch_parent, target, Some(destination))?;
74 let staging = stage(&report, destination)?;
75
76 let lock =
77 MigrationLock::acquire(&staging.workspace, "migrate-embeddings").map_err(query_error)?;
78 let result = execute_locked(
79 &report,
80 target,
81 destination,
82 &staging.workspace,
83 &lock,
84 &ExecutePass {
85 embedder,
86 batch,
87 resuming: staging.resuming,
88 settled_fingerprint: &staging.settled_fingerprint,
89 },
90 );
91 let rebuild = reconcile(result, lock.release())?;
96 Ok(ExecuteOutcome {
97 report,
98 rebuild,
99 destination: destination.to_path_buf(),
100 workspace: staging.workspace,
101 })
102}
103
104pub(super) fn reconcile<T>(
113 result: Result<T, crate::MemoryError>,
114 released: Result<(), String>,
115) -> Result<T, crate::MemoryError> {
116 match (result, released) {
117 (Ok(value), Ok(())) => Ok(value),
118 (Ok(_), Err(release_error)) => Err(query_error(format!(
119 "the pass completed, but releasing the migration lock failed: \
120 {release_error}. The canonical lock record remains and must be \
121 removed by hand before the next run"
122 ))),
123 (Err(error), Ok(())) => Err(error),
124 (Err(error), Err(release_error)) => Err(query_error(format!(
125 "{error}; additionally, releasing the migration lock failed: \
126 {release_error} — the canonical lock record remains and must be \
127 removed by hand before the next run"
128 ))),
129 }
130}
131
132struct Staging {
134 workspace: PathBuf,
135 resuming: bool,
136 settled_fingerprint: String,
137}
138
139fn stage(report: &DiagnosisReport, destination: &Path) -> Result<Staging, crate::MemoryError> {
142 if let Resolution::Refuse { because, requested } = &report.resolution {
143 return Err(query_error(format!(
144 "the requested regime '{}' cannot run: {because:?}. Nothing was \
145 created; re-run --dry-run for the full report",
146 regime_word(*requested),
147 )));
148 }
149 {
160 let _settle = Database::open(&report.source_path)?;
161 }
162 let settled_fingerprint = super::filesystem::fingerprint(&report.source_path)?;
163 let workspace = journal_workspace(destination)?;
164 let resuming = workspace.join(super::state::STATE_FILE).exists();
165 ensure_destination(destination, resuming)?;
166 Ok(Staging {
167 workspace,
168 resuming,
169 settled_fingerprint,
170 })
171}
172
173struct ExecutePass<'a> {
178 embedder: &'a dyn Embedder,
179 batch: usize,
180 resuming: bool,
181 settled_fingerprint: &'a str,
182}
183
184fn execute_locked(
185 report: &DiagnosisReport,
186 target: &TargetContract,
187 destination: &Path,
188 workspace: &Path,
189 lock: &MigrationLock,
190 pass: &ExecutePass<'_>,
191) -> Result<RebuildOutcome, crate::MemoryError> {
192 let mut state = journal_entry(report, target, workspace, lock, pass)?;
193 let policy = match report.resolution {
194 Resolution::Reuse => VectorPolicy::Reuse,
195 Resolution::Reembed { .. } => VectorPolicy::Reembed(pass.embedder),
196 Resolution::Refuse { .. } => {
197 unreachable!("execute gated Refuse before the lock was taken")
198 }
199 };
200 let Some(source_dimension) = report.source_dimension else {
201 return Err(query_error(
202 "the source collections do not establish one shared dimension, so \
203 no AgentMemory view can open them; the diagnosis carries the \
204 details",
205 ));
206 };
207
208 let source_db = std::sync::Arc::new(Database::open(&report.source_path)?);
209 let source_memory =
210 AgentMemory::with_dimension(std::sync::Arc::clone(&source_db), source_dimension)?;
211 let destination_db = std::sync::Arc::new(Database::open(destination)?);
212 let destination_memory =
213 AgentMemory::with_dimension(std::sync::Arc::clone(&destination_db), target.dimension)?;
214
215 rebuild(
216 &RebuildSource {
217 db: &source_db,
218 memory: &source_memory,
219 },
220 &RebuildDestination {
221 db: &destination_db,
222 memory: &destination_memory,
223 },
224 &mut state,
225 &RebuildJournal { workspace, lock },
226 &policy,
227 pass.batch,
228 )
229}
230
231const WITNESS_SENTENCE: &str =
235 "velesdb embedder witness v1: one fixed sentence, embedded at prepare and at every resume";
236
237fn embedder_witness(
240 resolution: Resolution,
241 embedder: &dyn Embedder,
242) -> Result<Option<String>, crate::MemoryError> {
243 match resolution {
244 Resolution::Reuse => Ok(None),
245 Resolution::Reembed { .. } => {
246 use sha2::Digest;
247 let vector = embedder.embed(WITNESS_SENTENCE).map_err(|err| {
248 query_error(format!(
249 "the target embedder cannot embed the witness: {err}"
250 ))
251 })?;
252 let mut hash = sha2::Sha256::new();
253 for value in &vector {
254 hash.update(value.to_le_bytes());
255 }
256 Ok(Some(format!(
257 "sha256:{}",
258 super::filesystem::encode_hex(&hash.finalize())
259 )))
260 }
261 Resolution::Refuse { .. } => {
262 unreachable!("execute gated Refuse before the witness was computed")
263 }
264 }
265}
266
267fn journal_entry(
277 report: &DiagnosisReport,
278 target: &TargetContract,
279 workspace: &Path,
280 lock: &MigrationLock,
281 pass: &ExecutePass<'_>,
282) -> Result<MigrationState, crate::MemoryError> {
283 let witness = embedder_witness(report.resolution, pass.embedder)?;
284 if pass.resuming {
285 return resume_journal(report, target, workspace, pass, witness.as_deref());
286 }
287 let state = MigrationState {
288 format_version: super::state::STATE_FORMAT_VERSION,
289 phase: Phase::Prepared,
290 source_path: report.source_path.clone(),
291 source_fingerprint: pass.settled_fingerprint.to_owned(),
292 target_model: target.model.clone(),
293 target_dimension: target.dimension,
294 progress: super::enumeration::AGENT_COLLECTIONS
295 .iter()
296 .map(|name| {
297 (
298 (*name).to_owned(),
299 CollectionProgress::Facts { cursor: None },
300 )
301 })
302 .collect(),
303 embedder_witness: witness,
304 };
305 state.write(workspace, lock).map_err(query_error)?;
306 Ok(state)
307}
308
309fn resume_journal(
311 report: &DiagnosisReport,
312 target: &TargetContract,
313 workspace: &Path,
314 pass: &ExecutePass<'_>,
315 witness: Option<&str>,
316) -> Result<MigrationState, crate::MemoryError> {
317 let state = MigrationState::read(workspace)
318 .map_err(query_error)?
319 .ok_or_else(|| {
320 query_error(format!(
321 "the journal at {} disappeared between inspection and locking",
322 workspace.display()
323 ))
324 })?;
325 state
326 .may_resume(
327 &report.source_path,
328 pass.settled_fingerprint,
329 &target.model,
330 target.dimension,
331 )
332 .map_err(query_error)?;
333 if state.embedder_witness.as_deref() != witness {
334 return Err(query_error(format!(
335 "this migration was prepared with an embedder whose witness was \
336 {:?}, and the embedder answering to '{}' now produces {:?}. Same \
337 name, different vectors — the model was updated in place, or the \
338 regime changed between runs. Resuming would mix two vector spaces \
339 in one store; start a fresh migration",
340 state.embedder_witness, target.model, witness,
341 )));
342 }
343 Ok(state)
344}
345
346fn regime_word(strategy: super::strategy::Strategy) -> &'static str {
348 match strategy {
349 super::strategy::Strategy::Auto => "auto",
350 super::strategy::Strategy::Reuse => "reuse",
351 super::strategy::Strategy::Reembed => "reembed",
352 }
353}
354
355pub(super) fn journal_workspace(destination: &Path) -> Result<PathBuf, crate::MemoryError> {
357 let name = destination
358 .file_name()
359 .and_then(|name| name.to_str())
360 .ok_or_else(|| {
361 query_error(format!(
362 "the destination {} has no usable directory name to derive the \
363 journal workspace from",
364 destination.display()
365 ))
366 })?;
367 let workspace = destination.with_file_name(format!("{name}.migration-journal"));
368 std::fs::create_dir_all(&workspace).map_err(|err| {
369 query_error(format!(
370 "cannot create the journal workspace {}: {err}",
371 workspace.display()
372 ))
373 })?;
374 Ok(workspace)
375}
376
377fn ensure_destination(destination: &Path, resuming: bool) -> Result<(), crate::MemoryError> {
379 if !destination.exists() {
380 std::fs::create_dir_all(destination).map_err(|err| {
381 query_error(format!(
382 "cannot create the destination {}: {err}",
383 destination.display()
384 ))
385 })?;
386 return Ok(());
387 }
388 if resuming {
389 return Ok(());
392 }
393 let mut entries = std::fs::read_dir(destination).map_err(|err| {
394 query_error(format!(
395 "cannot inspect the destination {}: {err}",
396 destination.display()
397 ))
398 })?;
399 if entries.next().is_some() {
400 return Err(query_error(format!(
401 "the destination {} already holds data and no migration journal \
402 accounts for it; rebuilding into it could mix two stores, so \
403 choose an empty destination or remove it deliberately",
404 destination.display()
405 )));
406 }
407 Ok(())
408}