1use crate::columns::ColumnStore;
2use crate::edge_props::EdgeProps;
3use crate::idmap::IdMap;
4use crate::interner::Interner;
5use crate::pack::{push_u32, read_u32};
6use crate::topology::Topology;
7use crate::types::{GraphError, Result};
8use crate::v8::encode::V8Meta;
9use serde::{Deserialize, Serialize};
10use std::collections::{BTreeMap, BTreeSet, HashMap};
11
12pub const MAGIC: [u8; 4] = *b"GDB1";
13pub const VERSION_5: u16 = 5;
15pub const VERSION_6: u16 = 6;
17pub const VERSION_7: u16 = 7;
19pub const VERSION_8: u16 = 8;
21pub const VERSION_9: u16 = 9;
31pub const VERSION: u16 = VERSION_9;
33
34#[derive(Serialize, Deserialize, Clone, Default, Debug)]
38pub struct SideIvfState {
39 pub centroids: Vec<Vec<f64>>,
41 pub clusters: BTreeMap<u32, usize>,
43 pub drift: u64,
45}
46
47#[derive(Serialize, Deserialize, Clone, Default, Debug)]
49pub struct PerRuleIvfState {
50 pub src: SideIvfState,
51 pub dst: SideIvfState,
52}
53
54#[derive(Serialize, Deserialize)]
55pub struct SnapshotState {
56 pub ids: IdMap,
57 pub syms: Interner,
58 pub topo: Topology,
59 pub props: ColumnStore,
60 pub labels: Vec<u32>,
61 pub edge_props: EdgeProps,
62 pub rule_defs: Vec<Vec<u8>>,
65 pub provenance: BTreeMap<String, BTreeSet<(u32, u32, u32)>>,
66 pub rule_tripped: BTreeMap<String, bool>,
68 pub rule_fires: BTreeMap<String, u64>,
70 pub ivf_state: BTreeMap<String, PerRuleIvfState>,
74 pub view_defs: Vec<Vec<u8>>,
79 #[serde(skip)]
86 pub wal_truncated: bool,
87 #[serde(skip)]
95 pub hnsw_state: BTreeMap<String, (Vec<u8>, Vec<u8>)>,
96}
97
98#[derive(Serialize, Deserialize)]
99struct V7Meta {
100 ids: IdMap,
101 syms: Interner,
102 labels: Vec<u32>,
103 edge_props: EdgeProps,
104 rule_defs: Vec<Vec<u8>>,
105 provenance: BTreeMap<String, BTreeSet<(u32, u32, u32)>>,
106 rule_tripped: BTreeMap<String, bool>,
107 rule_fires: BTreeMap<String, u64>,
108 ivf_state: BTreeMap<String, PerRuleIvfState>,
109 view_defs: Vec<Vec<u8>>,
110 wal_truncated: bool,
112 #[serde(default)]
116 hnsw: BTreeMap<String, (Vec<u8>, Vec<u8>)>,
117}
118
119fn wrap_zstd(version: u16, inner: Vec<u8>) -> Vec<u8> {
120 let compressed =
121 zstd::encode_all(inner.as_slice(), 3).expect("zstd compress cannot fail on in-memory buf");
122 let mut out = Vec::with_capacity(6 + compressed.len());
123 out.extend(MAGIC);
124 out.extend(version.to_le_bytes());
125 out.extend(compressed);
126 out
127}
128
129fn crc_inner(payload: &[u8]) -> Vec<u8> {
130 let crc = crc32fast::hash(payload);
131 let mut inner = Vec::with_capacity(4 + payload.len());
132 inner.extend(crc.to_le_bytes());
133 inner.extend(payload);
134 inner
135}
136
137pub fn encode(state: &SnapshotState) -> Result<Vec<u8>> {
144 encode_v8_from_state(state)
145}
146
147fn encode_v8_from_state(state: &SnapshotState) -> Result<Vec<u8>> {
148 let ivf_bytes = if state.ivf_state.is_empty() {
149 Vec::new()
150 } else {
151 bincode::serialize(&state.ivf_state).expect("IVF state serialize cannot fail")
152 };
153 let meta = V8Meta {
154 labels: state.labels.clone(),
155 edge_props: state.edge_props.clone(),
156 rule_defs: state.rule_defs.clone(),
157 provenance: state.provenance.clone(),
158 rule_tripped: state.rule_tripped.clone(),
159 rule_fires: state.rule_fires.clone(),
160 ivf_bytes,
161 view_defs: state.view_defs.clone(),
162 wal_truncated: state.wal_truncated,
163 hnsw: state.hnsw_state.clone(),
164 last_change: HashMap::new(),
165 };
166 let mut out = Vec::new();
167 crate::v8::encode::encode_v8(
168 None,
169 None,
170 None,
171 None,
172 None,
173 &state.topo,
174 &state.props,
175 &state.ids,
176 &state.syms,
177 &meta,
178 &mut out,
179 )?;
180 Ok(out)
181}
182
183pub fn encode_v6(state: &SnapshotState) -> Vec<u8> {
185 let payload = bincode::serialize(state).expect("snapshot serialize cannot fail");
186 wrap_zstd(VERSION_6, crc_inner(&payload))
187}
188
189fn section_len(name: &str, len: usize) -> Result<u32> {
190 u32::try_from(len).map_err(|_| GraphError::Corrupt {
191 detail: format!(
192 "snapshot: packed {name} section is {len} bytes, exceeds u32 length prefix"
193 ),
194 })
195}
196
197pub fn encode_v7(state: &SnapshotState) -> Result<Vec<u8>> {
198 let mut topo = Vec::new();
199 state.topo.pack(&mut topo);
200 let mut props = Vec::new();
201 state.props.pack(&mut props);
202 let meta = V7Meta {
203 ids: state.ids.clone(),
204 syms: state.syms.clone(),
205 labels: state.labels.clone(),
206 edge_props: state.edge_props.clone(),
207 rule_defs: state.rule_defs.clone(),
208 provenance: state.provenance.clone(),
209 rule_tripped: state.rule_tripped.clone(),
210 rule_fires: state.rule_fires.clone(),
211 ivf_state: state.ivf_state.clone(),
212 view_defs: state.view_defs.clone(),
213 wal_truncated: state.wal_truncated,
214 hnsw: state.hnsw_state.clone(),
215 };
216 let meta_bytes = bincode::serialize(&meta).expect("snapshot meta serialize cannot fail");
217 let mut payload = Vec::with_capacity(8 + topo.len() + props.len() + meta_bytes.len());
218 push_u32(&mut payload, section_len("topology", topo.len())?);
219 payload.extend_from_slice(&topo);
220 push_u32(&mut payload, section_len("columns", props.len())?);
221 payload.extend_from_slice(&props);
222 payload.extend_from_slice(&meta_bytes);
223 Ok(wrap_zstd(VERSION_7, crc_inner(&payload)))
224}
225
226pub fn peek_version(bytes: &[u8]) -> Result<Option<u16>> {
232 if bytes.is_empty() {
233 return Ok(None);
234 }
235 if bytes.len() < 6 || bytes[0..4] != MAGIC {
236 return Err(crate::types::GraphError::Corrupt {
237 detail: "snapshot: bad magic or truncated header".into(),
238 });
239 }
240 Ok(Some(u16::from_le_bytes(bytes[4..6].try_into().unwrap())))
242}
243
244pub fn decode(bytes: &[u8]) -> Result<Option<SnapshotState>> {
245 if bytes.is_empty() {
246 return Ok(None);
247 }
248 if bytes.len() < 6 || bytes[0..4] != MAGIC {
249 return Err(GraphError::Corrupt {
250 detail: "snapshot: bad magic".into(),
251 });
252 }
253 let version = u16::from_le_bytes(bytes[4..6].try_into().unwrap());
255 match version {
256 VERSION_5 => decode_v5(&bytes[6..]),
257 VERSION_6 => decode_v6(&bytes[6..]),
258 VERSION_7 => decode_v7(&bytes[6..]),
259 VERSION_8 | VERSION_9 => {
262 let mapped = crate::v8::MappedBase::from_bytes(bytes.to_vec())?;
263 decode_v8_from_mapped(&mapped)
264 }
265 other => {
266 let hint = if other == 3 {
267 " — V3 snapshot is no longer supported; re-snapshot with a V8 binary"
268 } else if other == 4 {
269 " — V4 snapshot is no longer supported; re-snapshot with a V8 binary"
270 } else {
271 ""
272 };
273 Err(GraphError::Corrupt {
274 detail: format!("snapshot: unsupported version {other}{hint}"),
275 })
276 }
277 }
278}
279
280pub fn decode_v8_from_mapped(mapped: &crate::v8::MappedBase) -> Result<Option<SnapshotState>> {
291 use crate::v8::encode::{
292 archived_edge_props_to_owned, archived_hnsw_to_owned, archived_provenance_to_owned,
293 archived_rules_meta_to_owned, archived_to_columnstore, archived_to_idmap,
294 archived_to_interner, archived_views_to_owned, csr_to_topology, decode_ivf_bytes,
295 decode_meta,
296 };
297 use crate::v8::{
298 SECTION_COLUMNS, SECTION_EDGE_PROPS, SECTION_HNSW, SECTION_PROVENANCE, SECTION_STRINGS,
299 SECTION_TOPOLOGY,
300 };
301
302 let archived_topo = rkyv::access::<crate::v8::layout::ArchivedCsrData, rkyv::rancor::Error>(
307 mapped.section_bytes(SECTION_TOPOLOGY)?,
308 )
309 .map_err(|e| GraphError::Corrupt {
310 detail: format!("v8: topology rkyv access: {e}"),
311 })?;
312 let topo = csr_to_topology(archived_topo);
313
314 let archived_cols =
315 rkyv::access::<crate::v8::layout::ArchivedColumnsData, rkyv::rancor::Error>(
316 mapped.section_bytes(SECTION_COLUMNS)?,
317 )
318 .map_err(|e| GraphError::Corrupt {
319 detail: format!("v8: columns rkyv access: {e}"),
320 })?;
321 let shared_strings = if mapped.has_section(SECTION_STRINGS) {
332 Some(
333 rkyv::access::<crate::v8::layout::ArchivedStringTableData, rkyv::rancor::Error>(
334 mapped.section_bytes(SECTION_STRINGS)?,
335 )
336 .map_err(|e| GraphError::Corrupt {
337 detail: format!("v8: strings rkyv access: {e}"),
338 })?,
339 )
340 } else {
341 None
342 };
343 let props = archived_to_columnstore(archived_cols, shared_strings);
344
345 let archived_ids = mapped.ids()?;
346 let ids = archived_to_idmap(archived_ids);
347
348 let archived_syms = mapped.syms()?;
349 let syms = archived_to_interner(archived_syms);
350
351 let meta_bytes = mapped.meta_bytes()?;
354 let meta = decode_meta(meta_bytes)?;
355
356 let archived_ep =
357 rkyv::access::<crate::v8::layout::ArchivedEdgePropsData, rkyv::rancor::Error>(
358 mapped.section_bytes(SECTION_EDGE_PROPS)?,
359 )
360 .map_err(|e| GraphError::Corrupt {
361 detail: format!("v8: edge_props rkyv access: {e}"),
362 })?;
363 let edge_props = archived_edge_props_to_owned(archived_ep);
364
365 let archived_hnsw = rkyv::access::<
366 crate::v8::layout::ArchivedHnswSectionData,
367 rkyv::rancor::Error,
368 >(mapped.section_bytes(SECTION_HNSW)?)
369 .map_err(|e| GraphError::Corrupt {
370 detail: format!("v8: hnsw rkyv access: {e}"),
371 })?;
372 let hnsw_state = archived_hnsw_to_owned(archived_hnsw);
373
374 let archived_prov = rkyv::access::<
375 crate::v8::layout::ArchivedProvenanceSectionData,
376 rkyv::rancor::Error,
377 >(mapped.section_bytes(SECTION_PROVENANCE)?)
378 .map_err(|e| GraphError::Corrupt {
379 detail: format!("v8: provenance rkyv access: {e}"),
380 })?;
381 let provenance = archived_provenance_to_owned(archived_prov);
382
383 let (rule_defs, rule_tripped, rule_fires) =
384 archived_rules_meta_to_owned(mapped.rules_meta_section()?);
385 let view_defs = archived_views_to_owned(mapped.views_section()?);
386 let ivf_state = decode_ivf_bytes(mapped.ivf_bytes()?);
387
388 Ok(Some(SnapshotState {
389 ids,
390 syms,
391 topo,
392 props,
393 labels: meta.labels,
394 edge_props,
395 rule_defs,
396 provenance,
397 rule_tripped,
398 rule_fires,
399 ivf_state,
400 view_defs,
401 wal_truncated: meta.wal_truncated,
402 hnsw_state,
403 }))
404}
405
406fn decode_v5(body: &[u8]) -> Result<Option<SnapshotState>> {
409 let payload = strip_crc(body)?;
410 bincode::deserialize(payload)
411 .map(Some)
412 .map_err(|e| GraphError::Corrupt {
413 detail: format!("snapshot: {e}"),
414 })
415}
416
417fn decode_v6(body: &[u8]) -> Result<Option<SnapshotState>> {
420 let inner = zstd::decode_all(body).map_err(|e| GraphError::Corrupt {
421 detail: format!("snapshot: zstd decompress failed: {e}"),
422 })?;
423 decode_v5(&inner)
424}
425
426fn decode_v7(body: &[u8]) -> Result<Option<SnapshotState>> {
427 let inner = zstd::decode_all(body).map_err(|e| GraphError::Corrupt {
428 detail: format!("snapshot: zstd decompress failed: {e}"),
429 })?;
430 let payload = strip_crc(&inner)?;
431 let mut pos = 0usize;
432 let topo_len = read_u32(payload, &mut pos)? as usize;
433 let topo_bytes = payload
434 .get(pos..pos + topo_len)
435 .ok_or_else(|| GraphError::Corrupt {
436 detail: "snapshot: truncated packed topology".into(),
437 })?;
438 pos += topo_len;
439 let (topo, topo_consumed) = Topology::unpack(topo_bytes)?;
440 if topo_consumed != topo_len {
441 return Err(GraphError::Corrupt {
442 detail: format!("snapshot: topology pack consumed {topo_consumed} of {topo_len}"),
443 });
444 }
445 let props_len = read_u32(payload, &mut pos)? as usize;
446 let props_bytes = payload
447 .get(pos..pos + props_len)
448 .ok_or_else(|| GraphError::Corrupt {
449 detail: "snapshot: truncated packed columns".into(),
450 })?;
451 pos += props_len;
452 let (props, props_consumed) = ColumnStore::unpack(props_bytes)?;
453 if props_consumed != props_len {
454 return Err(GraphError::Corrupt {
455 detail: format!("snapshot: columns pack consumed {props_consumed} of {props_len}"),
456 });
457 }
458 let meta: V7Meta = bincode::deserialize(&payload[pos..]).map_err(|e| GraphError::Corrupt {
459 detail: format!("snapshot: {e}"),
460 })?;
461 Ok(Some(SnapshotState {
462 ids: meta.ids,
463 syms: meta.syms,
464 topo,
465 props,
466 labels: meta.labels,
467 edge_props: meta.edge_props,
468 rule_defs: meta.rule_defs,
469 provenance: meta.provenance,
470 rule_tripped: meta.rule_tripped,
471 rule_fires: meta.rule_fires,
472 ivf_state: meta.ivf_state,
473 view_defs: meta.view_defs,
474 wal_truncated: meta.wal_truncated,
475 hnsw_state: meta.hnsw,
476 }))
477}
478
479fn strip_crc(body: &[u8]) -> Result<&[u8]> {
480 if body.len() < 4 {
481 return Err(GraphError::Corrupt {
482 detail: "snapshot: truncated CRC header".into(),
483 });
484 }
485 let crc = u32::from_le_bytes(body[0..4].try_into().unwrap());
487 let payload = &body[4..];
488 if crc32fast::hash(payload) != crc {
489 return Err(GraphError::Corrupt {
490 detail: "snapshot: crc mismatch".into(),
491 });
492 }
493 Ok(payload)
494}
495
496#[cfg(test)]
497mod tests {
498 use super::*;
499 use crate::types::Value;
500
501 fn tiny_state() -> SnapshotState {
502 let mut ids = IdMap::new();
503 ids.get_or_insert("a");
504 ids.get_or_insert("b");
505 let mut syms = Interner::new();
506 let n = syms.intern("N");
507 let e = syms.intern("E");
508 let mut topo = Topology::new();
509 topo.add_edge(e, 0, 1);
510 let mut props = ColumnStore::new();
511 props.set(0, "v", Value::Int(42));
512 props.set(1, "name", Value::Str("bob".into()));
513 SnapshotState {
514 ids,
515 syms,
516 topo,
517 props,
518 labels: vec![n, n],
519 edge_props: EdgeProps::new(),
520 rule_defs: vec![],
521 provenance: BTreeMap::new(),
522 rule_tripped: BTreeMap::new(),
523 rule_fires: BTreeMap::new(),
524 ivf_state: BTreeMap::new(),
525 view_defs: vec![],
526 wal_truncated: true,
527 hnsw_state: BTreeMap::new(),
528 }
529 }
530
531 #[test]
532 fn decode_v6_bytes_still_works_after_version_9_default_encode() {
533 let state = tiny_state();
534 let v6 = encode_v6(&state);
535 assert_eq!(&v6[0..4], MAGIC);
536 assert_eq!(u16::from_le_bytes([v6[4], v6[5]]), VERSION_6);
537 let v9 = encode(&state).unwrap();
538 assert_eq!(u16::from_le_bytes([v9[4], v9[5]]), VERSION_9);
539 assert_eq!(VERSION, VERSION_9);
540
541 let back6 = decode(&v6).unwrap().unwrap();
542 assert!(
543 !back6.wal_truncated,
544 "V6 payload never carried wal_truncated; decode must default false"
545 );
546 assert_eq!(back6.ids.get("a"), Some(0));
547 assert_eq!(back6.props.get(0, "v"), Some(&Value::Int(42)));
548 assert_eq!(back6.topo.edge_count(), 1);
549 assert_eq!(
550 back6
551 .topo
552 .neighbors(
553 back6.syms.get("E").unwrap(),
554 crate::topology::Direction::Out,
555 0
556 )
557 .as_ref(),
558 &[1]
559 );
560
561 let back9 = decode(&v9).unwrap().unwrap();
562 assert!(
563 back9.wal_truncated,
564 "V9 meta must round-trip wal_truncated=true"
565 );
566 assert_eq!(back9.ids.get("b"), Some(1));
567 assert_eq!(
568 back9.props.get(1, "name"),
569 Some(&Value::Str("bob".into())),
570 "a string property must survive the shared table"
571 );
572 assert_eq!(back9.topo.edge_count(), 1);
573 assert_eq!(back9.labels, vec![back9.syms.get("N").unwrap(); 2]);
574 }
575}