use crate::types::Value;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum WalRecord {
InsertNode {
label: String,
key: String,
props: Vec<(String, Value)>,
},
InsertEdge {
edge_type: String,
src_key: String,
dst_key: String,
},
SetProp {
key: String,
field: String,
value: Value,
},
CreateRule {
def_bytes: Vec<u8>,
},
DeleteRule {
name: String,
},
RemoveProp {
key: String,
field: String,
},
DeleteEdge {
edge_type: String,
src_key: String,
dst_key: String,
},
DeleteNode {
key: String,
},
Batch(Vec<WalRecord>),
RebuildRule {
name: String,
},
CreateView {
def_bytes: Vec<u8>,
},
DeleteView {
name: String,
},
EnableFulltext {
label: String,
field: String,
},
DisableFulltext {
label: String,
field: String,
},
InsertNodeId {
label: u32,
key: String,
props: Vec<(u32, Value)>,
},
SetPropId {
id: u32,
field: u32,
value: Value,
},
InsertEdgeId {
etype: u32,
src: u32,
dst: u32,
},
Intern {
id: u32,
text: String,
},
}
pub fn encode_record(rec: &WalRecord) -> Vec<u8> {
if let WalRecord::Batch(inner) = rec {
debug_assert!(
!inner.iter().any(|r| matches!(r, WalRecord::Batch(_))),
"nested Batch is invalid: a Batch may not contain another Batch"
);
}
let payload = bincode::serialize(rec).expect("walrecord serialize cannot fail");
let crc = crc32fast::hash(&payload);
let mut out = Vec::with_capacity(8 + payload.len());
out.extend((payload.len() as u32).to_le_bytes());
out.extend(crc.to_le_bytes());
out.extend(payload);
out
}
pub fn wal_commits(bytes: &[u8]) -> u64 {
decode_all(bytes).0.len() as u64
}
pub fn decode_all(bytes: &[u8]) -> (Vec<WalRecord>, usize) {
let mut recs = Vec::new();
let mut pos = 0usize;
loop {
if bytes.len() < pos + 8 {
return (recs, pos);
}
let len = u32::from_le_bytes(bytes[pos..pos + 4].try_into().unwrap()) as usize;
let crc = u32::from_le_bytes(bytes[pos + 4..pos + 8].try_into().unwrap());
let start = pos + 8;
if bytes.len() < start + len {
return (recs, pos); }
let payload = &bytes[start..start + len];
if crc32fast::hash(payload) != crc {
return (recs, pos); }
match bincode::deserialize::<WalRecord>(payload) {
Ok(WalRecord::Batch(inner)) => {
if inner.iter().any(|r| matches!(r, WalRecord::Batch(_))) {
return (recs, pos);
}
recs.push(WalRecord::Batch(inner));
}
Ok(r) => recs.push(r),
Err(_) => return (recs, pos),
}
pos = start + len;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::Value;
fn sample() -> Vec<WalRecord> {
vec![
WalRecord::InsertNode {
label: "L".into(),
key: "k1".into(),
props: vec![("f".into(), Value::Int(1))],
},
WalRecord::InsertEdge {
edge_type: "E".into(),
src_key: "k1".into(),
dst_key: "k2".into(),
},
]
}
#[test]
fn roundtrip_multiple_records() {
let mut bytes = Vec::new();
for r in sample() {
bytes.extend(encode_record(&r));
}
let (recs, consumed) = decode_all(&bytes);
assert_eq!(recs, sample());
assert_eq!(consumed, bytes.len());
}
#[test]
fn torn_tail_is_dropped_whole() {
let mut bytes = Vec::new();
for r in sample() {
bytes.extend(encode_record(&r));
}
let full = bytes.len();
let first = encode_record(&sample()[0]).len();
bytes.truncate(full - 3); let (recs, consumed) = decode_all(&bytes);
assert_eq!(recs.len(), 1);
assert_eq!(consumed, first);
}
#[test]
fn corrupt_crc_stops_replay_at_last_valid() {
let mut bytes = encode_record(&sample()[0]);
let n = bytes.len();
bytes[n - 1] ^= 0xFF; let (recs, consumed) = decode_all(&bytes);
assert!(recs.is_empty());
assert_eq!(consumed, 0);
}
#[test]
fn empty_input_is_fine() {
let (recs, consumed) = decode_all(&[]);
assert!(recs.is_empty());
assert_eq!(consumed, 0);
}
#[test]
fn roundtrip_remove_prop() {
let r = WalRecord::RemoveProp {
key: "n1".into(),
field: "age".into(),
};
let bytes = encode_record(&r);
let (recs, _) = decode_all(&bytes);
assert_eq!(recs, vec![r]);
}
#[test]
fn roundtrip_delete_edge() {
let r = WalRecord::DeleteEdge {
edge_type: "KNOWS".into(),
src_key: "a".into(),
dst_key: "b".into(),
};
let bytes = encode_record(&r);
let (recs, _) = decode_all(&bytes);
assert_eq!(recs, vec![r]);
}
#[test]
fn roundtrip_delete_node() {
let r = WalRecord::DeleteNode { key: "x".into() };
let bytes = encode_record(&r);
let (recs, _) = decode_all(&bytes);
assert_eq!(recs, vec![r]);
}
#[test]
fn roundtrip_rebuild_rule() {
let r = WalRecord::RebuildRule { name: "eq".into() };
let bytes = encode_record(&r);
let (recs, _) = decode_all(&bytes);
assert_eq!(recs, vec![r]);
}
#[test]
fn batch_of_three_is_one_frame() {
let inner = vec![
WalRecord::DeleteNode { key: "a".into() },
WalRecord::DeleteNode { key: "b".into() },
WalRecord::DeleteNode { key: "c".into() },
];
let batch = WalRecord::Batch(inner.clone());
let frame = encode_record(&batch);
let (recs, consumed) = decode_all(&frame);
assert_eq!(consumed, frame.len(), "should consume the whole frame");
assert_eq!(recs.len(), 1, "one decoded record (the Batch)");
assert_eq!(recs[0], WalRecord::Batch(inner));
}
#[test]
fn torn_mid_batch_frame_drops_whole_batch() {
let pre = sample();
let batch = WalRecord::Batch(vec![
WalRecord::DeleteNode { key: "a".into() },
WalRecord::DeleteNode { key: "b".into() },
]);
let mut bytes = Vec::new();
for r in &pre {
bytes.extend(encode_record(r));
}
let batch_start = bytes.len();
bytes.extend(encode_record(&batch));
bytes.truncate(bytes.len() - 3);
let (recs, consumed) = decode_all(&bytes);
assert_eq!(recs, pre, "only pre-batch records survive");
assert_eq!(
consumed, batch_start,
"valid_len stops at batch frame start"
);
}
#[test]
#[cfg(debug_assertions)]
#[should_panic(expected = "nested Batch")]
fn nested_batch_encode_panics_in_debug() {
let inner_batch = WalRecord::Batch(vec![WalRecord::DeleteNode { key: "z".into() }]);
let outer = WalRecord::Batch(vec![inner_batch]);
encode_record(&outer); }
#[test]
fn golden_bytes_pin_wire_format() {
let insert_node = WalRecord::InsertNode {
label: "L".into(),
key: "k".into(),
props: vec![],
};
#[rustfmt::skip]
let expected_insert_node: &[u8] = &[
30, 0, 0, 0, 114, 69, 253, 24,
0, 0, 0, 0,
1, 0, 0, 0, 0, 0, 0, 0, 76,
1, 0, 0, 0, 0, 0, 0, 0, 107,
0, 0, 0, 0, 0, 0, 0, 0,
];
assert_eq!(
encode_record(&insert_node),
expected_insert_node,
"InsertNode wire format changed — this breaks all existing WAL files"
);
let remove_prop = WalRecord::RemoveProp {
key: "n1".into(),
field: "age".into(),
};
#[rustfmt::skip]
let expected_remove_prop: &[u8] = &[
25, 0, 0, 0, 35, 214, 55, 239,
5, 0, 0, 0,
2, 0, 0, 0, 0, 0, 0, 0, 110, 49,
3, 0, 0, 0, 0, 0, 0, 0, 97, 103, 101,
];
assert_eq!(
encode_record(&remove_prop),
expected_remove_prop,
"RemoveProp wire format changed — this breaks all existing WAL files"
);
let batch_single = WalRecord::Batch(vec![WalRecord::DeleteNode { key: "z".into() }]);
#[rustfmt::skip]
let batch_payload: &[u8] = &[
8, 0, 0, 0,
1, 0, 0, 0, 0, 0, 0, 0,
7, 0, 0, 0,
1, 0, 0, 0, 0, 0, 0, 0, 122,
];
let batch_crc = crc32fast::hash(batch_payload);
let mut expected_batch_frame: Vec<u8> = Vec::with_capacity(8 + batch_payload.len());
expected_batch_frame.extend((batch_payload.len() as u32).to_le_bytes());
expected_batch_frame.extend(batch_crc.to_le_bytes());
expected_batch_frame.extend_from_slice(batch_payload);
assert_eq!(
encode_record(&batch_single),
expected_batch_frame,
"Batch (discriminant 8) wire format changed — a variant may have \
been inserted before position 8, breaking all existing WAL Batch frames"
);
let rebuild = WalRecord::RebuildRule { name: "eq".into() };
#[rustfmt::skip]
let expected_rebuild: &[u8] = &[
14, 0, 0, 0, 242, 136, 144, 68,
9, 0, 0, 0,
2, 0, 0, 0, 0, 0, 0, 0, 101, 113,
];
assert_eq!(
encode_record(&rebuild),
expected_rebuild,
"RebuildRule wire format changed — append-only WAL variants"
);
}
#[test]
fn roundtrip_create_view() {
let r = WalRecord::CreateView {
def_bytes: vec![1, 2, 3],
};
let bytes = encode_record(&r);
let (recs, _) = decode_all(&bytes);
assert_eq!(recs, vec![r]);
}
#[test]
fn roundtrip_delete_view() {
let r = WalRecord::DeleteView {
name: "my_view".into(),
};
let bytes = encode_record(&r);
let (recs, _) = decode_all(&bytes);
assert_eq!(recs, vec![r]);
}
#[test]
fn golden_bytes_pin_view_wire_format() {
let create_view = WalRecord::CreateView {
def_bytes: vec![0xDE, 0xAD],
};
let cv_payload = bincode::serialize(&create_view).unwrap();
assert_eq!(
&cv_payload[0..4],
&[10, 0, 0, 0],
"CreateView discriminant changed — a variant was inserted before position 10"
);
let delete_view = WalRecord::DeleteView { name: "v".into() };
let dv_payload = bincode::serialize(&delete_view).unwrap();
assert_eq!(
&dv_payload[0..4],
&[11, 0, 0, 0],
"DeleteView discriminant changed — a variant was inserted before position 11"
);
let mut buf = encode_record(&create_view);
buf.extend(encode_record(&delete_view));
let (recs, consumed) = decode_all(&buf);
assert_eq!(consumed, buf.len());
assert_eq!(recs.len(), 2);
assert_eq!(
recs[0],
WalRecord::CreateView {
def_bytes: vec![0xDE, 0xAD]
}
);
assert_eq!(recs[1], WalRecord::DeleteView { name: "v".into() });
}
#[test]
fn roundtrip_enable_fulltext() {
let r = WalRecord::EnableFulltext {
label: "Person".into(),
field: "bio".into(),
};
let bytes = encode_record(&r);
let (recs, _) = decode_all(&bytes);
assert_eq!(recs, vec![r]);
}
#[test]
fn roundtrip_disable_fulltext() {
let r = WalRecord::DisableFulltext {
label: "Person".into(),
field: "bio".into(),
};
let bytes = encode_record(&r);
let (recs, _) = decode_all(&bytes);
assert_eq!(recs, vec![r]);
}
#[test]
fn golden_bytes_pin_fulltext_wire_format() {
let enable = WalRecord::EnableFulltext {
label: "A".into(),
field: "b".into(),
};
let ep = bincode::serialize(&enable).unwrap();
assert_eq!(
&ep[0..4],
&[12, 0, 0, 0],
"EnableFulltext discriminant changed — a variant was inserted before position 12"
);
let disable = WalRecord::DisableFulltext {
label: "A".into(),
field: "b".into(),
};
let dp = bincode::serialize(&disable).unwrap();
assert_eq!(
&dp[0..4],
&[13, 0, 0, 0],
"DisableFulltext discriminant changed — a variant was inserted before position 13"
);
let mut buf = encode_record(&enable);
buf.extend(encode_record(&disable));
let (recs, consumed) = decode_all(&buf);
assert_eq!(consumed, buf.len());
assert_eq!(recs.len(), 2);
assert_eq!(
recs[0],
WalRecord::EnableFulltext {
label: "A".into(),
field: "b".into()
}
);
assert_eq!(
recs[1],
WalRecord::DisableFulltext {
label: "A".into(),
field: "b".into()
}
);
}
#[test]
fn roundtrip_dense_id_variants_append_after_fulltext() {
let recs = vec![
WalRecord::Intern {
id: 0,
text: "Person".into(),
},
WalRecord::InsertNodeId {
label: 0,
key: "a".into(),
props: vec![(1, Value::Int(1))],
},
WalRecord::SetPropId {
id: 0,
field: 1,
value: Value::Int(2),
},
WalRecord::InsertEdgeId {
etype: 2,
src: 0,
dst: 1,
},
];
for r in &recs {
let bytes = encode_record(r);
let (got, n) = decode_all(&bytes);
assert_eq!(n, bytes.len());
assert_eq!(got, vec![r.clone()]);
}
let p = bincode::serialize(&recs[1]).unwrap();
assert_eq!(&p[0..4], &[14, 0, 0, 0], "InsertNodeId discriminant is 14");
let p = bincode::serialize(&recs[2]).unwrap();
assert_eq!(&p[0..4], &[15, 0, 0, 0], "SetPropId discriminant is 15");
let p = bincode::serialize(&recs[3]).unwrap();
assert_eq!(&p[0..4], &[16, 0, 0, 0], "InsertEdgeId discriminant is 16");
let p = bincode::serialize(&recs[0]).unwrap();
assert_eq!(&p[0..4], &[17, 0, 0, 0], "Intern discriminant is 17");
}
#[test]
fn nested_batch_decode_is_treated_as_corrupt() {
let inner_batch = WalRecord::Batch(vec![WalRecord::DeleteNode { key: "z".into() }]);
let outer = WalRecord::Batch(vec![inner_batch]);
let payload = bincode::serialize(&outer).unwrap();
let crc = crc32fast::hash(&payload);
let mut frame = Vec::with_capacity(8 + payload.len());
frame.extend((payload.len() as u32).to_le_bytes());
frame.extend(crc.to_le_bytes());
frame.extend(&payload);
let good = encode_record(&WalRecord::DeleteNode { key: "good".into() });
let good_len = good.len();
let mut bytes = good;
bytes.extend(&frame);
let (recs, consumed) = decode_all(&bytes);
assert_eq!(recs.len(), 1);
assert_eq!(recs[0], WalRecord::DeleteNode { key: "good".into() });
assert_eq!(
consumed, good_len,
"stops cleanly before the nested-batch frame"
);
}
}