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}