Skip to main content

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, &copy);
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}