use nodedb::bridge::envelope::{Request, Status};
use nodedb::engine::graph::edge_store::Direction;
use nodedb::types::TxnId;
use nodedb_physical::physical_plan::{GraphOp, KvOp, MetaOp, PhysicalPlan};
use crate::helpers::*;
fn send_txn(
core: &mut nodedb::data::executor::core_loop::CoreLoop,
req_tx: &mut nodedb_bridge::buffer::Producer<nodedb::bridge::dispatch::BridgeRequest>,
resp_rx: &mut nodedb_bridge::buffer::Consumer<nodedb::bridge::dispatch::BridgeResponse>,
txn_id: TxnId,
plan: PhysicalPlan,
) -> nodedb::bridge::envelope::Response {
let request = Request {
txn_id: Some(txn_id),
..make_request(plan)
};
req_tx
.try_push(nodedb::bridge::dispatch::BridgeRequest { inner: request })
.unwrap();
core.tick();
resp_rx.try_pop().unwrap().inner
}
fn stage_edge_put(collection: &str, src: &str, label: &str, dst: &str) -> PhysicalPlan {
PhysicalPlan::Meta(MetaOp::StageWrite {
plan: Box::new(PhysicalPlan::Graph(GraphOp::EdgePut {
collection: collection.into(),
src_id: src.into(),
label: label.into(),
dst_id: dst.into(),
properties: Vec::new(),
src_surrogate: nodedb_types::Surrogate::ZERO,
dst_surrogate: nodedb_types::Surrogate::ZERO,
})),
})
}
fn stage_edge_delete(collection: &str, src: &str, label: &str, dst: &str) -> PhysicalPlan {
PhysicalPlan::Meta(MetaOp::StageWrite {
plan: Box::new(PhysicalPlan::Graph(GraphOp::EdgeDelete {
collection: collection.into(),
src_id: src.into(),
label: label.into(),
dst_id: dst.into(),
src_surrogate: nodedb_types::Surrogate::ZERO,
dst_surrogate: nodedb_types::Surrogate::ZERO,
})),
})
}
fn neighbors(node: &str, label: &str) -> PhysicalPlan {
PhysicalPlan::Graph(GraphOp::Neighbors {
node_id: node.into(),
edge_label: Some(label.into()),
direction: Direction::Out,
rls_filters: Vec::new(),
})
}
fn parse_markers(payload: &[u8]) -> (u64, u64) {
assert_eq!(payload.len(), 16, "MarkSavepoint must return 16 bytes");
let mut v = [0u8; 8];
v.copy_from_slice(&payload[..8]);
let mut g = [0u8; 8];
g.copy_from_slice(&payload[8..16]);
(u64::from_le_bytes(v), u64::from_le_bytes(g))
}
fn neighbor_nodes(payload: &[u8]) -> Vec<String> {
let json = payload_json(payload);
let parsed: Vec<serde_json::Value> = serde_json::from_str(&json).unwrap_or_default();
parsed
.iter()
.filter_map(|e| e.get("node").and_then(|n| n.as_str()).map(String::from))
.collect()
}
#[test]
fn rollback_to_savepoint_discards_graph_edge_staged_after_marker() {
let (mut core, mut tx, mut rx, _dir) = make_core();
let txn_id = TxnId::new(1);
let resp = send_txn(
&mut core,
&mut tx,
&mut rx,
txn_id,
stage_edge_put("g", "a", "knows", "b"),
);
assert_eq!(resp.status, Status::Ok, "{:?}", resp.error_code);
let resp = send_txn(
&mut core,
&mut tx,
&mut rx,
txn_id,
PhysicalPlan::Meta(MetaOp::MarkSavepoint { txn_id }),
);
assert_eq!(resp.status, Status::Ok);
let (value_marker, graph_marker) = parse_markers(resp.payload.as_ref());
let resp = send_txn(
&mut core,
&mut tx,
&mut rx,
txn_id,
stage_edge_put("g", "a", "knows", "c"),
);
assert_eq!(resp.status, Status::Ok, "{:?}", resp.error_code);
let resp = send_txn(&mut core, &mut tx, &mut rx, txn_id, neighbors("a", "knows"));
let before = neighbor_nodes(resp.payload.as_ref());
assert!(
before.contains(&"b".to_string()) && before.contains(&"c".to_string()),
"{before:?}"
);
let resp = send_txn(
&mut core,
&mut tx,
&mut rx,
txn_id,
PhysicalPlan::Meta(MetaOp::RollbackToSavepoint {
txn_id,
value_marker,
graph_marker,
}),
);
assert_eq!(resp.status, Status::Ok);
let resp = send_txn(&mut core, &mut tx, &mut rx, txn_id, neighbors("a", "knows"));
let after = neighbor_nodes(resp.payload.as_ref());
assert!(
after.contains(&"b".to_string()),
"A→B must survive rollback, got {after:?}"
);
assert!(
!after.contains(&"c".to_string()),
"A→C must be discarded by rollback, got {after:?}"
);
}
#[test]
fn rollback_to_savepoint_restores_cross_set_cleared_tombstone() {
let (mut core, mut tx, mut rx, _dir) = make_core();
let txn_id = TxnId::new(2);
send_ok(
&mut core,
&mut tx,
&mut rx,
PhysicalPlan::Graph(GraphOp::EdgePut {
collection: "g".into(),
src_id: "x".into(),
label: "knows".into(),
dst_id: "y".into(),
properties: Vec::new(),
src_surrogate: nodedb_types::Surrogate::ZERO,
dst_surrogate: nodedb_types::Surrogate::ZERO,
}),
);
let resp = send_txn(
&mut core,
&mut tx,
&mut rx,
txn_id,
stage_edge_delete("g", "x", "knows", "y"),
);
assert_eq!(resp.status, Status::Ok, "{:?}", resp.error_code);
let resp = send_txn(&mut core, &mut tx, &mut rx, txn_id, neighbors("x", "knows"));
assert!(
neighbor_nodes(resp.payload.as_ref()).is_empty(),
"tombstone must hide durable Y"
);
let resp = send_txn(
&mut core,
&mut tx,
&mut rx,
txn_id,
PhysicalPlan::Meta(MetaOp::MarkSavepoint { txn_id }),
);
let (value_marker, graph_marker) = parse_markers(resp.payload.as_ref());
let resp = send_txn(
&mut core,
&mut tx,
&mut rx,
txn_id,
stage_edge_put("g", "x", "knows", "y"),
);
assert_eq!(resp.status, Status::Ok, "{:?}", resp.error_code);
let resp = send_txn(&mut core, &mut tx, &mut rx, txn_id, neighbors("x", "knows"));
assert!(
neighbor_nodes(resp.payload.as_ref()).contains(&"y".to_string()),
"re-put must restore Y"
);
let resp = send_txn(
&mut core,
&mut tx,
&mut rx,
txn_id,
PhysicalPlan::Meta(MetaOp::RollbackToSavepoint {
txn_id,
value_marker,
graph_marker,
}),
);
assert_eq!(resp.status, Status::Ok);
let resp = send_txn(&mut core, &mut tx, &mut rx, txn_id, neighbors("x", "knows"));
assert!(
neighbor_nodes(resp.payload.as_ref()).is_empty(),
"rollback must restore the tombstone the re-put cleared, hiding durable Y again"
);
}
#[test]
fn one_savepoint_reverts_value_and_graph_overlays_together() {
let (mut core, mut tx, mut rx, _dir) = make_core();
let txn_id = TxnId::new(3);
send_ok(
&mut core,
&mut tx,
&mut rx,
PhysicalPlan::Kv(KvOp::Put {
collection: "c".into(),
key: b"k".to_vec(),
value: b"v".to_vec(),
ttl_ms: 0,
surrogate: nodedb_types::Surrogate::ZERO,
}),
);
let resp = send_txn(
&mut core,
&mut tx,
&mut rx,
txn_id,
stage_edge_put("g", "a", "knows", "b"),
);
assert_eq!(resp.status, Status::Ok, "{:?}", resp.error_code);
let resp = send_txn(
&mut core,
&mut tx,
&mut rx,
txn_id,
PhysicalPlan::Meta(MetaOp::StageWrite {
plan: Box::new(PhysicalPlan::Kv(KvOp::Expire {
collection: "c".into(),
key: b"k".to_vec(),
ttl_ms: 60_000,
})),
}),
);
assert_eq!(resp.status, Status::Ok, "{:?}", resp.error_code);
let resp = send_txn(
&mut core,
&mut tx,
&mut rx,
txn_id,
PhysicalPlan::Meta(MetaOp::MarkSavepoint { txn_id }),
);
let (value_marker, graph_marker) = parse_markers(resp.payload.as_ref());
assert_eq!(
(value_marker, graph_marker),
(1, 1),
"one value + one graph mutation staged"
);
send_txn(
&mut core,
&mut tx,
&mut rx,
txn_id,
stage_edge_put("g", "a", "knows", "c"),
);
send_txn(
&mut core,
&mut tx,
&mut rx,
txn_id,
PhysicalPlan::Meta(MetaOp::StageWrite {
plan: Box::new(PhysicalPlan::Kv(KvOp::Persist {
collection: "c".into(),
key: b"k".to_vec(),
})),
}),
);
let resp = send_txn(
&mut core,
&mut tx,
&mut rx,
txn_id,
PhysicalPlan::Meta(MetaOp::RollbackToSavepoint {
txn_id,
value_marker,
graph_marker,
}),
);
assert_eq!(resp.status, Status::Ok);
let resp = send_txn(&mut core, &mut tx, &mut rx, txn_id, neighbors("a", "knows"));
let after = neighbor_nodes(resp.payload.as_ref());
assert!(
after.contains(&"b".to_string()) && !after.contains(&"c".to_string()),
"{after:?}"
);
let resp = send_txn(
&mut core,
&mut tx,
&mut rx,
txn_id,
PhysicalPlan::Kv(KvOp::GetTtl {
collection: "c".into(),
key: b"k".to_vec(),
}),
);
let ttl_ms = payload_value(resp.payload.as_ref())["ttl_ms"]
.as_i64()
.unwrap();
assert!(
(0..=60_000).contains(&ttl_ms),
"value overlay must revert the post-marker PERSIST, leaving the staged EXPIRE; got {ttl_ms}"
);
}