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