Skip to main content

velesdb_memory/migration/
edges.rs

1//! Lossless edge export and reinsertion (#1762, PR C2a).
2//!
3//! The fact export ([`super::enumeration`]) moves points. It cannot move
4//! relations, and a rebuild that shipped without them would hand back a store
5//! whose facts are all present and whose graph is empty — a loss that no
6//! per-fact comparison detects, because every fact is intact.
7//!
8//! # Why there is no transport type here
9//!
10//! Facts travel as [`RawFact`](super::RawFact), a type this module's sibling
11//! defines. Edges travel as [`GraphEdge`] itself, the engine's own type, and
12//! that is deliberate. `velesdb-memory` already owns a reduced edge — the
13//! public `MemoryEdge` (`crate::model`) — and it has no `properties` field at
14//! all: `storage::to_memory_edges` builds it from `id`, `source`, `target` and
15//! `label` and never calls `edge.properties()`. Reusing it here would have
16//! compiled, round-tripped, and lost every property in silence. Carrying the
17//! engine's own tuple means there is no field for a conversion to forget.
18//!
19//! # What makes the export sound
20//!
21//! An edge is exported when it lies between two facts the fact export also
22//! carries. That is not a filter this module implements: the edge walk and the
23//! fact walk already agree on which endpoints are live, because
24//! `MemoryTtl`'s map is refilled from the durable `_veles_expires_at` key by
25//! every subsystem constructor (`rebuild_ttl_from_payloads`). The agreement is
26//! MEASURED, three-armed, in `tests::edges::the_two_walks_agree_on_which_endpoints_are_live`
27//! — it was reasoned wrongly twice before it was measured once.
28//!
29//! # The one shape this refuses
30//!
31//! Every edge `relate` writes derives its id from its triple through
32//! [`velesdb_core::hash_edge_id`]. `VelesQL` DML does not: it accepts an
33//! explicit edge id. An edge whose id its triple does not derive cannot be
34//! reinserted honestly — `relate` at the destination would compute a
35//! *different* id, and one logical edge would end up with two identities across
36//! the migration. Rather than renumber it silently, the export stops and names
37//! it.
38
39use velesdb_core::agent::AgentMemory;
40use velesdb_core::collection::graph::GraphEdge;
41use velesdb_core::Database;
42
43use super::enumeration::{enumerate_by_cursor, AGENT_COLLECTIONS};
44
45/// Which agent subsystem owns a collection.
46///
47/// The three expose byte-identical `relations`/`incoming_relations`/`relate`
48/// signatures but are distinct types, so the dispatch is explicit rather than
49/// generic. `tests::edges::every_agent_collection_is_dispatchable` is what
50/// proves this list has not drifted from [`AGENT_COLLECTIONS`].
51#[derive(Debug, Clone, Copy)]
52enum Subsystem {
53    Semantic,
54    Episodic,
55    Procedural,
56}
57
58fn subsystem_of(collection: &str) -> Result<Subsystem, crate::MemoryError> {
59    if collection == AGENT_COLLECTIONS[0] {
60        Ok(Subsystem::Semantic)
61    } else if collection == AGENT_COLLECTIONS[1] {
62        Ok(Subsystem::Episodic)
63    } else if collection == AGENT_COLLECTIONS[2] {
64        Ok(Subsystem::Procedural)
65    } else {
66        Err(velesdb_core::Error::Query(format!(
67            "`{collection}` is not an agent memory collection; edges are exported \
68             per subsystem, and the subsystems are {AGENT_COLLECTIONS:?}"
69        ))
70        .into())
71    }
72}
73
74/// Which adjacency map a walk reads.
75///
76/// `outgoing` and `incoming` are separate maps, so an edge missing from one of
77/// them is caught by the other. They are not separate copies: both resolve the
78/// id through the same stored record. See [`cross_check_edges`] for what that
79/// buys and what it does not.
80#[derive(Debug, Clone, Copy)]
81enum Direction {
82    Outgoing,
83    Incoming,
84}
85
86fn edges_at(
87    memory: &AgentMemory,
88    subsystem: Subsystem,
89    direction: Direction,
90    id: u64,
91) -> Result<Vec<GraphEdge>, crate::MemoryError> {
92    let result = match (subsystem, direction) {
93        (Subsystem::Semantic, Direction::Outgoing) => memory.semantic().relations(id),
94        (Subsystem::Semantic, Direction::Incoming) => memory.semantic().incoming_relations(id),
95        (Subsystem::Episodic, Direction::Outgoing) => memory.episodic().relations(id),
96        (Subsystem::Episodic, Direction::Incoming) => memory.episodic().incoming_relations(id),
97        (Subsystem::Procedural, Direction::Outgoing) => memory.procedural().relations(id),
98        (Subsystem::Procedural, Direction::Incoming) => memory.procedural().incoming_relations(id),
99    };
100    result.map_err(crate::MemoryError::from)
101}
102
103/// Refuse an edge whose id its own triple does not derive.
104///
105/// Returning the edge rather than `()` keeps the guard on the value path, so a
106/// caller cannot collect the edge and forget to call this.
107fn require_derived_id(edge: GraphEdge) -> Result<GraphEdge, crate::MemoryError> {
108    let derived = velesdb_core::hash_edge_id(edge.source(), edge.target(), edge.label());
109    if edge.id() == derived {
110        return Ok(edge);
111    }
112    Err(velesdb_core::Error::Query(format!(
113        "edge {} ({} -{}-> {}) carries an id its triple does not derive (expected \
114         {derived}); reinserting it would rederive the expected id and give one \
115         logical edge two identities, so the export stops here rather than \
116         renumbering it",
117        edge.id(),
118        edge.source(),
119        edge.label(),
120        edge.target(),
121    ))
122    .into())
123}
124
125/// Collect the edges reachable from `ids` through `direction`.
126///
127/// Takes the fact ids rather than finding them, so that a caller checking one
128/// direction against the other can hand both walks the SAME snapshot. That is
129/// not a convenience: expiry is evaluated against the wall clock on every read,
130/// so two walks that each enumerated the store afresh could straddle a fact's
131/// expiry instant and disagree — reporting a divergence that is a tick of the
132/// clock rather than a lost edge, and aborting a migration whose indexes were
133/// never anything but consistent.
134fn collect(
135    memory: &AgentMemory,
136    subsystem: Subsystem,
137    ids: &[u64],
138    direction: Direction,
139) -> Result<Vec<GraphEdge>, crate::MemoryError> {
140    let mut out: Vec<GraphEdge> = Vec::new();
141    let mut seen: std::collections::HashSet<u64> = std::collections::HashSet::new();
142    for &id in ids {
143        for edge in edges_at(memory, subsystem, direction, id)? {
144            let edge = require_derived_id(edge)?;
145            // Each walk reads ONE index, and an edge id is unique within a
146            // collection — the edge store refuses a duplicate id inside its
147            // write guard. A self-loop is pushed into both the outgoing and the
148            // incoming index, but a single walk still meets it once. So this
149            // branch is unreachable today, measured rather than assumed, and it
150            // is an ERROR rather than a silent skip: were it ever reached, the
151            // edge store would be handing out one id twice and quietly keeping
152            // the first is the worst available answer.
153            if !seen.insert(edge.id()) {
154                return Err(velesdb_core::Error::Query(format!(
155                    "edge {} was reported twice by a single {direction:?} walk of \
156                     one collection; edge ids are unique per collection, so the \
157                     edge index is inconsistent and no export from it can be \
158                     trusted",
159                    edge.id(),
160                ))
161                .into());
162            }
163            out.push(edge);
164        }
165    }
166    Ok(out)
167}
168
169fn live_ids(db: &Database, collection: &str, batch: usize) -> Result<Vec<u64>, crate::MemoryError> {
170    Ok(enumerate_by_cursor(db, collection, batch)?
171        .into_iter()
172        .map(|fact| fact.id)
173        .collect())
174}
175
176/// Every edge of `collection` that lies between two exported facts, as complete
177/// tuples.
178///
179/// Walks the live fact ids with the same cursor the fact export uses, then
180/// takes each fact's outgoing edges. Each edge is checked against
181/// [`require_derived_id`] before it is collected.
182///
183/// # Errors
184/// Returns [`crate::MemoryError`] if `collection` is not an agent subsystem, if
185/// the fact walk fails, or if any edge carries an id its triple does not derive.
186pub fn export_edges(
187    memory: &AgentMemory,
188    db: &Database,
189    collection: &str,
190    batch: usize,
191) -> Result<Vec<GraphEdge>, crate::MemoryError> {
192    let subsystem = subsystem_of(collection)?;
193    collect(
194        memory,
195        subsystem,
196        &live_ids(db, collection, batch)?,
197        Direction::Outgoing,
198    )
199}
200
201/// The export, checked against the incoming index over ONE snapshot of the live
202/// facts.
203///
204/// This is the entry point a rebuild should use. [`export_edges`] and
205/// [`cross_check_edges`] each enumerate the store for themselves, which is fine
206/// in isolation and wrong as a pair: expiry is evaluated against the wall clock
207/// on every read, so a fact whose expiry falls between the two calls leaves the
208/// two walks disagreeing about an edge neither of them lost. Enumerating once
209/// and walking both directions over that one list removes the window rather than
210/// narrowing it.
211///
212/// # Errors
213/// Returns [`crate::MemoryError`] under the conditions of [`export_edges`], and
214/// additionally when the two indexes do not yield the same set of tuples.
215pub fn export_edges_verified(
216    memory: &AgentMemory,
217    db: &Database,
218    collection: &str,
219    batch: usize,
220) -> Result<Vec<GraphEdge>, crate::MemoryError> {
221    let subsystem = subsystem_of(collection)?;
222    let ids = live_ids(db, collection, batch)?;
223    let exported = collect(memory, subsystem, &ids, Direction::Outgoing)?;
224    let crossed = collect(memory, subsystem, &ids, Direction::Incoming)?;
225    let (left, right) = (comparable(&exported), comparable(&crossed));
226    if left != right {
227        return Err(velesdb_core::Error::Query(format!(
228            "the outgoing and incoming edge indexes disagree over the same {} live \
229             facts: {} tuples out, {} tuples in, {} present in only one of them. \
230             An export cannot be called lossless while the two indexes describe \
231             different graphs",
232            ids.len(),
233            left.len(),
234            right.len(),
235            left.symmetric_difference(&right).count(),
236        ))
237        .into());
238    }
239    Ok(exported)
240}
241
242/// Whether two collections of edges hold the same SET OF TUPLES, every field
243/// included.
244///
245/// The failure names the sizes and the symmetric difference count rather than
246/// the tuples themselves: an operator debugging a mismatch wants to know HOW
247/// diverged before wading into WHAT, and the full tuples are one export away.
248///
249/// # Errors
250/// A message describing the divergence when the sets differ.
251pub(super) fn same_edge_tuples(left: &[GraphEdge], right: &[GraphEdge]) -> Result<(), String> {
252    let (left, right) = (comparable(left), comparable(right));
253    if left == right {
254        return Ok(());
255    }
256    Err(format!(
257        "{} tuples on one side, {} on the other, {} present in only one of them",
258        left.len(),
259        right.len(),
260        left.symmetric_difference(&right).count(),
261    ))
262}
263
264/// The canonical, orderable form of one edge: every field, with properties
265/// rendered through a `BTreeMap` for a stable key order. This is THE reduction
266/// every comparison in the migration uses — the cross-check here and the
267/// validation pass both — so two comparisons cannot quietly disagree about
268/// what "the same edge" means.
269pub(super) type CanonicalEdge = (u64, u64, u64, String, String);
270
271/// See [`CanonicalEdge`]. The render of a `BTreeMap` of stored JSON values
272/// cannot fail in practice; if it ever does, the error TEXT becomes the
273/// rendering — still deterministic, still distinct from every successful
274/// render, and (unlike a default empty string) incapable of making a failed
275/// comparison pass by making both sides say nothing.
276pub(super) fn canonical_edge(edge: &GraphEdge) -> CanonicalEdge {
277    let properties: std::collections::BTreeMap<_, _> = edge.properties().iter().collect();
278    (
279        edge.id(),
280        edge.source(),
281        edge.target(),
282        edge.label().to_owned(),
283        serde_json::to_string(&properties)
284            .unwrap_or_else(|err| format!("<properties failed to render: {err}>")),
285    )
286}
287
288/// Edges as a SET of canonical tuples. Comparing counts would let two walks
289/// that had each lost one edge agree.
290fn comparable(edges: &[GraphEdge]) -> std::collections::BTreeSet<CanonicalEdge> {
291    edges.iter().map(canonical_edge).collect()
292}
293
294/// The same collection of edges, gathered through the INCOMING index instead.
295///
296/// Prefer [`export_edges_verified`], which walks both directions over one
297/// snapshot; this is the single-direction primitive, and comparing its result
298/// against a separately-enumerated [`export_edges`] reintroduces the clock
299/// window that entry point exists to close.
300///
301/// # What the agreement of the two indexes does and does not prove
302///
303/// `outgoing` and `incoming` are separate adjacency maps, so an edge that fell
304/// out of one of them is caught. They are NOT separate copies of the edge: both
305/// resolve the id through the same `edges` map in the shard, so a tuple whose
306/// stored properties were corrupted would come back identically corrupted
307/// through both. This checks MEMBERSHIP in two indexes, not the content twice.
308/// The content is checked by re-reading the destination after reinsertion.
309///
310/// # Errors
311/// Returns [`crate::MemoryError`] under the same conditions as [`export_edges`].
312pub fn cross_check_edges(
313    memory: &AgentMemory,
314    db: &Database,
315    collection: &str,
316    batch: usize,
317) -> Result<Vec<GraphEdge>, crate::MemoryError> {
318    let subsystem = subsystem_of(collection)?;
319    collect(
320        memory,
321        subsystem,
322        &live_ids(db, collection, batch)?,
323        Direction::Incoming,
324    )
325}
326
327/// What putting the edges back produced.
328///
329/// Deliberately thin. It is NOT the verdict, and a caller that treats it as one
330/// has been misled: `relate` is idempotent on an id that already exists and
331/// IGNORES the properties it was handed in that case, so a destination that
332/// dropped every property would still report every edge inserted. The verdict
333/// is a re-read of the destination compared against the export — see
334/// `tests::edges::reinserted_edges_are_read_back_identical_at_the_destination`.
335#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
336pub struct EdgeReinsertion {
337    /// Edges `relate` accepted, each having answered with the exported id.
338    pub inserted: u64,
339}
340
341/// Put `edges` back into `collection`, AFTER the facts.
342///
343/// `relate` requires both endpoints live, so this cannot run before the fact
344/// reinsertion — not as a matter of tidiness but because it would fail. The id
345/// `relate` answers with is compared against the exported id on every edge: they
346/// must agree, since both sides derive it from the same triple, and a
347/// disagreement means the destination is deriving ids differently from the
348/// source and the whole export is void.
349///
350/// # Errors
351/// Returns [`crate::MemoryError`] if `collection` is not an agent subsystem, if
352/// an endpoint is missing or expired at the destination, or if a reinserted edge
353/// answers with an id other than the one exported.
354pub fn reinsert_edges(
355    memory: &AgentMemory,
356    collection: &str,
357    edges: &[GraphEdge],
358) -> Result<EdgeReinsertion, crate::MemoryError> {
359    let subsystem = subsystem_of(collection)?;
360    let mut inserted = 0u64;
361    for edge in edges {
362        let properties: serde_json::Map<String, serde_json::Value> = edge
363            .properties()
364            .iter()
365            .map(|(key, value)| (key.clone(), value.clone()))
366            .collect();
367        let properties = if properties.is_empty() {
368            None
369        } else {
370            Some(properties)
371        };
372        let returned = relate_on(
373            memory,
374            subsystem,
375            (edge.source(), edge.target()),
376            edge.label(),
377            properties.as_ref(),
378        )?;
379        if returned != edge.id() {
380            return Err(velesdb_core::Error::Query(format!(
381                "edge {} ({} -{}-> {}) was reinserted under id {returned}; the \
382                 destination derives edge ids differently from the source, so no \
383                 edge in this export can be trusted to keep its identity",
384                edge.id(),
385                edge.source(),
386                edge.label(),
387                edge.target(),
388            ))
389            .into());
390        }
391        inserted += 1;
392    }
393    Ok(EdgeReinsertion { inserted })
394}
395
396fn relate_on(
397    memory: &AgentMemory,
398    subsystem: Subsystem,
399    endpoints: (u64, u64),
400    label: &str,
401    properties: Option<&serde_json::Map<String, serde_json::Value>>,
402) -> Result<u64, crate::MemoryError> {
403    let (from, to) = endpoints;
404    let result = match subsystem {
405        Subsystem::Semantic => memory.semantic().relate(from, to, label, properties),
406        Subsystem::Episodic => memory.episodic().relate(from, to, label, properties),
407        Subsystem::Procedural => memory.procedural().relate(from, to, label, properties),
408    };
409    result.map_err(crate::MemoryError::from)
410}