use super::*;
static CANCELLED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true);
fn gate_rows() -> usize {
crate::graph::parallel::PROJECTION_MIN_ROWS
}
fn bound_rows(graph: &DirGraph, count: usize) -> ResultSet {
let idx = graph
.type_indices
.get("Person")
.expect("Person index")
.get(0)
.expect("at least one Person");
let mut set = ResultSet::new();
set.rows = (0..count)
.map(|_| {
let mut row = ResultRow::new();
row.node_bindings.insert("n".to_string(), idx);
row
})
.collect();
set
}
fn projected_rows(count: usize) -> ResultSet {
let mut set = ResultSet::new();
set.columns = vec!["x".to_string()];
set.rows = (0..count)
.map(|i| {
let mut row = ResultRow::new();
row.projected
.insert("x".to_string(), Value::Int64(i as i64));
row
})
.collect();
set
}
fn return_clause_of(query: &CypherQuery) -> &ReturnClause {
match query.clauses.last().expect("clauses") {
Clause::Return(rc) => rc,
other => panic!("expected RETURN clause, got {other:?}"),
}
}
#[test]
fn parallel_projection_observes_the_cancel_flag() {
let graph = build_test_graph();
let params = HashMap::new();
let query = parser::parse_cypher("MATCH (n:Person) RETURN n.name AS name").unwrap();
let clause = return_clause_of(&query);
let executor = CypherExecutor::with_params(&graph, ¶ms, None).with_cancel(Some(&CANCELLED));
let cancelled = executor
.execute_return_projection(clause, bound_rows(&graph, gate_rows()))
.unwrap_err();
assert_eq!(cancelled, "Query cancelled");
assert!(
executor
.execute_return_projection(clause, bound_rows(&graph, gate_rows() - 1))
.is_ok(),
"below-threshold projection must still take the unpolled sequential branch"
);
}
#[test]
fn parallel_projection_observes_the_deadline() {
let graph = build_test_graph();
let params = HashMap::new();
let query = parser::parse_cypher("MATCH (n:Person) RETURN n.name AS name").unwrap();
let clause = return_clause_of(&query);
let past = std::time::Instant::now() - std::time::Duration::from_secs(1);
let executor = CypherExecutor::with_params(&graph, ¶ms, Some(past));
let timed_out = executor
.execute_return_projection(clause, bound_rows(&graph, gate_rows()))
.unwrap_err();
assert!(
timed_out.starts_with("Query timed out."),
"unexpected error: {timed_out}"
);
assert!(
executor
.execute_return_projection(clause, bound_rows(&graph, gate_rows() - 1))
.is_ok(),
"below-threshold projection must still take the unpolled sequential branch"
);
}
#[test]
fn parallel_window_projection_observes_the_cancel_flag() {
let graph = build_test_graph();
let params = HashMap::new();
let query = parser::parse_cypher(
"MATCH (n:Person) RETURN n.name AS name, row_number() OVER (ORDER BY n.name) AS rn",
)
.unwrap();
let clause = return_clause_of(&query);
let executor = CypherExecutor::with_params(&graph, ¶ms, None).with_cancel(Some(&CANCELLED));
let cancelled = executor
.execute_return_with_windows(clause, bound_rows(&graph, gate_rows()))
.unwrap_err();
assert_eq!(cancelled, "Query cancelled");
assert!(
executor
.execute_return_with_windows(clause, bound_rows(&graph, gate_rows() - 1))
.is_ok(),
"below-threshold window projection must still take the sequential branch"
);
}
#[test]
fn parallel_result_materialisation_observes_the_cancel_flag() {
let graph = build_test_graph();
let params = HashMap::new();
let executor = CypherExecutor::with_params(&graph, ¶ms, None).with_cancel(Some(&CANCELLED));
let cancelled = executor
.finalize_result(projected_rows(gate_rows()))
.unwrap_err();
assert_eq!(cancelled, "Query cancelled");
assert!(
executor
.finalize_result(projected_rows(gate_rows() - 1))
.is_ok(),
"below-threshold materialisation must still take the sequential branch"
);
}
use crate::graph::parallel::{
parallel_scans, PARALLEL_MIN_ROWS_COMPILED, PARALLEL_MIN_ROWS_INTERPRETED,
};
static METER: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn meter_guard() -> std::sync::MutexGuard<'static, ()> {
METER
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
fn scan_graph(n: usize) -> DirGraph {
let mut graph = DirGraph::new();
for i in 0..n {
let node = NodeData::new(
Value::UniqueId(i as u32),
Value::String(format!("Item_{i}")),
"Item".to_string(),
HashMap::from([
("value".to_string(), Value::Int64((i % 1000) as i64)),
("cat".to_string(), Value::String(format!("cat_{}", i % 7))),
]),
&mut graph.interner,
);
let idx = graph.graph.add_node(node);
graph
.type_indices
.entry_or_default("Item".to_string())
.push(idx);
}
graph
}
fn run(graph: &DirGraph, query: &str, parallel: bool) -> CypherResult {
let params: HashMap<String, Value> = HashMap::new();
let mut parsed = parser::parse_cypher(query).expect("query parses");
crate::graph::languages::cypher::planner::optimize(&mut parsed, graph, ¶ms);
CypherExecutor::with_params(graph, ¶ms, None)
.with_parallel(parallel)
.execute(&parsed)
.expect("query executes")
}
#[test]
fn parallel_scan_aggregate_matches_serial() {
let _meter = meter_guard();
let graph = scan_graph(PARALLEL_MIN_ROWS_INTERPRETED + 137);
let queries = [
"MATCH (n:Item) RETURN toUpper(n.cat) AS c, count(*) AS n",
"MATCH (n:Item) RETURN toUpper(n.cat) AS c, sum(n.value) AS s, avg(n.value) AS a",
"MATCH (n:Item) RETURN toUpper(n.cat) AS c, min(n.value) AS lo, max(n.value) AS hi",
"MATCH (n:Item) RETURN toUpper(n.cat) AS c, count(DISTINCT n.value) AS d",
"MATCH (n:Item) WHERE n.value > 500 RETURN toUpper(n.cat) AS c, count(*) AS n",
"MATCH (n:Item) RETURN count(*) AS n, toUpper(n.cat) AS c",
];
let before = parallel_scans();
for query in queries {
let serial = run(&graph, query, false);
let parallel = run(&graph, query, true);
assert_eq!(serial.columns, parallel.columns, "columns differ: {query}");
assert_eq!(
serial.rows, parallel.rows,
"parallel diverged from serial (values or group order): {query}"
);
assert!(!serial.rows.is_empty(), "vacuous fixture for {query}");
}
assert!(
parallel_scans() > before,
"no query fanned out — the equality assertions compared two serial runs"
);
}
#[test]
fn scan_aggregate_stays_serial_below_the_gate() {
let _meter = meter_guard();
let graph = scan_graph(PARALLEL_MIN_ROWS_INTERPRETED - 1);
let query = "MATCH (n:Item) RETURN toUpper(n.cat) AS c, count(*) AS n";
let before = parallel_scans();
let opted_in = run(&graph, query, true);
assert_eq!(
parallel_scans(),
before,
"a below-gate query fanned out — the runtime gate is not being consulted"
);
assert_eq!(opted_in.rows, run(&graph, query, false).rows);
}
#[test]
fn scan_aggregate_stays_serial_without_opt_in() {
let _meter = meter_guard();
let graph = scan_graph(PARALLEL_MIN_ROWS_INTERPRETED + 137);
let query = "MATCH (n:Item) RETURN toUpper(n.cat) AS c, count(*) AS n";
let before = parallel_scans();
run(&graph, query, false);
assert_eq!(
parallel_scans(),
before,
"parallel=false fanned out — the opt-in is not being honoured"
);
}
#[test]
fn parallel_scan_aggregate_honours_a_pre_tripped_flag() {
let _meter = meter_guard();
let graph = scan_graph(PARALLEL_MIN_ROWS_INTERPRETED + 137);
let params: HashMap<String, Value> = HashMap::new();
let query = "MATCH (n:Item) RETURN toUpper(n.cat) AS c, count(*) AS n";
let mut parsed = parser::parse_cypher(query).expect("query parses");
crate::graph::languages::cypher::planner::optimize(&mut parsed, &graph, ¶ms);
let cancelled = CypherExecutor::with_params(&graph, ¶ms, None)
.with_parallel(true)
.with_cancel(Some(&CANCELLED))
.execute(&parsed)
.unwrap_err();
assert_eq!(cancelled, "Query cancelled");
let past = std::time::Instant::now() - std::time::Duration::from_secs(1);
let timed_out = CypherExecutor::with_params(&graph, ¶ms, Some(past))
.with_parallel(true)
.execute(&parsed)
.unwrap_err();
assert!(
timed_out.starts_with("Query timed out."),
"unexpected error: {timed_out}"
);
}
#[test]
fn parallel_scan_aggregate_is_interruptible_mid_scan() {
let _meter = meter_guard();
static MID_SCAN: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
MID_SCAN.store(false, std::sync::atomic::Ordering::Relaxed);
let graph = scan_graph(PARALLEL_MIN_ROWS_INTERPRETED * 10);
let params: HashMap<String, Value> = HashMap::new();
let query = "MATCH (n:Item) RETURN toUpper(n.cat) AS c, count(*) AS n";
let mut parsed = parser::parse_cypher(query).expect("query parses");
crate::graph::languages::cypher::planner::optimize(&mut parsed, &graph, ¶ms);
let before = parallel_scans();
let flipper = std::thread::spawn(move || {
let give_up = std::time::Instant::now() + std::time::Duration::from_secs(30);
while parallel_scans() == before && std::time::Instant::now() < give_up {
std::hint::spin_loop();
}
MID_SCAN.store(true, std::sync::atomic::Ordering::Relaxed);
});
let outcome = CypherExecutor::with_params(&graph, ¶ms, None)
.with_parallel(true)
.with_cancel(Some(&MID_SCAN))
.execute(&parsed);
flipper.join().expect("flipper thread");
assert!(
parallel_scans() > before,
"the query never fanned out — this test is not measuring the parallel scan"
);
assert_eq!(
outcome.err(),
Some("Query cancelled".to_string()),
"the parallel scan ran to completion through a cancellation raised mid-scan"
);
}
#[test]
fn parallel_is_off_in_the_default_execute_options() {
let params: HashMap<String, Value> = HashMap::new();
assert!(!crate::graph::session::ExecuteOptions::eager(¶ms).parallel);
assert!(!crate::graph::session::ExecuteOptions::new(¶ms).parallel);
let graph = build_test_graph();
assert!(!CypherExecutor::with_params(&graph, ¶ms, None).parallel);
}
use crate::graph::parallel::parallel_candidate_scans;
fn linked_graph(n: usize) -> DirGraph {
let mut graph = scan_graph(n);
let indices: Vec<petgraph::graph::NodeIndex> =
graph.type_indices.get("Item").expect("Item index").to_vec();
for (i, &src) in indices.iter().enumerate() {
let dst = indices[(i * 7 + 13) % indices.len()];
let edge = crate::graph::schema::EdgeData::new(
"LINKS".to_string(),
HashMap::new(),
&mut graph.interner,
);
graph.graph.add_edge(src, dst, edge);
}
graph.register_connection_type("LINKS".to_string());
graph
}
fn columnar_scan_graph(n: usize) -> DirGraph {
use crate::datatypes::DataFrame;
let mut graph = DirGraph::new();
let columns: Vec<String> = ["nid", "name", "value", "cat"]
.iter()
.map(|s| s.to_string())
.collect();
let rows: Vec<Vec<Value>> = (0..n)
.map(|i| {
vec![
Value::Int64(i as i64),
Value::String(format!("Item_{i}")),
Value::Int64((i % 1000) as i64),
Value::String(format!("cat_{}", i % 7)),
]
})
.collect();
let df = DataFrame::from_cypher_rows(columns, rows).unwrap();
crate::graph::mutation::maintain::add_nodes(
&mut graph,
df,
"Item".to_string(),
"nid".to_string(),
Some("name".to_string()),
None,
)
.unwrap();
graph
}
const SCAN_FILTER_QUERIES: &[&str] = &[
"MATCH (n:Item {cat: 'cat_3'}) RETURN n.name AS nm",
"MATCH (n:Item) WHERE n.cat = 'cat_3' RETURN n.name AS nm",
"MATCH (n:Item) WHERE n.value > 500 RETURN n.name AS nm",
"MATCH (n:Item) WHERE n.name STARTS WITH 'Item_1' RETURN n.name AS nm",
"MATCH (n:Item) WHERE n.name CONTAINS '99' RETURN n.name AS nm",
"MATCH (n:Item) WHERE n.cat IN ['cat_1', 'cat_5'] RETURN n.name AS nm",
"MATCH (n:Item) WHERE n.value >= 100 AND n.value < 200 RETURN n.name AS nm",
];
#[test]
fn parallel_candidate_scan_matches_serial_in_order() {
let _meter = meter_guard();
let graph = scan_graph(PARALLEL_MIN_ROWS_INTERPRETED + 137);
let before = parallel_candidate_scans();
for query in SCAN_FILTER_QUERIES {
let serial = run(&graph, query, false);
let parallel = run(&graph, query, true);
assert_eq!(serial.columns, parallel.columns, "columns differ: {query}");
assert_eq!(
serial.rows, parallel.rows,
"parallel candidate scan diverged from serial (values or ORDER): {query}"
);
assert!(!serial.rows.is_empty(), "vacuous fixture for {query}");
}
assert!(
parallel_candidate_scans() > before,
"no query fanned out its candidate scan — the order assertions above \
compared two serial runs"
);
}
#[test]
fn parallel_candidate_scan_preserves_multi_label_order() {
let _meter = meter_guard();
let mut graph = scan_graph(PARALLEL_MIN_ROWS_INTERPRETED + 137);
let tagged: Vec<petgraph::graph::NodeIndex> = graph
.type_indices
.get("Item")
.expect("Item index")
.to_vec()
.into_iter()
.step_by(7)
.collect();
graph.secondary_label_index.insert(
crate::graph::schema::InternedKey::from_str("Tagged"),
tagged,
);
graph.has_secondary_labels = true;
let before = parallel_candidate_scans();
for query in [
"MATCH (n:Item:Tagged) RETURN n.name AS nm",
"MATCH (n:Item) WHERE n.value > 100 RETURN n.name AS nm",
] {
assert_eq!(
run(&graph, query, false).rows,
run(&graph, query, true).rows,
"multi-label scan order diverged: {query}"
);
}
assert!(parallel_candidate_scans() > before, "nothing fanned out");
}
#[test]
fn candidate_scan_respects_the_gate_and_the_opt_in() {
let _meter = meter_guard();
let query = SCAN_FILTER_QUERIES[0];
let small = scan_graph(PARALLEL_MIN_ROWS_INTERPRETED - 1);
let before = parallel_candidate_scans();
run(&small, query, true);
assert_eq!(
parallel_candidate_scans(),
before,
"a below-gate candidate scan fanned out"
);
let large = scan_graph(PARALLEL_MIN_ROWS_INTERPRETED + 137);
let before = parallel_candidate_scans();
run(&large, query, false);
assert_eq!(
parallel_candidate_scans(),
before,
"parallel=false fanned out its candidate scan"
);
}
#[test]
fn compiled_filter_meter_sees_rows_answered_on_workers() {
let _meter = meter_guard();
let graph = columnar_scan_graph(PARALLEL_MIN_ROWS_COMPILED + 137);
let query = "MATCH (n:Item {cat: 'cat_3'}) RETURN n.name AS nm";
crate::graph::core::pattern_matching::column_filter::reset_rows_filtered();
let serial = run(&graph, query, false);
let serial_rows = crate::graph::core::pattern_matching::column_filter::rows_filtered();
assert!(
serial_rows > 0,
"the serial run never reached a compiled filter — pick another shape"
);
let before = parallel_candidate_scans();
crate::graph::core::pattern_matching::column_filter::reset_rows_filtered();
let parallel = run(&graph, query, true);
let parallel_rows = crate::graph::core::pattern_matching::column_filter::rows_filtered();
assert!(
parallel_candidate_scans() > before,
"the query did not fan out — this test is not measuring the worker fold"
);
assert_eq!(
parallel_rows, serial_rows,
"the compiled-filter meter lost rows answered on worker threads"
);
assert_eq!(serial.rows, parallel.rows);
}
#[test]
fn parallel_candidate_scan_is_interruptible_mid_scan() {
use crate::graph::core::pattern_matching::{NodePattern, PatternExecutor, PropertyMatcher};
let _meter = meter_guard();
static MID_SCAN: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
MID_SCAN.store(false, std::sync::atomic::Ordering::Relaxed);
let graph = scan_graph(PARALLEL_MIN_ROWS_INTERPRETED * 10);
let pattern = NodePattern {
variable: Some("n".to_string()),
node_type: Some("Item".to_string()),
extra_labels: Vec::new(),
properties: Some(HashMap::from([(
"cat".to_string(),
PropertyMatcher::Equals(Value::String("cat_3".to_string())),
)])),
label_params: Vec::new(),
};
let before = parallel_candidate_scans();
let flipper = std::thread::spawn(move || {
let give_up = std::time::Instant::now() + std::time::Duration::from_secs(30);
while parallel_candidate_scans() == before && std::time::Instant::now() < give_up {
std::hint::spin_loop();
}
MID_SCAN.store(true, std::sync::atomic::Ordering::Relaxed);
});
let outcome = PatternExecutor::new(&graph, None)
.set_parallel(true)
.set_cancel(Some(&MID_SCAN))
.find_matching_nodes_pub(&pattern);
flipper.join().expect("flipper thread");
assert!(
parallel_candidate_scans() > before,
"the scan never fanned out"
);
assert_eq!(
outcome.err(),
Some("Query cancelled".to_string()),
"the parallel candidate scan ran to completion through a mid-scan cancellation"
);
}
use crate::graph::parallel::parallel_aggregations;
const AGG_QUERIES: &[&str] = &[
"MATCH (a:Item)-[:LINKS]->(b:Item) RETURN b.cat AS c, count(*) AS n",
"MATCH (a:Item)-[:LINKS]->(b:Item) RETURN b.cat AS c, sum(a.value) AS s, avg(a.value) AS av",
"MATCH (a:Item)-[:LINKS]->(b:Item) RETURN b.cat AS c, min(a.value) AS lo, max(a.value) AS hi",
"MATCH (a:Item)-[:LINKS]->(b:Item) RETURN b.cat AS c, count(DISTINCT a.value) AS d",
"MATCH (a:Item)-[:LINKS]->(b:Item) RETURN b.cat AS c, collect(a.value) AS vals",
"MATCH (a:Item)-[:LINKS]->(b:Item) RETURN b.cat AS c, collect(DISTINCT a.value) AS vals",
"MATCH (a:Item)-[:LINKS]->(b:Item) RETURN toUpper(b.cat) AS c, collect(a.value) AS v",
"MATCH (a:Item)-[:LINKS]->(b:Item) RETURN toUpper(b.cat) AS c, count(*) AS n, min(a.value) AS lo",
];
const AGG_QUERIES_EXTRA: &[&str] = &[
"MATCH (a:Item)-[:LINKS]->(b:Item) RETURN b.cat AS c, std(a.value) AS sd, variance(a.value) AS vr",
"MATCH (a:Item)-[:LINKS]->(b:Item) RETURN b.cat AS c, median(a.value) AS md, mode(a.value) AS mo",
"MATCH (a:Item)-[:LINKS]->(b:Item) RETURN b.cat AS c, percentile_cont(a.value, 0.9) AS p90, percentile_disc(a.value, 0.5) AS p50",
"MATCH (a:Item)-[:LINKS]->(b:Item) RETURN b.cat AS c, count(*) AS n ORDER BY n DESC, c ASC",
"MATCH (a:Item)-[:LINKS]->(b:Item) WITH b.cat AS c, count(*) AS n WHERE n > 1 RETURN c, n",
];
#[test]
fn parallel_aggregation_matches_serial_in_order() {
let _meter = meter_guard();
let graph = linked_graph(PARALLEL_MIN_ROWS_COMPILED * 2);
let before = parallel_aggregations();
for query in AGG_QUERIES.iter().chain(AGG_QUERIES_EXTRA) {
let serial = run(&graph, query, false);
let parallel = run(&graph, query, true);
assert_eq!(serial.columns, parallel.columns, "columns differ: {query}");
assert_eq!(
serial.rows, parallel.rows,
"parallel aggregation diverged from serial (values or group ORDER): {query}"
);
assert!(!serial.rows.is_empty(), "vacuous fixture for {query}");
}
assert!(
parallel_aggregations() > before,
"nothing fanned out — the assertions above compared two serial runs"
);
}
#[test]
fn parallel_aggregation_carries_the_global_first_row() {
let _meter = meter_guard();
let graph = linked_graph(PARALLEL_MIN_ROWS_COMPILED * 2);
let query = "MATCH (a:Item)-[:LINKS]->(b:Item) \
RETURN toUpper(b.cat) AS c, collect(a.value) AS v ORDER BY b.nid ASC";
let before = parallel_aggregations();
let serial = run(&graph, query, false);
let parallel = run(&graph, query, true);
assert!(
parallel_aggregations() > before,
"the query did not fan out its grouping pass"
);
assert_eq!(
serial.rows, parallel.rows,
"the partitioned grouping pass carried a partition-local first row"
);
assert!(
serial.rows.len() > 1,
"need several groups to be meaningful"
);
}
#[test]
fn aggregation_respects_the_gate_and_the_opt_in() {
let _meter = meter_guard();
let query = AGG_QUERIES[1];
let small = linked_graph(PARALLEL_MIN_ROWS_INTERPRETED - 1);
let before = parallel_aggregations();
run(&small, query, true);
assert_eq!(
parallel_aggregations(),
before,
"a below-gate aggregation fanned out"
);
let large = linked_graph(PARALLEL_MIN_ROWS_COMPILED * 2);
let before = parallel_aggregations();
run(&large, query, false);
assert_eq!(
parallel_aggregations(),
before,
"parallel=false fanned out its grouping pass"
);
}
#[test]
fn single_group_aggregation_stays_serial() {
let _meter = meter_guard();
let graph = linked_graph(PARALLEL_MIN_ROWS_COMPILED * 2);
let query = "MATCH (a:Item)-[:LINKS]->(b:Item) RETURN 1 AS one, collect(a.value) AS v";
let before = parallel_aggregations();
let opted_in = run(&graph, query, true);
assert_eq!(
parallel_aggregations(),
before,
"a one-group aggregation fanned out across groups"
);
assert_eq!(opted_in.rows, run(&graph, query, false).rows);
}
use crate::graph::parallel::parallel_sort_keys;
#[test]
fn parallel_sort_keys_match_serial_in_order() {
let _meter = meter_guard();
let graph = scan_graph(PARALLEL_MIN_ROWS_INTERPRETED * 4);
let before = parallel_sort_keys();
for query in [
"MATCH (n:Item) RETURN n.name AS nm, n.cat AS c ORDER BY n.cat ASC",
"MATCH (n:Item) RETURN n.name AS nm ORDER BY n.value DESC, n.name ASC",
"MATCH (n:Item) RETURN n.name AS nm ORDER BY toUpper(n.cat) ASC, n.nid ASC",
] {
let serial = run(&graph, query, false);
let parallel = run(&graph, query, true);
assert_eq!(
serial.rows, parallel.rows,
"parallel sort keys changed the sorted order (stability?): {query}"
);
assert!(!serial.rows.is_empty(), "vacuous fixture for {query}");
}
assert!(
parallel_sort_keys() > before,
"no ORDER BY fanned out its sort-key precompute"
);
}
#[test]
fn sort_keys_respect_the_gate_and_the_opt_in() {
let _meter = meter_guard();
let query = "MATCH (n:Item) RETURN n.name AS nm ORDER BY n.value DESC";
let small = scan_graph(PARALLEL_MIN_ROWS_INTERPRETED - 1);
let before = parallel_sort_keys();
run(&small, query, true);
assert_eq!(
parallel_sort_keys(),
before,
"a below-gate ORDER BY fanned out"
);
let large = scan_graph(PARALLEL_MIN_ROWS_INTERPRETED * 4);
let before = parallel_sort_keys();
run(&large, query, false);
assert_eq!(
parallel_sort_keys(),
before,
"parallel=false fanned out its sort keys"
);
}
#[test]
fn regex_predicate_matches_across_modes() {
let _meter = meter_guard();
let graph = scan_graph(PARALLEL_MIN_ROWS_INTERPRETED * 4);
for query in [
"MATCH (n:Item) WHERE n.name =~ '.*_1[0-9][0-9]$' RETURN count(*) AS n",
"MATCH (n:Item) WHERE n.cat =~ 'cat_[135]' RETURN n.name AS nm",
"MATCH (n:Item) WHERE NOT n.cat =~ 'cat_[135]' RETURN count(*) AS n",
] {
let serial = run(&graph, query, false);
let parallel = run(&graph, query, true);
assert_eq!(serial.rows, parallel.rows, "regex diverged: {query}");
assert!(!serial.rows.is_empty(), "vacuous fixture for {query}");
}
}