Skip to main content

lora_store/memory/
snapshot.rs

1//! Snapshot payload helpers for the in-memory graph.
2//!
3//! `lora-store` no longer ships its own on-disk codec. The byte-level
4//! columnar format lives in `lora-snapshot`; this module just bridges
5//! between [`InMemoryGraph`] and the portable [`SnapshotPayload`]
6//! vocabulary.
7
8use std::collections::BTreeSet;
9
10use crate::{
11    NodeId, NodeRecord, NodeRef, RelRef, RelationshipId, RelationshipRecord, SnapshotError,
12    SnapshotMeta, SnapshotPayload,
13};
14
15use super::distinct_stats::DistinctBuilder;
16use super::index_catalog::StoredIndexEntity;
17use super::{ConstraintDefinition, InMemoryGraph, IndexDefinition, VectorIndexSnapshot};
18
19/// Format-version stamp surfaced through [`SnapshotMeta::format_version`]
20/// for payloads produced via the inherent helpers below. Kept stable
21/// across `lora-snapshot` codec versions because the payload shape
22/// itself has not changed; only the on-disk encoding has.
23pub(super) const PAYLOAD_FORMAT_VERSION: u32 = 1;
24
25impl InMemoryGraph {
26    /// Return the portable graph-state payload. Callers downstream of
27    /// `lora-store` (typically `lora-database`) feed this into
28    /// `lora-snapshot` for byte-level encoding.
29    pub fn snapshot_payload(&self) -> SnapshotPayload {
30        let mut vector_indexes = self
31            .vector_indexes_read(StoredIndexEntity::Node)
32            .to_snapshots(StoredIndexEntity::Node);
33        vector_indexes.extend(
34            self.vector_indexes_read(StoredIndexEntity::Relationship)
35                .to_snapshots(StoredIndexEntity::Relationship),
36        );
37        SnapshotPayload {
38            next_node_id: self.next_node_id,
39            next_rel_id: self.next_rel_id,
40            nodes: self.iter_node_refs().map(|r| r.to_record()).collect(),
41            relationships: self.iter_rel_refs().map(|r| r.to_record()).collect(),
42            indexes: self.index_catalog_read().list(),
43            constraints: self.constraint_catalog_read().list(),
44            vector_indexes,
45        }
46    }
47
48    /// Replace the graph from a portable graph-state payload, preserving the
49    /// currently installed mutation recorder across the swap.
50    pub fn load_snapshot_payload(
51        &mut self,
52        payload: SnapshotPayload,
53    ) -> Result<SnapshotMeta, SnapshotError> {
54        let meta = SnapshotMeta {
55            format_version: PAYLOAD_FORMAT_VERSION,
56            node_count: payload.nodes.len(),
57            relationship_count: payload.relationships.len(),
58            wal_lsn: None,
59        };
60
61        validate_payload_ids(&payload)?;
62
63        let mut builder = SnapshotGraphBuilder::new(payload.next_node_id, payload.next_rel_id)?;
64        for node in payload.nodes {
65            builder.push_node(node)?;
66        }
67        for rel in payload.relationships {
68            builder.push_relationship(rel)?;
69        }
70        let mut rebuilt =
71            builder.finish(payload.indexes, payload.constraints, payload.vector_indexes)?;
72
73        // Preserve the existing recorder across the swap — observers of the
74        // store's identity should not be silently detached by a restore,
75        // same policy as `clear()`.
76        rebuilt.recorder = self.recorder.take();
77        *self = rebuilt;
78
79        Ok(meta)
80    }
81
82    /// The node records, in id order, as borrowed views. With
83    /// [`Self::snapshot_relationships`] and [`Self::snapshot_catalogs`] this
84    /// is what a snapshot encoder reads instead of a [`SnapshotPayload`],
85    /// which would hold a decoded copy of every record at once. Each call
86    /// is a fresh pass, so an encoder that writes column by column can
87    /// walk the graph once per column.
88    pub fn snapshot_nodes(&self) -> impl Iterator<Item = NodeRef<'_>> + '_ {
89        self.iter_node_refs()
90    }
91
92    /// The relationship records, in id order, as borrowed views.
93    pub fn snapshot_relationships(&self) -> impl Iterator<Item = RelRef<'_>> + '_ {
94        self.iter_rel_refs()
95    }
96
97    /// Everything a snapshot holds besides the records.
98    pub fn snapshot_catalogs(&self) -> SnapshotCatalogs {
99        let mut vector_indexes = self
100            .vector_indexes_read(StoredIndexEntity::Node)
101            .to_snapshots(StoredIndexEntity::Node);
102        vector_indexes.extend(
103            self.vector_indexes_read(StoredIndexEntity::Relationship)
104                .to_snapshots(StoredIndexEntity::Relationship),
105        );
106        SnapshotCatalogs {
107            next_node_id: self.next_node_id,
108            next_rel_id: self.next_rel_id,
109            indexes: self.index_catalog_read().list(),
110            constraints: self.constraint_catalog_read().list(),
111            vector_indexes,
112        }
113    }
114}
115
116/// The parts of a snapshot that are not records: the id counters and the
117/// declared catalogs. Small next to the graph.
118#[derive(Debug, Clone)]
119pub struct SnapshotCatalogs {
120    pub next_node_id: NodeId,
121    pub next_rel_id: RelationshipId,
122    pub indexes: Vec<IndexDefinition>,
123    pub constraints: Vec<ConstraintDefinition>,
124    pub vector_indexes: Vec<VectorIndexSnapshot>,
125}
126
127/// Builds an [`InMemoryGraph`] from snapshot records one at a time.
128///
129/// A decoder that streams a snapshot pushes each record as it decodes it,
130/// so the records are never all held at once beside the graph they fill.
131/// Push every node before the relationships that reference it, then call
132/// [`Self::finish`] with the catalogs, which registers the indexes and
133/// constraints over the loaded data.
134pub struct SnapshotGraphBuilder {
135    graph: InMemoryGraph,
136    node_distinct: DistinctBuilder,
137    rel_distinct: DistinctBuilder,
138}
139
140impl SnapshotGraphBuilder {
141    pub fn new(next_node_id: NodeId, next_rel_id: RelationshipId) -> Result<Self, SnapshotError> {
142        validate_next_id("node", next_node_id)?;
143        validate_next_id("relationship", next_rel_id)?;
144        let mut graph = InMemoryGraph::new();
145        graph.next_node_id = next_node_id;
146        graph.next_rel_id = next_rel_id;
147        Ok(Self {
148            graph,
149            node_distinct: DistinctBuilder::default(),
150            rel_distinct: DistinctBuilder::default(),
151        })
152    }
153
154    pub fn push_node(&mut self, node: NodeRecord) -> Result<(), SnapshotError> {
155        let id = node.id;
156        validate_entity_id("node", id, self.graph.next_node_id)?;
157        // Distinct-value sketches are not persisted: count the record as a
158        // live create would, through the bulk builder.
159        for label in &node.labels {
160            self.node_distinct.add_all(label, &node.properties);
161        }
162        if self.graph.has_node_at(id) {
163            return Err(SnapshotError::Decode(format!(
164                "duplicate node id {id} in snapshot payload"
165            )));
166        }
167        self.graph
168            .put_node_checked(id, &node)
169            .map_err(SnapshotError::Decode)?;
170        for label in &node.labels {
171            self.graph.insert_node_label_index(id, label);
172        }
173        Ok(())
174    }
175
176    pub fn push_relationship(&mut self, rel: RelationshipRecord) -> Result<(), SnapshotError> {
177        validate_entity_id("relationship", rel.id, self.graph.next_rel_id)?;
178        validate_slot_id("relationship source node", rel.src)?;
179        validate_slot_id("relationship target node", rel.dst)?;
180        if self.graph.has_rel_at(rel.id) {
181            return Err(SnapshotError::Decode(format!(
182                "duplicate relationship id {} in snapshot payload",
183                rel.id
184            )));
185        }
186        if !self.graph.has_node_at(rel.src) {
187            return Err(SnapshotError::Decode(format!(
188                "relationship {} references missing source node {}",
189                rel.id, rel.src
190            )));
191        }
192        if !self.graph.has_node_at(rel.dst) {
193            return Err(SnapshotError::Decode(format!(
194                "relationship {} references missing target node {}",
195                rel.id, rel.dst
196            )));
197        }
198        self.graph
199            .put_rel_checked(rel.id, &rel)
200            .map_err(SnapshotError::Decode)?;
201        self.graph.attach_relationship(&rel);
202        self.rel_distinct.add_all(&rel.rel_type, &rel.properties);
203        Ok(())
204    }
205
206    pub fn finish(
207        self,
208        indexes: Vec<IndexDefinition>,
209        constraints: Vec<ConstraintDefinition>,
210        vector_indexes: Vec<VectorIndexSnapshot>,
211    ) -> Result<InMemoryGraph, SnapshotError> {
212        let Self {
213            graph: mut rebuilt,
214            node_distinct,
215            rel_distinct,
216        } = self;
217        rebuilt.distinct_stats.nodes = node_distinct.finish();
218        rebuilt.distinct_stats.relationships = rel_distinct.finish();
219        // No hash property index is built here. Restart reproduces the
220        // state of a fresh process: the declared RANGE indexes and the
221        // uniqueness/key constraints below activate (and backfill) the
222        // hash indexes they need through `register_index` /
223        // `register_constraint`. Implicit, lookup-activated indexes are
224        // rebuilt lazily by the first lookup that needs them.
225
226        let constraint_owned_indexes: BTreeSet<String> = constraints
227            .iter()
228            .filter_map(|def| {
229                def.owned_index
230                    .clone()
231                    .or_else(|| def.kind.requires_backing_index().then(|| def.name.clone()))
232            })
233            .collect();
234
235        // Re-register every user-visible index in the catalog. Going through
236        // `register_index` re-populates RANGE buckets and keeps the
237        // `populate_index_data` invariant aligned with the catalog —
238        // skipping it would leave RANGE indexes registered but never populated.
239        // Constraint-owned backing indexes are restored by re-registering the
240        // owning constraint below, which keeps catalog ownership explicit.
241        for def in indexes {
242            if constraint_owned_indexes.contains(&def.name) {
243                continue;
244            }
245            // Errors here would mean the snapshot itself is corrupt or
246            // ambiguous; map them into Decode rather than panicking.
247            rebuilt
248                .register_index(
249                    crate::memory::IndexRequest {
250                        explicit_name: Some(def.name.clone()),
251                        kind: def.kind,
252                        entity: def.entity,
253                        label: def.label.clone(),
254                        additional_labels: def.additional_labels.clone(),
255                        properties: def.properties.clone(),
256                        options: def.options.clone(),
257                    },
258                    /*if_not_exists*/ true,
259                )
260                .map_err(|e| SnapshotError::Decode(format!("index `{}`: {e}", def.name)))?;
261        }
262
263        // Overlay persisted HNSW snapshots over the freshly-registered
264        // (and freshly-backfilled) vector indexes. This is the
265        // post-step that gives Phase 5 its raison d'être: instead of
266        // paying O(n log n) to re-insert every vector through the
267        // HNSW algorithm, we install the graph topology byte-for-byte.
268        // Snapshots from versions before this trailer round-trip with
269        // `vector_indexes = []` so the fallback path (the backfill
270        // that already ran inside `register_index`) handles them
271        // correctly.
272        for snap in vector_indexes {
273            let entity = snap.entity;
274            let mut registry = rebuilt.vector_indexes_write(entity);
275            if !registry.restore_snapshot(snap) {
276                // Catalog/snapshot mismatch — registry already
277                // contains the populate-built backend, which is the
278                // safe fallback. No further action.
279            }
280        }
281
282        // Re-register constraints. Uniqueness / key constraints recreate
283        // their own backing indexes as part of registration.
284        for def in constraints {
285            rebuilt
286                .register_constraint(
287                    crate::memory::ConstraintRequest {
288                        name: def.name.clone(),
289                        kind: def.kind.clone(),
290                        entity: def.entity,
291                        label: def.label.clone(),
292                        properties: def.properties.clone(),
293                    },
294                    /*if_not_exists*/ true,
295                )
296                .map_err(|e| SnapshotError::Decode(format!("constraint `{}`: {e}", def.name)))?;
297        }
298
299        Ok(rebuilt)
300    }
301}
302
303fn validate_payload_ids(payload: &SnapshotPayload) -> Result<(), SnapshotError> {
304    validate_next_id("node", payload.next_node_id)?;
305    validate_next_id("relationship", payload.next_rel_id)?;
306
307    for node in &payload.nodes {
308        validate_entity_id("node", node.id, payload.next_node_id)?;
309    }
310    for rel in &payload.relationships {
311        validate_entity_id("relationship", rel.id, payload.next_rel_id)?;
312        validate_slot_id("relationship source node", rel.src)?;
313        validate_slot_id("relationship target node", rel.dst)?;
314    }
315
316    Ok(())
317}
318
319fn validate_next_id(kind: &str, next_id: u64) -> Result<(), SnapshotError> {
320    validate_slot_id(&format!("next {kind} id"), next_id)?;
321    if next_id == u64::MAX {
322        return Err(SnapshotError::Decode(format!(
323            "next {kind} id {next_id} leaves no allocatable id"
324        )));
325    }
326    Ok(())
327}
328
329fn validate_entity_id(kind: &str, id: u64, next_id: u64) -> Result<(), SnapshotError> {
330    validate_slot_id(kind, id)?;
331    if id >= next_id {
332        return Err(SnapshotError::Decode(format!(
333            "{kind} id {id} is not below next {kind} id {next_id}"
334        )));
335    }
336    Ok(())
337}
338
339fn validate_slot_id(label: &str, id: u64) -> Result<(), SnapshotError> {
340    let idx = usize::try_from(id).map_err(|_| {
341        SnapshotError::Decode(format!(
342            "{label} {id} does not fit in usize on this platform"
343        ))
344    })?;
345    idx.checked_add(1)
346        .ok_or_else(|| SnapshotError::Decode(format!("{label} {id} leaves no valid slab slot")))?;
347    Ok(())
348}