velesdb_memory/migration/diagnosis.rs
1use super::diagnostic_copy::DiagnosticCopy;
2use super::strategy::{assess, resolve, Resolution, Strategy};
3use std::collections::{BTreeMap, BTreeSet};
4use std::path::{Path, PathBuf};
5
6mod capabilities;
7mod inventory;
8mod report;
9
10pub(super) fn switch_filesystem_capability(same_filesystem: Option<bool>) -> Capability {
11 capabilities::switch_filesystem_capability(same_filesystem)
12}
13
14/// Whether a capability the rebuild depends on is established, or missing.
15///
16/// `Missing` is a full stop, not a warning: PR B does not start while one is
17/// outstanding, and no identifier mapping is invented to work around it.
18#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
19#[serde(tag = "verdict", rename_all = "snake_case")]
20pub enum Capability {
21 /// Established by running it, with the evidence that established it.
22 Proven {
23 /// What was run, and what it produced.
24 evidence: String,
25 },
26 /// Not available, with the blocker named.
27 Missing {
28 /// Why the rebuild cannot rely on this.
29 blocker: String,
30 },
31}
32
33impl Capability {
34 /// Whether this capability may be relied on.
35 #[must_use]
36 pub fn is_proven(&self) -> bool {
37 matches!(self, Self::Proven { .. })
38 }
39}
40
41// ---------------------------------------------------------------------------
42// THE DIAGNOSIS
43// ---------------------------------------------------------------------------
44
45/// The shape of a [`DiagnosisReport`], stamped into every report.
46///
47/// A report is read back by a later run — possibly a later *binary* — to decide
48/// whether a prepared migration may resume. A report whose version this build
49/// does not understand is refused rather than guessed at, which is only
50/// possible because the number travels with the data.
51/// # v6 — `edge_export` became `Proven` (#1762, PR C2a)
52///
53/// The bump is not cosmetic and not optional. A capability's canonical verdict
54/// is part of the report's shape: [`DiagnosisReport::validate`] refuses a report
55/// whose `edge_export` disagrees with what this build derives. A v5 report on
56/// disk carries the `Missing` verdict that was canonical when it was written,
57/// so a v6 build reading it would reject it as *inconsistent* — an accusation
58/// about the report's contents, when the truth is that it predates the
59/// capability. The version number is what turns that into a clear refusal.
60pub const DIAGNOSIS_FORMAT_VERSION: u32 = 6;
61
62/// What the store itself records about the embedder that filled it.
63///
64/// `Unknown` is the NOMINAL case, not a fault: every store created before
65/// `embedding-provenance.json` existed has no record, and the one this daemon
66/// actually runs on is one of them. Reporting `Unknown` honestly is the whole
67/// point — a diagnosis that invented a model would be trusted.
68#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
69#[serde(tag = "kind", rename_all = "snake_case")]
70pub enum SourceProvenance {
71 /// The store records the model it was filled by.
72 Known {
73 /// The recorded model identifier.
74 model: String,
75 /// The width that model produces.
76 dimension: usize,
77 },
78 /// The store records nothing, and why that is expected.
79 Unknown {
80 /// What was looked for, and what its absence does and does not mean.
81 reason: String,
82 },
83}
84
85/// What the expiries in a collection amount to.
86///
87/// Counted from the payloads rather than from the in-memory TTL map, because
88/// the map is rebuilt from those payloads on open and a diagnosis must describe
89/// the DISK, not a derived view of it.
90#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
91pub struct TtlSummary {
92 /// Facts carrying an absolute `_veles_expires_at`.
93 pub with_expiry: u64,
94 /// The soonest expiry, as the absolute unix second stored.
95 pub earliest: Option<u64>,
96 /// The furthest expiry, as the absolute unix second stored.
97 pub latest: Option<u64>,
98}
99
100impl TtlSummary {
101 /// Fold one observed expiry in.
102 fn observe(&mut self, expires_at: u64) {
103 self.with_expiry += 1;
104 self.earliest = Some(self.earliest.map_or(expires_at, |e| e.min(expires_at)));
105 self.latest = Some(self.latest.map_or(expires_at, |e| e.max(expires_at)));
106 }
107
108 /// Fold a whole collection's summary into a store-wide one.
109 fn merge(&mut self, other: &Self) {
110 self.with_expiry += other.with_expiry;
111 if let Some(e) = other.earliest {
112 self.earliest = Some(self.earliest.map_or(e, |cur| cur.min(e)));
113 }
114 if let Some(l) = other.latest {
115 self.latest = Some(self.latest.map_or(l, |cur| cur.max(l)));
116 }
117 }
118}
119
120/// One collection as the rebuild will find it.
121#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
122pub struct CollectionInventory {
123 /// The collection name.
124 pub name: String,
125 /// Whether it exists at all. A store missing one of the three is a store
126 /// `AgentMemory` would CREATE the missing one in — a write, and so a thing
127 /// a diagnosis must report rather than trigger.
128 pub present: bool,
129 /// The width its vectors are stored at, `None` when absent.
130 pub dimension: Option<usize>,
131 /// Live facts, counted by walking the cursor — not read off `point_count`,
132 /// which counts what the config believes.
133 pub facts: u64,
134 /// Live edges, or `None` when the offline route could not establish them.
135 pub edges: Option<u64>,
136 /// Facts marked as saved working contexts.
137 pub working_contexts: u64,
138 /// Every reserved key actually observed in a payload. The rebuild has to
139 /// carry each one through verbatim, so an unenumerated one is a silent
140 /// loss waiting to happen.
141 pub reserved_metadata: BTreeSet<String>,
142 /// What the expiries here amount to.
143 pub ttl: TtlSummary,
144}
145
146impl CollectionInventory {
147 /// A collection the store does not have.
148 fn absent(name: &str) -> Self {
149 Self {
150 name: name.to_owned(),
151 present: false,
152 dimension: None,
153 facts: 0,
154 edges: None,
155 working_contexts: 0,
156 reserved_metadata: BTreeSet::new(),
157 ttl: TtlSummary::default(),
158 }
159 }
160
161 /// Whether this collection holds no facts.
162 ///
163 /// Distinct from [`Self::present`] on purpose: an empty collection that
164 /// EXISTS still pins the store's dimension and still blocks the open, so a
165 /// report that conflated the two would under-state the work.
166 #[must_use]
167 pub fn is_empty(&self) -> bool {
168 self.facts == 0
169 }
170}
171
172/// Everything a rebuild needs to know about a store, and nothing it could act
173/// on by accident.
174///
175/// This is deliberately NOT a migration state. A state says "a migration is
176/// under way, here is how far it got" and is written to disk; producing one
177/// from a diagnosis would turn a question into a commitment. A report answers
178/// "what is here, and what could go wrong" and is returned, never written.
179#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
180pub struct DiagnosisReport {
181 /// The shape of this report — see [`DIAGNOSIS_FORMAT_VERSION`].
182 pub format_version: u32,
183 /// The store that was inspected.
184 pub source_path: PathBuf,
185 /// A digest of the store's files, so a resume can tell whether the source
186 /// changed under it. See [`fingerprint`].
187 pub source_fingerprint: String,
188 /// The width the store's collections are at, `None` when they disagree or
189 /// the store has none.
190 pub source_dimension: Option<usize>,
191 /// What the store records about its embedder.
192 pub source_provenance: SourceProvenance,
193 /// The model the rebuild would target.
194 pub target_model: String,
195 /// The width that model produces.
196 pub target_dimension: usize,
197 /// The regime the operator selected — `auto` unless they said otherwise.
198 pub requested_strategy: Strategy,
199 /// What that request resolves to against this store, and why.
200 ///
201 /// Derived, never independently observed: [`DiagnosisReport::validate`]
202 /// recomputes it from the provenance and the target contract and refuses a
203 /// report whose stated regime does not follow from its own fields. A
204 /// diagnosis an operator reads a regime off is a diagnosis that can lie
205 /// about one.
206 pub resolution: Resolution,
207 /// One entry per collection in [`AGENT_COLLECTIONS`], absent ones included.
208 pub collections: Vec<CollectionInventory>,
209 /// Live facts across the store.
210 pub facts: u64,
211 /// Live edges across the store, `0` when none were established — read
212 /// alongside the `edge_counts` capability, which says whether that `0` is a
213 /// count or an absence.
214 pub edges: u64,
215 /// Saved working contexts across the store.
216 pub working_contexts: u64,
217 /// Every reserved key observed anywhere in the store.
218 pub reserved_metadata: BTreeSet<String>,
219 /// What the expiries across the store amount to.
220 pub ttl_summary: TtlSummary,
221 /// What the store occupies, summed over its files.
222 pub bytes_on_disk: u64,
223 /// Space required for the verified ephemeral diagnostic copy.
224 pub diagnostic_staging_required: u64,
225 /// Space observed on the staging volume before any scratch was created.
226 pub diagnostic_staging_available: u64,
227 /// Free space at the destination, or `None` when it could not be
228 /// established — see the `disk_headroom` blocker.
229 pub disk_headroom: Option<u64>,
230 /// Whether destination and source sit on one filesystem, `None` when there
231 /// is no destination to compare or the platform does not say.
232 pub same_filesystem: Option<bool>,
233 /// What the rebuild may rely on, and what it may not.
234 pub capabilities: BTreeMap<String, Capability>,
235 /// Everything that must be settled before PR B starts.
236 pub blockers: Vec<String>,
237}
238
239/// Whether `a` and `b` sit on the same filesystem.
240///
241/// The destination normally does NOT exist yet — that is the whole point of
242/// asking before creating it — so each path is resolved to its deepest existing
243/// ancestor first. Stat-ing the destination itself would answer "unknown" for
244/// every question actually worth asking.
245///
246/// `None` off unix, where the standard library exposes no device id: a rename
247/// across filesystems fails where one within a filesystem does not, so a
248/// migration that assumed "same" would discover it at switch-over time. Saying
249/// "unknown" keeps that decision with the operator.
250#[must_use]
251pub fn same_filesystem(a: &Path, b: &Path) -> Option<bool> {
252 #[cfg(unix)]
253 {
254 use std::os::unix::fs::MetadataExt;
255 let (a, b) = (existing_ancestor(a)?, existing_ancestor(b)?);
256 Some(a.dev() == b.dev())
257 }
258 #[cfg(not(unix))]
259 {
260 let _ = (a, b);
261 None
262 }
263}
264
265/// Metadata of `path`, or of the nearest ancestor that exists.
266#[cfg(unix)]
267fn existing_ancestor(path: &Path) -> Option<std::fs::Metadata> {
268 path.ancestors().find_map(|p| std::fs::metadata(p).ok())
269}
270
271/// Inspect `source` and report what a rebuild onto `target_model` would face.
272///
273/// The live source is read only with ordinary file handles and may remain held
274/// by the daemon. Because [`velesdb_core::Database::open`] rewrites derived
275/// files and takes an exclusive lock, it is called only on a verified ephemeral
276/// copy under `scratch_parent`. The source is fingerprinted before and after
277/// capture and once more after inventory; any movement refuses the report.
278/// `destination` is inspected only for filesystem topology and is not created.
279///
280/// # Errors
281/// Returns [`crate::MemoryError`] if the store cannot be read or walked.
282pub fn diagnose(
283 source: &Path,
284 scratch_parent: &Path,
285 target: &TargetContract,
286 destination: Option<&Path>,
287) -> Result<DiagnosisReport, crate::MemoryError> {
288 let source = canonical_source(source)?;
289 let copy = DiagnosticCopy::capture(&source, scratch_parent)?;
290 let result = diagnose_copy(&source, target, destination, ©);
291 copy.finish(result)
292}
293
294/// What the operator is pointing the rebuild at: the target embedder's
295/// identity, and the regime they selected.
296///
297/// Grouped rather than passed as three parallel arguments because the three
298/// only ever travel together, and because a call site that passed a model and a
299/// width belonging to two different embedders would type-check perfectly.
300#[derive(Debug, Clone, PartialEq, Eq)]
301pub struct TargetContract {
302 /// The model identifier the rebuild would embed with.
303 pub model: String,
304 /// The width that model produces, as the embedder itself reports it.
305 pub dimension: usize,
306 /// `auto` unless the operator named a regime.
307 pub strategy: Strategy,
308}
309
310impl TargetContract {
311 /// The usual case: a target embedder, and no opinion about the regime.
312 #[must_use]
313 pub fn automatic(model: impl Into<String>, dimension: usize) -> Self {
314 Self {
315 model: model.into(),
316 dimension,
317 strategy: Strategy::Auto,
318 }
319 }
320}
321
322fn canonical_source(source: &Path) -> Result<PathBuf, crate::MemoryError> {
323 let metadata = std::fs::symlink_metadata(source).map_err(|err| {
324 velesdb_core::Error::Query(format!(
325 "cannot inspect migration source {}: {err}",
326 source.display()
327 ))
328 })?;
329 if metadata.file_type().is_symlink() {
330 return Err(velesdb_core::Error::Query(format!(
331 "migration source {} is a symlink; diagnose the canonical store directory directly",
332 source.display()
333 ))
334 .into());
335 }
336 let canonical = std::fs::canonicalize(source).map_err(|err| {
337 velesdb_core::Error::Query(format!(
338 "cannot canonicalize migration source {}: {err}",
339 source.display()
340 ))
341 })?;
342 if !canonical.is_absolute() {
343 return Err(velesdb_core::Error::Query(format!(
344 "canonical migration source is not absolute: {}",
345 canonical.display()
346 ))
347 .into());
348 }
349 Ok(canonical)
350}
351
352pub(super) fn diagnose_copy(
353 source: &Path,
354 target: &TargetContract,
355 destination: Option<&Path>,
356 copy: &DiagnosticCopy,
357) -> Result<DiagnosisReport, crate::MemoryError> {
358 let inventory = inventory::inspect(copy.store_path())?;
359 copy.verify_source_unchanged(source)?;
360 let same_filesystem = destination.and_then(|dest| same_filesystem(source, dest));
361 Ok(report_from_inventory(
362 source,
363 target,
364 same_filesystem,
365 copy,
366 inventory,
367 ))
368}
369
370/// The strategy resolution of [`report_from_inventory`]: what the caller
371/// asked for, arbitrated against what the store's provenance and dimension
372/// actually permit.
373fn resolved_strategy(
374 target: &TargetContract,
375 inventory: &inventory::StoreInventory,
376) -> crate::migration::strategy::Resolution {
377 resolve(
378 target.strategy,
379 assess(
380 &inventory.source_provenance,
381 inventory.source_dimension,
382 &target.model,
383 target.dimension,
384 ),
385 )
386}
387
388fn report_from_inventory(
389 source: &Path,
390 target: &TargetContract,
391 same_filesystem: Option<bool>,
392 copy: &DiagnosticCopy,
393 inventory: inventory::StoreInventory,
394) -> DiagnosisReport {
395 let resolution = resolved_strategy(target, &inventory);
396 let capabilities = capabilities::capability_map(
397 &inventory.source_provenance,
398 inventory.source_dimension,
399 &target.model,
400 target.dimension,
401 inventory.edge_counts,
402 same_filesystem,
403 copy,
404 );
405 let blockers = capabilities::blockers_for(&capabilities, &inventory.collections);
406 DiagnosisReport {
407 format_version: DIAGNOSIS_FORMAT_VERSION,
408 source_path: source.to_path_buf(),
409 source_fingerprint: copy.source_fingerprint().to_owned(),
410 source_dimension: inventory.source_dimension,
411 source_provenance: inventory.source_provenance,
412 target_model: target.model.clone(),
413 target_dimension: target.dimension,
414 requested_strategy: target.strategy,
415 resolution,
416 collections: inventory.collections,
417 facts: inventory.facts,
418 edges: inventory.edges,
419 working_contexts: inventory.working_contexts,
420 reserved_metadata: inventory.reserved_metadata,
421 ttl_summary: inventory.ttl,
422 bytes_on_disk: copy.source_bytes(),
423 diagnostic_staging_required: copy.staging_required(),
424 diagnostic_staging_available: copy.staging_available(),
425 disk_headroom: None,
426 same_filesystem,
427 capabilities,
428 blockers,
429 }
430}