use crate::columns::ColumnStore;
use crate::edge_props::EdgeProps;
use crate::idmap::IdMap;
use crate::interner::Interner;
use crate::pack::{push_u32, read_u32};
use crate::topology::Topology;
use crate::types::{GraphError, Result};
use crate::v8::encode::V8Meta;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet, HashMap};
pub const MAGIC: [u8; 4] = *b"GDB1";
pub const VERSION_5: u16 = 5;
pub const VERSION_6: u16 = 6;
pub const VERSION_7: u16 = 7;
pub const VERSION_8: u16 = 8;
pub const VERSION_9: u16 = 9;
pub const VERSION_10: u16 = 10;
pub const VERSION: u16 = VERSION_9;
pub const MMAP_CONTAINER_VERSIONS: &[u16] = &[VERSION_8, VERSION_9, VERSION_10];
pub fn is_mmap_container(version: u16) -> bool {
MMAP_CONTAINER_VERSIONS.contains(&version)
}
pub fn version_for(multiplicity: bool) -> u16 {
if multiplicity {
VERSION_10
} else {
VERSION_9
}
}
pub fn stamp_container_version(buf: &mut [u8], version: u16) -> Result<()> {
if buf.len() < crate::v8::HEADER_SIZE || buf[0..4] != MAGIC {
return Err(GraphError::Corrupt {
detail: "snapshot: cannot stamp a version onto a non-container buffer".into(),
});
}
let section_count = u16::from_le_bytes(buf[6..8].try_into().unwrap()) as usize;
let dir_end = 8 + section_count * 16;
if dir_end + 4 > crate::v8::HEADER_SIZE {
return Err(GraphError::Corrupt {
detail: format!("snapshot: {section_count} sections overflow the header page"),
});
}
buf[4..6].copy_from_slice(&version.to_le_bytes());
let crc = crc32fast::hash(&buf[0..dir_end]);
buf[dir_end..dir_end + 4].copy_from_slice(&crc.to_le_bytes());
Ok(())
}
#[derive(Serialize, Deserialize, Clone, Default, Debug)]
pub struct SideIvfState {
pub centroids: Vec<Vec<f64>>,
pub clusters: BTreeMap<u32, usize>,
pub drift: u64,
}
#[derive(Serialize, Deserialize, Clone, Default, Debug)]
pub struct PerRuleIvfState {
pub src: SideIvfState,
pub dst: SideIvfState,
}
#[derive(Serialize, Deserialize)]
pub struct SnapshotState {
pub ids: IdMap,
pub syms: Interner,
pub topo: Topology,
pub props: ColumnStore,
pub labels: Vec<u32>,
pub edge_props: EdgeProps,
pub rule_defs: Vec<Vec<u8>>,
pub provenance: BTreeMap<String, BTreeSet<(u32, u32, u32)>>,
pub rule_tripped: BTreeMap<String, bool>,
pub rule_fires: BTreeMap<String, u64>,
pub ivf_state: BTreeMap<String, PerRuleIvfState>,
pub view_defs: Vec<Vec<u8>>,
#[serde(skip)]
pub wal_truncated: bool,
#[serde(skip)]
pub hnsw_state: BTreeMap<String, (Vec<u8>, Vec<u8>)>,
}
#[derive(Serialize, Deserialize)]
struct V7Meta {
ids: IdMap,
syms: Interner,
labels: Vec<u32>,
edge_props: EdgeProps,
rule_defs: Vec<Vec<u8>>,
provenance: BTreeMap<String, BTreeSet<(u32, u32, u32)>>,
rule_tripped: BTreeMap<String, bool>,
rule_fires: BTreeMap<String, u64>,
ivf_state: BTreeMap<String, PerRuleIvfState>,
view_defs: Vec<Vec<u8>>,
wal_truncated: bool,
#[serde(default)]
hnsw: BTreeMap<String, (Vec<u8>, Vec<u8>)>,
}
fn wrap_zstd(version: u16, inner: Vec<u8>) -> Vec<u8> {
let compressed =
zstd::encode_all(inner.as_slice(), 3).expect("zstd compress cannot fail on in-memory buf");
let mut out = Vec::with_capacity(6 + compressed.len());
out.extend(MAGIC);
out.extend(version.to_le_bytes());
out.extend(compressed);
out
}
fn crc_inner(payload: &[u8]) -> Vec<u8> {
let crc = crc32fast::hash(payload);
let mut inner = Vec::with_capacity(4 + payload.len());
inner.extend(crc.to_le_bytes());
inner.extend(payload);
inner
}
pub fn encode(state: &SnapshotState) -> Result<Vec<u8>> {
encode_v8_from_state(state)
}
fn encode_v8_from_state(state: &SnapshotState) -> Result<Vec<u8>> {
let ivf_bytes = if state.ivf_state.is_empty() {
Vec::new()
} else {
bincode::serialize(&state.ivf_state).expect("IVF state serialize cannot fail")
};
let meta = V8Meta {
labels: state.labels.clone(),
edge_props: state.edge_props.clone(),
rule_defs: state.rule_defs.clone(),
provenance: state.provenance.clone(),
rule_tripped: state.rule_tripped.clone(),
rule_fires: state.rule_fires.clone(),
ivf_bytes,
view_defs: state.view_defs.clone(),
wal_truncated: state.wal_truncated,
hnsw: state.hnsw_state.clone(),
last_change: HashMap::new(),
fulltext_blob: Vec::new(),
wal_mark: Vec::new(),
};
let mut out = Vec::new();
crate::v8::encode::encode_v8(
None,
None,
None,
None,
None,
&state.topo,
&state.props,
&state.ids,
&state.syms,
&meta,
&mut out,
)?;
Ok(out)
}
pub fn encode_v6(state: &SnapshotState) -> Vec<u8> {
let payload = bincode::serialize(state).expect("snapshot serialize cannot fail");
wrap_zstd(VERSION_6, crc_inner(&payload))
}
fn section_len(name: &str, len: usize) -> Result<u32> {
u32::try_from(len).map_err(|_| GraphError::Corrupt {
detail: format!(
"snapshot: packed {name} section is {len} bytes, exceeds u32 length prefix"
),
})
}
pub fn encode_v7(state: &SnapshotState) -> Result<Vec<u8>> {
let mut topo = Vec::new();
state.topo.pack(&mut topo);
let mut props = Vec::new();
state.props.pack(&mut props);
let meta = V7Meta {
ids: state.ids.clone(),
syms: state.syms.clone(),
labels: state.labels.clone(),
edge_props: state.edge_props.clone(),
rule_defs: state.rule_defs.clone(),
provenance: state.provenance.clone(),
rule_tripped: state.rule_tripped.clone(),
rule_fires: state.rule_fires.clone(),
ivf_state: state.ivf_state.clone(),
view_defs: state.view_defs.clone(),
wal_truncated: state.wal_truncated,
hnsw: state.hnsw_state.clone(),
};
let meta_bytes = bincode::serialize(&meta).expect("snapshot meta serialize cannot fail");
let mut payload = Vec::with_capacity(8 + topo.len() + props.len() + meta_bytes.len());
push_u32(&mut payload, section_len("topology", topo.len())?);
payload.extend_from_slice(&topo);
push_u32(&mut payload, section_len("columns", props.len())?);
payload.extend_from_slice(&props);
payload.extend_from_slice(&meta_bytes);
Ok(wrap_zstd(VERSION_7, crc_inner(&payload)))
}
pub fn peek_version(bytes: &[u8]) -> Result<Option<u16>> {
if bytes.is_empty() {
return Ok(None);
}
if bytes.len() < 6 || bytes[0..4] != MAGIC {
return Err(crate::types::GraphError::Corrupt {
detail: "snapshot: bad magic or truncated header".into(),
});
}
Ok(Some(u16::from_le_bytes(bytes[4..6].try_into().unwrap())))
}
pub fn decode(bytes: &[u8]) -> Result<Option<SnapshotState>> {
if bytes.is_empty() {
return Ok(None);
}
if bytes.len() < 6 || bytes[0..4] != MAGIC {
return Err(GraphError::Corrupt {
detail: "snapshot: bad magic".into(),
});
}
let version = u16::from_le_bytes(bytes[4..6].try_into().unwrap());
match version {
VERSION_5 => decode_v5(&bytes[6..]),
VERSION_6 => decode_v6(&bytes[6..]),
VERSION_7 => decode_v7(&bytes[6..]),
v if is_mmap_container(v) => {
let mapped = crate::v8::MappedBase::from_bytes(bytes.to_vec())?;
decode_v8_from_mapped(&mapped)
}
other => {
let hint = if other == 3 {
" — V3 snapshot is no longer supported; re-snapshot with a V8 binary"
} else if other == 4 {
" — V4 snapshot is no longer supported; re-snapshot with a V8 binary"
} else {
""
};
Err(GraphError::Corrupt {
detail: format!("snapshot: unsupported version {other}{hint}"),
})
}
}
}
pub fn decode_v8_from_mapped(mapped: &crate::v8::MappedBase) -> Result<Option<SnapshotState>> {
use crate::v8::encode::{
archived_edge_props_to_owned, archived_hnsw_to_owned, archived_provenance_to_owned,
archived_rules_meta_to_owned, archived_to_columnstore, archived_to_idmap,
archived_to_interner, archived_views_to_owned, csr_to_topology, decode_ivf_bytes,
decode_meta,
};
use crate::v8::{
SECTION_COLUMNS, SECTION_EDGE_PROPS, SECTION_HNSW, SECTION_PROVENANCE, SECTION_STRINGS,
SECTION_TOPOLOGY,
};
let archived_topo = rkyv::access::<crate::v8::layout::ArchivedCsrData, rkyv::rancor::Error>(
mapped.section_bytes(SECTION_TOPOLOGY)?,
)
.map_err(|e| GraphError::Corrupt {
detail: format!("v8: topology rkyv access: {e}"),
})?;
let topo = csr_to_topology(archived_topo);
let archived_cols =
rkyv::access::<crate::v8::layout::ArchivedColumnsData, rkyv::rancor::Error>(
mapped.section_bytes(SECTION_COLUMNS)?,
)
.map_err(|e| GraphError::Corrupt {
detail: format!("v8: columns rkyv access: {e}"),
})?;
let shared_strings = if mapped.has_section(SECTION_STRINGS) {
Some(
rkyv::access::<crate::v8::layout::ArchivedStringTableData, rkyv::rancor::Error>(
mapped.section_bytes(SECTION_STRINGS)?,
)
.map_err(|e| GraphError::Corrupt {
detail: format!("v8: strings rkyv access: {e}"),
})?,
)
} else {
None
};
let props = archived_to_columnstore(archived_cols, shared_strings);
let archived_ids = mapped.ids()?;
let ids = archived_to_idmap(archived_ids);
let archived_syms = mapped.syms()?;
let syms = archived_to_interner(archived_syms);
let meta_bytes = mapped.meta_bytes()?;
let meta = decode_meta(meta_bytes)?;
let archived_ep =
rkyv::access::<crate::v8::layout::ArchivedEdgePropsData, rkyv::rancor::Error>(
mapped.section_bytes(SECTION_EDGE_PROPS)?,
)
.map_err(|e| GraphError::Corrupt {
detail: format!("v8: edge_props rkyv access: {e}"),
})?;
let edge_props = archived_edge_props_to_owned(archived_ep);
let archived_hnsw = rkyv::access::<
crate::v8::layout::ArchivedHnswSectionData,
rkyv::rancor::Error,
>(mapped.section_bytes(SECTION_HNSW)?)
.map_err(|e| GraphError::Corrupt {
detail: format!("v8: hnsw rkyv access: {e}"),
})?;
let hnsw_state = archived_hnsw_to_owned(archived_hnsw);
let archived_prov = rkyv::access::<
crate::v8::layout::ArchivedProvenanceSectionData,
rkyv::rancor::Error,
>(mapped.section_bytes(SECTION_PROVENANCE)?)
.map_err(|e| GraphError::Corrupt {
detail: format!("v8: provenance rkyv access: {e}"),
})?;
let provenance = archived_provenance_to_owned(archived_prov);
let (rule_defs, rule_tripped, rule_fires) =
archived_rules_meta_to_owned(mapped.rules_meta_section()?);
let view_defs = archived_views_to_owned(mapped.views_section()?);
let ivf_state = decode_ivf_bytes(mapped.ivf_bytes()?);
Ok(Some(SnapshotState {
ids,
syms,
topo,
props,
labels: meta.labels,
edge_props,
rule_defs,
provenance,
rule_tripped,
rule_fires,
ivf_state,
view_defs,
wal_truncated: meta.wal_truncated,
hnsw_state,
}))
}
fn decode_v5(body: &[u8]) -> Result<Option<SnapshotState>> {
let payload = strip_crc(body)?;
bincode::deserialize(payload)
.map(Some)
.map_err(|e| GraphError::Corrupt {
detail: format!("snapshot: {e}"),
})
}
fn decode_v6(body: &[u8]) -> Result<Option<SnapshotState>> {
let inner = zstd::decode_all(body).map_err(|e| GraphError::Corrupt {
detail: format!("snapshot: zstd decompress failed: {e}"),
})?;
decode_v5(&inner)
}
fn decode_v7(body: &[u8]) -> Result<Option<SnapshotState>> {
let inner = zstd::decode_all(body).map_err(|e| GraphError::Corrupt {
detail: format!("snapshot: zstd decompress failed: {e}"),
})?;
let payload = strip_crc(&inner)?;
let mut pos = 0usize;
let topo_len = read_u32(payload, &mut pos)? as usize;
let topo_bytes = payload
.get(pos..pos + topo_len)
.ok_or_else(|| GraphError::Corrupt {
detail: "snapshot: truncated packed topology".into(),
})?;
pos += topo_len;
let (topo, topo_consumed) = Topology::unpack(topo_bytes)?;
if topo_consumed != topo_len {
return Err(GraphError::Corrupt {
detail: format!("snapshot: topology pack consumed {topo_consumed} of {topo_len}"),
});
}
let props_len = read_u32(payload, &mut pos)? as usize;
let props_bytes = payload
.get(pos..pos + props_len)
.ok_or_else(|| GraphError::Corrupt {
detail: "snapshot: truncated packed columns".into(),
})?;
pos += props_len;
let (props, props_consumed) = ColumnStore::unpack(props_bytes)?;
if props_consumed != props_len {
return Err(GraphError::Corrupt {
detail: format!("snapshot: columns pack consumed {props_consumed} of {props_len}"),
});
}
let meta: V7Meta = bincode::deserialize(&payload[pos..]).map_err(|e| GraphError::Corrupt {
detail: format!("snapshot: {e}"),
})?;
Ok(Some(SnapshotState {
ids: meta.ids,
syms: meta.syms,
topo,
props,
labels: meta.labels,
edge_props: meta.edge_props,
rule_defs: meta.rule_defs,
provenance: meta.provenance,
rule_tripped: meta.rule_tripped,
rule_fires: meta.rule_fires,
ivf_state: meta.ivf_state,
view_defs: meta.view_defs,
wal_truncated: meta.wal_truncated,
hnsw_state: meta.hnsw,
}))
}
fn strip_crc(body: &[u8]) -> Result<&[u8]> {
if body.len() < 4 {
return Err(GraphError::Corrupt {
detail: "snapshot: truncated CRC header".into(),
});
}
let crc = u32::from_le_bytes(body[0..4].try_into().unwrap());
let payload = &body[4..];
if crc32fast::hash(payload) != crc {
return Err(GraphError::Corrupt {
detail: "snapshot: crc mismatch".into(),
});
}
Ok(payload)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::Value;
fn tiny_state() -> SnapshotState {
let mut ids = IdMap::new();
ids.get_or_insert("a");
ids.get_or_insert("b");
let mut syms = Interner::new();
let n = syms.intern("N");
let e = syms.intern("E");
let mut topo = Topology::new();
topo.add_edge(e, 0, 1);
let mut props = ColumnStore::new();
props.set(0, "v", Value::Int(42));
props.set(1, "name", Value::Str("bob".into()));
SnapshotState {
ids,
syms,
topo,
props,
labels: vec![n, n],
edge_props: EdgeProps::new(),
rule_defs: vec![],
provenance: BTreeMap::new(),
rule_tripped: BTreeMap::new(),
rule_fires: BTreeMap::new(),
ivf_state: BTreeMap::new(),
view_defs: vec![],
wal_truncated: true,
hnsw_state: BTreeMap::new(),
}
}
#[test]
fn decode_v6_bytes_still_works_after_version_9_default_encode() {
let state = tiny_state();
let v6 = encode_v6(&state);
assert_eq!(&v6[0..4], MAGIC);
assert_eq!(u16::from_le_bytes([v6[4], v6[5]]), VERSION_6);
let v9 = encode(&state).unwrap();
assert_eq!(u16::from_le_bytes([v9[4], v9[5]]), VERSION_9);
assert_eq!(VERSION, VERSION_9);
let back6 = decode(&v6).unwrap().unwrap();
assert!(
!back6.wal_truncated,
"V6 payload never carried wal_truncated; decode must default false"
);
assert_eq!(back6.ids.get("a"), Some(0));
assert_eq!(back6.props.get(0, "v"), Some(&Value::Int(42)));
assert_eq!(back6.topo.edge_count(), 1);
assert_eq!(
back6
.topo
.neighbors(
back6.syms.get("E").unwrap(),
crate::topology::Direction::Out,
0
)
.as_ref(),
&[1]
);
let back9 = decode(&v9).unwrap().unwrap();
assert!(
back9.wal_truncated,
"V9 meta must round-trip wal_truncated=true"
);
assert_eq!(back9.ids.get("b"), Some(1));
assert_eq!(
back9.props.get(1, "name"),
Some(&Value::Str("bob".into())),
"a string property must survive the shared table"
);
assert_eq!(back9.topo.edge_count(), 1);
assert_eq!(back9.labels, vec![back9.syms.get("N").unwrap(); 2]);
}
#[test]
fn v10_is_v9_with_a_different_stamp() {
let state = tiny_state();
let v9 = encode(&state).unwrap();
let mut v10 = v9.clone();
stamp_container_version(&mut v10, VERSION_10).unwrap();
assert_eq!(peek_version(&v10).unwrap(), Some(VERSION_10));
assert_eq!(v9.len(), v10.len(), "the stamp must not resize the file");
let diffs: Vec<usize> = (0..v9.len()).filter(|&i| v9[i] != v10[i]).collect();
assert!(
diffs
.iter()
.all(|&i| (4..6).contains(&i) || i < crate::v8::HEADER_SIZE),
"the stamp must touch nothing outside the header page: {diffs:?}"
);
let back = decode(&v10).unwrap().unwrap();
assert_eq!(back.ids.get("b"), Some(1));
assert_eq!(back.props.get(1, "name"), Some(&Value::Str("bob".into())));
assert_eq!(back.topo.edge_count(), 1);
let mut round = v10.clone();
stamp_container_version(&mut round, VERSION_9).unwrap();
assert_eq!(round, v9, "restamping V9 must reproduce the original bytes");
}
#[test]
fn only_multiplicity_moves_the_version() {
assert_eq!(version_for(false), VERSION_9);
assert_eq!(version_for(true), VERSION_10);
assert_eq!(VERSION, VERSION_9);
}
#[test]
fn every_version_the_encoder_writes_is_mmap_able() {
for multiplicity in [false, true] {
let v = version_for(multiplicity);
assert!(
is_mmap_container(v),
"V{v} is written but not in MMAP_CONTAINER_VERSIONS"
);
}
assert!(!is_mmap_container(VERSION_7));
assert!(!is_mmap_container(VERSION_10 + 1));
}
#[test]
fn decode_dispatch_matches_the_container_list() {
let state = tiny_state();
let v9 = encode(&state).unwrap();
for v in 0u16..=16 {
let mut buf = v9.clone();
if stamp_container_version(&mut buf, v).is_err() {
continue;
}
let decoded_ok = decode(&buf).map(|s| s.is_some()).unwrap_or(false);
assert_eq!(
decoded_ok,
is_mmap_container(v),
"V{v}: is_mmap_container says {}, but decode {} it — \
decode's dispatch and MMAP_CONTAINER_VERSIONS have drifted",
is_mmap_container(v),
if decoded_ok { "accepted" } else { "refused" }
);
}
}
#[test]
fn stamping_a_non_container_is_an_error() {
let mut junk = vec![0u8; crate::v8::HEADER_SIZE];
assert!(stamp_container_version(&mut junk, VERSION_10).is_err());
let mut short = MAGIC.to_vec();
assert!(stamp_container_version(&mut short, VERSION_10).is_err());
}
}