use super::query_error;
use std::path::{Path, PathBuf};
use velesdb_core::agent::AgentMemory;
use velesdb_core::Database;
use super::diagnosis::{diagnose, DiagnosisReport, TargetContract};
use super::rebuild::{
rebuild, RebuildDestination, RebuildJournal, RebuildOutcome, RebuildSource, VectorPolicy,
};
use super::state::{CollectionProgress, MigrationLock, MigrationState, Phase};
use super::strategy::Resolution;
use crate::embedder::Embedder;
#[derive(Debug)]
pub struct ExecuteOutcome {
pub report: DiagnosisReport,
pub rebuild: RebuildOutcome,
pub destination: PathBuf,
pub workspace: PathBuf,
}
pub fn execute(
store: &Path,
scratch_parent: &Path,
target: &TargetContract,
destination: &Path,
embedder: &dyn Embedder,
batch: usize,
) -> Result<ExecuteOutcome, crate::MemoryError> {
let report = diagnose(store, scratch_parent, target, Some(destination))?;
let staging = stage(&report, destination)?;
let lock =
MigrationLock::acquire(&staging.workspace, "migrate-embeddings").map_err(query_error)?;
let result = execute_locked(
&report,
target,
destination,
&staging.workspace,
&lock,
&ExecutePass {
embedder,
batch,
resuming: staging.resuming,
settled_fingerprint: &staging.settled_fingerprint,
},
);
let rebuild = reconcile(result, lock.release())?;
Ok(ExecuteOutcome {
report,
rebuild,
destination: destination.to_path_buf(),
workspace: staging.workspace,
})
}
pub(super) fn reconcile<T>(
result: Result<T, crate::MemoryError>,
released: Result<(), String>,
) -> Result<T, crate::MemoryError> {
match (result, released) {
(Ok(value), Ok(())) => Ok(value),
(Ok(_), Err(release_error)) => Err(query_error(format!(
"the pass completed, but releasing the migration lock failed: \
{release_error}. The canonical lock record remains and must be \
removed by hand before the next run"
))),
(Err(error), Ok(())) => Err(error),
(Err(error), Err(release_error)) => Err(query_error(format!(
"{error}; additionally, releasing the migration lock failed: \
{release_error} — the canonical lock record remains and must be \
removed by hand before the next run"
))),
}
}
struct Staging {
workspace: PathBuf,
resuming: bool,
settled_fingerprint: String,
}
fn stage(report: &DiagnosisReport, destination: &Path) -> Result<Staging, crate::MemoryError> {
if let Resolution::Refuse { because, requested } = &report.resolution {
return Err(query_error(format!(
"the requested regime '{}' cannot run: {because:?}. Nothing was \
created; re-run --dry-run for the full report",
regime_word(*requested),
)));
}
{
let _settle = Database::open(&report.source_path)?;
}
let settled_fingerprint = super::filesystem::fingerprint(&report.source_path)?;
let workspace = journal_workspace(destination)?;
let resuming = workspace.join(super::state::STATE_FILE).exists();
ensure_destination(destination, resuming)?;
Ok(Staging {
workspace,
resuming,
settled_fingerprint,
})
}
struct ExecutePass<'a> {
embedder: &'a dyn Embedder,
batch: usize,
resuming: bool,
settled_fingerprint: &'a str,
}
fn execute_locked(
report: &DiagnosisReport,
target: &TargetContract,
destination: &Path,
workspace: &Path,
lock: &MigrationLock,
pass: &ExecutePass<'_>,
) -> Result<RebuildOutcome, crate::MemoryError> {
let mut state = journal_entry(report, target, workspace, lock, pass)?;
let policy = match report.resolution {
Resolution::Reuse => VectorPolicy::Reuse,
Resolution::Reembed { .. } => VectorPolicy::Reembed(pass.embedder),
Resolution::Refuse { .. } => {
unreachable!("execute gated Refuse before the lock was taken")
}
};
let Some(source_dimension) = report.source_dimension else {
return Err(query_error(
"the source collections do not establish one shared dimension, so \
no AgentMemory view can open them; the diagnosis carries the \
details",
));
};
let source_db = std::sync::Arc::new(Database::open(&report.source_path)?);
let source_memory =
AgentMemory::with_dimension(std::sync::Arc::clone(&source_db), source_dimension)?;
let destination_db = std::sync::Arc::new(Database::open(destination)?);
let destination_memory =
AgentMemory::with_dimension(std::sync::Arc::clone(&destination_db), target.dimension)?;
rebuild(
&RebuildSource {
db: &source_db,
memory: &source_memory,
},
&RebuildDestination {
db: &destination_db,
memory: &destination_memory,
},
&mut state,
&RebuildJournal { workspace, lock },
&policy,
pass.batch,
)
}
const WITNESS_SENTENCE: &str =
"velesdb embedder witness v1: one fixed sentence, embedded at prepare and at every resume";
fn embedder_witness(
resolution: Resolution,
embedder: &dyn Embedder,
) -> Result<Option<String>, crate::MemoryError> {
match resolution {
Resolution::Reuse => Ok(None),
Resolution::Reembed { .. } => target_embedder_witness(embedder).map(Some),
Resolution::Refuse { .. } => {
unreachable!("execute gated Refuse before the witness was computed")
}
}
}
pub(crate) fn target_embedder_witness(
embedder: &dyn Embedder,
) -> Result<String, crate::MemoryError> {
use sha2::Digest;
let vector = embedder.embed(WITNESS_SENTENCE).map_err(|err| {
query_error(format!(
"the target embedder cannot embed the witness: {err}"
))
})?;
let mut hash = sha2::Sha256::new();
for value in &vector {
hash.update(value.to_le_bytes());
}
Ok(format!(
"sha256:{}",
super::filesystem::encode_hex(&hash.finalize())
))
}
fn journal_entry(
report: &DiagnosisReport,
target: &TargetContract,
workspace: &Path,
lock: &MigrationLock,
pass: &ExecutePass<'_>,
) -> Result<MigrationState, crate::MemoryError> {
let witness = embedder_witness(report.resolution, pass.embedder)?;
if pass.resuming {
return resume_journal(report, target, workspace, pass, witness.as_deref());
}
let state = MigrationState {
format_version: super::state::STATE_FORMAT_VERSION,
phase: Phase::Prepared,
source_path: report.source_path.clone(),
source_fingerprint: pass.settled_fingerprint.to_owned(),
target_model: target.model.clone(),
target_dimension: target.dimension,
progress: super::enumeration::AGENT_COLLECTIONS
.iter()
.map(|name| {
(
(*name).to_owned(),
CollectionProgress::Facts { cursor: None },
)
})
.collect(),
embedder_witness: witness,
};
state.write(workspace, lock).map_err(query_error)?;
Ok(state)
}
fn resume_journal(
report: &DiagnosisReport,
target: &TargetContract,
workspace: &Path,
pass: &ExecutePass<'_>,
witness: Option<&str>,
) -> Result<MigrationState, crate::MemoryError> {
let state = MigrationState::read(workspace)
.map_err(query_error)?
.ok_or_else(|| {
query_error(format!(
"the journal at {} disappeared between inspection and locking",
workspace.display()
))
})?;
state
.may_resume(
&report.source_path,
pass.settled_fingerprint,
&target.model,
target.dimension,
)
.map_err(query_error)?;
if state.embedder_witness.as_deref() != witness {
return Err(query_error(format!(
"this migration was prepared with an embedder whose witness was \
{:?}, and the embedder answering to '{}' now produces {:?}. Same \
name, different vectors — the model was updated in place, or the \
regime changed between runs. Resuming would mix two vector spaces \
in one store; start a fresh migration",
state.embedder_witness, target.model, witness,
)));
}
Ok(state)
}
fn regime_word(strategy: super::strategy::Strategy) -> &'static str {
match strategy {
super::strategy::Strategy::Auto => "auto",
super::strategy::Strategy::Reuse => "reuse",
super::strategy::Strategy::Reembed => "reembed",
}
}
pub(crate) fn journal_workspace(destination: &Path) -> Result<PathBuf, crate::MemoryError> {
let name = destination
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| {
query_error(format!(
"the destination {} has no usable directory name to derive the \
journal workspace from",
destination.display()
))
})?;
let workspace = destination.with_file_name(format!("{name}.migration-journal"));
std::fs::create_dir_all(&workspace).map_err(|err| {
query_error(format!(
"cannot create the journal workspace {}: {err}",
workspace.display()
))
})?;
Ok(workspace)
}
fn ensure_destination(destination: &Path, resuming: bool) -> Result<(), crate::MemoryError> {
if !destination.exists() {
std::fs::create_dir_all(destination).map_err(|err| {
query_error(format!(
"cannot create the destination {}: {err}",
destination.display()
))
})?;
return Ok(());
}
if resuming {
return Ok(());
}
let mut entries = std::fs::read_dir(destination).map_err(|err| {
query_error(format!(
"cannot inspect the destination {}: {err}",
destination.display()
))
})?;
if entries.next().is_some() {
return Err(query_error(format!(
"the destination {} already holds data and no migration journal \
accounts for it; rebuilding into it could mix two stores, so \
choose an empty destination or remove it deliberately",
destination.display()
)));
}
Ok(())
}