use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use crate::datatypes::Value;
use crate::graph::schema::{DirGraph, PropertyStorage};
use crate::graph::session::execute::{execute_mut, ExecuteOptions};
use crate::graph::storage::GraphRead;
type PropPairs = Vec<(String, String)>;
type NodeFingerprint = (usize, String, String, String, PropPairs, Vec<String>);
type EdgeFingerprint = (usize, usize, usize, String, PropPairs);
type MasterRows = Vec<(u32, String, String, PropPairs)>;
#[derive(Debug, PartialEq, Eq)]
struct Fingerprint {
version: u64,
node_count: usize,
edge_count: usize,
nodes: Vec<NodeFingerprint>,
edges: Vec<EdgeFingerprint>,
type_indices: Vec<(String, Vec<usize>)>,
secondary_labels: Vec<(String, Vec<usize>)>,
has_secondary_labels: bool,
node_type_metadata: Vec<(String, PropPairs)>,
connection_type_metadata: Vec<String>,
id_lookup: Vec<(String, String, usize)>,
column_masters: Vec<(String, MasterRows)>,
columnar_rows: Vec<(usize, u32)>,
user_indexes: Vec<(String, String, Vec<usize>)>,
}
fn sorted_props(props: &HashMap<String, Value>) -> Vec<(String, String)> {
let mut out: Vec<(String, String)> = props
.iter()
.map(|(k, v)| (k.clone(), format!("{v:?}")))
.collect();
out.sort();
out
}
fn fingerprint(graph: &mut DirGraph) -> Fingerprint {
let mut nodes = Vec::new();
for idx in graph.graph.node_indices().collect::<Vec<_>>() {
let Some(node) = graph.graph.node_view(idx) else {
continue;
};
let mut labels: Vec<String> = graph
.node_labels(idx)
.into_iter()
.map(|key| graph.interner.resolve(key).to_string())
.collect();
labels.sort();
nodes.push((
idx.index(),
node.node_type_str(&graph.interner).to_string(),
format!("{:?}", node.id()),
format!("{:?}", node.title()),
sorted_props(&node.properties_cloned(&graph.interner)),
labels,
));
}
nodes.sort();
let mut edges = Vec::new();
for eidx in graph.graph.edge_indices().collect::<Vec<_>>() {
let (Some((src, tgt)), Some(edge)) = (
graph.graph.edge_endpoints(eidx),
graph.graph.edge_weight(eidx),
) else {
continue;
};
edges.push((
eidx.index(),
src.index(),
tgt.index(),
edge.connection_type_str(&graph.interner).to_string(),
sorted_props(&edge.properties_cloned(&graph.interner)),
));
}
edges.sort();
let mut type_indices: Vec<(String, Vec<usize>)> = graph
.type_indices
.iter()
.map(|(name, members)| {
(
name.to_string(),
members.to_vec().iter().map(|i| i.index()).collect(),
)
})
.collect();
type_indices.sort();
let mut secondary_labels: Vec<(String, Vec<usize>)> = graph
.secondary_label_index
.iter()
.map(|(label, members)| {
(
graph.interner.resolve(*label).to_string(),
members.iter().map(|i| i.index()).collect(),
)
})
.collect();
secondary_labels.sort();
let mut node_type_metadata: Vec<(String, Vec<(String, String)>)> = graph
.node_type_metadata
.iter()
.map(|(t, props)| {
let mut props: Vec<(String, String)> =
props.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
props.sort();
(t.clone(), props)
})
.collect();
node_type_metadata.sort();
let mut connection_type_metadata: Vec<String> =
graph.connection_type_metadata.keys().cloned().collect();
connection_type_metadata.sort();
let mut id_lookup = Vec::new();
for entry in &nodes {
let (slot, node_type, id_debug, ..) = entry;
if let Some(node) = graph
.graph
.node_weight(petgraph::graph::NodeIndex::new(*slot))
{
let owned_id = node.id().into_owned();
if let Some(found) = graph.lookup_by_id(node_type, &owned_id) {
id_lookup.push((node_type.clone(), id_debug.clone(), found.index()));
}
}
}
id_lookup.sort();
let mut column_masters: Vec<(String, MasterRows)> = graph
.column_stores_by_name()
.into_iter()
.map(|(node_type, store)| {
let rows: MasterRows = (0..store.row_count())
.map(|row| {
let mut props: PropPairs = store
.row_properties(row)
.into_iter()
.map(|(key, value)| {
(
graph.interner.resolve(key).to_string(),
format!("{value:?}"),
)
})
.collect();
props.sort();
(
row,
format!("{:?}", store.get_id(row)),
format!("{:?}", store.get_title(row)),
props,
)
})
.collect();
(node_type.to_string(), rows)
})
.collect();
column_masters.sort();
let mut columnar_rows: Vec<(usize, u32)> = Vec::new();
for idx in graph.graph.node_indices().collect::<Vec<_>>() {
let Some(node) = graph.graph.node_weight(idx) else {
continue;
};
let PropertyStorage::Columnar(row) = &node.properties else {
continue;
};
columnar_rows.push((idx.index(), row.row_id()));
}
columnar_rows.sort();
let mut user_indexes: Vec<(String, String, Vec<usize>)> = Vec::new();
for ((node_type, property), value_map) in &graph.property_indices {
for (value, members) in value_map.iter() {
user_indexes.push((
format!("property {node_type}.{property}"),
format!("{value:?}"),
members.iter().map(|idx| idx.index()).collect(),
));
}
}
for ((node_type, property), btree) in &graph.range_indices {
for (value, members) in btree {
user_indexes.push((
format!("range {node_type}.{property}"),
format!("{value:?}"),
members.iter().map(|idx| idx.index()).collect(),
));
}
}
for ((node_type, properties), comp_map) in &graph.composite_indices {
for (value, members) in comp_map.iter() {
user_indexes.push((
format!("composite {node_type}.{}", properties.join("+")),
format!("{value:?}"),
members.iter().map(|idx| idx.index()).collect(),
));
}
}
user_indexes.sort();
Fingerprint {
version: graph.version,
node_count: graph.graph.node_count(),
edge_count: graph.graph.edge_count(),
nodes,
edges,
type_indices,
secondary_labels,
has_secondary_labels: graph.has_secondary_labels,
node_type_metadata,
connection_type_metadata,
id_lookup,
column_masters,
columnar_rows,
user_indexes,
}
}
fn run(graph: &mut DirGraph, query: &str) {
let params = HashMap::new();
let opts = ExecuteOptions::eager(¶ms);
execute_mut(graph, query, &opts).unwrap_or_else(|e| panic!("setup query failed: {query}: {e}"));
}
fn expect_failure(graph: &mut DirGraph, query: &str, scope: Option<&[&str]>) -> String {
let params = HashMap::new();
let mut opts = ExecuteOptions::eager(¶ms);
let owned: Option<HashSet<String>> =
scope.map(|names| names.iter().map(|s| s.to_string()).collect());
opts.write_scope = owned.as_ref();
match execute_mut(graph, query, &opts) {
Ok(_) => panic!("expected {query} to fail mid-statement"),
Err(error) => error.to_string(),
}
}
fn assert_rolls_back(graph: &mut DirGraph, query: &str, scope: Option<&[&str]>) {
let before = fingerprint(graph);
let error = expect_failure(graph, query, scope);
let after = fingerprint(graph);
assert_eq!(
before, after,
"statement must roll back completely.\nquery: {query}\nerror: {error}"
);
}
fn seeded() -> DirGraph {
let mut graph = DirGraph::new();
seed_into(&mut graph);
graph
}
fn seed_into(graph: &mut DirGraph) {
run(
graph,
"CREATE (a:Item {id: 1, name: 'a', qty: 10}), \
(b:Item {id: 2, name: 'b', qty: 20}), \
(c:Item {id: 3, name: 'c', qty: 30})",
);
run(graph, "CREATE (t:Tag:Hot {id: 1, name: 'urgent'})");
run(graph, "CREATE (t:Tag:Cold {id: 2, name: 'later'})");
run(
graph,
"MATCH (a:Item {id: 1}), (b:Item {id: 2}) CREATE (a)-[:LINKS {weight: 5}]->(b)",
);
run(
graph,
"MATCH (b:Item {id: 2}), (c:Item {id: 3}) CREATE (b)-[:LINKS {weight: 7}]->(c)",
);
run(
graph,
"MATCH (a:Item {id: 1}), (t:Tag {id: 1}) CREATE (a)-[:TAGGED]->(t)",
);
}
fn seeded_columnar() -> DirGraph {
let mut graph = seeded();
graph.enable_columnar();
assert!(
graph.is_columnar(),
"the fixture must own master column stores, or the columnar arms are vacuous"
);
let columnar_nodes = graph
.graph
.node_indices()
.filter(|idx| {
matches!(
graph.graph.node_weight(*idx).map(|node| &node.properties),
Some(PropertyStorage::Columnar { .. })
)
})
.count();
assert_eq!(
columnar_nodes,
graph.graph.node_count(),
"every seeded node must read its properties through a column store"
);
graph
}
fn seeded_mapped() -> DirGraph {
use crate::graph::storage::mode::{new_dir_graph_in_mode, StorageMode};
let mut graph = new_dir_graph_in_mode(StorageMode::Mapped, None).expect("mapped graph");
assert!(
graph.graph.is_mapped(),
"the mapped fixture must actually be on the Mapped backend, or every \
arm below is a second run of the plain fixture"
);
seed_into(&mut graph);
graph
}
fn seeded_indexed() -> DirGraph {
let mut graph = seeded();
graph.create_index("Item", "name");
graph.create_index("Item", "qty");
graph.create_range_index("Item", "qty");
graph.create_composite_index("Item", &["name", "qty"]);
assert!(
!graph.property_indices.is_empty()
&& !graph.range_indices.is_empty()
&& !graph.composite_indices.is_empty(),
"all three index families must be live, or the indexed arms are vacuous"
);
assert!(
graph
.property_indices
.values()
.any(|value_map| value_map.iter().any(|(_, members)| !members.is_empty())),
"the indexes must have been populated from the seeded nodes"
);
graph
}
macro_rules! rollback_shapes {
($($(#[$doc:meta])* $name:ident: $query:expr, $scope:expr;)*) => {
$(
$(#[$doc])*
mod $name {
use super::*;
/// A fresh in-memory graph: no column stores, no user indexes.
#[test]
fn plain() {
assert_rolls_back(&mut seeded(), $query, $scope);
}
#[test]
fn columnar() {
assert_rolls_back(&mut seeded_columnar(), $query, $scope);
}
#[test]
fn indexed() {
assert_rolls_back(&mut seeded_indexed(), $query, $scope);
}
#[test]
fn mapped() {
assert_rolls_back(&mut seeded_mapped(), $query, $scope);
}
}
)*
};
}
rollback_shapes! {
create_nodes:
"CREATE (:Item {id: 100}), (:Item {id: 101, bad: duration({months: 2147483648})})",
None;
create_nodes_and_edges:
"CREATE (x:Item {id: 200})-[:LINKS {weight: 1}]->(y:Item {id: 201}), \
(z:Blocked {id: 202})",
Some(&["Item"]);
create_with_secondary_labels:
"CREATE (:Tag:Hot:Fresh {id: 300}), (:Blocked {id: 301})",
Some(&["Tag"]);
set_properties:
"MATCH (n:Item) SET n.qty = n.qty + 1, n.name = 'touched', \
n.bad = duration({months: 2147483648})",
None;
set_on_second_type:
"MATCH (n:Item), (t:Tag) WHERE n.id = 1 AND t.id = 1 \
SET n.marker = 'x', t.marker = 'y'",
Some(&["Item"]);
set_label:
"MATCH (t:Tag {id: 2}) SET t:Hot, t.bad = duration({months: 2147483648})",
None;
remove_property_and_label:
"MATCH (t:Tag {id: 1}) REMOVE t.name, t:Hot \
CREATE (:Blocked {id: 400})",
Some(&["Tag"]);
detach_delete_one:
"MATCH (n:Item {id: 2}) DETACH DELETE n CREATE (:Blocked {id: 500})",
Some(&["Item"]);
detach_delete_all:
"MATCH (n) DETACH DELETE n CREATE (:Blocked {id: 501})",
Some(&["Item", "Tag"]);
delete_labelled_node:
"MATCH (t:Tag) DETACH DELETE t CREATE (:Blocked {id: 502})",
Some(&["Tag"]);
delete_edge:
"MATCH ()-[r:LINKS]->() DELETE r CREATE (:Blocked {id: 600})",
Some(&["Item"]);
merge_create_arm:
"MERGE (n:Item {id: 700}) ON CREATE SET n.name = 'new' \
CREATE (:Blocked {id: 701})",
Some(&["Item"]);
merge_match_arm:
"MERGE (n:Item {id: 1}) ON MATCH SET n.name = 'seen' \
CREATE (:Blocked {id: 702})",
Some(&["Item"]);
foreach:
"FOREACH (i IN [1, 2, 3] | CREATE (:Item {id: 800 + i})) \
CREATE (:Blocked {id: 804})",
Some(&["Item"]);
multi_clause_create_then_set_then_delete:
"MATCH (n:Item {id: 3}) SET n.qty = 999 \
CREATE (:Item {id: 900}) \
CREATE (:Blocked {id: 901})",
Some(&["Item"]);
}
#[test]
fn rollback_reuses_the_vacated_slots() {
let mut graph = seeded();
let slots_before: Vec<usize> = graph
.graph
.node_indices()
.map(|idx| idx.index())
.collect::<Vec<_>>();
expect_failure(
&mut graph,
"MATCH (n:Item) DETACH DELETE n CREATE (:Blocked {id: 1})",
Some(&["Item"]),
);
let slots_after: Vec<usize> = graph.graph.node_indices().map(|idx| idx.index()).collect();
assert_eq!(
slots_before, slots_after,
"restored nodes must land on the slots they vacated"
);
}
#[test]
fn successful_statement_leaves_no_journal_installed() {
let mut graph = seeded();
run(&mut graph, "CREATE (:Item {id: 1000})");
assert!(
graph.graph.take_undo().is_none(),
"a committed statement must uninstall its journal"
);
}
#[test]
fn failed_statement_leaves_no_journal_installed() {
let mut graph = seeded();
expect_failure(
&mut graph,
"CREATE (:Item {id: 1001}), (:Blocked {id: 1002})",
Some(&["Item"]),
);
assert!(
graph.graph.take_undo().is_none(),
"a rolled-back statement must uninstall its journal"
);
}
#[test]
fn a_second_statement_after_a_rollback_still_commits() {
let mut graph = seeded();
expect_failure(
&mut graph,
"MATCH (n:Item) DETACH DELETE n CREATE (:Blocked {id: 1})",
Some(&["Item"]),
);
run(&mut graph, "CREATE (:Item {id: 1100, name: 'after'})");
let params = HashMap::new();
let opts = ExecuteOptions::eager(¶ms);
let out = execute_mut(&mut graph, "MATCH (n:Item) RETURN count(n) AS c", &opts)
.expect("read after rollback");
let rows = out.result.rows;
assert_eq!(rows.len(), 1);
assert_eq!(
format!("{:?}", rows[0][0]),
format!("{:?}", Value::Int64(4))
);
}
const ZERO_COPY_QUERIES: &[&str] = &[
"CREATE (:Item {id: 2000, name: 'x'})",
"MATCH (n:Item {id: 1}) SET n.qty = 11, n.name = 'renamed'",
"MATCH (n:Item {id: 2000}) SET n:Featured",
"MATCH (a:Item {id: 1}), (b:Item {id: 3}) CREATE (a)-[:LINKS {weight: 2}]->(b)",
"MATCH (n:Item {id: 2000}) DETACH DELETE n",
"MERGE (n:Item {id: 2001}) ON CREATE SET n.name = 'merged'",
];
fn assert_statements_copy_zero_nodes(graph: &mut DirGraph, fixture: &str) {
use crate::graph::storage::backend::{backend_clone_nodes, reset_backend_clone_count};
for &query in ZERO_COPY_QUERIES {
reset_backend_clone_count();
run(graph, query);
assert_eq!(
backend_clone_nodes(),
0,
"statement must not copy any node on the {fixture} fixture: {query}"
);
}
}
#[test]
fn journalled_statements_copy_zero_nodes() {
assert_statements_copy_zero_nodes(&mut seeded(), "plain");
}
#[test]
fn journalled_statements_copy_zero_nodes_on_a_saved_graph() {
assert_statements_copy_zero_nodes(&mut seeded_columnar(), "columnar");
}
#[test]
fn journalled_statements_copy_zero_nodes_on_an_indexed_graph() {
assert_statements_copy_zero_nodes(&mut seeded_indexed(), "indexed");
}
#[test]
fn mapped_statements_copy_zero_nodes() {
let mut graph = seeded_mapped();
assert!(
graph.graph.node_count() > 0,
"fixture must have nodes or the counter proves nothing"
);
assert_statements_copy_zero_nodes(&mut graph, "mapped");
}
#[test]
fn mapped_rolls_back_completely() {
let mut graph = seeded_mapped();
assert_rolls_back(
&mut graph,
"MATCH (n:Item) DETACH DELETE n CREATE (:Blocked {id: 1})",
Some(&["Item"]),
);
}
#[test]
fn the_mapped_silent_write_path_records_nothing() {
use crate::graph::storage::GraphWrite;
let mut graph = seeded_mapped();
let idx = graph
.graph
.node_indices()
.next()
.expect("the fixture must have a node");
graph.graph.begin_undo();
GraphWrite::node_weight_mut(&mut graph.graph, idx).expect("node is live");
let recorded = graph
.graph
.take_undo()
.expect("begin_undo must install a journal on a mapped graph")
.into_replay_order()
.count();
assert_eq!(
recorded, 1,
"the recorded seam must capture a pre-image, or the silent arm below \
is comparing against an empty journal for the wrong reason"
);
graph.graph.begin_undo();
GraphWrite::node_weight_mut_silent(&mut graph.graph, idx).expect("node is live");
let silent = graph
.graph
.take_undo()
.expect("begin_undo must install a journal on a mapped graph")
.into_replay_order()
.count();
assert_eq!(
silent, 0,
"the mapped silent write path journalled {silent} entries; it must \
journal none, or the columnar detach/reattach and handle-refresh \
sweeps cost one pre-image per node of the type per chunk"
);
}
fn assert_rollback_copies_zero_nodes(graph: &mut DirGraph, fixture: &str) {
use crate::graph::storage::backend::{backend_clone_nodes, reset_backend_clone_count};
reset_backend_clone_count();
expect_failure(
graph,
"MATCH (n:Item) DETACH DELETE n CREATE (:Blocked {id: 1})",
Some(&["Item"]),
);
assert_eq!(
backend_clone_nodes(),
0,
"rollback must not copy any node on the {fixture} fixture"
);
}
#[test]
fn journalled_rollback_copies_zero_nodes() {
assert_rollback_copies_zero_nodes(&mut seeded(), "plain");
}
#[test]
fn journalled_rollback_copies_zero_nodes_on_a_saved_graph() {
assert_rollback_copies_zero_nodes(&mut seeded_columnar(), "columnar");
}
#[test]
fn journalled_rollback_copies_zero_nodes_on_an_indexed_graph() {
assert_rollback_copies_zero_nodes(&mut seeded_indexed(), "indexed");
}
#[test]
fn journalled_rollback_copies_zero_nodes_on_a_mapped_graph() {
assert_rollback_copies_zero_nodes(&mut seeded_mapped(), "mapped");
}
#[test]
fn the_columnar_fixture_writes_through_the_master_store() {
let mut graph = seeded_columnar();
let before = fingerprint(&mut graph);
run(&mut graph, "MATCH (n:Item {id: 1}) SET n.qty = 12345");
let after = fingerprint(&mut graph);
assert_ne!(
before.column_masters, after.column_masters,
"a successful columnar SET must land in the master store; if it does \
not, the columnar arms are exercising the per-node fallback"
);
assert_eq!(
before.columnar_rows, after.columnar_rows,
"a columnar SET must not move any node to a different row — it writes \
a cell of the store the backend owns, and the node's row identity is \
exactly what must stay put"
);
}
const WIDE_ITEMS: usize = 200;
fn wide_columnar() -> DirGraph {
wide_columnar_into(DirGraph::new())
}
fn wide_columnar_mapped() -> DirGraph {
use crate::graph::storage::mode::{new_dir_graph_in_mode, StorageMode};
let graph = new_dir_graph_in_mode(StorageMode::Mapped, None).expect("mapped graph");
assert!(graph.graph.is_mapped(), "fixture must be on Mapped");
wide_columnar_into(graph)
}
fn wide_columnar_into(mut graph: DirGraph) -> DirGraph {
let rows: Vec<String> = (0..WIDE_ITEMS)
.map(|i| format!("(:Item {{id: {i}, name: 'n{i}', qty: {i}}})"))
.collect();
run(&mut graph, &format!("CREATE {}", rows.join(", ")));
graph.enable_columnar();
assert!(
graph.is_columnar(),
"the fixture must own a master column store, or this test is vacuous"
);
graph
}
#[test]
fn a_columnar_set_journals_one_pre_image_per_changed_node() {
use crate::graph::storage::undo::{journal_node_pre_images, reset_journal_node_pre_images};
let mut graph = wide_columnar();
reset_journal_node_pre_images();
run(&mut graph, "MATCH (i:Item {id: 7}) SET i.priority = 3");
let captured = journal_node_pre_images();
assert!(
captured <= 2,
"a one-row columnar SET captured {captured} node pre-images across \
{WIDE_ITEMS} nodes of the type; it must be O(nodes changed), not \
O(nodes of the type) — the handle-refresh sweep is being journalled"
);
}
#[test]
fn a_mapped_columnar_set_journals_one_pre_image_per_changed_node() {
use crate::graph::storage::undo::{journal_node_pre_images, reset_journal_node_pre_images};
let mut graph = wide_columnar_mapped();
reset_journal_node_pre_images();
run(&mut graph, "MATCH (i:Item {id: 7}) SET i.priority = 3");
let captured = journal_node_pre_images();
assert!(
captured <= 2,
"a one-row columnar SET on a mapped graph captured {captured} node \
pre-images across {WIDE_ITEMS} nodes of the type; the mapped \
handle-refresh sweep is being journalled"
);
}
#[test]
fn a_plain_set_journals_one_pre_image_per_changed_node() {
use crate::graph::storage::undo::{journal_node_pre_images, reset_journal_node_pre_images};
let mut graph = wide_columnar();
graph.disable_columnar();
reset_journal_node_pre_images();
run(&mut graph, "MATCH (i:Item {id: 7}) SET i.priority = 3");
let captured = journal_node_pre_images();
assert!(
captured <= 2,
"a one-row SET on a non-columnar graph captured {captured} node \
pre-images across {WIDE_ITEMS} nodes"
);
}
#[test]
fn two_columnar_writes_in_one_statement_both_land() {
let mut graph = wide_columnar();
let idx = graph
.graph
.node_indices()
.find(|i| {
graph
.graph
.node_view(*i)
.and_then(|n| n.get_property_value("qty"))
.map(|v| v == crate::datatypes::Value::Int64(1))
.unwrap_or(false)
})
.expect("fixture seeds qty = node index");
run(
&mut graph,
"MATCH (n:Item {id: 1}) SET n.qty = 111 SET n.qty = 222",
);
let node = graph.graph.node_view(idx).expect("node still present");
assert_eq!(
node.get_property_value("qty"),
Some(crate::datatypes::Value::Int64(222)),
"both writes must be visible; reading 1 means the second write landed \
somewhere the read route does not resolve"
);
let master = graph.column_store("Item").expect("master");
assert_eq!(
Arc::strong_count(master),
1,
"between statements nothing but the backend may hold the master, or \
every write still pays a whole-store copy"
);
}
#[test]
fn no_node_holds_a_column_store_handle() {
let graph = wide_columnar();
let master = graph
.column_store("Item")
.expect("the fixture installs a master store for Item");
assert_eq!(
Arc::strong_count(master),
1,
"the backend must be the only owner of the master; a second handle \
means something re-introduced a replica, and every columnar write \
would silently go back to copying the whole store"
);
let columnar = graph
.graph
.node_indices()
.filter(|idx| {
matches!(
graph.graph.node_weight(*idx).map(|n| &n.properties),
Some(PropertyStorage::Columnar(_))
)
})
.count();
assert_eq!(
columnar, WIDE_ITEMS,
"every node of the type must still be columnar, or the refcount above \
is 1 because the fixture stopped being saved"
);
}
#[test]
fn the_master_is_uniquely_owned_between_statements() {
let mut graph = wide_columnar();
assert_eq!(
Arc::strong_count(graph.column_store("Item").expect("master")),
1,
"precondition: uniquely owned before any statement"
);
run(&mut graph, "MATCH (n:Item {id: 1}) SET n.qty = 111");
assert_eq!(
Arc::strong_count(graph.column_store("Item").expect("master")),
1,
"a committed statement must release the journal's pre-image, or the \
next statement forks the whole store again"
);
assert_eq!(
graph
.graph
.node_view(
graph
.graph
.node_indices()
.find(|i| graph.graph.get_node_id(*i)
== Some(crate::datatypes::Value::Int64(1)))
.expect("node 1")
)
.and_then(|n| n.get_property_value("qty")),
Some(crate::datatypes::Value::Int64(111)),
"and the write must actually be visible"
);
}
#[test]
fn indexed_graph_rolls_back_without_copying_the_graph() {
use crate::graph::storage::backend::{backend_clone_nodes, reset_backend_clone_count};
let mut graph = seeded_indexed();
reset_backend_clone_count();
assert_rolls_back(
&mut graph,
"MATCH (n:Item) SET n.name = 'touched', n.bad = duration({months: 2147483648})",
None,
);
assert_eq!(
backend_clone_nodes(),
0,
"an indexed graph must take the journal path, not the clone checkpoint"
);
}
#[test]
fn rollback_restores_index_bucket_order_not_just_membership() {
let mut graph = seeded_indexed();
run(&mut graph, "MATCH (n:Item {id: 3}) SET n.qty = 10");
let bucket_before = index_bucket(&graph, "qty", Value::Int64(10));
assert_eq!(
bucket_before.len(),
2,
"the fixture needs a bucket with two members to have an order at all"
);
let before = fingerprint(&mut graph);
let error = expect_failure(
&mut graph,
"MATCH (n:Item {id: 1}) SET n.qty = 999 \
WITH n MATCH (m:Item {id: 2}) SET m.bad = duration({months: 2147483648})",
None,
);
let after = fingerprint(&mut graph);
assert_eq!(before, after, "statement must roll back.\nerror: {error}");
assert_eq!(
index_bucket(&graph, "qty", Value::Int64(10)),
bucket_before,
"the evicted member must come back at its original position"
);
}
fn index_bucket(graph: &DirGraph, property: &str, value: Value) -> Vec<usize> {
graph
.property_indices
.get(&("Item".to_string(), property.to_string()))
.and_then(|value_map| value_map.get(&value))
.map(|members| members.iter().map(|idx| idx.index()).collect())
.unwrap_or_default()
}
type UniqueClaims = Vec<(String, usize)>;
type UniqueFingerprint = Vec<(String, Vec<String>, UniqueClaims)>;
fn unique_fingerprint(graph: &DirGraph) -> UniqueFingerprint {
let mut out: Vec<_> = graph
.unique_indices
.iter()
.map(|((node_type, properties), occupants)| {
let mut claims: UniqueClaims = occupants
.iter()
.map(|(value, idx)| (format!("{value:?}"), idx.index()))
.collect();
claims.sort();
(node_type.clone(), properties.clone(), claims)
})
.collect();
out.sort();
out
}
fn seeded_with_unique_name() -> DirGraph {
let mut graph = seeded();
run(
&mut graph,
"CREATE CONSTRAINT FOR (i:Item) REQUIRE i.name IS UNIQUE",
);
assert_eq!(
graph.unique_indices.len(),
1,
"the constraint must be declared and enforcing"
);
assert!(
graph.property_indices.is_empty()
&& graph.composite_indices.is_empty()
&& graph.range_indices.is_empty(),
"a unique constraint must not create a user index, or these tests \
would exercise the clone checkpoint instead of the journal"
);
graph
}
fn seeded_columnar_with_unique_qty() -> DirGraph {
let mut graph = seeded();
run(
&mut graph,
"CREATE CONSTRAINT FOR (i:Item) REQUIRE i.qty IS UNIQUE",
);
graph.enable_columnar();
assert_eq!(
graph.unique_indices.len(),
1,
"the constraint must be declared and enforcing"
);
assert!(
graph.is_columnar(),
"the graph must be saved, or this is the plain unique fixture again"
);
graph
}
#[test]
fn rollback_restores_claims_moved_by_a_columnar_property_overwrite() {
let mut graph = seeded_columnar_with_unique_qty();
let before = unique_fingerprint(&graph);
assert!(
!before.is_empty() && !before[0].2.is_empty(),
"the constraint must hold claims, or this test is vacuous"
);
let error = expect_failure(
&mut graph,
"MATCH (i:Item {id: 1}) SET i.qty = 999 \
WITH i MATCH (j:Item {id: 2}) SET j.bad = duration({months: 2147483648})",
None,
);
assert_eq!(
unique_fingerprint(&graph),
before,
"a claim moved through the master column store must move back.\
\nerror: {error}"
);
expect_failure(&mut graph, "CREATE (:Item {id: 40, qty: 10})", None);
run(&mut graph, "CREATE (:Item {id: 41, qty: 999})");
}
#[test]
fn rollback_releases_a_claim_the_failed_statement_added() {
let mut graph = seeded_with_unique_name();
let before = unique_fingerprint(&graph);
let error = expect_failure(
&mut graph,
"CREATE (:Item {id: 10, name: 'zeta'}), (:Item {id: 11, name: 'b'})",
None,
);
assert_eq!(
unique_fingerprint(&graph),
before,
"the rolled-back claim must be gone.\nerror: {error}"
);
run(&mut graph, "CREATE (:Item {id: 13, name: 'zeta'})");
}
#[test]
fn rollback_restores_a_claim_the_failed_statement_released() {
let mut graph = seeded_with_unique_name();
let before = unique_fingerprint(&graph);
let error = expect_failure(
&mut graph,
"MATCH (i:Item {id: 1}) DETACH DELETE i CREATE (:Item {id: 20, name: 'b'})",
None,
);
assert_eq!(
unique_fingerprint(&graph),
before,
"the released claim must be restored, pointing at the restored slot.\
\nerror: {error}"
);
expect_failure(&mut graph, "CREATE (:Item {id: 21, name: 'a'})", None);
}
#[test]
fn rollback_restores_claims_moved_by_a_property_overwrite() {
let mut graph = seeded_with_unique_name();
let before = unique_fingerprint(&graph);
let error = expect_failure(
&mut graph,
"MATCH (i:Item {id: 1}) SET i.name = 'renamed' \
WITH i MATCH (j:Item {id: 2}) SET j.bad = duration({months: 2147483648})",
None,
);
assert_eq!(
unique_fingerprint(&graph),
before,
"an overwritten claim must move back.\nerror: {error}"
);
expect_failure(&mut graph, "CREATE (:Item {id: 30, name: 'a'})", None);
run(&mut graph, "CREATE (:Item {id: 31, name: 'renamed'})");
}
#[test]
fn a_rollback_while_a_reader_is_held_touches_neither_graph() {
use crate::graph::handle::make_dir_graph_mut;
use std::sync::Arc;
for (name, build) in [
("plain", seeded as fn() -> DirGraph),
("columnar", seeded_columnar as fn() -> DirGraph),
("indexed", seeded_indexed as fn() -> DirGraph),
] {
let mut writer = Arc::new(build());
let reader = Arc::clone(&writer);
let reader_before = fingerprint(&mut (*reader).clone());
let writer_before = {
let graph = make_dir_graph_mut(&mut writer);
assert!(
graph.graph.is_forked(),
"{name}: precondition — a held reader must produce an overlay"
);
let before = fingerprint(&mut graph.clone());
expect_failure(
graph,
"CREATE (:Item {id: 4000, name: 'first'}), (:Blocked {id: 4001, name: 'second'})",
Some(&["Item"]),
);
before
};
assert_eq!(
fingerprint(&mut (*reader).clone()),
reader_before,
"{name}: the reader's graph must be untouched by a write it never \
asked for — a difference here means the undo journal reversed into \
the shared base instead of the overlay (D2 R3)"
);
assert_eq!(
fingerprint(&mut (*writer).clone()),
writer_before,
"{name}: the writer's failed statement must roll back exactly, \
overlay or not"
);
}
}
#[test]
fn forked_statements_copy_zero_nodes_except_one_flatten() {
use crate::graph::handle::make_dir_graph_mut;
use crate::graph::storage::backend::{backend_clone_nodes, reset_backend_clone_count};
use std::sync::Arc;
const OVERLAY_QUERIES: &[&str] = &[
"CREATE (:Item {id: 2000, name: 'x'})",
"MATCH (n:Item {id: 1}) SET n.qty = 11, n.name = 'renamed'",
"MATCH (n:Item {id: 2000}) SET n:Featured",
"MERGE (n:Item {id: 2001}) ON CREATE SET n.name = 'merged'",
];
const ADJACENCY_QUERY: &str =
"MATCH (a:Item {id: 1}), (b:Item {id: 3}) CREATE (a)-[:LINKS {weight: 2}]->(b)";
let mut writer = Arc::new(seeded());
let reader = Arc::clone(&writer);
let fixture_nodes = reader.graph.node_count();
let graph = make_dir_graph_mut(&mut writer);
assert!(graph.graph.is_forked(), "precondition: the write forked");
for &query in OVERLAY_QUERIES {
reset_backend_clone_count();
run(graph, query);
assert_eq!(
backend_clone_nodes(),
0,
"an overlay-expressible statement on a forked backend must copy no node: {query}"
);
assert!(
graph.graph.is_forked(),
"...and must leave the backend forked: {query}"
);
}
reset_backend_clone_count();
run(graph, ADJACENCY_QUERY);
assert_eq!(
backend_clone_nodes(),
fixture_nodes,
"the adjacency write flattens the overlay — exactly one copy of the base"
);
assert!(
!graph.graph.is_forked(),
"flattening must leave a plain backend, so the copy is paid once"
);
reset_backend_clone_count();
run(graph, "MATCH (n:Item {id: 2000}) DETACH DELETE n");
run(graph, "CREATE (:Item {id: 2002, name: 'after'})");
assert_eq!(
backend_clone_nodes(),
0,
"after flattening, later statements mutate in place — one copy per fork, \
not one per statement"
);
assert_eq!(reader.graph.node_count(), fixture_nodes);
}