Skip to main content

nodedb_graph/csr/
persist.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! CSR checkpoint serialization via rkyv. On little-endian platforms
4//! dense arrays are restored zero-copy by pointing `DenseArray` at the
5//! archived buffer.
6//!
7//! Used by both Origin (via redb storage) and Lite (via embedded checkpoint).
8
9use std::collections::HashMap;
10use std::mem::size_of;
11
12use nodedb_mem::EngineId;
13
14use super::index::CsrIndex;
15use crate::GraphError;
16
17/// Magic header for rkyv-serialized CSR snapshots (6 bytes).
18const RKYV_MAGIC: &[u8; 6] = b"RKCS2\0";
19/// Current format version for rkyv-serialized CSR snapshots.
20///
21/// Bumped to `2` when per-edge collection tags were added (parallel
22/// `out_collections` / `in_collections` arrays + collection interning). A v1
23/// snapshot has no collection axis and is rejected; the CSR is instead rebuilt
24/// from the collection-scoped durable edge store.
25pub const CSR_FORMAT_VERSION: u8 = 2;
26
27/// Errors during CSR checkpoint operations.
28#[derive(Debug, thiserror::Error)]
29#[non_exhaustive]
30pub enum CsrCheckpointError {
31    #[error("unsupported CSR checkpoint version {found}; expected {expected}")]
32    UnsupportedVersion { found: u8, expected: u8 },
33    #[error("CSR checkpoint rkyv deserialization failed")]
34    RkyvDeserialize,
35}
36
37/// rkyv-serialized CSR snapshot for fast save/load.
38#[derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)]
39struct CsrSnapshotRkyv {
40    nodes: Vec<String>,
41    labels: Vec<String>,
42    collections: Vec<String>,
43    out_offsets: Vec<u32>,
44    out_targets: Vec<u32>,
45    out_labels: Vec<u32>,
46    out_collections: Vec<u32>,
47    in_offsets: Vec<u32>,
48    in_targets: Vec<u32>,
49    in_labels: Vec<u32>,
50    in_collections: Vec<u32>,
51    buffer_out: Vec<Vec<(u32, u32)>>,
52    buffer_in: Vec<Vec<(u32, u32)>>,
53    buffer_out_collections: Vec<Vec<u32>>,
54    buffer_in_collections: Vec<Vec<u32>>,
55    /// Deleted-edge identities `(src, label, dst, collection)`. Collection is
56    /// part of the key so per-collection copies of a shared triple tombstone
57    /// independently. The v2 snapshot already carries the collection axis, so
58    /// widening this key needs no new format version.
59    deleted: Vec<(u32, u32, u32, u32)>,
60    has_weights: bool,
61    out_weights: Option<Vec<f64>>,
62    in_weights: Option<Vec<f64>>,
63    buffer_out_weights: Vec<Vec<f64>>,
64    buffer_in_weights: Vec<Vec<f64>>,
65}
66
67impl CsrIndex {
68    /// Serialize the index to rkyv bytes (with magic header) for storage.
69    ///
70    /// # Errors
71    ///
72    /// Returns [`GraphError::MemoryBudget`] if a memory governor is installed
73    /// and the serialization buffer would exceed the `Graph` engine budget.
74    pub fn checkpoint_to_bytes(&self) -> Result<Vec<u8>, GraphError> {
75        let snapshot = CsrSnapshotRkyv {
76            nodes: self.id_to_node.clone(),
77            labels: self.id_to_label.clone(),
78            collections: self.id_to_collection.clone(),
79            out_offsets: self.out_offsets.clone(),
80            out_targets: self.out_targets.to_vec(),
81            out_labels: self.out_labels.to_vec(),
82            out_collections: self.out_collections.clone(),
83            in_offsets: self.in_offsets.clone(),
84            in_targets: self.in_targets.to_vec(),
85            in_labels: self.in_labels.to_vec(),
86            in_collections: self.in_collections.clone(),
87            buffer_out: self.buffer_out.clone(),
88            buffer_in: self.buffer_in.clone(),
89            buffer_out_collections: self.buffer_out_collections.clone(),
90            buffer_in_collections: self.buffer_in_collections.clone(),
91            deleted: self.deleted_edges.iter().copied().collect(),
92            has_weights: self.has_weights,
93            out_weights: self.out_weights.as_ref().map(|w| w.to_vec()),
94            in_weights: self.in_weights.as_ref().map(|w| w.to_vec()),
95            buffer_out_weights: self.buffer_out_weights.clone(),
96            buffer_in_weights: self.buffer_in_weights.clone(),
97        };
98        let rkyv_bytes = rkyv::to_bytes::<rkyv::rancor::Error>(&snapshot)
99            .expect("CSR rkyv serialization should not fail");
100        let buf_capacity = RKYV_MAGIC.len() + 1 + rkyv_bytes.len();
101        let _budget_guard = self
102            .governor
103            .as_ref()
104            .map(|g| g.reserve(EngineId::Graph, buf_capacity * size_of::<u8>()))
105            .transpose()?;
106        let mut buf = Vec::with_capacity(buf_capacity);
107        buf.extend_from_slice(RKYV_MAGIC);
108        buf.push(CSR_FORMAT_VERSION);
109        buf.extend_from_slice(&rkyv_bytes);
110        Ok(buf)
111    }
112
113    /// Restore an index from a checkpoint snapshot.
114    ///
115    /// Returns:
116    /// - `Ok(Some(index))` — successfully decoded.
117    /// - `Ok(None)` — buffer does not start with the magic header (no legacy
118    ///   format exists for CSR; callers should treat this as an invalid buffer).
119    /// - `Err(CsrCheckpointError::UnsupportedVersion)` — magic matches but the
120    ///   version byte is not `CSR_FORMAT_VERSION`.
121    pub fn from_checkpoint(bytes: &[u8]) -> Result<Option<Self>, CsrCheckpointError> {
122        let header_len = RKYV_MAGIC.len() + 1; // magic + version byte
123        if bytes.len() > header_len && &bytes[..RKYV_MAGIC.len()] == RKYV_MAGIC {
124            let version = bytes[RKYV_MAGIC.len()];
125            if version != CSR_FORMAT_VERSION {
126                return Err(CsrCheckpointError::UnsupportedVersion {
127                    found: version,
128                    expected: CSR_FORMAT_VERSION,
129                });
130            }
131            return Ok(Self::from_rkyv_checkpoint(&bytes[header_len..]));
132        }
133        Ok(None)
134    }
135
136    /// Restore from rkyv-serialized bytes.
137    ///
138    /// On little-endian platforms (x86_64, ARM), dense arrays (targets, labels,
139    /// weights) are zero-copy: DenseArray points directly into the archived
140    /// buffer with no per-element parsing. On big-endian, falls back to full
141    /// deserialization.
142    fn from_rkyv_checkpoint(bytes: &[u8]) -> Option<Self> {
143        let mut aligned = rkyv::util::AlignedVec::<16>::with_capacity(bytes.len());
144        aligned.extend_from_slice(bytes);
145
146        #[cfg(target_endian = "little")]
147        {
148            Self::from_rkyv_zero_copy(aligned)
149        }
150        #[cfg(not(target_endian = "little"))]
151        {
152            let snap: CsrSnapshotRkyv =
153                rkyv::from_bytes::<CsrSnapshotRkyv, rkyv::rancor::Error>(&aligned).ok()?;
154            Some(Self::from_snapshot_fields(snap))
155        }
156    }
157
158    /// Zero-copy restore on little-endian platforms.
159    ///
160    /// SAFETY: On little-endian, rkyv's `u32_le`/`u16_le`/`f64_le` have
161    /// identical memory layout to native `u32`/`u16`/`f64`. The pointer
162    /// casts are sound because `ArchivedVec<T>` stores contiguous `T_le`
163    /// values, and the `Arc<AlignedVec>` keeps the buffer alive.
164    #[cfg(target_endian = "little")]
165    fn from_rkyv_zero_copy(aligned: rkyv::util::AlignedVec) -> Option<Self> {
166        use super::dense_array::DenseArray;
167
168        let backing = std::sync::Arc::new(aligned);
169
170        // Access archived data (zero-copy reference into the buffer).
171        let archived =
172            rkyv::access::<rkyv::Archived<CsrSnapshotRkyv>, rkyv::rancor::Error>(&backing).ok()?;
173
174        // Zero-copy DenseArrays for dense CSR arrays.
175        let out_targets = unsafe {
176            let s = archived.out_targets.as_slice();
177            DenseArray::zero_copy(backing.clone(), s.as_ptr().cast::<u32>(), s.len())
178        };
179        let out_labels = unsafe {
180            let s = archived.out_labels.as_slice();
181            DenseArray::zero_copy(backing.clone(), s.as_ptr().cast::<u32>(), s.len())
182        };
183        let in_targets = unsafe {
184            let s = archived.in_targets.as_slice();
185            DenseArray::zero_copy(backing.clone(), s.as_ptr().cast::<u32>(), s.len())
186        };
187        let in_labels = unsafe {
188            let s = archived.in_labels.as_slice();
189            DenseArray::zero_copy(backing.clone(), s.as_ptr().cast::<u32>(), s.len())
190        };
191        let out_weights = archived.out_weights.as_ref().map(|w| unsafe {
192            let s = w.as_slice();
193            DenseArray::zero_copy(backing.clone(), s.as_ptr().cast::<f64>(), s.len())
194        });
195        let in_weights = archived.in_weights.as_ref().map(|w| unsafe {
196            let s = w.as_slice();
197            DenseArray::zero_copy(backing.clone(), s.as_ptr().cast::<f64>(), s.len())
198        });
199
200        // Deserialize mutable/small fields (strings, buffers, offsets).
201        let snap: CsrSnapshotRkyv =
202            rkyv::from_bytes::<CsrSnapshotRkyv, rkyv::rancor::Error>(&backing).ok()?;
203
204        let node_to_id: HashMap<String, u32> = snap
205            .nodes
206            .iter()
207            .enumerate()
208            .map(|(i, n)| (n.clone(), i as u32))
209            .collect();
210        let label_to_id: HashMap<String, u32> = snap
211            .labels
212            .iter()
213            .enumerate()
214            .map(|(i, l)| (l.clone(), i as u32))
215            .collect();
216        let node_count = snap.nodes.len();
217        let access_counts = (0..node_count).map(|_| std::cell::Cell::new(0)).collect();
218        let buffer_out_weights = if snap.buffer_out_weights.len() == node_count {
219            snap.buffer_out_weights
220        } else {
221            vec![Vec::new(); node_count]
222        };
223        let buffer_in_weights = if snap.buffer_in_weights.len() == node_count {
224            snap.buffer_in_weights
225        } else {
226            vec![Vec::new(); node_count]
227        };
228        let collection_to_id: HashMap<String, u32> = snap
229            .collections
230            .iter()
231            .enumerate()
232            .map(|(i, c)| (c.clone(), i as u32))
233            .collect();
234        let buffer_out_collections = if snap.buffer_out_collections.len() == node_count {
235            snap.buffer_out_collections
236        } else {
237            vec![Vec::new(); node_count]
238        };
239        let buffer_in_collections = if snap.buffer_in_collections.len() == node_count {
240            snap.buffer_in_collections
241        } else {
242            vec![Vec::new(); node_count]
243        };
244
245        Some(Self {
246            node_to_id,
247            id_to_node: snap.nodes,
248            label_to_id,
249            id_to_label: snap.labels,
250            collection_to_id,
251            id_to_collection: snap.collections,
252            out_offsets: snap.out_offsets,
253            out_targets,
254            out_labels,
255            out_collections: snap.out_collections,
256            out_weights,
257            in_offsets: snap.in_offsets,
258            in_targets,
259            in_labels,
260            in_collections: snap.in_collections,
261            in_weights,
262            buffer_out: snap.buffer_out,
263            buffer_in: snap.buffer_in,
264            buffer_out_weights,
265            buffer_in_weights,
266            buffer_out_collections,
267            buffer_in_collections,
268            deleted_edges: snap.deleted.into_iter().collect(),
269            has_weights: snap.has_weights,
270            node_label_bits: vec![0; node_count],
271            node_label_to_id: HashMap::new(),
272            node_label_names: Vec::new(),
273            // Surrogates are runtime-only and not persisted. After checkpoint
274            // restore they start at zero and are repopulated by subsequent EdgePuts.
275            node_surrogates: vec![0; node_count],
276            surrogate_to_local: HashMap::new(),
277            access_counts,
278            query_epoch: 0,
279            partition_tag: crate::csr::local_node_id::next_partition_tag(),
280            // Checkpoint restore creates an ungoverned index; callers that
281            // need budget enforcement should call `set_governor` afterwards.
282            governor: None,
283        })
284    }
285
286    /// Reconstruct CsrIndex from deserialized snapshot fields.
287    #[cfg(not(target_endian = "little"))]
288    fn from_snapshot_fields(snap: CsrSnapshotRkyv) -> Self {
289        let node_to_id: HashMap<String, u32> = snap
290            .nodes
291            .iter()
292            .enumerate()
293            .map(|(i, n)| (n.clone(), i as u32))
294            .collect();
295        let label_to_id: HashMap<String, u32> = snap
296            .labels
297            .iter()
298            .enumerate()
299            .map(|(i, l)| (l.clone(), i as u32))
300            .collect();
301
302        let node_count = snap.nodes.len();
303        let access_counts = (0..node_count).map(|_| std::cell::Cell::new(0)).collect();
304
305        let buffer_out_weights = if snap.buffer_out_weights.len() == node_count {
306            snap.buffer_out_weights
307        } else {
308            vec![Vec::new(); node_count]
309        };
310        let buffer_in_weights = if snap.buffer_in_weights.len() == node_count {
311            snap.buffer_in_weights
312        } else {
313            vec![Vec::new(); node_count]
314        };
315        let collection_to_id: HashMap<String, u32> = snap
316            .collections
317            .iter()
318            .enumerate()
319            .map(|(i, c)| (c.clone(), i as u32))
320            .collect();
321        let buffer_out_collections = if snap.buffer_out_collections.len() == node_count {
322            snap.buffer_out_collections
323        } else {
324            vec![Vec::new(); node_count]
325        };
326        let buffer_in_collections = if snap.buffer_in_collections.len() == node_count {
327            snap.buffer_in_collections
328        } else {
329            vec![Vec::new(); node_count]
330        };
331
332        Self {
333            node_to_id,
334            id_to_node: snap.nodes,
335            label_to_id,
336            id_to_label: snap.labels,
337            collection_to_id,
338            id_to_collection: snap.collections,
339            out_offsets: snap.out_offsets,
340            out_targets: snap.out_targets.into(),
341            out_labels: snap.out_labels.into(),
342            out_collections: snap.out_collections,
343            out_weights: snap.out_weights.map(Into::into),
344            in_offsets: snap.in_offsets,
345            in_targets: snap.in_targets.into(),
346            in_labels: snap.in_labels.into(),
347            in_collections: snap.in_collections,
348            in_weights: snap.in_weights.map(Into::into),
349            buffer_out: snap.buffer_out,
350            buffer_in: snap.buffer_in,
351            buffer_out_weights,
352            buffer_in_weights,
353            buffer_out_collections,
354            buffer_in_collections,
355            deleted_edges: snap.deleted.into_iter().collect(),
356            has_weights: snap.has_weights,
357            node_label_bits: vec![0; node_count],
358            node_label_to_id: HashMap::new(),
359            node_label_names: Vec::new(),
360            // Surrogates are runtime-only and not persisted. After checkpoint
361            // restore they start at zero and are repopulated by subsequent EdgePuts.
362            node_surrogates: vec![0; node_count],
363            surrogate_to_local: HashMap::new(),
364            access_counts,
365            query_epoch: 0,
366            partition_tag: crate::csr::local_node_id::next_partition_tag(),
367            // Checkpoint restore creates an ungoverned index.
368            governor: None,
369        }
370    }
371}
372
373#[cfg(test)]
374mod tests {
375    use super::*;
376    use crate::csr::index::Direction;
377
378    #[test]
379    fn checkpoint_roundtrip_unweighted() {
380        let mut csr = CsrIndex::new();
381        csr.add_edge("a", "KNOWS", "b").unwrap();
382        csr.add_edge("b", "KNOWS", "c").unwrap();
383        csr.compact().expect("no governor, cannot fail");
384
385        let bytes = csr.checkpoint_to_bytes().expect("no governor, cannot fail");
386        let restored = CsrIndex::from_checkpoint(&bytes)
387            .expect("roundtrip")
388            .unwrap();
389        assert_eq!(restored.node_count(), 3);
390        assert_eq!(restored.edge_count(), 2);
391        assert!(!restored.has_weights());
392
393        let n = restored.neighbors("a", Some("KNOWS"), Direction::Out);
394        assert_eq!(n.len(), 1);
395        assert_eq!(n[0].1, "b");
396    }
397
398    #[test]
399    fn checkpoint_roundtrip_weighted() {
400        let mut csr = CsrIndex::new();
401        csr.add_edge_weighted("a", "R", "b", 2.5).unwrap();
402        csr.add_edge_weighted("b", "R", "c", 7.0).unwrap();
403        csr.add_edge("c", "R", "d").unwrap();
404        csr.compact().expect("no governor, cannot fail");
405
406        let bytes = csr.checkpoint_to_bytes().expect("no governor, cannot fail");
407        let restored = CsrIndex::from_checkpoint(&bytes)
408            .expect("roundtrip")
409            .unwrap();
410        assert!(restored.has_weights());
411        assert_eq!(restored.edge_weight("a", "R", "b"), Some(2.5));
412        assert_eq!(restored.edge_weight("b", "R", "c"), Some(7.0));
413        assert_eq!(restored.edge_weight("c", "R", "d"), Some(1.0));
414    }
415
416    #[test]
417    fn checkpoint_roundtrip_with_buffer() {
418        let mut csr = CsrIndex::new();
419        csr.add_edge("a", "L", "b").unwrap();
420        // Don't compact — edges in buffer.
421        let bytes = csr.checkpoint_to_bytes().expect("no governor, cannot fail");
422        let restored = CsrIndex::from_checkpoint(&bytes)
423            .expect("roundtrip")
424            .unwrap();
425        assert_eq!(restored.edge_count(), 1);
426    }
427
428    #[test]
429    fn golden_header_layout() {
430        let mut csr = CsrIndex::new();
431        csr.add_edge("a", "KNOWS", "b").unwrap();
432        let bytes = csr.checkpoint_to_bytes().expect("no governor, cannot fail");
433        // Magic at bytes[0..6].
434        assert_eq!(&bytes[0..6], b"RKCS2\0");
435        // Version byte at bytes[6].
436        assert_eq!(bytes[6], super::CSR_FORMAT_VERSION);
437        // rkyv payload follows immediately.
438        assert!(bytes.len() > 7);
439    }
440
441    #[test]
442    fn version_mismatch_returns_error() {
443        let mut csr = CsrIndex::new();
444        csr.add_edge("a", "KNOWS", "b").unwrap();
445        let mut bytes = csr.checkpoint_to_bytes().expect("no governor, cannot fail");
446        // Corrupt the version byte to an unsupported value.
447        bytes[6] = 0;
448        match CsrIndex::from_checkpoint(&bytes) {
449            Err(CsrCheckpointError::UnsupportedVersion { found, expected }) => {
450                assert_eq!(found, 0);
451                assert_eq!(expected, super::CSR_FORMAT_VERSION);
452            }
453            Err(other) => panic!("unexpected error: {other}"),
454            Ok(_) => panic!("expected UnsupportedVersion error, got Ok"),
455        }
456    }
457}