fn read_filter_names(names: &[&str]) -> Vec<String> {
names.iter().map(|name| (*name).to_string()).collect()
}
fn read_node_label_filter(names: &[&str], mode: LabelMatchMode) -> NodeLabelFilter {
NodeLabelFilter {
labels: read_filter_names(names),
mode,
}
}
fn read_node_key_queries(keys: &[(&str, &str)]) -> Vec<NodeKeyQuery> {
keys.iter()
.map(|&(label, key)| NodeKeyQuery {
label: label.to_string(),
key: key.to_string(),
})
.collect()
}
fn read_query_test_props(entries: &[(&str, PropValue)]) -> BTreeMap<String, PropValue> {
entries
.iter()
.map(|(key, value)| ((*key).to_string(), value.clone()))
.collect()
}
fn traverse_depth_two_read(
engine: &DatabaseEngine,
start: u64,
direction: Direction,
edge_label_filter: Option<&[&str]>,
node_label_filter: Option<&[&str]>,
at_epoch: Option<i64>,
) -> Vec<TraversalHit> {
engine
.traverse(
start,
2,
&TraverseOptions {
min_depth: 2,
direction,
edge_label_filter: edge_label_filter.map(read_filter_names),
emit_node_label_filter: node_label_filter
.map(|labels| read_node_label_filter(labels, LabelMatchMode::Any)),
at_epoch,
..Default::default()
},
)
.unwrap()
.items
}
#[test]
fn test_nodes_by_labels_memtable_only() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let a = engine
.upsert_node(
"Person",
"alice",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
let b = engine
.upsert_node(
"Person",
"bob",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
let c = engine
.upsert_node(
"Company",
"charlie",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
let mut person_ids = engine.nodes_by_labels("Person").unwrap();
person_ids.sort();
assert_eq!(person_ids, vec![a, b]);
assert_eq!(engine.nodes_by_labels("Company").unwrap(), vec![c]);
assert!(engine.nodes_by_labels("MissingLabel").unwrap().is_empty());
engine.close().unwrap();
}
#[test]
fn test_nodes_by_labels_multi_label_all() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let both = engine
.upsert_node(
&["Person", "Employee"],
"alice",
UpsertNodeOptions::default(),
)
.unwrap();
let person_only = engine
.upsert_node("Person", "bob", UpsertNodeOptions::default())
.unwrap();
let _employee_only = engine
.upsert_node("Employee", "cara", UpsertNodeOptions::default())
.unwrap();
assert_eq!(
engine
.nodes_by_labels(vec!["Person".to_string(), "Employee".to_string()])
.unwrap(),
vec![both]
);
assert_eq!(
engine.nodes_by_labels("Person").unwrap(),
vec![both, person_only]
);
engine.close().unwrap();
}
#[test]
fn test_edges_by_label_memtable_only() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let a = engine
.upsert_node(
"Person",
"a",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
let b = engine
.upsert_node(
"Person",
"b",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
let e1 = engine
.upsert_edge(a, b, "KNOWS", UpsertEdgeOptions::default())
.unwrap();
let e2 = engine
.upsert_edge(a, b, "REPORTS_TO", UpsertEdgeOptions::default())
.unwrap();
assert_eq!(engine.edges_by_label("KNOWS").unwrap(), vec![e1]);
assert_eq!(engine.edges_by_label("REPORTS_TO").unwrap(), vec![e2]);
assert!(engine.edges_by_label("MISSING_EDGE_LABEL").unwrap().is_empty());
engine.close().unwrap();
}
#[test]
fn test_nodes_by_labels_cross_source() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let a = engine
.upsert_node(
"Person",
"alice",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
let b = engine
.upsert_node(
"Person",
"bob",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
let c = engine
.upsert_node(
"Person",
"charlie",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
let d = engine
.upsert_node(
"Company",
"delta",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
let mut person_ids = engine.nodes_by_labels("Person").unwrap();
person_ids.sort();
assert_eq!(person_ids, vec![a, b, c]);
assert_eq!(engine.nodes_by_labels("Company").unwrap(), vec![d]);
engine.close().unwrap();
}
#[test]
fn test_nodes_by_labels_excludes_deleted() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let a = engine
.upsert_node(
"Person",
"alice",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
let b = engine
.upsert_node(
"Person",
"bob",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
engine.delete_node(a).unwrap();
let person_ids = engine.nodes_by_labels("Person").unwrap();
assert_eq!(person_ids, vec![b]);
engine.close().unwrap();
}
#[test]
fn test_label_index_survives_flush_and_reopen() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let a;
let b;
{
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
a = engine
.upsert_node(
"Person",
"alice",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
b = engine
.upsert_node(
"Company",
"bob",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
engine
.upsert_edge(a, b, "KNOWS", UpsertEdgeOptions::default())
.unwrap();
engine.flush().unwrap();
engine.close().unwrap();
}
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
assert_eq!(engine.nodes_by_labels("Person").unwrap(), vec![a]);
assert_eq!(engine.nodes_by_labels("Company").unwrap(), vec![b]);
assert_eq!(engine.edges_by_label("KNOWS").unwrap().len(), 1);
engine.close().unwrap();
}
#[test]
fn test_get_nodes_by_labels_memtable_only() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let mut props = BTreeMap::new();
props.insert("name".to_string(), PropValue::String("Alice".to_string()));
engine
.upsert_node(
"Person",
"alice",
UpsertNodeOptions {
props: props.clone(),
weight: 0.9,
..Default::default()
},
)
.unwrap();
props.insert("name".to_string(), PropValue::String("Bob".to_string()));
engine
.upsert_node(
"Person",
"bob",
UpsertNodeOptions {
props,
weight: 0.8,
..Default::default()
},
)
.unwrap();
engine
.upsert_node(
"Company",
"charlie",
UpsertNodeOptions {
weight: 0.7,
..Default::default()
},
)
.unwrap();
let people = engine.get_nodes_by_labels("Person").unwrap();
assert_eq!(people.len(), 2);
assert!(people.iter().all(|n| n.labels.as_slice() == ["Person"]));
assert!(people.iter().any(|n| n.key == "alice"));
assert!(people.iter().any(|n| n.key == "bob"));
let companies = engine.get_nodes_by_labels("Company").unwrap();
assert_eq!(companies.len(), 1);
assert_eq!(companies[0].key, "charlie");
let empty = engine.get_nodes_by_labels("MissingLabel").unwrap();
assert!(empty.is_empty());
engine.close().unwrap();
}
#[test]
fn test_get_nodes_by_labels_multi_label_all() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let both = engine
.upsert_node(
&["Person", "Employee"],
"alice",
UpsertNodeOptions::default(),
)
.unwrap();
let _person_only = engine
.upsert_node("Person", "bob", UpsertNodeOptions::default())
.unwrap();
let _employee_only = engine
.upsert_node("Employee", "cara", UpsertNodeOptions::default())
.unwrap();
let nodes = engine
.get_nodes_by_labels(vec!["Person".to_string(), "Employee".to_string()])
.unwrap();
assert_eq!(
nodes.iter().map(|node| node.id).collect::<Vec<_>>(),
vec![both]
);
assert_eq!(
nodes[0].labels,
vec!["Person".to_string(), "Employee".to_string()]
);
engine.close().unwrap();
}
#[test]
fn test_get_nodes_by_labels_cross_source() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let opts = DbOptions {
compact_after_n_flushes: 0,
..DbOptions::default()
};
let engine = DatabaseEngine::open(&db_path, &opts).unwrap();
engine
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
engine
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
engine.flush().unwrap();
engine
.upsert_node("Person", "c", UpsertNodeOptions::default())
.unwrap();
let records = engine.get_nodes_by_labels("Person").unwrap();
assert_eq!(records.len(), 3);
let keys: Vec<&str> = records.iter().map(|n| n.key.as_str()).collect();
assert!(keys.contains(&"a"));
assert!(keys.contains(&"b"));
assert!(keys.contains(&"c"));
for r in &records {
assert_eq!(r.labels.as_slice(), ["Person"]);
assert!(r.weight > 0.0);
assert!(r.created_at > 0);
}
engine.close().unwrap();
}
#[test]
fn test_get_nodes_by_labels_excludes_deleted() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let a = engine
.upsert_node(
"Person",
"alice",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
engine
.upsert_node(
"Person",
"bob",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
engine.delete_node(a).unwrap();
let records = engine.get_nodes_by_labels("Person").unwrap();
assert_eq!(records.len(), 1);
assert_eq!(records[0].key, "bob");
engine.close().unwrap();
}
#[test]
fn test_get_nodes_by_labels_excludes_pruned() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
engine
.upsert_node(
"Person",
"low",
UpsertNodeOptions {
weight: 0.1,
..Default::default()
},
)
.unwrap();
engine
.upsert_node(
"Person",
"high",
UpsertNodeOptions {
weight: 0.9,
..Default::default()
},
)
.unwrap();
engine
.set_prune_policy(
"low-weight",
PrunePolicy {
max_weight: Some(0.5),
max_age_ms: None,
label: None,
},
)
.unwrap();
let records = engine.get_nodes_by_labels("Person").unwrap();
assert_eq!(records.len(), 1);
assert_eq!(records[0].key, "high");
engine.close().unwrap();
}
#[test]
fn test_get_nodes_by_labels_post_compaction() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let opts = DbOptions {
compact_after_n_flushes: 0,
..DbOptions::default()
};
let engine = DatabaseEngine::open(&db_path, &opts).unwrap();
engine
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
engine
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
engine.flush().unwrap();
engine
.upsert_node("Person", "c", UpsertNodeOptions::default())
.unwrap();
engine.flush().unwrap();
engine.compact().unwrap();
let records = engine.get_nodes_by_labels("Person").unwrap();
assert_eq!(records.len(), 3);
engine.close().unwrap();
}
#[test]
fn test_get_edges_by_label_memtable_and_segment() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let opts = DbOptions {
edge_uniqueness: true,
compact_after_n_flushes: 0,
..DbOptions::default()
};
let engine = DatabaseEngine::open(&db_path, &opts).unwrap();
let a = engine
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let b = engine
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
let c = engine
.upsert_node("Person", "c", UpsertNodeOptions::default())
.unwrap();
engine
.upsert_edge(a, b, "KNOWS", UpsertEdgeOptions::default())
.unwrap();
engine.flush().unwrap();
engine
.upsert_edge(
b,
c,
"KNOWS",
UpsertEdgeOptions {
weight: 0.8,
..Default::default()
},
)
.unwrap();
engine
.upsert_edge(
a,
c,
"REPORTS_TO",
UpsertEdgeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
let label10 = engine.get_edges_by_label("KNOWS").unwrap();
assert_eq!(label10.len(), 2);
assert!(label10.iter().all(|e| e.label == "KNOWS"));
let label20 = engine.get_edges_by_label("REPORTS_TO").unwrap();
assert_eq!(label20.len(), 1);
assert_eq!(label20[0].label, "REPORTS_TO");
for e in &label10 {
assert!(e.weight > 0.0);
assert!(e.from > 0);
assert!(e.to > 0);
}
let empty = engine.get_edges_by_label("MISSING_EDGE_LABEL").unwrap();
assert!(empty.is_empty());
engine.close().unwrap();
}
#[test]
fn test_get_edges_by_label_excludes_deleted() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let opts = DbOptions {
edge_uniqueness: true,
compact_after_n_flushes: 0,
..DbOptions::default()
};
let engine = DatabaseEngine::open(&db_path, &opts).unwrap();
let a = engine
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let b = engine
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
let c = engine
.upsert_node("Person", "c", UpsertNodeOptions::default())
.unwrap();
let e1 = engine
.upsert_edge(a, b, "KNOWS", UpsertEdgeOptions::default())
.unwrap();
engine
.upsert_edge(b, c, "KNOWS", UpsertEdgeOptions::default())
.unwrap();
engine.flush().unwrap();
engine.delete_edge(e1).unwrap();
let label10 = engine.get_edges_by_label("KNOWS").unwrap();
assert_eq!(label10.len(), 1);
assert_eq!(label10[0].from, b);
assert_eq!(label10[0].to, c);
engine.close().unwrap();
}
#[test]
fn test_count_nodes_by_labels_single_label() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let opts = DbOptions {
compact_after_n_flushes: 0,
..DbOptions::default()
};
let engine = DatabaseEngine::open(&db_path, &opts).unwrap();
engine
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
engine
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
engine.flush().unwrap();
engine
.upsert_node("Person", "c", UpsertNodeOptions::default())
.unwrap();
engine
.upsert_node("Company", "x", UpsertNodeOptions::default())
.unwrap();
assert_eq!(engine.count_nodes_by_labels("Person").unwrap(), 3);
assert_eq!(engine.count_nodes_by_labels("Company").unwrap(), 1);
assert_eq!(engine.count_nodes_by_labels("MissingLabel").unwrap(), 0);
engine.close().unwrap();
}
#[test]
fn test_count_nodes_by_labels_multi_label_all() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
engine
.upsert_node(
&["Person", "Employee"],
"alice",
UpsertNodeOptions::default(),
)
.unwrap();
engine
.upsert_node(
&["Person", "Employee"],
"bob",
UpsertNodeOptions::default(),
)
.unwrap();
engine
.upsert_node("Person", "cara", UpsertNodeOptions::default())
.unwrap();
assert_eq!(
engine
.count_nodes_by_labels(vec!["Person".to_string(), "Employee".to_string()])
.unwrap(),
2
);
assert_eq!(engine.count_nodes_by_labels("Person").unwrap(), 3);
engine.close().unwrap();
}
#[test]
fn test_count_nodes_by_labels_suppresses_stale_memberships() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let opts = DbOptions {
compact_after_n_flushes: 0,
..DbOptions::default()
};
{
let engine = DatabaseEngine::open(&db_path, &opts).unwrap();
let id = engine
.upsert_node(
&["Person", "Employee"],
"alice",
UpsertNodeOptions::default(),
)
.unwrap();
engine.flush().unwrap();
assert_eq!(
engine
.upsert_node("Person", "alice", UpsertNodeOptions::default())
.unwrap(),
id
);
assert_eq!(
engine
.count_nodes_by_labels(vec!["Person".to_string(), "Employee".to_string()])
.unwrap(),
0
);
engine.flush().unwrap();
engine.close().unwrap();
}
{
let engine = DatabaseEngine::open(&db_path, &opts).unwrap();
assert_eq!(
engine
.count_nodes_by_labels(vec!["Person".to_string(), "Employee".to_string()])
.unwrap(),
0
);
engine.compact().unwrap();
assert_eq!(
engine
.count_nodes_by_labels(vec!["Person".to_string(), "Employee".to_string()])
.unwrap(),
0
);
engine.close().unwrap();
}
}
#[test]
fn test_nodes_by_labels_all_superset_verifies_after_replacement() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let opts = DbOptions {
compact_after_n_flushes: 0,
..DbOptions::default()
};
{
let engine = DatabaseEngine::open(&db_path, &opts).unwrap();
let id = engine
.upsert_node(
&["Person", "Employee"],
"alice",
UpsertNodeOptions::default(),
)
.unwrap();
engine.flush().unwrap();
assert_eq!(
engine
.upsert_node("Person", "alice", UpsertNodeOptions::default())
.unwrap(),
id
);
assert!(engine
.nodes_by_labels(&["Person", "Employee"])
.unwrap()
.is_empty());
assert!(engine
.get_nodes_by_labels(&["Person", "Employee"])
.unwrap()
.is_empty());
engine.flush().unwrap();
engine.close().unwrap();
}
{
let engine = DatabaseEngine::open(&db_path, &opts).unwrap();
assert!(engine
.nodes_by_labels(&["Person", "Employee"])
.unwrap()
.is_empty());
assert!(engine
.get_nodes_by_labels(&["Person", "Employee"])
.unwrap()
.is_empty());
engine.compact().unwrap();
assert!(engine
.nodes_by_labels(&["Person", "Employee"])
.unwrap()
.is_empty());
engine.close().unwrap();
}
}
#[test]
fn test_count_nodes_by_labels_unknown_and_invalid_inputs() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
engine
.upsert_node("Person", "alice", UpsertNodeOptions::default())
.unwrap();
assert_eq!(engine.count_nodes_by_labels("Missing").unwrap(), 0);
assert_eq!(
engine
.count_nodes_by_labels(vec!["Person".to_string(), "Missing".to_string()])
.unwrap(),
0
);
assert!(engine.count_nodes_by_labels(Vec::<String>::new()).is_err());
assert!(engine
.count_nodes_by_labels(vec!["Person".to_string(), "Person".to_string()])
.is_err());
engine.close().unwrap();
}
#[test]
fn test_count_nodes_by_labels_respects_policies() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
engine
.upsert_node(
&["Person", "Employee"],
"low",
UpsertNodeOptions {
weight: 0.1,
..Default::default()
},
)
.unwrap();
engine
.upsert_node(
&["Person", "Employee"],
"high",
UpsertNodeOptions {
weight: 0.9,
..Default::default()
},
)
.unwrap();
assert_eq!(
engine
.count_nodes_by_labels(vec!["Person".to_string(), "Employee".to_string()])
.unwrap(),
2
);
engine
.set_prune_policy(
"low-weight",
PrunePolicy {
max_weight: Some(0.5),
max_age_ms: None,
label: None,
},
)
.unwrap();
assert_eq!(
engine
.count_nodes_by_labels(vec!["Person".to_string(), "Employee".to_string()])
.unwrap(),
1
);
engine.close().unwrap();
}
#[test]
fn test_count_edges_by_label() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let opts = DbOptions {
edge_uniqueness: true,
compact_after_n_flushes: 0,
..DbOptions::default()
};
let engine = DatabaseEngine::open(&db_path, &opts).unwrap();
let a = engine
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let b = engine
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
let c = engine
.upsert_node("Person", "c", UpsertNodeOptions::default())
.unwrap();
engine
.upsert_edge(a, b, "KNOWS", UpsertEdgeOptions::default())
.unwrap();
engine
.upsert_edge(b, c, "KNOWS", UpsertEdgeOptions::default())
.unwrap();
engine.flush().unwrap();
engine
.upsert_edge(a, c, "REPORTS_TO", UpsertEdgeOptions::default())
.unwrap();
assert_eq!(engine.count_edges_by_label("KNOWS").unwrap(), 2);
assert_eq!(engine.count_edges_by_label("REPORTS_TO").unwrap(), 1);
assert_eq!(engine.count_edges_by_label("MISSING_EDGE_LABEL").unwrap(), 0);
engine.close().unwrap();
}
#[test]
fn test_count_nodes_by_labels_single_label_respects_policies() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
engine
.upsert_node(
"Person",
"low",
UpsertNodeOptions {
weight: 0.1,
..Default::default()
},
)
.unwrap();
engine
.upsert_node(
"Person",
"high",
UpsertNodeOptions {
weight: 0.9,
..Default::default()
},
)
.unwrap();
assert_eq!(engine.count_nodes_by_labels("Person").unwrap(), 2);
engine
.set_prune_policy(
"low-weight",
PrunePolicy {
max_weight: Some(0.5),
max_age_ms: None,
label: None,
},
)
.unwrap();
assert_eq!(engine.count_nodes_by_labels("Person").unwrap(), 1);
engine.close().unwrap();
}
#[test]
fn test_nodes_by_labels_paged_basic() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let mut ids: Vec<u64> = Vec::new();
for i in 0..10 {
let id = engine
.upsert_node("Person", &format!("n{}", i), UpsertNodeOptions::default())
.unwrap();
ids.push(id);
}
ids.sort();
let page1 = engine
.nodes_by_labels_paged("Person",
&PageRequest {
limit: Some(3),
after: None,
},
)
.unwrap();
assert_eq!(page1.items.len(), 3);
assert_eq!(page1.items, ids[0..3]);
assert!(page1.next_cursor.is_some());
let page2 = engine
.nodes_by_labels_paged("Person",
&PageRequest {
limit: Some(3),
after: page1.next_cursor,
},
)
.unwrap();
assert_eq!(page2.items.len(), 3);
assert_eq!(page2.items, ids[3..6]);
assert!(page2.next_cursor.is_some());
let page3 = engine
.nodes_by_labels_paged("Person",
&PageRequest {
limit: Some(3),
after: page2.next_cursor,
},
)
.unwrap();
assert_eq!(page3.items.len(), 3);
assert_eq!(page3.items, ids[6..9]);
assert!(page3.next_cursor.is_some());
let page4 = engine
.nodes_by_labels_paged("Person",
&PageRequest {
limit: Some(3),
after: page3.next_cursor,
},
)
.unwrap();
assert_eq!(page4.items.len(), 1);
assert_eq!(page4.items, ids[9..10]);
assert!(page4.next_cursor.is_none()); }
#[test]
fn test_nodes_by_labels_paged_multi_label_all() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let alice = engine
.upsert_node(&["Person", "Admin"], "alice", UpsertNodeOptions::default())
.unwrap();
let bob = engine
.upsert_node(&["Person", "Admin"], "bob", UpsertNodeOptions::default())
.unwrap();
engine
.upsert_node("Person", "carol", UpsertNodeOptions::default())
.unwrap();
engine
.upsert_node("Admin", "dave", UpsertNodeOptions::default())
.unwrap();
let page1 = engine
.nodes_by_labels_paged(
&["Person", "Admin"],
&PageRequest {
limit: Some(1),
after: None,
},
)
.unwrap();
assert_eq!(page1.items, vec![alice]);
assert_eq!(page1.next_cursor, Some(alice));
let page2 = engine
.nodes_by_labels_paged(
&["Person", "Admin"],
&PageRequest {
limit: Some(1),
after: page1.next_cursor,
},
)
.unwrap();
assert_eq!(page2.items, vec![bob]);
assert_eq!(page2.next_cursor, None);
}
#[test]
fn test_nodes_by_labels_paged_roundtrip() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
for i in 0..20 {
engine
.upsert_node("Person", &format!("n{}", i), UpsertNodeOptions::default())
.unwrap();
}
let mut all_paged: Vec<u64> = Vec::new();
let mut cursor: Option<u64> = None;
loop {
let page = engine
.nodes_by_labels_paged("Person",
&PageRequest {
limit: Some(4),
after: cursor,
},
)
.unwrap();
all_paged.extend(&page.items);
cursor = page.next_cursor;
if cursor.is_none() {
break;
}
}
let mut all_unpaged = engine.nodes_by_labels("Person").unwrap();
all_unpaged.sort();
assert_eq!(all_paged, all_unpaged);
}
#[test]
fn test_nodes_by_labels_paged_default_returns_all() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
for i in 0..5 {
engine
.upsert_node("Person", &format!("n{}", i), UpsertNodeOptions::default())
.unwrap();
}
let result = engine
.nodes_by_labels_paged("Person", &PageRequest::default())
.unwrap();
assert_eq!(result.items.len(), 5);
assert!(result.next_cursor.is_none());
}
#[test]
fn test_nodes_by_labels_paged_empty_label() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let result = engine
.nodes_by_labels_paged("MissingLabel",
&PageRequest {
limit: Some(10),
after: None,
},
)
.unwrap();
assert!(result.items.is_empty());
assert!(result.next_cursor.is_none());
}
#[test]
fn test_nodes_by_labels_paged_cursor_past_end() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
for i in 0..3 {
engine
.upsert_node("Person", &format!("n{}", i), UpsertNodeOptions::default())
.unwrap();
}
let result = engine
.nodes_by_labels_paged("Person",
&PageRequest {
limit: Some(10),
after: Some(u64::MAX),
},
)
.unwrap();
assert!(result.items.is_empty());
assert!(result.next_cursor.is_none());
}
#[test]
fn test_nodes_by_labels_paged_cross_source() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
for i in 0..5 {
engine
.upsert_node("Person", &format!("seg{}", i), UpsertNodeOptions::default())
.unwrap();
}
engine.flush().unwrap();
for i in 0..5 {
engine
.upsert_node("Person", &format!("mem{}", i), UpsertNodeOptions::default())
.unwrap();
}
let mut all_paged: Vec<u64> = Vec::new();
let mut cursor: Option<u64> = None;
loop {
let page = engine
.nodes_by_labels_paged("Person",
&PageRequest {
limit: Some(3),
after: cursor,
},
)
.unwrap();
all_paged.extend(&page.items);
cursor = page.next_cursor;
if cursor.is_none() {
break;
}
}
assert_eq!(all_paged.len(), 10);
for i in 1..all_paged.len() {
assert!(all_paged[i] > all_paged[i - 1]);
}
}
#[test]
fn test_nodes_by_labels_paged_respects_tombstones() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let id1 = engine
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let id2 = engine
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
let id3 = engine
.upsert_node("Person", "c", UpsertNodeOptions::default())
.unwrap();
engine.delete_node(id2).unwrap();
let result = engine
.nodes_by_labels_paged("Person",
&PageRequest {
limit: Some(10),
after: None,
},
)
.unwrap();
let mut expected = vec![id1, id3];
expected.sort();
assert_eq!(result.items, expected);
assert!(result.next_cursor.is_none());
}
#[test]
fn test_nodes_by_labels_paged_respects_prune_policies() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
engine
.upsert_node("Person", "keep", UpsertNodeOptions::default())
.unwrap();
engine
.upsert_node(
"Person",
"prune_me",
UpsertNodeOptions {
weight: 0.1,
..Default::default()
},
)
.unwrap();
engine
.set_prune_policy(
"low_weight",
PrunePolicy {
max_age_ms: None,
max_weight: Some(0.5),
label: None,
},
)
.unwrap();
let result = engine
.nodes_by_labels_paged("Person",
&PageRequest {
limit: Some(10),
after: None,
},
)
.unwrap();
assert_eq!(result.items.len(), 1); }
#[test]
fn test_edges_by_label_paged_basic() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let n1 = engine
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let n2 = engine
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
let n3 = engine
.upsert_node("Person", "c", UpsertNodeOptions::default())
.unwrap();
let mut edge_ids: Vec<u64> = Vec::new();
for _ in 0..6 {
let eid = engine
.upsert_edge(n1, n2, "OWNS", UpsertEdgeOptions::default())
.unwrap();
edge_ids.push(eid);
let eid = engine
.upsert_edge(n2, n3, "OWNS", UpsertEdgeOptions::default())
.unwrap();
edge_ids.push(eid);
}
edge_ids.sort();
let page1 = engine
.edges_by_label_paged("OWNS",
&PageRequest {
limit: Some(2),
after: None,
},
)
.unwrap();
assert_eq!(page1.items.len(), 2);
assert!(page1.next_cursor.is_some());
let page2 = engine
.edges_by_label_paged("OWNS",
&PageRequest {
limit: Some(2),
after: page1.next_cursor,
},
)
.unwrap();
assert_eq!(page2.items.len(), 2);
}
#[test]
fn test_edges_by_label_paged_roundtrip() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let n1 = engine
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let n2 = engine
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
for _ in 0..10 {
engine
.upsert_edge(n1, n2, "LIKES", UpsertEdgeOptions::default())
.unwrap();
}
let mut all_paged: Vec<u64> = Vec::new();
let mut cursor: Option<u64> = None;
loop {
let page = engine
.edges_by_label_paged("LIKES",
&PageRequest {
limit: Some(3),
after: cursor,
},
)
.unwrap();
all_paged.extend(&page.items);
cursor = page.next_cursor;
if cursor.is_none() {
break;
}
}
let mut all_unpaged = engine.edges_by_label("LIKES").unwrap();
all_unpaged.sort();
assert_eq!(all_paged, all_unpaged);
}
#[test]
fn test_get_nodes_by_labels_paged_hydrates_page_only() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
for i in 0..10 {
let mut props = BTreeMap::new();
props.insert("idx".to_string(), PropValue::Int(i));
engine
.upsert_node(
"Person",
&format!("n{}", i),
UpsertNodeOptions {
props,
..Default::default()
},
)
.unwrap();
}
let page1 = engine
.get_nodes_by_labels_paged("Person",
&PageRequest {
limit: Some(3),
after: None,
},
)
.unwrap();
assert_eq!(page1.items.len(), 3);
assert!(page1.next_cursor.is_some());
for node in &page1.items {
assert_eq!(node.labels.as_slice(), ["Person"]);
assert!(node.props.contains_key("idx"));
}
let page2 = engine
.get_nodes_by_labels_paged("Person",
&PageRequest {
limit: Some(3),
after: page1.next_cursor,
},
)
.unwrap();
assert_eq!(page2.items.len(), 3);
let page1_ids: Vec<u64> = page1.items.iter().map(|n| n.id).collect();
let page2_ids: Vec<u64> = page2.items.iter().map(|n| n.id).collect();
for id in &page2_ids {
assert!(!page1_ids.contains(id));
}
}
#[test]
fn test_get_nodes_by_labels_paged_multi_label_all() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let alice = engine
.upsert_node(&["Person", "Admin"], "alice", UpsertNodeOptions::default())
.unwrap();
let bob = engine
.upsert_node(&["Person", "Admin"], "bob", UpsertNodeOptions::default())
.unwrap();
engine
.upsert_node("Person", "carol", UpsertNodeOptions::default())
.unwrap();
engine
.upsert_node("Admin", "dave", UpsertNodeOptions::default())
.unwrap();
let page = engine
.get_nodes_by_labels_paged(
&["Person", "Admin"],
&PageRequest {
limit: Some(2),
after: None,
},
)
.unwrap();
let ids: Vec<u64> = page.items.iter().map(|node| node.id).collect();
assert_eq!(ids, vec![alice, bob]);
assert_eq!(page.next_cursor, None);
assert_eq!(page.items[0].labels.as_slice(), ["Person", "Admin"]);
assert_eq!(page.items[1].labels.as_slice(), ["Person", "Admin"]);
}
#[test]
fn test_get_edges_by_label_paged() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let n1 = engine
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let n2 = engine
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
for _ in 0..6 {
engine
.upsert_edge(n1, n2, "FRIENDS_WITH", UpsertEdgeOptions::default())
.unwrap();
}
let page1 = engine
.get_edges_by_label_paged("FRIENDS_WITH",
&PageRequest {
limit: Some(2),
after: None,
},
)
.unwrap();
assert_eq!(page1.items.len(), 2);
assert!(page1.next_cursor.is_some());
for edge in &page1.items {
assert_eq!(edge.label, "FRIENDS_WITH");
assert_eq!(edge.from, n1);
assert_eq!(edge.to, n2);
}
let mut all_paged: Vec<EdgeView> = Vec::new();
let mut cursor: Option<u64> = None;
loop {
let page = engine
.get_edges_by_label_paged("FRIENDS_WITH",
&PageRequest {
limit: Some(2),
after: cursor,
},
)
.unwrap();
cursor = page.next_cursor;
all_paged.extend(page.items);
if cursor.is_none() {
break;
}
}
assert_eq!(all_paged.len(), 6);
}
#[test]
fn test_paged_single_item_pages() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let id1 = engine
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let id2 = engine
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
let id3 = engine
.upsert_node("Person", "c", UpsertNodeOptions::default())
.unwrap();
let mut expected = [id1, id2, id3];
expected.sort();
let p1 = engine
.nodes_by_labels_paged("Person",
&PageRequest {
limit: Some(1),
after: None,
},
)
.unwrap();
assert_eq!(p1.items, vec![expected[0]]);
assert!(p1.next_cursor.is_some());
let p2 = engine
.nodes_by_labels_paged("Person",
&PageRequest {
limit: Some(1),
after: p1.next_cursor,
},
)
.unwrap();
assert_eq!(p2.items, vec![expected[1]]);
assert!(p2.next_cursor.is_some());
let p3 = engine
.nodes_by_labels_paged("Person",
&PageRequest {
limit: Some(1),
after: p2.next_cursor,
},
)
.unwrap();
assert_eq!(p3.items, vec![expected[2]]);
assert!(p3.next_cursor.is_none()); }
#[test]
fn test_paged_limit_larger_than_result_set() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
engine
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
engine
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
let result = engine
.nodes_by_labels_paged("Person",
&PageRequest {
limit: Some(100),
after: None,
},
)
.unwrap();
assert_eq!(result.items.len(), 2);
assert!(result.next_cursor.is_none()); }
#[test]
fn test_paged_limit_zero_returns_all() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
for i in 0..5 {
engine
.upsert_node("Person", &format!("n{}", i), UpsertNodeOptions::default())
.unwrap();
}
let result = engine
.nodes_by_labels_paged("Person",
&PageRequest {
limit: Some(0),
after: None,
},
)
.unwrap();
assert_eq!(result.items.len(), 5);
assert!(result.next_cursor.is_none());
}
#[test]
fn test_paged_cursor_on_deleted_id() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let id1 = engine
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let id2 = engine
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
let id3 = engine
.upsert_node("Person", "c", UpsertNodeOptions::default())
.unwrap();
engine.delete_node(id2).unwrap();
let result = engine
.nodes_by_labels_paged("Person",
&PageRequest {
limit: Some(10),
after: Some(id2),
},
)
.unwrap();
let mut expected: Vec<u64> = vec![id1, id3].into_iter().filter(|&id| id > id2).collect();
expected.sort();
assert_eq!(result.items, expected);
}
#[test]
fn test_merge_paged_early_termination() {
let memtable = vec![5u64, 1, 9]; let seg1 = vec![2u64, 4, 6, 8, 10];
let seg2 = vec![3u64, 7];
let deleted = NodeIdSet::default();
let page = PageRequest {
limit: Some(4),
after: None,
};
let result = merge_record_ids_paged(
memtable.clone(),
vec![seg1.clone(), seg2.clone()],
&deleted,
&page,
);
assert_eq!(result.items, vec![1, 2, 3, 4]);
assert!(result.next_cursor.is_some());
let page2 = PageRequest {
limit: Some(4),
after: result.next_cursor,
};
let result2 = merge_record_ids_paged(
memtable.clone(),
vec![seg1.clone(), seg2.clone()],
&deleted,
&page2,
);
assert_eq!(result2.items, vec![5, 6, 7, 8]);
assert!(result2.next_cursor.is_some());
let page3 = PageRequest {
limit: Some(4),
after: result2.next_cursor,
};
let result3 = merge_record_ids_paged(memtable, vec![seg1, seg2], &deleted, &page3);
assert_eq!(result3.items, vec![9, 10]);
assert!(result3.next_cursor.is_none());
}
#[test]
fn test_merge_paged_cross_source_sorted_output() {
let memtable = vec![10u64, 30, 50];
let seg1 = vec![20u64, 40];
let seg2 = vec![15u64, 35, 55];
let deleted = NodeIdSet::default();
let page = PageRequest {
limit: None,
after: None,
};
let result = merge_record_ids_paged(memtable, vec![seg1, seg2], &deleted, &page);
assert_eq!(result.items, vec![10, 15, 20, 30, 35, 40, 50, 55]);
assert!(result.next_cursor.is_none());
for i in 1..result.items.len() {
assert!(result.items[i] > result.items[i - 1]);
}
}
#[test]
fn test_merge_paged_dedup_across_sources() {
let memtable = vec![1u64, 3, 5];
let seg1 = vec![1u64, 2, 3]; let seg2 = vec![3u64, 4, 5]; let deleted = NodeIdSet::default();
let page = PageRequest {
limit: None,
after: None,
};
let result = merge_record_ids_paged(memtable, vec![seg1, seg2], &deleted, &page);
assert_eq!(result.items, vec![1, 2, 3, 4, 5]);
}
#[test]
fn test_merge_paged_cursor_seek() {
let memtable = vec![1u64, 5, 9];
let seg1 = vec![2u64, 6, 10];
let deleted = NodeIdSet::default();
let page = PageRequest {
limit: Some(3),
after: Some(5),
};
let result = merge_record_ids_paged(memtable, vec![seg1], &deleted, &page);
assert_eq!(result.items, vec![6, 9, 10]);
assert!(result.next_cursor.is_none());
}
#[test]
fn test_merge_paged_with_policies() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let mut keep_ids = Vec::new();
for i in 0..3 {
let id = engine
.upsert_node("Person", &format!("keep{}", i), UpsertNodeOptions::default())
.unwrap();
keep_ids.push(id);
}
for i in 0..3 {
engine
.upsert_node(
"Person",
&format!("prune{}", i),
UpsertNodeOptions {
weight: 0.1,
..Default::default()
},
)
.unwrap();
}
engine
.set_prune_policy(
"low_weight",
PrunePolicy {
max_age_ms: None,
max_weight: Some(0.5),
label: None,
},
)
.unwrap();
let p1 = engine
.nodes_by_labels_paged("Person",
&PageRequest {
limit: Some(2),
after: None,
},
)
.unwrap();
assert_eq!(p1.items.len(), 2);
assert!(p1.next_cursor.is_some());
let p2 = engine
.nodes_by_labels_paged("Person",
&PageRequest {
limit: Some(2),
after: p1.next_cursor,
},
)
.unwrap();
assert_eq!(p2.items.len(), 1);
assert!(p2.next_cursor.is_none());
let mut all_paged: Vec<u64> = Vec::new();
all_paged.extend(&p1.items);
all_paged.extend(&p2.items);
keep_ids.sort();
assert_eq!(all_paged, keep_ids);
}
#[test]
fn test_find_nodes_memtable_only() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let mut props = BTreeMap::new();
props.insert("color".to_string(), PropValue::String("red".to_string()));
let a = engine
.upsert_node(
"Person",
"apple",
UpsertNodeOptions {
props: props.clone(),
weight: 0.5,
..Default::default()
},
)
.unwrap();
let mut props2 = BTreeMap::new();
props2.insert("color".to_string(), PropValue::String("red".to_string()));
let b = engine
.upsert_node(
"Person",
"cherry",
UpsertNodeOptions {
props: props2,
weight: 0.5,
..Default::default()
},
)
.unwrap();
let mut props3 = BTreeMap::new();
props3.insert("color".to_string(), PropValue::String("green".to_string()));
engine
.upsert_node(
"Person",
"lime",
UpsertNodeOptions {
props: props3,
weight: 0.5,
..Default::default()
},
)
.unwrap();
let mut reds = engine
.find_nodes("Person", "color", &PropValue::String("red".to_string()))
.unwrap();
reds.sort();
assert_eq!(reds, vec![a, b]);
let greens = engine
.find_nodes("Person", "color", &PropValue::String("green".to_string()))
.unwrap();
assert_eq!(greens.len(), 1);
assert!(engine
.find_nodes("Person", "color", &PropValue::String("blue".to_string()))
.unwrap()
.is_empty());
assert!(engine
.find_nodes("Company", "color", &PropValue::String("red".to_string()))
.unwrap()
.is_empty());
engine.close().unwrap();
}
#[test]
fn test_find_nodes_cross_source() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let mut props = BTreeMap::new();
props.insert("color".to_string(), PropValue::String("red".to_string()));
let a = engine
.upsert_node(
"Person",
"apple",
UpsertNodeOptions {
props,
weight: 0.5,
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
let mut props2 = BTreeMap::new();
props2.insert("color".to_string(), PropValue::String("red".to_string()));
let b = engine
.upsert_node(
"Person",
"cherry",
UpsertNodeOptions {
props: props2,
weight: 0.5,
..Default::default()
},
)
.unwrap();
let mut reds = engine
.find_nodes("Person", "color", &PropValue::String("red".to_string()))
.unwrap();
reds.sort();
assert_eq!(reds, vec![a, b]);
engine.close().unwrap();
}
#[test]
fn test_find_nodes_excludes_deleted() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let mut props = BTreeMap::new();
props.insert("color".to_string(), PropValue::String("red".to_string()));
let a = engine
.upsert_node(
"Person",
"apple",
UpsertNodeOptions {
props: props.clone(),
weight: 0.5,
..Default::default()
},
)
.unwrap();
let b = engine
.upsert_node(
"Person",
"cherry",
UpsertNodeOptions {
props,
weight: 0.5,
..Default::default()
},
)
.unwrap();
engine.delete_node(b).unwrap();
let reds = engine
.find_nodes("Person", "color", &PropValue::String("red".to_string()))
.unwrap();
assert_eq!(reds, vec![a]);
engine.close().unwrap();
}
#[test]
fn test_find_nodes_survives_flush_and_reopen() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let a;
{
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let mut props = BTreeMap::new();
props.insert("lang".to_string(), PropValue::String("rust".to_string()));
a = engine
.upsert_node(
"Person",
"overgraph",
UpsertNodeOptions {
props,
weight: 0.9,
..Default::default()
},
)
.unwrap();
let mut props2 = BTreeMap::new();
props2.insert("lang".to_string(), PropValue::String("python".to_string()));
engine
.upsert_node(
"Person",
"other",
UpsertNodeOptions {
props: props2,
weight: 0.5,
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
engine.close().unwrap();
}
{
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let results = engine
.find_nodes("Person", "lang", &PropValue::String("rust".to_string()))
.unwrap();
assert_eq!(results, vec![a]);
let py = engine
.find_nodes("Person", "lang", &PropValue::String("python".to_string()))
.unwrap();
assert_eq!(py.len(), 1);
engine.close().unwrap();
}
}
#[test]
fn test_find_nodes_update_changes_index() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let mut props = BTreeMap::new();
props.insert(
"status".to_string(),
PropValue::String("active".to_string()),
);
let a = engine
.upsert_node(
"Person",
"item",
UpsertNodeOptions {
props,
weight: 0.5,
..Default::default()
},
)
.unwrap();
assert_eq!(
engine
.find_nodes("Person", "status", &PropValue::String("active".to_string()))
.unwrap(),
vec![a]
);
let mut props2 = BTreeMap::new();
props2.insert(
"status".to_string(),
PropValue::String("inactive".to_string()),
);
let a2 = engine
.upsert_node(
"Person",
"item",
UpsertNodeOptions {
props: props2,
weight: 0.5,
..Default::default()
},
)
.unwrap();
assert_eq!(a, a2);
assert!(engine
.find_nodes("Person", "status", &PropValue::String("active".to_string()))
.unwrap()
.is_empty());
assert_eq!(
engine
.find_nodes("Person", "status", &PropValue::String("inactive".to_string()))
.unwrap(),
vec![a]
);
engine.close().unwrap();
}
#[test]
fn test_find_nodes_fallback_routes_and_filters_latest_visible_records() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let red = PropValue::String("red".to_string());
let blue = PropValue::String("blue".to_string());
let mut red_props = BTreeMap::new();
red_props.insert("color".to_string(), red.clone());
let a = engine
.upsert_node(
"Person",
"seg_update",
UpsertNodeOptions {
props: red_props.clone(),
..Default::default()
},
)
.unwrap();
let b = engine
.upsert_node(
"Person",
"seg_keep",
UpsertNodeOptions {
props: red_props.clone(),
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
let mut blue_props = BTreeMap::new();
blue_props.insert("color".to_string(), blue);
assert_eq!(
engine
.upsert_node(
"Person",
"seg_update",
UpsertNodeOptions {
props: blue_props,
..Default::default()
},
)
.unwrap(),
a
);
let c = engine
.upsert_node(
"Person",
"imm_delete",
UpsertNodeOptions {
props: red_props.clone(),
..Default::default()
},
)
.unwrap();
engine.freeze_memtable().unwrap();
let d = engine
.upsert_node(
"Person",
"active_keep",
UpsertNodeOptions {
props: red_props.clone(),
..Default::default()
},
)
.unwrap();
engine
.upsert_node(
"Person",
"active_pruned",
UpsertNodeOptions {
props: red_props,
weight: 0.1,
..Default::default()
},
)
.unwrap();
engine.delete_node(c).unwrap();
engine
.set_prune_policy(
"light",
PrunePolicy {
max_age_ms: None,
max_weight: Some(0.5),
label: None,
},
)
.unwrap();
engine.reset_property_query_routes();
let mut results = engine.find_nodes("Person", "color", &red).unwrap();
results.sort_unstable();
assert_eq!(results, vec![b, d]);
let routes = engine.property_query_route_snapshot();
assert_eq!(routes.equality_scan_fallback, 1);
assert_eq!(routes.equality_index_lookup, 0);
let page = engine
.find_nodes_paged("Person",
"color",
&red,
&PageRequest {
limit: Some(10),
after: None,
},
)
.unwrap();
assert_eq!(page.items, vec![b, d]);
assert!(page.next_cursor.is_none());
let routes = engine.property_query_route_snapshot();
assert_eq!(routes.equality_scan_fallback, 2);
assert_eq!(routes.equality_index_lookup, 0);
engine.close().unwrap();
}
#[test]
fn test_find_nodes_building_declaration_still_uses_fallback() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let red = PropValue::String("red".to_string());
let mut props = BTreeMap::new();
props.insert("color".to_string(), red.clone());
let a = engine
.upsert_node(
"Person",
"a",
UpsertNodeOptions {
props: props.clone(),
..Default::default()
},
)
.unwrap();
let b = engine
.upsert_node(
"Person",
"b",
UpsertNodeOptions {
props,
..Default::default()
},
)
.unwrap();
let entry = SecondaryIndexManifestEntry {
index_id: 1,
target: SecondaryIndexTarget::NodeProperty {
label_id: 1,
prop_key: "color".to_string(),
},
kind: SecondaryIndexKind::Equality,
state: SecondaryIndexState::Building,
last_error: None,
};
engine
.with_runtime_manifest_write(|manifest| {
manifest.secondary_indexes.push(entry.clone());
manifest.next_secondary_index_id = 2;
Ok(())
})
.unwrap();
engine.rebuild_secondary_index_catalog().unwrap();
engine.seed_secondary_index_entry(&entry).unwrap();
let info = engine
.list_node_property_indexes().unwrap()
.into_iter()
.find(|info| info.index_id == entry.index_id)
.unwrap();
assert_eq!(info.state, SecondaryIndexState::Building);
engine.reset_property_query_routes();
let mut results = engine.find_nodes("Person", "color", &red).unwrap();
results.sort_unstable();
assert_eq!(results, vec![a, b]);
let routes = engine.property_query_route_snapshot();
assert_eq!(routes.equality_scan_fallback, 1);
assert_eq!(routes.equality_index_lookup, 0);
engine.close().unwrap();
}
#[test]
fn test_find_nodes_ready_declaration_uses_index_lookup_across_sources() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let red = PropValue::String("red".to_string());
let mut seg_props = BTreeMap::new();
seg_props.insert("color".to_string(), red.clone());
let seg_id = engine
.upsert_node(
"Person",
"seg",
UpsertNodeOptions {
props: seg_props,
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
let mut imm_props = BTreeMap::new();
imm_props.insert("color".to_string(), red.clone());
let imm_id = engine
.upsert_node(
"Person",
"imm",
UpsertNodeOptions {
props: imm_props,
..Default::default()
},
)
.unwrap();
engine.freeze_memtable().unwrap();
let mut active_props = BTreeMap::new();
active_props.insert("color".to_string(), red.clone());
let active_id = engine
.upsert_node(
"Person",
"active",
UpsertNodeOptions {
props: active_props,
..Default::default()
},
)
.unwrap();
let info = engine
.ensure_node_property_index("Person", "color", SecondaryIndexKind::Equality)
.unwrap();
let ready = wait_for_property_index_state(&engine, info.index_id, SecondaryIndexState::Ready);
assert_eq!(ready.index_id, info.index_id);
engine.reset_property_query_routes();
let results = engine.find_nodes("Person", "color", &red).unwrap();
assert_eq!(results, vec![seg_id, imm_id, active_id]);
let routes = engine.property_query_route_snapshot();
assert_eq!(routes.equality_scan_fallback, 0);
assert_eq!(routes.equality_index_lookup, 1);
let all_page = engine
.find_nodes_paged("Person", "color", &red, &PageRequest::default())
.unwrap();
assert_eq!(all_page.items, results);
assert!(all_page.next_cursor.is_none());
let first_page = engine
.find_nodes_paged("Person",
"color",
&red,
&PageRequest {
limit: Some(2),
after: None,
},
)
.unwrap();
assert_eq!(first_page.items, vec![seg_id, imm_id]);
assert_eq!(first_page.next_cursor, Some(imm_id));
let second_page = engine
.find_nodes_paged("Person",
"color",
&red,
&PageRequest {
limit: Some(2),
after: first_page.next_cursor,
},
)
.unwrap();
assert_eq!(second_page.items, vec![active_id]);
assert!(second_page.next_cursor.is_none());
let routes = engine.property_query_route_snapshot();
assert_eq!(routes.equality_scan_fallback, 0);
assert_eq!(routes.equality_index_lookup, 4);
engine.close().unwrap();
}
#[test]
fn test_find_nodes_ready_equality_index_matches_signed_zero_verifier_semantics() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let mut neg_zero_props = BTreeMap::new();
neg_zero_props.insert("temp".to_string(), PropValue::Float(-0.0));
let neg_zero = engine
.upsert_node(
"Person",
"temp-neg-zero",
UpsertNodeOptions {
props: neg_zero_props,
..Default::default()
},
)
.unwrap();
let mut pos_zero_props = BTreeMap::new();
pos_zero_props.insert("temp".to_string(), PropValue::Float(0.0));
let pos_zero = engine
.upsert_node(
"Person",
"temp-pos-zero",
UpsertNodeOptions {
props: pos_zero_props,
..Default::default()
},
)
.unwrap();
let mut non_zero_props = BTreeMap::new();
non_zero_props.insert("temp".to_string(), PropValue::Float(1.0));
engine
.upsert_node(
"Person",
"temp-one",
UpsertNodeOptions {
props: non_zero_props,
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
let info = engine
.ensure_node_property_index("Person", "temp", SecondaryIndexKind::Equality)
.unwrap();
wait_for_property_index_state(&engine, info.index_id, SecondaryIndexState::Ready);
wait_for_published_property_index_state(&engine, info.index_id, SecondaryIndexState::Ready);
engine.reset_property_query_routes();
assert_eq!(
engine
.find_nodes("Person", "temp", &PropValue::Float(-0.0))
.unwrap(),
vec![neg_zero, pos_zero]
);
let routes = engine.property_query_route_snapshot();
assert_eq!(routes.equality_scan_fallback, 0);
assert_eq!(routes.equality_index_lookup, 1);
engine.reset_property_query_routes();
assert_eq!(
engine
.find_nodes("Person", "temp", &PropValue::Float(0.0))
.unwrap(),
vec![neg_zero, pos_zero]
);
let routes = engine.property_query_route_snapshot();
assert_eq!(routes.equality_scan_fallback, 0);
assert_eq!(routes.equality_index_lookup, 1);
engine.close().unwrap();
}
#[test]
fn test_find_nodes_active_equality_index_uses_semantic_hashes() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let info = engine
.ensure_node_property_index("Person", "score", SecondaryIndexKind::Equality)
.unwrap();
wait_for_property_index_state(&engine, info.index_id, SecondaryIndexState::Ready);
let mut expected_numeric = Vec::new();
for (key, value) in [
("score-int", PropValue::Int(1)),
("score-uint", PropValue::UInt(1)),
("score-float", PropValue::Float(1.0)),
] {
expected_numeric.push(
engine
.upsert_node(
"Person",
key,
UpsertNodeOptions {
props: read_query_test_props(&[("score", value)]),
..Default::default()
},
)
.unwrap(),
);
}
let string_id = engine
.upsert_node(
"Person",
"score-string",
UpsertNodeOptions {
props: read_query_test_props(&[("score", PropValue::String("1".to_string()))]),
..Default::default()
},
)
.unwrap();
let array_id = engine
.upsert_node(
"Person",
"score-array-int",
UpsertNodeOptions {
props: read_query_test_props(&[(
"score",
PropValue::Array(vec![PropValue::Int(1)]),
)]),
..Default::default()
},
)
.unwrap();
let mut map_int = BTreeMap::new();
map_int.insert("x".to_string(), PropValue::Int(1));
let map_id = engine
.upsert_node(
"Person",
"score-map-int",
UpsertNodeOptions {
props: read_query_test_props(&[("score", PropValue::Map(map_int))]),
..Default::default()
},
)
.unwrap();
let inf_id = engine
.upsert_node(
"Person",
"score-infinity",
UpsertNodeOptions {
props: read_query_test_props(&[("score", PropValue::Float(f64::INFINITY))]),
..Default::default()
},
)
.unwrap();
engine
.upsert_node(
"Person",
"score-nan",
UpsertNodeOptions {
props: read_query_test_props(&[("score", PropValue::Float(f64::NAN))]),
..Default::default()
},
)
.unwrap();
expected_numeric.sort_unstable();
engine.reset_property_query_routes();
for rhs in [PropValue::Int(1), PropValue::UInt(1), PropValue::Float(1.0)] {
assert_eq!(engine.find_nodes("Person", "score", &rhs).unwrap(), expected_numeric);
}
assert_eq!(
engine
.find_nodes("Person", "score", &PropValue::String("1".to_string()))
.unwrap(),
vec![string_id]
);
assert_eq!(
engine
.find_nodes(
"Person",
"score",
&PropValue::Array(vec![PropValue::Int(1)])
)
.unwrap(),
vec![array_id]
);
assert!(engine
.find_nodes(
"Person",
"score",
&PropValue::Array(vec![PropValue::Float(1.0)])
)
.unwrap()
.is_empty());
let mut map_float = BTreeMap::new();
map_float.insert("x".to_string(), PropValue::Float(1.0));
assert_eq!(
engine
.find_nodes("Person", "score", &PropValue::Map({
let mut expected = BTreeMap::new();
expected.insert("x".to_string(), PropValue::Int(1));
expected
}))
.unwrap(),
vec![map_id]
);
assert!(engine
.find_nodes("Person", "score", &PropValue::Map(map_float))
.unwrap()
.is_empty());
assert_eq!(
engine
.find_nodes("Person", "score", &PropValue::Float(f64::INFINITY))
.unwrap(),
vec![inf_id]
);
assert!(engine
.find_nodes("Person", "score", &PropValue::Float(f64::NAN))
.unwrap()
.is_empty());
let routes = engine.property_query_route_snapshot();
assert_eq!(routes.equality_scan_fallback, 0);
assert_eq!(routes.equality_index_lookup, 10);
engine.close().unwrap();
}
#[test]
fn test_find_nodes_semantic_equality_index_updates_tombstones_and_shadows() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let info = engine
.ensure_node_property_index("Person", "score", SecondaryIndexKind::Equality)
.unwrap();
wait_for_property_index_state(&engine, info.index_id, SecondaryIndexState::Ready);
let node_id = engine
.upsert_node(
"Person",
"mutable-score",
UpsertNodeOptions {
props: read_query_test_props(&[("score", PropValue::Int(1))]),
..Default::default()
},
)
.unwrap();
assert_eq!(
engine
.find_nodes("Person", "score", &PropValue::Float(1.0))
.unwrap(),
vec![node_id]
);
engine
.upsert_node(
"Person",
"mutable-score",
UpsertNodeOptions {
props: read_query_test_props(&[("score", PropValue::Float(1.0))]),
..Default::default()
},
)
.unwrap();
assert_eq!(
engine
.find_nodes("Person", "score", &PropValue::Int(1))
.unwrap(),
vec![node_id]
);
engine
.upsert_node(
"Person",
"mutable-score",
UpsertNodeOptions {
props: read_query_test_props(&[("score", PropValue::Float(2.0))]),
..Default::default()
},
)
.unwrap();
assert!(engine
.find_nodes("Person", "score", &PropValue::Int(1))
.unwrap()
.is_empty());
assert_eq!(
engine
.find_nodes("Person", "score", &PropValue::UInt(2))
.unwrap(),
vec![node_id]
);
engine.flush().unwrap();
engine
.upsert_node(
"Person",
"mutable-score",
UpsertNodeOptions {
props: read_query_test_props(&[("score", PropValue::String("2".to_string()))]),
..Default::default()
},
)
.unwrap();
assert!(engine
.find_nodes("Person", "score", &PropValue::Float(2.0))
.unwrap()
.is_empty());
assert_eq!(
engine
.find_nodes("Person", "score", &PropValue::String("2".to_string()))
.unwrap(),
vec![node_id]
);
engine.delete_node(node_id).unwrap();
assert!(engine
.find_nodes("Person", "score", &PropValue::String("2".to_string()))
.unwrap()
.is_empty());
engine.close().unwrap();
}
#[test]
fn test_find_nodes_scan_fallback_uses_semantic_numeric_equality() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let mut expected = Vec::new();
for (key, value) in [
("score-int", PropValue::Int(1)),
("score-uint", PropValue::UInt(1)),
("score-float", PropValue::Float(1.0)),
] {
let mut props = BTreeMap::new();
props.insert("score".to_string(), value);
expected.push(
engine
.upsert_node(
"Person",
key,
UpsertNodeOptions {
props,
..Default::default()
},
)
.unwrap(),
);
}
let mut nan_props = BTreeMap::new();
nan_props.insert("score".to_string(), PropValue::Float(f64::NAN));
engine
.upsert_node(
"Person",
"score-nan",
UpsertNodeOptions {
props: nan_props,
..Default::default()
},
)
.unwrap();
let mut array_props = BTreeMap::new();
array_props.insert("score".to_string(), PropValue::Array(vec![PropValue::Int(1)]));
let array_id = engine
.upsert_node(
"Person",
"score-array",
UpsertNodeOptions {
props: array_props,
..Default::default()
},
)
.unwrap();
expected.sort_unstable();
for rhs in [PropValue::Int(1), PropValue::UInt(1), PropValue::Float(1.0)] {
let mut actual = engine.find_nodes("Person", "score", &rhs).unwrap();
actual.sort_unstable();
assert_eq!(actual, expected);
}
assert!(engine
.find_nodes("Person", "score", &PropValue::Float(f64::NAN))
.unwrap()
.is_empty());
assert_eq!(
engine
.find_nodes(
"Person",
"score",
&PropValue::Array(vec![PropValue::Int(1)])
)
.unwrap(),
vec![array_id]
);
assert!(engine
.find_nodes(
"Person",
"score",
&PropValue::Array(vec![PropValue::Float(1.0)])
)
.unwrap()
.is_empty());
let routes = engine.property_query_route_snapshot();
assert_eq!(routes.equality_scan_fallback, 6);
assert_eq!(routes.equality_index_lookup, 0);
engine.close().unwrap();
}
#[test]
fn test_find_nodes_ready_declaration_suppresses_stale_and_collision_candidates() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let red = PropValue::String("red".to_string());
let blue = PropValue::String("blue".to_string());
let mut red_props = BTreeMap::new();
red_props.insert("color".to_string(), red.clone());
let node_id = engine
.upsert_node(
"Person",
"mutable",
UpsertNodeOptions {
props: red_props,
..Default::default()
},
)
.unwrap();
let mut blue_props = BTreeMap::new();
blue_props.insert("color".to_string(), blue.clone());
let blue_id = engine
.upsert_node(
"Person",
"blue-only",
UpsertNodeOptions {
props: blue_props,
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
let info = engine
.ensure_node_property_index("Person", "color", SecondaryIndexKind::Equality)
.unwrap();
let ready = wait_for_property_index_state(&engine, info.index_id, SecondaryIndexState::Ready);
assert_eq!(ready.index_id, info.index_id);
let seg_dir =
crate::segment_writer::segment_dir(&db_path, engine.segments_for_test()[0].segment_id);
engine.close().unwrap();
drop(engine);
let mut tampered_groups = std::collections::BTreeMap::new();
tampered_groups.insert(hash_prop_equality_key(&red), vec![node_id, blue_id]);
let manifest = crate::manifest::load_manifest_readonly(&db_path)
.unwrap()
.unwrap();
let entry = manifest
.secondary_indexes
.iter()
.find(|entry| entry.index_id == info.index_id)
.unwrap();
crate::segment_writer::publish_node_prop_eq_sidecar_component(
&seg_dir,
entry,
&tampered_groups,
)
.unwrap();
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let mut updated_props = BTreeMap::new();
updated_props.insert("color".to_string(), blue.clone());
assert_eq!(
engine
.upsert_node(
"Person",
"mutable",
UpsertNodeOptions {
props: updated_props,
..Default::default()
},
)
.unwrap(),
node_id
);
engine.reset_property_query_routes();
assert!(engine.find_nodes("Person", "color", &red).unwrap().is_empty());
assert_eq!(engine.find_nodes("Person", "color", &blue).unwrap(), vec![node_id]);
let routes = engine.property_query_route_snapshot();
assert_eq!(routes.equality_scan_fallback, 0);
assert_eq!(routes.equality_index_lookup, 2);
engine.close().unwrap();
}
#[test]
fn test_find_nodes_ready_declaration_respects_prune_policies() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let red = PropValue::String("red".to_string());
let mut red_props = BTreeMap::new();
red_props.insert("color".to_string(), red.clone());
let keep_id = engine
.upsert_node(
"Person",
"keep",
UpsertNodeOptions {
props: red_props.clone(),
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
let pruned_id = engine
.upsert_node(
"Person",
"pruned",
UpsertNodeOptions {
props: red_props,
weight: 0.1,
..Default::default()
},
)
.unwrap();
engine
.set_prune_policy(
"light",
PrunePolicy {
max_age_ms: None,
max_weight: Some(0.5),
label: None,
},
)
.unwrap();
let info = engine
.ensure_node_property_index("Person", "color", SecondaryIndexKind::Equality)
.unwrap();
wait_for_property_index_state(&engine, info.index_id, SecondaryIndexState::Ready);
engine.reset_property_query_routes();
assert_eq!(engine.find_nodes("Person", "color", &red).unwrap(), vec![keep_id]);
let all_page = engine
.find_nodes_paged("Person", "color", &red, &PageRequest::default())
.unwrap();
assert_eq!(all_page.items, vec![keep_id]);
assert!(all_page.next_cursor.is_none());
let first_page = engine
.find_nodes_paged("Person",
"color",
&red,
&PageRequest {
limit: Some(10),
after: None,
},
)
.unwrap();
assert_eq!(first_page.items, vec![keep_id]);
assert!(first_page.next_cursor.is_none());
let exact_limit_page = engine
.find_nodes_paged("Person",
"color",
&red,
&PageRequest {
limit: Some(1),
after: None,
},
)
.unwrap();
assert_eq!(exact_limit_page.items, vec![keep_id]);
assert!(
exact_limit_page.next_cursor.is_none(),
"ready equality pagination must not report a next page unless another verified node exists"
);
let routes = engine.property_query_route_snapshot();
assert_eq!(routes.equality_scan_fallback, 0);
assert_eq!(routes.equality_index_lookup, 4);
assert_ne!(keep_id, pruned_id);
engine.close().unwrap();
}
#[test]
fn test_find_nodes_ready_eq_cursor_no_false_next_after_stale_candidates() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let red = PropValue::String("red".to_string());
let mut red_props = BTreeMap::new();
red_props.insert("color".to_string(), red.clone());
let info = engine
.ensure_node_property_index("Employee", "color", SecondaryIndexKind::Equality)
.unwrap();
wait_for_property_index_state(&engine, info.index_id, SecondaryIndexState::Ready);
let keep_id = engine
.upsert_node(
&["Employee", "Current"],
"keep",
UpsertNodeOptions {
props: red_props.clone(),
..Default::default()
},
)
.unwrap();
let stale_ids = (0..5)
.map(|idx| {
engine
.upsert_node(
&["Employee", "Former"],
&format!("stale-{idx}"),
UpsertNodeOptions {
props: red_props.clone(),
..Default::default()
},
)
.unwrap()
})
.collect::<Vec<_>>();
engine.flush().unwrap();
for stale_id in stale_ids {
assert!(engine.remove_node_label(stale_id, "Employee").unwrap());
}
engine.reset_property_query_routes();
let page = engine
.find_nodes_paged(
"Employee",
"color",
&red,
&PageRequest {
limit: Some(1),
after: None,
},
)
.unwrap();
assert_eq!(page.items, vec![keep_id]);
assert!(
page.next_cursor.is_none(),
"ready equality pagination must not report a next page for stale label candidates"
);
let routes = engine.property_query_route_snapshot();
assert_eq!(routes.equality_scan_fallback, 0);
assert_eq!(routes.equality_index_lookup, 1);
engine.close().unwrap();
}
#[test]
fn test_find_nodes_ready_declaration_keeps_revived_same_id_after_tombstone() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let red = PropValue::String("red".to_string());
let mut red_props = BTreeMap::new();
red_props.insert("color".to_string(), red.clone());
let make_node = |updated_at: i64, key: &str| NodeRecord {
id: 7,
label_ids: NodeLabelSet::single(1).unwrap(),
key: key.to_string(),
props: red_props.clone(),
created_at: updated_at,
updated_at,
weight: 1.0,
dense_vector: None,
sparse_vector: None,
last_write_seq: 0,
};
write_internal_wal_op(&engine, &WalOp::UpsertNode(make_node(1_000, "older-segment")))
.unwrap();
engine.flush().unwrap();
write_internal_wal_op(&engine, &WalOp::DeleteNode {
id: 7,
deleted_at: 2_000,
})
.unwrap();
engine.flush().unwrap();
write_internal_wal_op(&engine, &WalOp::UpsertNode(make_node(3_000, "revived-active")))
.unwrap();
let info = engine
.ensure_node_property_index("Person", "color", SecondaryIndexKind::Equality)
.unwrap();
wait_for_property_index_state(&engine, info.index_id, SecondaryIndexState::Ready);
engine.reset_property_query_routes();
assert_eq!(engine.find_nodes("Person", "color", &red).unwrap(), vec![7]);
let page = engine
.find_nodes_paged("Person",
"color",
&red,
&PageRequest {
limit: Some(10),
after: None,
},
)
.unwrap();
assert_eq!(page.items, vec![7]);
assert!(page.next_cursor.is_none());
let routes = engine.property_query_route_snapshot();
assert_eq!(routes.equality_scan_fallback, 0);
assert_eq!(routes.equality_index_lookup, 2);
engine.close().unwrap();
}
#[test]
fn test_find_nodes_ready_declaration_filters_same_id_label_change() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let red = PropValue::String("red".to_string());
let mut red_props = BTreeMap::new();
red_props.insert("color".to_string(), red.clone());
let make_node = |label_id: u32, updated_at: i64, key: &str| NodeRecord {
id: 9,
label_ids: NodeLabelSet::single(label_id).unwrap(),
key: key.to_string(),
props: red_props.clone(),
created_at: updated_at,
updated_at,
weight: 1.0,
dense_vector: None,
sparse_vector: None,
last_write_seq: 0,
};
write_internal_wal_op(&engine, &WalOp::UpsertNode(make_node(1, 1_000, "label1-segment")))
.unwrap();
engine.flush().unwrap();
write_internal_wal_op(&engine, &WalOp::UpsertNode(make_node(2, 2_000, "label2-active")))
.unwrap();
let info = engine
.ensure_node_property_index("Person", "color", SecondaryIndexKind::Equality)
.unwrap();
wait_for_property_index_state(&engine, info.index_id, SecondaryIndexState::Ready);
engine.reset_property_query_routes();
assert!(engine.find_nodes("Person", "color", &red).unwrap().is_empty());
let page = engine
.find_nodes_paged("Person",
"color",
&red,
&PageRequest {
limit: Some(10),
after: None,
},
)
.unwrap();
assert!(page.items.is_empty());
assert!(page.next_cursor.is_none());
let routes = engine.property_query_route_snapshot();
assert_eq!(routes.equality_scan_fallback, 0);
assert_eq!(routes.equality_index_lookup, 2);
engine.close().unwrap();
}
#[test]
fn test_find_nodes_paged_basic() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let mut ids = Vec::new();
for i in 0..8 {
let mut props = BTreeMap::new();
props.insert("color".to_string(), PropValue::String("red".to_string()));
let id = engine
.upsert_node(
"Person",
&format!("r{}", i),
UpsertNodeOptions {
props,
..Default::default()
},
)
.unwrap();
ids.push(id);
}
for i in 0..3 {
let mut props = BTreeMap::new();
props.insert("color".to_string(), PropValue::String("blue".to_string()));
engine
.upsert_node(
"Person",
&format!("b{}", i),
UpsertNodeOptions {
props,
..Default::default()
},
)
.unwrap();
}
ids.sort();
let red = PropValue::String("red".to_string());
let p1 = engine
.find_nodes_paged("Person",
"color",
&red,
&PageRequest {
limit: Some(3),
after: None,
},
)
.unwrap();
assert_eq!(p1.items.len(), 3);
assert_eq!(p1.items, ids[0..3]);
assert!(p1.next_cursor.is_some());
let p2 = engine
.find_nodes_paged("Person",
"color",
&red,
&PageRequest {
limit: Some(3),
after: p1.next_cursor,
},
)
.unwrap();
assert_eq!(p2.items.len(), 3);
assert_eq!(p2.items, ids[3..6]);
assert!(p2.next_cursor.is_some());
let p3 = engine
.find_nodes_paged("Person",
"color",
&red,
&PageRequest {
limit: Some(3),
after: p2.next_cursor,
},
)
.unwrap();
assert_eq!(p3.items.len(), 2);
assert_eq!(p3.items, ids[6..8]);
assert!(p3.next_cursor.is_none());
}
#[test]
fn test_find_nodes_paged_cross_source() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let red = PropValue::String("red".to_string());
let mut props = BTreeMap::new();
props.insert("color".to_string(), red.clone());
for i in 0..4 {
engine
.upsert_node(
"Person",
&format!("seg{}", i),
UpsertNodeOptions {
props: props.clone(),
..Default::default()
},
)
.unwrap();
}
engine.flush().unwrap();
for i in 0..4 {
engine
.upsert_node(
"Person",
&format!("mem{}", i),
UpsertNodeOptions {
props: props.clone(),
..Default::default()
},
)
.unwrap();
}
let mut all_paged: Vec<u64> = Vec::new();
let mut cursor: Option<u64> = None;
loop {
let page = engine
.find_nodes_paged("Person",
"color",
&red,
&PageRequest {
limit: Some(3),
after: cursor,
},
)
.unwrap();
all_paged.extend(&page.items);
cursor = page.next_cursor;
if cursor.is_none() {
break;
}
}
assert_eq!(all_paged.len(), 8);
for i in 1..all_paged.len() {
assert!(all_paged[i] > all_paged[i - 1]);
}
}
#[test]
fn test_find_nodes_paged_excludes_deleted() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let red = PropValue::String("red".to_string());
let mut props = BTreeMap::new();
props.insert("color".to_string(), red.clone());
let id1 = engine
.upsert_node(
"Person",
"a",
UpsertNodeOptions {
props: props.clone(),
..Default::default()
},
)
.unwrap();
let id2 = engine
.upsert_node(
"Person",
"b",
UpsertNodeOptions {
props: props.clone(),
..Default::default()
},
)
.unwrap();
let id3 = engine
.upsert_node(
"Person",
"c",
UpsertNodeOptions {
props: props.clone(),
..Default::default()
},
)
.unwrap();
engine.delete_node(id2).unwrap();
let result = engine
.find_nodes_paged("Person",
"color",
&red,
&PageRequest {
limit: Some(10),
after: None,
},
)
.unwrap();
let mut expected = vec![id1, id3];
expected.sort();
assert_eq!(result.items, expected);
assert!(result.next_cursor.is_none());
}
#[test]
fn test_find_nodes_paged_with_policies() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let red = PropValue::String("red".to_string());
let mut props = BTreeMap::new();
props.insert("color".to_string(), red.clone());
engine
.upsert_node(
"Person",
"keep",
UpsertNodeOptions {
props: props.clone(),
..Default::default()
},
)
.unwrap();
engine
.upsert_node(
"Person",
"prune",
UpsertNodeOptions {
props: props.clone(),
weight: 0.1,
..Default::default()
},
)
.unwrap();
engine
.set_prune_policy(
"low_weight",
PrunePolicy {
max_age_ms: None,
max_weight: Some(0.5),
label: None,
},
)
.unwrap();
let result = engine
.find_nodes_paged("Person",
"color",
&red,
&PageRequest {
limit: Some(10),
after: None,
},
)
.unwrap();
assert_eq!(result.items.len(), 1);
}
#[test]
fn test_find_nodes_range_fallback_orders_and_paginates() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let mut expected = Vec::new();
for (key, score) in [
("n5", 5),
("n20a", 20),
("n20b", 20),
("n30", 30),
("n40", 40),
] {
let mut props = BTreeMap::new();
props.insert("score".to_string(), PropValue::Int(score));
let id = engine
.upsert_node(
"Person",
key,
UpsertNodeOptions {
props,
..Default::default()
},
)
.unwrap();
expected.push((score, id));
}
let mut bad_props = BTreeMap::new();
bad_props.insert("score".to_string(), PropValue::String("bad".to_string()));
engine
.upsert_node(
"Person",
"bad",
UpsertNodeOptions {
props: bad_props,
..Default::default()
},
)
.unwrap();
let info = engine
.ensure_node_property_index("Person",
"score",
SecondaryIndexKind::Range,
)
.unwrap();
assert_eq!(info.state, SecondaryIndexState::Building);
engine.reset_property_query_routes();
let lower_only = engine
.find_nodes_range("Person",
"score",
Some(&PropertyRangeBound::Included(PropValue::Int(20))),
None,
)
.unwrap();
assert_eq!(
lower_only,
vec![expected[1].1, expected[2].1, expected[3].1, expected[4].1]
);
let upper_only = engine
.find_nodes_range("Person",
"score",
None,
Some(&PropertyRangeBound::Included(PropValue::Int(20))),
)
.unwrap();
assert_eq!(
upper_only,
vec![expected[0].1, expected[1].1, expected[2].1]
);
let page1 = engine
.find_nodes_range_paged("Person",
"score",
Some(&PropertyRangeBound::Included(PropValue::Int(20))),
Some(&PropertyRangeBound::Included(PropValue::Int(30))),
&PropertyRangePageRequest {
limit: Some(2),
after: None,
},
)
.unwrap();
assert_eq!(page1.items, vec![expected[1].1, expected[2].1]);
assert_eq!(
page1.next_cursor,
Some(PropertyRangeCursor {
value: PropValue::Int(20),
node_id: expected[2].1,
})
);
let page2 = engine
.find_nodes_range_paged("Person",
"score",
Some(&PropertyRangeBound::Included(PropValue::Int(20))),
Some(&PropertyRangeBound::Included(PropValue::Int(30))),
&PropertyRangePageRequest {
limit: Some(2),
after: page1.next_cursor.clone(),
},
)
.unwrap();
assert_eq!(page2.items, vec![expected[3].1]);
assert!(page2.next_cursor.is_none());
let routes = engine.property_query_route_snapshot();
assert_eq!(routes.range_scan_fallback, 4);
assert_eq!(routes.range_index_lookup, 0);
engine.close().unwrap();
}
#[test]
fn test_find_nodes_range_accepts_mixed_bounds_and_normalizes_empty_intervals() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let mut one_id = None;
for (key, value) in [("neg_zero", -0.0), ("pos_zero", 0.0), ("one", 1.0)] {
let mut props = BTreeMap::new();
props.insert("score".to_string(), PropValue::Float(value));
let id = engine
.upsert_node(
"Person",
key,
UpsertNodeOptions {
props,
..Default::default()
},
)
.unwrap();
if key == "one" {
one_id = Some(id);
}
}
let zeros = engine
.find_nodes_range("Person",
"score",
Some(&PropertyRangeBound::Included(PropValue::Float(-0.0))),
Some(&PropertyRangeBound::Included(PropValue::Float(0.0))),
)
.unwrap();
assert_eq!(zeros.len(), 2);
assert!(zeros[0] < zeros[1]);
let mixed_singleton = engine
.find_nodes_range("Person",
"score",
Some(&PropertyRangeBound::Included(PropValue::Int(1))),
Some(&PropertyRangeBound::Included(PropValue::Float(1.0))),
)
.unwrap();
assert_eq!(mixed_singleton, vec![one_id.unwrap()]);
let empty_descending = engine
.find_nodes_range("Person",
"score",
Some(&PropertyRangeBound::Included(PropValue::Float(2.0))),
Some(&PropertyRangeBound::Included(PropValue::Float(1.0))),
)
.unwrap();
assert!(empty_descending.is_empty());
let empty_exclusive_zero = engine
.find_nodes_range("Person",
"score",
Some(&PropertyRangeBound::Excluded(PropValue::Float(0.0))),
Some(&PropertyRangeBound::Included(PropValue::Float(0.0))),
)
.unwrap();
assert!(empty_exclusive_zero.is_empty());
engine.close().unwrap();
}
#[test]
fn test_find_nodes_range_scan_fallback_uses_semantic_numeric_ordering() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let mut ids_by_key = BTreeMap::new();
for (key, value) in [
("score-int", PropValue::Int(1)),
("score-uint", PropValue::UInt(2)),
("score-float", PropValue::Float(1.5)),
("score-neg-zero", PropValue::Float(-0.0)),
] {
let mut props = BTreeMap::new();
props.insert("score".to_string(), value);
ids_by_key.insert(
key,
engine
.upsert_node(
"Person",
key,
UpsertNodeOptions {
props,
..Default::default()
},
)
.unwrap(),
);
}
let mut string_props = BTreeMap::new();
string_props.insert("score".to_string(), PropValue::String("1".to_string()));
engine
.upsert_node(
"Person",
"score-string",
UpsertNodeOptions {
props: string_props,
..Default::default()
},
)
.unwrap();
let actual = engine
.find_nodes_range(
"Person",
"score",
Some(&PropertyRangeBound::Included(PropValue::Float(-0.0))),
Some(&PropertyRangeBound::Included(PropValue::UInt(2))),
)
.unwrap();
let expected = vec![
ids_by_key["score-neg-zero"],
ids_by_key["score-int"],
ids_by_key["score-float"],
ids_by_key["score-uint"],
];
assert_eq!(actual, expected);
let err = engine
.find_nodes_range(
"Person",
"score",
Some(&PropertyRangeBound::Included(PropValue::Float(f64::INFINITY))),
None,
)
.unwrap_err();
assert!(err
.to_string()
.contains("non-finite float is not valid for numeric range bounds"));
let err = engine
.find_nodes_range(
"Person",
"score",
Some(&PropertyRangeBound::Included(PropValue::String("1".to_string()))),
None,
)
.unwrap_err();
assert!(err
.to_string()
.contains("range bound must be a finite numeric scalar"));
engine.close().unwrap();
}
#[test]
fn test_find_nodes_range_fallback_mixed_sources_filters_latest_visible_records() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let mut props_a = BTreeMap::new();
props_a.insert("score".to_string(), PropValue::Int(20));
let a = engine
.upsert_node(
"Person",
"seg_update",
UpsertNodeOptions {
props: props_a,
..Default::default()
},
)
.unwrap();
let mut props_b = BTreeMap::new();
props_b.insert("score".to_string(), PropValue::Int(25));
let b = engine
.upsert_node(
"Person",
"seg_keep",
UpsertNodeOptions {
props: props_b,
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
let mut props_a_new = BTreeMap::new();
props_a_new.insert("score".to_string(), PropValue::Int(50));
assert_eq!(
engine
.upsert_node(
"Person",
"seg_update",
UpsertNodeOptions {
props: props_a_new,
..Default::default()
},
)
.unwrap(),
a
);
let mut props_c = BTreeMap::new();
props_c.insert("score".to_string(), PropValue::Int(22));
let c = engine
.upsert_node(
"Person",
"imm_delete",
UpsertNodeOptions {
props: props_c,
..Default::default()
},
)
.unwrap();
engine.freeze_memtable().unwrap();
let mut props_d = BTreeMap::new();
props_d.insert("score".to_string(), PropValue::Int(21));
let d = engine
.upsert_node(
"Person",
"active_keep",
UpsertNodeOptions {
props: props_d,
..Default::default()
},
)
.unwrap();
let mut props_pruned = BTreeMap::new();
props_pruned.insert("score".to_string(), PropValue::Int(23));
engine
.upsert_node(
"Person",
"active_pruned",
UpsertNodeOptions {
props: props_pruned,
weight: 0.1,
..Default::default()
},
)
.unwrap();
engine.delete_node(c).unwrap();
engine
.set_prune_policy(
"light",
PrunePolicy {
max_age_ms: None,
max_weight: Some(0.5),
label: None,
},
)
.unwrap();
engine.reset_property_query_routes();
let page1 = engine
.find_nodes_range_paged("Person",
"score",
Some(&PropertyRangeBound::Included(PropValue::Int(20))),
Some(&PropertyRangeBound::Included(PropValue::Int(30))),
&PropertyRangePageRequest {
limit: Some(1),
after: None,
},
)
.unwrap();
assert_eq!(page1.items, vec![d]);
let page2 = engine
.find_nodes_range_paged("Person",
"score",
Some(&PropertyRangeBound::Included(PropValue::Int(20))),
Some(&PropertyRangeBound::Included(PropValue::Int(30))),
&PropertyRangePageRequest {
limit: Some(1),
after: page1.next_cursor.clone(),
},
)
.unwrap();
assert_eq!(page2.items, vec![b]);
assert!(page2.next_cursor.is_none());
let routes = engine.property_query_route_snapshot();
assert_eq!(routes.range_scan_fallback, 2);
assert_eq!(routes.range_index_lookup, 0);
engine.close().unwrap();
}
fn brute_force_range_oracle(
engine: &DatabaseEngine,
node_ids: &[u64],
label_id: u32,
prop_key: &str,
lower: Option<&PropertyRangeBound>,
upper: Option<&PropertyRangeBound>,
) -> Vec<u64> {
DatabaseEngine::validate_property_range_bounds(lower, upper, None).unwrap();
let mut matches = Vec::new();
let nodes = engine.get_nodes_raw(node_ids).unwrap();
for (&node_id, node) in node_ids.iter().zip(nodes.iter()) {
let Some(node) = node.as_ref() else {
continue;
};
if !node.label_ids.contains(label_id) {
continue;
}
let Some(value) = node.props.get(prop_key) else {
continue;
};
if range_value_within_bounds(value, lower, upper) != Some(true) {
continue;
}
let encoded_value =
crate::property_value_semantics::numeric_range_sort_key_for_value(value).unwrap();
matches.push((encoded_value, node_id));
}
matches.sort_unstable();
matches.into_iter().map(|(_, node_id)| node_id).collect()
}
#[test]
fn test_find_nodes_range_open_and_closed_intervals_match_in_fallback_and_ready_paths() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let mut seg_low_props = BTreeMap::new();
seg_low_props.insert("score".to_string(), PropValue::Int(10));
let seg_low = engine
.upsert_node(
"Person",
"seg-low",
UpsertNodeOptions {
props: seg_low_props,
..Default::default()
},
)
.unwrap();
let mut seg_high_props = BTreeMap::new();
seg_high_props.insert("score".to_string(), PropValue::Int(30));
let seg_high = engine
.upsert_node(
"Person",
"seg-high",
UpsertNodeOptions {
props: seg_high_props,
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
let mut imm_props = BTreeMap::new();
imm_props.insert("score".to_string(), PropValue::Int(20));
let imm_20 = engine
.upsert_node(
"Person",
"imm-20",
UpsertNodeOptions {
props: imm_props,
..Default::default()
},
)
.unwrap();
engine.freeze_memtable().unwrap();
let mut active_props = BTreeMap::new();
active_props.insert("score".to_string(), PropValue::Int(20));
let active_20 = engine
.upsert_node(
"Person",
"active-20",
UpsertNodeOptions {
props: active_props,
..Default::default()
},
)
.unwrap();
let cases = vec![
(
Some(PropertyRangeBound::Included(PropValue::Int(10))),
Some(PropertyRangeBound::Included(PropValue::Int(30))),
vec![seg_low, imm_20, active_20, seg_high],
),
(
Some(PropertyRangeBound::Excluded(PropValue::Int(10))),
Some(PropertyRangeBound::Included(PropValue::Int(30))),
vec![imm_20, active_20, seg_high],
),
(
Some(PropertyRangeBound::Included(PropValue::Int(10))),
Some(PropertyRangeBound::Excluded(PropValue::Int(30))),
vec![seg_low, imm_20, active_20],
),
(
Some(PropertyRangeBound::Excluded(PropValue::Int(10))),
Some(PropertyRangeBound::Excluded(PropValue::Int(30))),
vec![imm_20, active_20],
),
(
Some(PropertyRangeBound::Excluded(PropValue::Int(20))),
None,
vec![seg_high],
),
(
None,
Some(PropertyRangeBound::Excluded(PropValue::Int(20))),
vec![seg_low],
),
];
engine.reset_property_query_routes();
for (lower, upper, expected) in &cases {
assert_eq!(
engine
.find_nodes_range("Person", "score", lower.as_ref(), upper.as_ref())
.unwrap(),
*expected
);
}
let routes = engine.property_query_route_snapshot();
assert_eq!(routes.range_scan_fallback, cases.len());
assert_eq!(routes.range_index_lookup, 0);
let info = engine
.ensure_node_property_index("Person",
"score",
SecondaryIndexKind::Range,
)
.unwrap();
wait_for_property_index_state(&engine, info.index_id, SecondaryIndexState::Ready);
engine.reset_property_query_routes();
for (lower, upper, expected) in &cases {
assert_eq!(
engine
.find_nodes_range("Person", "score", lower.as_ref(), upper.as_ref())
.unwrap(),
*expected
);
}
let routes = engine.property_query_route_snapshot();
assert_eq!(routes.range_scan_fallback, 0);
assert_eq!(routes.range_index_lookup, cases.len());
engine.close().unwrap();
}
#[test]
fn test_find_nodes_range_ready_parity_matches_bruteforce_oracle_across_domains() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let mut all_ids = Vec::new();
let mut int_a_props = BTreeMap::new();
int_a_props.insert("score_i".to_string(), PropValue::Int(10));
all_ids.push(
engine
.upsert_node(
"Person",
"int-a",
UpsertNodeOptions {
props: int_a_props,
..Default::default()
},
)
.unwrap(),
);
let mut int_stale_props = BTreeMap::new();
int_stale_props.insert("score_i".to_string(), PropValue::Int(15));
let int_stale = engine
.upsert_node(
"Person",
"int-stale",
UpsertNodeOptions {
props: int_stale_props,
..Default::default()
},
)
.unwrap();
all_ids.push(int_stale);
let mut uint_a_props = BTreeMap::new();
uint_a_props.insert("score_u".to_string(), PropValue::UInt(1));
let uint_a = engine
.upsert_node(
"Person",
"uint-a",
UpsertNodeOptions {
props: uint_a_props,
..Default::default()
},
)
.unwrap();
all_ids.push(uint_a);
let mut float_a_props = BTreeMap::new();
float_a_props.insert("score_f".to_string(), PropValue::Float(-0.0));
all_ids.push(
engine
.upsert_node(
"Person",
"float-a",
UpsertNodeOptions {
props: float_a_props,
..Default::default()
},
)
.unwrap(),
);
let mut float_stale_props = BTreeMap::new();
float_stale_props.insert("score_f".to_string(), PropValue::Float(2.0));
let float_stale = engine
.upsert_node(
"Person",
"float-stale",
UpsertNodeOptions {
props: float_stale_props,
..Default::default()
},
)
.unwrap();
all_ids.push(float_stale);
engine.flush().unwrap();
let mut int_c_props = BTreeMap::new();
int_c_props.insert("score_i".to_string(), PropValue::Int(20));
all_ids.push(
engine
.upsert_node(
"Person",
"int-c",
UpsertNodeOptions {
props: int_c_props,
..Default::default()
},
)
.unwrap(),
);
let mut uint_b_props = BTreeMap::new();
uint_b_props.insert("score_u".to_string(), PropValue::UInt(3));
all_ids.push(
engine
.upsert_node(
"Person",
"uint-b",
UpsertNodeOptions {
props: uint_b_props,
..Default::default()
},
)
.unwrap(),
);
let mut float_c_props = BTreeMap::new();
float_c_props.insert("score_f".to_string(), PropValue::Float(0.5));
all_ids.push(
engine
.upsert_node(
"Person",
"float-c",
UpsertNodeOptions {
props: float_c_props,
..Default::default()
},
)
.unwrap(),
);
engine.freeze_memtable().unwrap();
let mut int_stale_new_props = BTreeMap::new();
int_stale_new_props.insert("score_i".to_string(), PropValue::String("bad".to_string()));
assert_eq!(
engine
.upsert_node(
"Person",
"int-stale",
UpsertNodeOptions {
props: int_stale_new_props,
..Default::default()
},
)
.unwrap(),
int_stale
);
let mut int_d_props = BTreeMap::new();
int_d_props.insert("score_i".to_string(), PropValue::Int(25));
all_ids.push(
engine
.upsert_node(
"Person",
"int-d",
UpsertNodeOptions {
props: int_d_props,
..Default::default()
},
)
.unwrap(),
);
engine.delete_node(uint_a).unwrap();
let mut uint_c_props = BTreeMap::new();
uint_c_props.insert("score_u".to_string(), PropValue::UInt(5));
all_ids.push(
engine
.upsert_node(
"Person",
"uint-c",
UpsertNodeOptions {
props: uint_c_props,
..Default::default()
},
)
.unwrap(),
);
let mut float_stale_new_props = BTreeMap::new();
float_stale_new_props.insert("score_f".to_string(), PropValue::Float(f64::INFINITY));
assert_eq!(
engine
.upsert_node(
"Person",
"float-stale",
UpsertNodeOptions {
props: float_stale_new_props,
..Default::default()
},
)
.unwrap(),
float_stale
);
let mut float_d_props = BTreeMap::new();
float_d_props.insert("score_f".to_string(), PropValue::Float(1.5));
all_ids.push(
engine
.upsert_node(
"Person",
"float-d",
UpsertNodeOptions {
props: float_d_props,
..Default::default()
},
)
.unwrap(),
);
let queries = vec![
(
"score_i",
SecondaryIndexKind::Range,
Some(PropertyRangeBound::Included(PropValue::Int(0))),
Some(PropertyRangeBound::Included(PropValue::Int(30))),
),
(
"score_u",
SecondaryIndexKind::Range,
Some(PropertyRangeBound::Included(PropValue::UInt(0))),
Some(PropertyRangeBound::Included(PropValue::UInt(10))),
),
(
"score_f",
SecondaryIndexKind::Range,
Some(PropertyRangeBound::Included(PropValue::Float(-0.0))),
Some(PropertyRangeBound::Included(PropValue::Float(1.5))),
),
];
engine.reset_property_query_routes();
let mut oracles = Vec::new();
for (prop_key, _, lower, upper) in &queries {
let oracle = brute_force_range_oracle(
&engine,
&all_ids,
1,
prop_key,
lower.as_ref(),
upper.as_ref(),
);
assert_eq!(
engine
.find_nodes_range("Person", prop_key, lower.as_ref(), upper.as_ref())
.unwrap(),
oracle
);
oracles.push(oracle);
}
let routes = engine.property_query_route_snapshot();
assert_eq!(routes.range_scan_fallback, queries.len());
assert_eq!(routes.range_index_lookup, 0);
for (prop_key, kind, _, _) in &queries {
let info = engine.ensure_node_property_index("Person", prop_key, kind.clone()).unwrap();
wait_for_property_index_state(&engine, info.index_id, SecondaryIndexState::Ready);
}
engine.reset_property_query_routes();
for ((prop_key, _, lower, upper), oracle) in queries.iter().zip(oracles.iter()) {
assert_eq!(
engine
.find_nodes_range("Person", prop_key, lower.as_ref(), upper.as_ref())
.unwrap(),
*oracle
);
assert_eq!(
engine
.find_nodes_range_paged("Person",
prop_key,
lower.as_ref(),
upper.as_ref(),
&PropertyRangePageRequest::default(),
)
.unwrap()
.items,
*oracle
);
}
let routes = engine.property_query_route_snapshot();
assert_eq!(routes.range_scan_fallback, 0);
assert_eq!(routes.range_index_lookup, queries.len() * 2);
engine.close().unwrap();
}
#[test]
fn test_find_nodes_range_ready_refills_segment_chunks_with_pruned_overrides() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
for score in 1..=80i64 {
let mut props = BTreeMap::new();
props.insert("score".to_string(), PropValue::Int(score));
engine
.upsert_node(
"Person",
&format!("seg-{score:02}"),
UpsertNodeOptions {
props,
..Default::default()
},
)
.unwrap();
}
engine.flush().unwrap();
for score in 1..=60i64 {
let mut props = BTreeMap::new();
props.insert("score".to_string(), PropValue::Int(score));
engine
.upsert_node(
"Person",
&format!("seg-{score:02}"),
UpsertNodeOptions {
props,
weight: 0.1,
..Default::default()
},
)
.unwrap();
}
engine
.set_prune_policy(
"light",
PrunePolicy {
max_age_ms: None,
max_weight: Some(0.5),
label: None,
},
)
.unwrap();
let info = engine
.ensure_node_property_index("Person",
"score",
SecondaryIndexKind::Range,
)
.unwrap();
wait_for_property_index_state(&engine, info.index_id, SecondaryIndexState::Ready);
let expected_page1: Vec<u64> = (61..=70)
.map(|score| engine.get_node_by_key("Person", &format!("seg-{score:02}")).unwrap().unwrap().id)
.collect();
let expected_page2: Vec<u64> = (71..=80)
.map(|score| engine.get_node_by_key("Person", &format!("seg-{score:02}")).unwrap().unwrap().id)
.collect();
engine.reset_property_query_routes();
let page1 = engine
.find_nodes_range_paged("Person",
"score",
Some(&PropertyRangeBound::Included(PropValue::Int(1))),
Some(&PropertyRangeBound::Included(PropValue::Int(80))),
&PropertyRangePageRequest {
limit: Some(10),
after: None,
},
)
.unwrap();
assert_eq!(page1.items, expected_page1);
assert_eq!(
page1.next_cursor,
Some(PropertyRangeCursor {
value: PropValue::Int(70),
node_id: *expected_page1.last().unwrap(),
})
);
let page2 = engine
.find_nodes_range_paged("Person",
"score",
Some(&PropertyRangeBound::Included(PropValue::Int(1))),
Some(&PropertyRangeBound::Included(PropValue::Int(80))),
&PropertyRangePageRequest {
limit: Some(10),
after: page1.next_cursor.clone(),
},
)
.unwrap();
assert_eq!(page2.items, expected_page2);
assert!(page2.next_cursor.is_none());
let routes = engine.property_query_route_snapshot();
assert_eq!(routes.range_scan_fallback, 0);
assert_eq!(routes.range_index_lookup, 2);
engine.close().unwrap();
}
#[test]
fn test_find_nodes_range_ready_declaration_routes_and_orders_across_sources() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let mut seg_props = BTreeMap::new();
seg_props.insert("score".to_string(), PropValue::Int(30));
let seg_id = engine
.upsert_node(
"Person",
"seg-30",
UpsertNodeOptions {
props: seg_props,
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
let mut imm_props_a = BTreeMap::new();
imm_props_a.insert("score".to_string(), PropValue::Int(20));
let imm_a = engine
.upsert_node(
"Person",
"imm-20-a",
UpsertNodeOptions {
props: imm_props_a,
..Default::default()
},
)
.unwrap();
let mut imm_props_b = BTreeMap::new();
imm_props_b.insert("score".to_string(), PropValue::Int(20));
let imm_b = engine
.upsert_node(
"Person",
"imm-20-b",
UpsertNodeOptions {
props: imm_props_b,
..Default::default()
},
)
.unwrap();
engine.freeze_memtable().unwrap();
let mut active_props = BTreeMap::new();
active_props.insert("score".to_string(), PropValue::Int(25));
let active_25 = engine
.upsert_node(
"Person",
"active-25",
UpsertNodeOptions {
props: active_props,
..Default::default()
},
)
.unwrap();
let mut active_props_20 = BTreeMap::new();
active_props_20.insert("score".to_string(), PropValue::Int(20));
let active_20 = engine
.upsert_node(
"Person",
"active-20",
UpsertNodeOptions {
props: active_props_20,
..Default::default()
},
)
.unwrap();
let info = engine
.ensure_node_property_index("Person",
"score",
SecondaryIndexKind::Range,
)
.unwrap();
let ready = wait_for_property_index_state(&engine, info.index_id, SecondaryIndexState::Ready);
assert_eq!(ready.index_id, info.index_id);
let published_ready =
wait_for_published_property_index_state(&engine, info.index_id, SecondaryIndexState::Ready);
assert_eq!(published_ready.index_id, info.index_id);
let expected = vec![imm_a, imm_b, active_20, active_25, seg_id];
engine.reset_property_query_routes();
assert_eq!(
engine
.find_nodes_range("Person",
"score",
Some(&PropertyRangeBound::Included(PropValue::Int(20))),
Some(&PropertyRangeBound::Included(PropValue::Int(30))),
)
.unwrap(),
expected
);
let paged_all = engine
.find_nodes_range_paged("Person",
"score",
Some(&PropertyRangeBound::Included(PropValue::Int(20))),
Some(&PropertyRangeBound::Included(PropValue::Int(30))),
&PropertyRangePageRequest::default(),
)
.unwrap();
assert_eq!(paged_all.items, expected);
assert!(paged_all.next_cursor.is_none());
let first_page = engine
.find_nodes_range_paged("Person",
"score",
Some(&PropertyRangeBound::Included(PropValue::Int(20))),
Some(&PropertyRangeBound::Included(PropValue::Int(30))),
&PropertyRangePageRequest {
limit: Some(2),
after: None,
},
)
.unwrap();
assert_eq!(first_page.items, vec![imm_a, imm_b]);
assert_eq!(
first_page.next_cursor,
Some(PropertyRangeCursor {
value: PropValue::Int(20),
node_id: imm_b,
})
);
let second_page = engine
.find_nodes_range_paged("Person",
"score",
Some(&PropertyRangeBound::Included(PropValue::Int(20))),
Some(&PropertyRangeBound::Included(PropValue::Int(30))),
&PropertyRangePageRequest {
limit: Some(3),
after: first_page.next_cursor.clone(),
},
)
.unwrap();
assert_eq!(second_page.items, vec![active_20, active_25, seg_id]);
assert!(second_page.next_cursor.is_none());
let routes = engine.property_query_route_snapshot();
assert_eq!(routes.range_scan_fallback, 0);
assert_eq!(routes.range_index_lookup, 4);
engine.close().unwrap();
}
#[test]
fn test_find_nodes_range_ready_declaration_hides_stale_and_incompatible_older_matches() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let mut props = BTreeMap::new();
props.insert("score".to_string(), PropValue::Int(20));
let mutable_id = engine
.upsert_node(
"Person",
"mutable",
UpsertNodeOptions {
props,
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
let info = engine
.ensure_node_property_index("Person",
"score",
SecondaryIndexKind::Range,
)
.unwrap();
wait_for_property_index_state(&engine, info.index_id, SecondaryIndexState::Ready);
let mut incompatible_props = BTreeMap::new();
incompatible_props.insert("score".to_string(), PropValue::String("bad".to_string()));
assert_eq!(
engine
.upsert_node(
"Person",
"mutable",
UpsertNodeOptions {
props: incompatible_props,
..Default::default()
},
)
.unwrap(),
mutable_id
);
let mut keep_props = BTreeMap::new();
keep_props.insert("score".to_string(), PropValue::Int(25));
let keep_id = engine
.upsert_node(
"Person",
"keep",
UpsertNodeOptions {
props: keep_props,
..Default::default()
},
)
.unwrap();
engine.reset_property_query_routes();
assert_eq!(
engine
.find_nodes_range("Person",
"score",
Some(&PropertyRangeBound::Included(PropValue::Int(0))),
Some(&PropertyRangeBound::Included(PropValue::Int(30))),
)
.unwrap(),
vec![keep_id]
);
let routes = engine.property_query_route_snapshot();
assert_eq!(routes.range_scan_fallback, 0);
assert_eq!(routes.range_index_lookup, 1);
engine.close().unwrap();
}
#[test]
fn test_find_nodes_range_ready_paged_verifies_latest_numeric_key_before_cursor() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let mut mutable_props = BTreeMap::new();
mutable_props.insert("score".to_string(), PropValue::Int(20));
let mutable_id = engine
.upsert_node(
"Person",
"mutable",
UpsertNodeOptions {
props: mutable_props,
..Default::default()
},
)
.unwrap();
let mut mid_props = BTreeMap::new();
mid_props.insert("score".to_string(), PropValue::Int(22));
let mid_id = engine
.upsert_node(
"Person",
"mid",
UpsertNodeOptions {
props: mid_props,
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
let info = engine
.ensure_node_property_index("Person",
"score",
SecondaryIndexKind::Range,
)
.unwrap();
wait_for_property_index_state(&engine, info.index_id, SecondaryIndexState::Ready);
let mut updated_props = BTreeMap::new();
updated_props.insert("score".to_string(), PropValue::Int(25));
assert_eq!(
engine
.upsert_node(
"Person",
"mutable",
UpsertNodeOptions {
props: updated_props,
..Default::default()
},
)
.unwrap(),
mutable_id
);
engine.reset_property_query_routes();
let page1 = engine
.find_nodes_range_paged("Person",
"score",
Some(&PropertyRangeBound::Included(PropValue::Int(0))),
Some(&PropertyRangeBound::Included(PropValue::Int(30))),
&PropertyRangePageRequest {
limit: Some(1),
after: None,
},
)
.unwrap();
assert_eq!(page1.items, vec![mid_id]);
assert_eq!(
page1.next_cursor,
Some(PropertyRangeCursor {
value: PropValue::Int(22),
node_id: mid_id,
})
);
let page2 = engine
.find_nodes_range_paged("Person",
"score",
Some(&PropertyRangeBound::Included(PropValue::Int(0))),
Some(&PropertyRangeBound::Included(PropValue::Int(30))),
&PropertyRangePageRequest {
limit: Some(1),
after: page1.next_cursor.clone(),
},
)
.unwrap();
assert_eq!(page2.items, vec![mutable_id]);
assert!(page2.next_cursor.is_none());
let routes = engine.property_query_route_snapshot();
assert_eq!(routes.range_scan_fallback, 0);
assert_eq!(routes.range_index_lookup, 2);
engine.close().unwrap();
}
#[test]
fn test_find_nodes_range_ready_domainless_uint_int_and_float() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let mut count_props_a = BTreeMap::new();
count_props_a.insert("count".to_string(), PropValue::UInt(5));
let count_a = engine
.upsert_node(
"Person",
"count-a",
UpsertNodeOptions {
props: count_props_a,
..Default::default()
},
)
.unwrap();
let mut count_props_b = BTreeMap::new();
count_props_b.insert("count".to_string(), PropValue::UInt(10));
let count_b = engine
.upsert_node(
"Person",
"count-b",
UpsertNodeOptions {
props: count_props_b,
..Default::default()
},
)
.unwrap();
let mut incompatible_count_props = BTreeMap::new();
incompatible_count_props.insert("count".to_string(), PropValue::Int(7));
let compatible_count = engine
.upsert_node(
"Person",
"count-compatible-int",
UpsertNodeOptions {
props: incompatible_count_props,
..Default::default()
},
)
.unwrap();
let uint_info = engine
.ensure_node_property_index("Person",
"count",
SecondaryIndexKind::Range,
)
.unwrap();
wait_for_property_index_state(&engine, uint_info.index_id, SecondaryIndexState::Ready);
engine.reset_property_query_routes();
assert_eq!(
engine
.find_nodes_range("Person",
"count",
Some(&PropertyRangeBound::Included(PropValue::UInt(0))),
Some(&PropertyRangeBound::Included(PropValue::UInt(10))),
)
.unwrap(),
vec![count_a, compatible_count, count_b]
);
let routes = engine.property_query_route_snapshot();
assert_eq!(routes.range_scan_fallback, 0);
assert_eq!(routes.range_index_lookup, 1);
let mut temp_neg_zero = BTreeMap::new();
temp_neg_zero.insert("temp".to_string(), PropValue::Float(-0.0));
let neg_zero = engine
.upsert_node(
"Person",
"temp-neg-zero",
UpsertNodeOptions {
props: temp_neg_zero,
..Default::default()
},
)
.unwrap();
let mut temp_pos_zero = BTreeMap::new();
temp_pos_zero.insert("temp".to_string(), PropValue::Float(0.0));
let pos_zero = engine
.upsert_node(
"Person",
"temp-pos-zero",
UpsertNodeOptions {
props: temp_pos_zero,
..Default::default()
},
)
.unwrap();
let mut temp_one = BTreeMap::new();
temp_one.insert("temp".to_string(), PropValue::Float(1.5));
let one = engine
.upsert_node(
"Person",
"temp-one",
UpsertNodeOptions {
props: temp_one,
..Default::default()
},
)
.unwrap();
let mut temp_inf = BTreeMap::new();
temp_inf.insert("temp".to_string(), PropValue::Float(f64::INFINITY));
engine
.upsert_node(
"Person",
"temp-inf",
UpsertNodeOptions {
props: temp_inf,
..Default::default()
},
)
.unwrap();
let float_info = engine
.ensure_node_property_index("Person",
"temp",
SecondaryIndexKind::Range,
)
.unwrap();
wait_for_property_index_state(&engine, float_info.index_id, SecondaryIndexState::Ready);
engine.reset_property_query_routes();
assert_eq!(
engine
.find_nodes_range("Person",
"temp",
Some(&PropertyRangeBound::Included(PropValue::Float(-0.0))),
Some(&PropertyRangeBound::Included(PropValue::Float(1.5))),
)
.unwrap(),
vec![neg_zero, pos_zero, one]
);
let routes = engine.property_query_route_snapshot();
assert_eq!(routes.range_scan_fallback, 0);
assert_eq!(routes.range_index_lookup, 1);
engine.close().unwrap();
}
#[test]
fn test_nodes_by_labels_paged_policy_refills_past_sparse_filtered_window() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let mut visible_ids = Vec::new();
for i in 0..17u64 {
let weight = if i < 12 { 0.1 } else { 1.0 };
let id = engine
.upsert_node(
"Person",
&format!("n{}", i),
UpsertNodeOptions {
weight,
..Default::default()
},
)
.unwrap();
if weight > 0.5 {
visible_ids.push(id);
}
}
engine
.set_prune_policy(
"low_weight",
PrunePolicy {
max_age_ms: None,
max_weight: Some(0.5),
label: None,
},
)
.unwrap();
let page1 = engine
.nodes_by_labels_paged("Person",
&PageRequest {
limit: Some(3),
after: None,
},
)
.unwrap();
assert_eq!(page1.items, visible_ids[..3].to_vec());
assert!(page1.next_cursor.is_some());
let page2 = engine
.nodes_by_labels_paged("Person",
&PageRequest {
limit: Some(3),
after: page1.next_cursor,
},
)
.unwrap();
assert_eq!(page2.items, visible_ids[3..].to_vec());
assert!(page2.next_cursor.is_none());
}
#[test]
fn test_find_nodes_paged_policy_refills_past_sparse_filtered_window() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let red = PropValue::String("red".to_string());
let mut visible_ids = Vec::new();
for i in 0..17u64 {
let mut props = BTreeMap::new();
props.insert("color".to_string(), red.clone());
let weight = if i < 12 { 0.1 } else { 1.0 };
let id = engine
.upsert_node(
"Person",
&format!("n{}", i),
UpsertNodeOptions {
props,
weight,
..Default::default()
},
)
.unwrap();
if weight > 0.5 {
visible_ids.push(id);
}
}
engine
.set_prune_policy(
"low_weight",
PrunePolicy {
max_age_ms: None,
max_weight: Some(0.5),
label: None,
},
)
.unwrap();
let page1 = engine
.find_nodes_paged("Person",
"color",
&red,
&PageRequest {
limit: Some(3),
after: None,
},
)
.unwrap();
assert_eq!(page1.items, visible_ids[..3].to_vec());
assert!(page1.next_cursor.is_some());
let page2 = engine
.find_nodes_paged("Person",
"color",
&red,
&PageRequest {
limit: Some(3),
after: page1.next_cursor,
},
)
.unwrap();
assert_eq!(page2.items, visible_ids[3..].to_vec());
assert!(page2.next_cursor.is_none());
}
#[test]
fn test_find_nodes_paged_scan_fallback_cursor_requires_extra_verified_match() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let red = PropValue::String("red".to_string());
let mut props = BTreeMap::new();
props.insert("color".to_string(), red.clone());
let keep_id = engine
.upsert_node(
"Person",
"keep",
UpsertNodeOptions {
props: props.clone(),
weight: 1.0,
..Default::default()
},
)
.unwrap();
for i in 0..5 {
engine
.upsert_node(
"Person",
&format!("pruned-{i}"),
UpsertNodeOptions {
props: props.clone(),
weight: 0.1,
..Default::default()
},
)
.unwrap();
}
engine
.set_prune_policy(
"low_weight",
PrunePolicy {
max_age_ms: None,
max_weight: Some(0.5),
label: None,
},
)
.unwrap();
let page = engine
.find_nodes_paged("Person",
"color",
&red,
&PageRequest {
limit: Some(1),
after: None,
},
)
.unwrap();
assert_eq!(page.items, vec![keep_id]);
assert!(
page.next_cursor.is_none(),
"scan fallback pagination must not report a next page unless another verified node exists"
);
engine.close().unwrap();
}
#[test]
fn test_find_nodes_paged_default_returns_all() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let red = PropValue::String("red".to_string());
let mut props = BTreeMap::new();
props.insert("color".to_string(), red.clone());
for i in 0..5 {
engine
.upsert_node(
"Person",
&format!("n{}", i),
UpsertNodeOptions {
props: props.clone(),
..Default::default()
},
)
.unwrap();
}
let result = engine
.find_nodes_paged("Person", "color", &red, &PageRequest::default())
.unwrap();
assert_eq!(result.items.len(), 5);
assert!(result.next_cursor.is_none());
}
#[test]
fn test_upsert_edge_default_temporal_fields() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("db");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let a = engine
.upsert_node(
"Person",
"a",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
let b = engine
.upsert_node(
"Person",
"b",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
let eid = engine
.upsert_edge(a, b, "KNOWS", UpsertEdgeOptions::default())
.unwrap();
let edge = engine.get_edge(eid).unwrap().unwrap();
assert_eq!(edge.valid_from, edge.created_at);
assert_eq!(edge.valid_to, i64::MAX);
engine.close().unwrap();
}
#[test]
fn test_upsert_edge_custom_temporal_fields() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("db");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let a = engine
.upsert_node(
"Person",
"a",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
let b = engine
.upsert_node(
"Person",
"b",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
let eid = engine
.upsert_edge(
a,
b,
"KNOWS",
UpsertEdgeOptions {
valid_from: Some(1000),
valid_to: Some(5000),
..Default::default()
},
)
.unwrap();
let edge = engine.get_edge(eid).unwrap().unwrap();
assert_eq!(edge.valid_from, 1000);
assert_eq!(edge.valid_to, 5000);
engine.close().unwrap();
}
#[test]
fn test_temporal_fields_survive_flush_and_segment_read() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("db");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let a = engine
.upsert_node(
"Person",
"a",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
let b = engine
.upsert_node(
"Person",
"b",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
let eid = engine
.upsert_edge(
a,
b,
"KNOWS",
UpsertEdgeOptions {
valid_from: Some(2000),
valid_to: Some(8000),
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
let edge = engine.get_edge(eid).unwrap().unwrap();
assert_eq!(edge.valid_from, 2000);
assert_eq!(edge.valid_to, 8000);
engine.close().unwrap();
}
#[test]
fn test_temporal_fields_survive_wal_replay() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("db");
let eid;
{
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let a = engine
.upsert_node(
"Person",
"a",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
let b = engine
.upsert_node(
"Person",
"b",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
eid = engine
.upsert_edge(
a,
b,
"KNOWS",
UpsertEdgeOptions {
valid_from: Some(3000),
valid_to: Some(9000),
..Default::default()
},
)
.unwrap();
engine.close().unwrap();
}
{
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let edge = engine.get_edge(eid).unwrap().unwrap();
assert_eq!(edge.valid_from, 3000);
assert_eq!(edge.valid_to, 9000);
engine.close().unwrap();
}
}
#[test]
fn test_batch_upsert_edges_temporal_fields() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("db");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let a = engine
.upsert_node(
"Person",
"a",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
let b = engine
.upsert_node(
"Person",
"b",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
let c = engine
.upsert_node(
"Person",
"c",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
let inputs = vec![
EdgeInput {
from: a,
to: b,
label: "KNOWS".to_string(),
props: BTreeMap::new(),
weight: 1.0,
valid_from: Some(1000),
valid_to: Some(5000),
},
EdgeInput {
from: b,
to: c,
label: "KNOWS".to_string(),
props: BTreeMap::new(),
weight: 1.0,
valid_from: None,
valid_to: None, },
];
let ids = engine.batch_upsert_edges(inputs).unwrap();
let e1 = engine.get_edge(ids[0]).unwrap().unwrap();
assert_eq!(e1.valid_from, 1000);
assert_eq!(e1.valid_to, 5000);
let e2 = engine.get_edge(ids[1]).unwrap().unwrap();
assert_eq!(e2.valid_from, e2.created_at); assert_eq!(e2.valid_to, i64::MAX);
engine.close().unwrap();
}
#[test]
fn test_temporal_fields_survive_compaction() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("db");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let a = engine
.upsert_node(
"Person",
"a",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
let b = engine
.upsert_node(
"Person",
"b",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
let eid = engine
.upsert_edge(
a,
b,
"KNOWS",
UpsertEdgeOptions {
valid_from: Some(4000),
valid_to: Some(7000),
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
let c = engine
.upsert_node(
"Person",
"c",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
engine
.upsert_edge(b, c, "KNOWS", UpsertEdgeOptions::default())
.unwrap();
engine.flush().unwrap();
engine.compact().unwrap();
assert_eq!(engine.segment_count().unwrap(), 1);
let edge = engine.get_edge(eid).unwrap().unwrap();
assert_eq!(edge.valid_from, 4000);
assert_eq!(edge.valid_to, 7000);
engine.close().unwrap();
}
#[test]
fn test_invalidate_edge_closes_validity_window() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("db");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let a = engine
.upsert_node(
"Person",
"a",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
let b = engine
.upsert_node(
"Person",
"b",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
let eid = engine
.upsert_edge(a, b, "KNOWS", UpsertEdgeOptions::default())
.unwrap();
let edge = engine.get_edge(eid).unwrap().unwrap();
assert_eq!(edge.valid_to, i64::MAX);
let result = engine.invalidate_edge(eid, 5000).unwrap();
assert!(result.is_some());
let updated = result.unwrap();
assert_eq!(updated.valid_to, 5000);
let edge = engine.get_edge(eid).unwrap().unwrap();
assert_eq!(edge.valid_to, 5000);
engine.close().unwrap();
}
#[test]
fn test_invalidate_nonexistent_edge_returns_none() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("db");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let result = engine.invalidate_edge(999, 5000).unwrap();
assert!(result.is_none());
engine.close().unwrap();
}
#[test]
fn test_invalidated_edge_hidden_from_neighbors() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("db");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let a = engine
.upsert_node(
"Person",
"a",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
let b = engine
.upsert_node(
"Person",
"b",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
let c = engine
.upsert_node(
"Person",
"c",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
let e_ab = engine
.upsert_edge(a, b, "KNOWS", UpsertEdgeOptions::default())
.unwrap();
engine
.upsert_edge(a, c, "KNOWS", UpsertEdgeOptions::default())
.unwrap();
let out = engine.neighbors(a, &NeighborOptions::default()).unwrap();
assert_eq!(out.len(), 2);
engine.invalidate_edge(e_ab, 1).unwrap();
let out = engine.neighbors(a, &NeighborOptions::default()).unwrap();
assert_eq!(out.len(), 1);
assert_eq!(out[0].node_id, c);
engine.close().unwrap();
}
#[test]
fn test_invalidated_edge_hidden_after_flush() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("db");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let a = engine
.upsert_node(
"Person",
"a",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
let b = engine
.upsert_node(
"Person",
"b",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
let eid = engine
.upsert_edge(a, b, "KNOWS", UpsertEdgeOptions::default())
.unwrap();
engine.flush().unwrap();
engine.invalidate_edge(eid, 1).unwrap();
let out = engine.neighbors(a, &NeighborOptions::default()).unwrap();
assert!(out.is_empty());
let edge = engine.get_edge(eid).unwrap().unwrap();
assert_eq!(edge.valid_to, 1);
engine.close().unwrap();
}
#[test]
fn test_invalidated_edge_survives_wal_replay() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("db");
let (a, eid);
{
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
a = engine
.upsert_node(
"Person",
"a",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
let b = engine
.upsert_node(
"Person",
"b",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
eid = engine
.upsert_edge(a, b, "KNOWS", UpsertEdgeOptions::default())
.unwrap();
engine.invalidate_edge(eid, 1).unwrap();
engine.close().unwrap();
}
{
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let edge = engine.get_edge(eid).unwrap().unwrap();
assert_eq!(edge.valid_to, 1);
let out = engine.neighbors(a, &NeighborOptions::default()).unwrap();
assert!(out.is_empty());
engine.close().unwrap();
}
}
#[test]
fn test_point_in_time_query_sees_valid_edges() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let a = engine
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let b = engine
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
let c = engine
.upsert_node("Person", "c", UpsertNodeOptions::default())
.unwrap();
engine
.upsert_edge(
a,
b,
"RELATES_TO",
UpsertEdgeOptions {
valid_from: Some(1000),
valid_to: Some(5000),
..Default::default()
},
)
.unwrap();
engine
.upsert_edge(
a,
c,
"RELATES_TO",
UpsertEdgeOptions {
valid_from: Some(3000),
valid_to: Some(8000),
..Default::default()
},
)
.unwrap();
let out = engine
.neighbors(
a,
&NeighborOptions {
at_epoch: Some(500),
..Default::default()
},
)
.unwrap();
assert_eq!(out.len(), 0);
let out = engine
.neighbors(
a,
&NeighborOptions {
at_epoch: Some(2000),
..Default::default()
},
)
.unwrap();
assert_eq!(out.len(), 1);
assert_eq!(out[0].node_id, b);
let out = engine
.neighbors(
a,
&NeighborOptions {
at_epoch: Some(4000),
..Default::default()
},
)
.unwrap();
assert_eq!(out.len(), 2);
let out = engine
.neighbors(
a,
&NeighborOptions {
at_epoch: Some(6000),
..Default::default()
},
)
.unwrap();
assert_eq!(out.len(), 1);
assert_eq!(out[0].node_id, c);
let out = engine
.neighbors(
a,
&NeighborOptions {
at_epoch: Some(9000),
..Default::default()
},
)
.unwrap();
assert_eq!(out.len(), 0);
engine.close().unwrap();
}
#[test]
fn test_point_in_time_query_with_invalidated_edge() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let a = engine
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let b = engine
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
let eid = engine
.upsert_edge(
a,
b,
"RELATES_TO",
UpsertEdgeOptions {
valid_from: Some(1000),
valid_to: Some(10000),
..Default::default()
},
)
.unwrap();
engine.invalidate_edge(eid, 5000).unwrap();
let out = engine
.neighbors(
a,
&NeighborOptions {
at_epoch: Some(3000),
..Default::default()
},
)
.unwrap();
assert_eq!(out.len(), 1);
let out = engine
.neighbors(
a,
&NeighborOptions {
at_epoch: Some(5000),
..Default::default()
},
)
.unwrap();
assert_eq!(out.len(), 0);
let out = engine
.neighbors(
a,
&NeighborOptions {
at_epoch: Some(7000),
..Default::default()
},
)
.unwrap();
assert_eq!(out.len(), 0);
engine.close().unwrap();
}
#[test]
fn test_point_in_time_query_after_flush() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let a = engine
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let b = engine
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
engine
.upsert_edge(
a,
b,
"RELATES_TO",
UpsertEdgeOptions {
valid_from: Some(2000),
valid_to: Some(8000),
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
let out = engine
.neighbors(
a,
&NeighborOptions {
at_epoch: Some(5000),
..Default::default()
},
)
.unwrap();
assert_eq!(out.len(), 1);
let out = engine
.neighbors(
a,
&NeighborOptions {
at_epoch: Some(1000),
..Default::default()
},
)
.unwrap();
assert_eq!(out.len(), 0);
engine.close().unwrap();
}
#[test]
fn test_point_in_time_traverse_depth_two() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let a = engine
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let b = engine
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
let c = engine
.upsert_node("Person", "c", UpsertNodeOptions::default())
.unwrap();
engine
.upsert_edge(
a,
b,
"RELATES_TO",
UpsertEdgeOptions {
valid_from: Some(1000),
valid_to: Some(5000),
..Default::default()
},
)
.unwrap();
engine
.upsert_edge(
b,
c,
"RELATES_TO",
UpsertEdgeOptions {
valid_from: Some(2000),
valid_to: Some(6000),
..Default::default()
},
)
.unwrap();
let hop2 = traverse_depth_two_read(&engine, a, Direction::Outgoing, None, None, Some(3000));
assert_eq!(hop2.len(), 1);
assert_eq!(hop2[0].node_id, c);
let hop2 = traverse_depth_two_read(&engine, a, Direction::Outgoing, None, None, Some(500));
assert_eq!(hop2.len(), 0);
let hop2 = traverse_depth_two_read(&engine, a, Direction::Outgoing, None, None, Some(5500));
assert_eq!(hop2.len(), 0);
engine.close().unwrap();
}
#[test]
fn test_decay_scoring_orders_by_recency() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let hub = engine
.upsert_node("Person", "hub", UpsertNodeOptions::default())
.unwrap();
let old = engine
.upsert_node("Person", "old", UpsertNodeOptions::default())
.unwrap();
let recent = engine
.upsert_node("Person", "recent", UpsertNodeOptions::default())
.unwrap();
let now = now_millis();
let one_day_ago = now - 24 * 3_600_000; let one_hour_ago = now - 3_600_000;
engine
.upsert_edge(
hub,
old,
"RELATES_TO",
UpsertEdgeOptions {
valid_from: Some(one_day_ago),
..Default::default()
},
)
.unwrap();
engine
.upsert_edge(
hub,
recent,
"RELATES_TO",
UpsertEdgeOptions {
valid_from: Some(one_hour_ago),
..Default::default()
},
)
.unwrap();
let out = engine.neighbors(hub, &NeighborOptions::default()).unwrap();
assert_eq!(out.len(), 2);
let out = engine
.neighbors(
hub,
&NeighborOptions {
decay_lambda: Some(0.1),
..Default::default()
},
)
.unwrap();
assert_eq!(out.len(), 2);
assert_eq!(out[0].node_id, recent);
assert_eq!(out[1].node_id, old);
assert!(out[0].weight > out[1].weight);
engine.close().unwrap();
}
#[test]
fn test_decay_scoring_with_different_base_weights() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let hub = engine
.upsert_node("Person", "hub", UpsertNodeOptions::default())
.unwrap();
let heavy_old = engine
.upsert_node("Person", "heavy_old", UpsertNodeOptions::default())
.unwrap();
let light_new = engine
.upsert_node("Person", "light_new", UpsertNodeOptions::default())
.unwrap();
let now = now_millis();
let two_days_ago = now - 48 * 3_600_000;
let one_hour_ago = now - 3_600_000;
engine
.upsert_edge(
hub,
heavy_old,
"RELATES_TO",
UpsertEdgeOptions {
weight: 10.0,
valid_from: Some(two_days_ago),
..Default::default()
},
)
.unwrap();
engine
.upsert_edge(
hub,
light_new,
"RELATES_TO",
UpsertEdgeOptions {
valid_from: Some(one_hour_ago),
..Default::default()
},
)
.unwrap();
let out = engine
.neighbors(
hub,
&NeighborOptions {
decay_lambda: Some(0.1),
..Default::default()
},
)
.unwrap();
assert_eq!(out.len(), 2);
assert_eq!(out[0].node_id, light_new); assert!(out[0].weight > out[1].weight);
engine.close().unwrap();
}
#[test]
fn test_decay_zero_lambda_no_reorder() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let a = engine
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let b = engine
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
engine
.upsert_edge(
a,
b,
"RELATES_TO",
UpsertEdgeOptions {
weight: 5.0,
..Default::default()
},
)
.unwrap();
let out = engine.neighbors(a, &NeighborOptions::default()).unwrap();
assert_eq!(out.len(), 1);
assert!((out[0].weight - 5.0).abs() < 0.001); engine.close().unwrap();
}
#[test]
fn test_decay_with_limit_returns_top_scored() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let hub = engine
.upsert_node("Person", "hub", UpsertNodeOptions::default())
.unwrap();
let n1 = engine
.upsert_node("Person", "n1", UpsertNodeOptions::default())
.unwrap();
let n2 = engine
.upsert_node("Person", "n2", UpsertNodeOptions::default())
.unwrap();
let n3 = engine
.upsert_node("Person", "n3", UpsertNodeOptions::default())
.unwrap();
let now = now_millis();
engine
.upsert_edge(
hub,
n1,
"RELATES_TO",
UpsertEdgeOptions {
valid_from: Some(now - 72 * 3_600_000),
..Default::default()
},
)
.unwrap(); engine
.upsert_edge(
hub,
n2,
"RELATES_TO",
UpsertEdgeOptions {
valid_from: Some(now - 24 * 3_600_000),
..Default::default()
},
)
.unwrap(); engine
.upsert_edge(
hub,
n3,
"RELATES_TO",
UpsertEdgeOptions {
valid_from: Some(now - 3_600_000),
..Default::default()
},
)
.unwrap();
let out = engine
.neighbors(
hub,
&NeighborOptions {
limit: Some(2),
decay_lambda: Some(0.05),
..Default::default()
},
)
.unwrap();
assert_eq!(out.len(), 2);
assert_eq!(out[0].node_id, n3); assert_eq!(out[1].node_id, n2); engine.close().unwrap();
}
#[test]
fn test_point_in_time_with_decay() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let hub = engine
.upsert_node("Person", "hub", UpsertNodeOptions::default())
.unwrap();
let n1 = engine
.upsert_node("Person", "n1", UpsertNodeOptions::default())
.unwrap();
let n2 = engine
.upsert_node("Person", "n2", UpsertNodeOptions::default())
.unwrap();
engine
.upsert_edge(
hub,
n1,
"RELATES_TO",
UpsertEdgeOptions {
valid_from: Some(1000),
valid_to: Some(5000),
..Default::default()
},
)
.unwrap();
engine
.upsert_edge(
hub,
n2,
"RELATES_TO",
UpsertEdgeOptions {
valid_from: Some(2000),
valid_to: Some(8000),
..Default::default()
},
)
.unwrap();
let out = engine
.neighbors(
hub,
&NeighborOptions {
at_epoch: Some(3000),
decay_lambda: Some(0.01),
..Default::default()
},
)
.unwrap();
assert_eq!(out.len(), 2);
assert_eq!(out[0].node_id, n2);
let out = engine
.neighbors(
hub,
&NeighborOptions {
at_epoch: Some(6000),
decay_lambda: Some(0.01),
..Default::default()
},
)
.unwrap();
assert_eq!(out.len(), 1);
assert_eq!(out[0].node_id, n2);
engine.close().unwrap();
}
#[test]
fn test_decay_scoring_after_flush_segment_sourced() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let hub = engine
.upsert_node("Person", "hub", UpsertNodeOptions::default())
.unwrap();
let old = engine
.upsert_node("Person", "old", UpsertNodeOptions::default())
.unwrap();
let recent = engine
.upsert_node("Person", "recent", UpsertNodeOptions::default())
.unwrap();
let now = now_millis();
engine
.upsert_edge(
hub,
old,
"RELATES_TO",
UpsertEdgeOptions {
valid_from: Some(now - 48 * 3_600_000),
..Default::default()
},
)
.unwrap();
engine
.upsert_edge(
hub,
recent,
"RELATES_TO",
UpsertEdgeOptions {
valid_from: Some(now - 3_600_000),
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
let out = engine
.neighbors(
hub,
&NeighborOptions {
decay_lambda: Some(0.05),
..Default::default()
},
)
.unwrap();
assert_eq!(out.len(), 2);
assert_eq!(out[0].node_id, recent); assert_eq!(out[1].node_id, old);
assert!(out[0].weight > out[1].weight);
engine.close().unwrap();
}
#[test]
fn test_negative_decay_lambda_returns_error() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let a = engine
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
engine
.upsert_edge(a, a, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
let result = engine.neighbors(
a,
&NeighborOptions {
decay_lambda: Some(-0.5),
..Default::default()
},
);
assert!(result.is_err());
engine.close().unwrap();
}
#[test]
fn test_temporal_adjacency_postings_survive_flush() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let engine = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let a = engine
.upsert_node(
"Person",
"a",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
let b = engine
.upsert_node(
"Person",
"b",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
let c = engine
.upsert_node(
"Person",
"c",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
engine
.upsert_edge(
a,
b,
"KNOWS",
UpsertEdgeOptions {
valid_from: Some(1000),
valid_to: Some(5000),
..Default::default()
},
)
.unwrap();
engine
.upsert_edge(
a,
c,
"KNOWS",
UpsertEdgeOptions {
valid_from: Some(3000),
valid_to: Some(9000),
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
let n = engine
.neighbors(
a,
&NeighborOptions {
at_epoch: Some(2000),
..Default::default()
},
)
.unwrap();
assert_eq!(n.len(), 1);
assert_eq!(n[0].node_id, b);
let n = engine
.neighbors(
a,
&NeighborOptions {
at_epoch: Some(4000),
..Default::default()
},
)
.unwrap();
assert_eq!(n.len(), 2);
let n = engine
.neighbors(
a,
&NeighborOptions {
at_epoch: Some(6000),
..Default::default()
},
)
.unwrap();
assert_eq!(n.len(), 1);
assert_eq!(n[0].node_id, c);
engine.close().unwrap();
}
#[test]
fn test_adjacency_hashmap_upsert_idempotent() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let opts = DbOptions {
edge_uniqueness: true,
..DbOptions::default()
};
let engine = DatabaseEngine::open(&db_path, &opts).unwrap();
let a = engine
.upsert_node(
"Person",
"a",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
let b = engine
.upsert_node(
"Person",
"b",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
let eid = engine
.upsert_edge(a, b, "KNOWS", UpsertEdgeOptions::default())
.unwrap();
let eid2 = engine
.upsert_edge(
a,
b,
"KNOWS",
UpsertEdgeOptions {
weight: 2.0,
..Default::default()
},
)
.unwrap();
let eid3 = engine
.upsert_edge(
a,
b,
"KNOWS",
UpsertEdgeOptions {
weight: 3.0,
..Default::default()
},
)
.unwrap();
assert_eq!(eid, eid2);
assert_eq!(eid, eid3);
let n = engine.neighbors(a, &NeighborOptions::default()).unwrap();
assert_eq!(n.len(), 1);
assert_eq!(n[0].edge_id, eid);
assert_eq!(n[0].weight, 3.0);
engine.close().unwrap();
}
#[test]
fn test_compact_with_progress_reports_all_phases() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let opts = DbOptions {
compact_after_n_flushes: 0,
..DbOptions::default()
};
let engine = DatabaseEngine::open(&db_path, &opts).unwrap();
let mut node_ids = Vec::new();
for i in 0..30 {
node_ids.push(
engine
.upsert_node("Person", &format!("n{}", i), UpsertNodeOptions::default())
.unwrap(),
);
}
engine.flush().unwrap();
for i in 30..60 {
node_ids.push(
engine
.upsert_node("Person", &format!("n{}", i), UpsertNodeOptions::default())
.unwrap(),
);
}
engine.flush().unwrap();
for i in 0..10 {
engine
.upsert_edge(
node_ids[i],
node_ids[i + 1],
"RELATES_TO",
UpsertEdgeOptions::default(),
)
.unwrap();
}
engine.flush().unwrap();
let mut phases_seen: Vec<CompactionPhase> = Vec::new();
let mut progress_calls = 0u32;
let stats = engine
.compact_with_progress(|progress| {
if phases_seen.last() != Some(&progress.phase) {
phases_seen.push(progress.phase);
}
progress_calls += 1;
true })
.unwrap();
assert!(stats.is_some());
let stats = stats.unwrap();
assert_eq!(stats.segments_merged, 3);
assert_eq!(stats.nodes_kept, 60);
assert!(stats.edges_kept >= 10);
assert_eq!(
phases_seen,
vec![
CompactionPhase::CollectingTombstones,
CompactionPhase::MergingNodes,
CompactionPhase::MergingEdges,
CompactionPhase::WritingOutput,
]
);
assert!(progress_calls >= 4, "got {} calls", progress_calls);
engine.close().unwrap();
}
#[test]
fn test_compact_with_progress_cancel_during_tombstones() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let opts = DbOptions::default();
let engine = DatabaseEngine::open(&db_path, &opts).unwrap();
let mut ids = Vec::new();
for i in 0..20 {
ids.push(
engine
.upsert_node("Person", &format!("n{}", i), UpsertNodeOptions::default())
.unwrap(),
);
}
engine.flush().unwrap();
for i in 20..40 {
ids.push(
engine
.upsert_node("Person", &format!("n{}", i), UpsertNodeOptions::default())
.unwrap(),
);
}
engine.flush().unwrap();
let result = engine.compact_with_progress(|_| false);
assert!(matches!(result, Err(EngineError::CompactionCancelled)));
for &id in &ids {
let node = engine.get_node(id).unwrap();
assert!(node.is_some(), "node {} missing after cancel", id);
}
let stats = engine.compact().unwrap();
assert!(stats.is_some());
assert_eq!(stats.unwrap().nodes_kept, 40);
engine.close().unwrap();
}
#[test]
fn test_compact_with_progress_cancel_during_merge_nodes() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let opts = DbOptions::default();
let engine = DatabaseEngine::open(&db_path, &opts).unwrap();
let mut ids = Vec::new();
for i in 0..20 {
ids.push(
engine
.upsert_node("Person", &format!("n{}", i), UpsertNodeOptions::default())
.unwrap(),
);
}
engine.flush().unwrap();
for i in 20..40 {
ids.push(
engine
.upsert_node("Person", &format!("n{}", i), UpsertNodeOptions::default())
.unwrap(),
);
}
engine.flush().unwrap();
let result =
engine.compact_with_progress(|progress| progress.phase != CompactionPhase::MergingNodes);
assert!(matches!(result, Err(EngineError::CompactionCancelled)));
for &id in &ids {
assert!(
engine.get_node(id).unwrap().is_some(),
"node {} missing",
id
);
}
engine.close().unwrap();
}
#[test]
fn test_compact_with_progress_cancel_during_merge_edges() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let opts = DbOptions::default();
let engine = DatabaseEngine::open(&db_path, &opts).unwrap();
let mut node_ids = Vec::new();
for i in 0..10 {
node_ids.push(
engine
.upsert_node("Person", &format!("n{}", i), UpsertNodeOptions::default())
.unwrap(),
);
}
engine.flush().unwrap();
for i in 0..5 {
engine
.upsert_edge(
node_ids[i],
node_ids[i + 1],
"RELATES_TO",
UpsertEdgeOptions::default(),
)
.unwrap();
}
engine.flush().unwrap();
let result =
engine.compact_with_progress(|progress| progress.phase != CompactionPhase::MergingEdges);
assert!(matches!(result, Err(EngineError::CompactionCancelled)));
for &id in &node_ids {
assert!(engine.get_node(id).unwrap().is_some());
}
for nid in &node_ids[..5] {
let neighbors = engine.neighbors(*nid, &NeighborOptions::default()).unwrap();
assert!(
!neighbors.is_empty(),
"node {} should have outgoing edges",
nid
);
}
engine.close().unwrap();
}
#[test]
fn test_compact_with_progress_cancel_before_write() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let opts = DbOptions::default();
let engine = DatabaseEngine::open(&db_path, &opts).unwrap();
let mut ids = Vec::new();
for i in 0..10 {
ids.push(
engine
.upsert_node("Person", &format!("n{}", i), UpsertNodeOptions::default())
.unwrap(),
);
}
engine.flush().unwrap();
for i in 10..20 {
ids.push(
engine
.upsert_node("Person", &format!("n{}", i), UpsertNodeOptions::default())
.unwrap(),
);
}
engine.flush().unwrap();
let result =
engine.compact_with_progress(|progress| progress.phase != CompactionPhase::WritingOutput);
assert!(matches!(result, Err(EngineError::CompactionCancelled)));
let segments_dir = db_path.join("segments");
if segments_dir.exists() {
for entry in std::fs::read_dir(&segments_dir).unwrap() {
let name = entry.unwrap().file_name();
let name_str = name.to_string_lossy();
assert!(
!name_str.contains(".tmp"),
"temp dir {} left after cancel",
name_str
);
}
}
let stats = engine.compact().unwrap().unwrap();
assert_eq!(stats.nodes_kept, 20);
engine.close().unwrap();
}
#[test]
fn test_compact_with_progress_records_processed_counts() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let opts = DbOptions::default();
let engine = DatabaseEngine::open(&db_path, &opts).unwrap();
for i in 0..50 {
engine
.upsert_node("Person", &format!("n{}", i), UpsertNodeOptions::default())
.unwrap();
}
engine.flush().unwrap();
for i in 50..100 {
engine
.upsert_node("Person", &format!("n{}", i), UpsertNodeOptions::default())
.unwrap();
}
engine.flush().unwrap();
let mut final_node_progress: Option<CompactionProgress> = None;
engine
.compact_with_progress(|progress| {
if progress.phase == CompactionPhase::MergingNodes {
final_node_progress = Some(progress.clone());
}
true
})
.unwrap();
let np = final_node_progress.unwrap();
assert_eq!(np.total_records, 100, "total_records should be 100 nodes");
assert_eq!(np.records_processed, 100, "all 100 should be processed");
assert_eq!(np.segments_processed, 2);
assert_eq!(np.total_segments, 2);
engine.close().unwrap();
}
#[test]
fn test_compact_with_progress_tombstone_counts_all_examined() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let opts = DbOptions::default();
let engine = DatabaseEngine::open(&db_path, &opts).unwrap();
let mut ids = Vec::new();
for i in 0..50 {
ids.push(
engine
.upsert_node("Person", &format!("n{}", i), UpsertNodeOptions::default())
.unwrap(),
);
}
engine.flush().unwrap();
for id in &ids[..20] {
engine.delete_node(*id).unwrap();
}
engine.flush().unwrap();
let mut final_node_progress: Option<CompactionProgress> = None;
let stats = engine
.compact_with_progress(|progress| {
if progress.phase == CompactionPhase::MergingNodes {
final_node_progress = Some(progress.clone());
}
true
})
.unwrap()
.unwrap();
let np = final_node_progress.unwrap();
assert_eq!(np.total_records, 50);
assert_eq!(np.records_processed, np.total_records);
assert_eq!(stats.nodes_kept, 30);
assert_eq!(stats.nodes_removed, 20);
engine.close().unwrap();
}
#[test]
fn test_compact_no_callback_wrapper() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let opts = DbOptions::default();
let engine = DatabaseEngine::open(&db_path, &opts).unwrap();
let mut ids = Vec::new();
for i in 0..20 {
ids.push(
engine
.upsert_node("Person", &format!("n{}", i), UpsertNodeOptions::default())
.unwrap(),
);
}
engine.flush().unwrap();
for i in 20..40 {
ids.push(
engine
.upsert_node("Person", &format!("n{}", i), UpsertNodeOptions::default())
.unwrap(),
);
}
engine.flush().unwrap();
let stats = engine.compact().unwrap().unwrap();
assert_eq!(stats.segments_merged, 2);
assert_eq!(stats.nodes_kept, 40);
for &id in &ids {
assert!(engine.get_node(id).unwrap().is_some());
}
engine.close().unwrap();
}
fn make_props(key: &str, val: &str) -> BTreeMap<String, PropValue> {
let mut m = BTreeMap::new();
m.insert(key.to_string(), PropValue::String(val.to_string()));
m
}
fn open_imm(path: &std::path::Path) -> DatabaseEngine {
let opts = DbOptions {
create_if_missing: true,
wal_sync_mode: WalSyncMode::Immediate,
compact_after_n_flushes: 0,
..Default::default()
};
DatabaseEngine::open(path, &opts).unwrap()
}
#[test]
fn test_get_node_by_key_found() {
let dir = TempDir::new().unwrap();
let engine = open_imm(&dir.path().join("db"));
let id = engine
.upsert_node(
"Person",
"alice",
UpsertNodeOptions {
props: make_props("name", "Alice"),
..Default::default()
},
)
.unwrap();
let node = engine.get_node_by_key("Person", "alice").unwrap().unwrap();
assert_eq!(node.id, id);
assert_eq!(node.labels.as_slice(), ["Person"]);
assert_eq!(node.key, "alice");
assert_eq!(
node.props.get("name"),
Some(&PropValue::String("Alice".to_string()))
);
engine.close().unwrap();
}
#[test]
fn test_get_node_by_key_not_found() {
let dir = TempDir::new().unwrap();
let engine = open_imm(&dir.path().join("db"));
engine
.upsert_node(
"Person",
"alice",
UpsertNodeOptions {
props: make_props("name", "Alice"),
..Default::default()
},
)
.unwrap();
assert!(engine.get_node_by_key("Person", "bob").unwrap().is_none());
assert!(engine.get_node_by_key("Company", "alice").unwrap().is_none());
engine.close().unwrap();
}
#[test]
fn test_get_node_by_key_after_flush() {
let dir = TempDir::new().unwrap();
let engine = open_imm(&dir.path().join("db"));
let id = engine
.upsert_node(
"Person",
"alice",
UpsertNodeOptions {
props: make_props("name", "Alice"),
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
let node = engine.get_node_by_key("Person", "alice").unwrap().unwrap();
assert_eq!(node.id, id);
assert_eq!(node.key, "alice");
engine.close().unwrap();
}
#[test]
fn test_get_node_by_key_after_compaction() {
let dir = TempDir::new().unwrap();
let engine = open_imm(&dir.path().join("db"));
engine
.upsert_node(
"Person",
"alice",
UpsertNodeOptions {
props: make_props("name", "v1"),
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
let id2 = engine
.upsert_node(
"Person",
"alice",
UpsertNodeOptions {
props: make_props("name", "v2"),
weight: 2.0,
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
engine.compact().unwrap();
let node = engine.get_node_by_key("Person", "alice").unwrap().unwrap();
assert_eq!(node.id, id2);
assert_eq!(
node.props.get("name"),
Some(&PropValue::String("v2".to_string()))
);
assert_eq!(node.weight, 2.0);
engine.close().unwrap();
}
#[test]
fn test_get_node_by_key_deleted() {
let dir = TempDir::new().unwrap();
let engine = open_imm(&dir.path().join("db"));
let id = engine
.upsert_node(
"Person",
"alice",
UpsertNodeOptions {
props: make_props("name", "Alice"),
..Default::default()
},
)
.unwrap();
engine.delete_node(id).unwrap();
assert!(engine.get_node_by_key("Person", "alice").unwrap().is_none());
engine.close().unwrap();
}
#[test]
fn test_get_node_by_key_deleted_cross_source() {
let dir = TempDir::new().unwrap();
let engine = open_imm(&dir.path().join("db"));
let id = engine
.upsert_node(
"Person",
"alice",
UpsertNodeOptions {
props: make_props("name", "Alice"),
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
engine.delete_node(id).unwrap();
assert!(engine.get_node_by_key("Person", "alice").unwrap().is_none());
engine.close().unwrap();
}
#[test]
fn test_get_node_by_key_memtable_shadows_segment() {
let dir = TempDir::new().unwrap();
let engine = open_imm(&dir.path().join("db"));
engine
.upsert_node(
"Person",
"alice",
UpsertNodeOptions {
props: make_props("name", "v1"),
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
let id2 = engine
.upsert_node(
"Person",
"alice",
UpsertNodeOptions {
props: make_props("name", "v2"),
weight: 2.0,
..Default::default()
},
)
.unwrap();
let node = engine.get_node_by_key("Person", "alice").unwrap().unwrap();
assert_eq!(node.id, id2);
assert_eq!(
node.props.get("name"),
Some(&PropValue::String("v2".to_string()))
);
engine.close().unwrap();
}
#[test]
fn test_get_edge_by_triple_found() {
let dir = TempDir::new().unwrap();
let engine = open_imm(&dir.path().join("db"));
let a = engine
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let b = engine
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
let eid = engine
.upsert_edge(
a,
b,
"KNOWS",
UpsertEdgeOptions {
props: make_props("rel", "knows"),
weight: 0.5,
..Default::default()
},
)
.unwrap();
let edge = engine.get_edge_by_triple(a, b, "KNOWS").unwrap().unwrap();
assert_eq!(edge.id, eid);
assert_eq!(edge.from, a);
assert_eq!(edge.to, b);
assert_eq!(edge.label, "KNOWS");
assert_eq!(
edge.props.get("rel"),
Some(&PropValue::String("knows".to_string()))
);
engine.close().unwrap();
}
#[test]
fn test_get_edge_by_triple_not_found() {
let dir = TempDir::new().unwrap();
let engine = open_imm(&dir.path().join("db"));
let a = engine
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let b = engine
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
engine
.upsert_edge(a, b, "KNOWS", UpsertEdgeOptions::default())
.unwrap();
assert!(engine.get_edge_by_triple(a, b, "MISSING_EDGE_LABEL").unwrap().is_none());
assert!(engine.get_edge_by_triple(b, a, "KNOWS").unwrap().is_none());
engine.close().unwrap();
}
#[test]
fn test_get_edge_by_triple_after_flush() {
let dir = TempDir::new().unwrap();
let engine = open_imm(&dir.path().join("db"));
let a = engine
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let b = engine
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
let eid = engine
.upsert_edge(a, b, "KNOWS", UpsertEdgeOptions::default())
.unwrap();
engine.flush().unwrap();
let edge = engine.get_edge_by_triple(a, b, "KNOWS").unwrap().unwrap();
assert_eq!(edge.id, eid);
engine.close().unwrap();
}
#[test]
fn test_get_edge_by_triple_deleted() {
let dir = TempDir::new().unwrap();
let engine = open_imm(&dir.path().join("db"));
let a = engine
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let b = engine
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
let eid = engine
.upsert_edge(a, b, "KNOWS", UpsertEdgeOptions::default())
.unwrap();
engine.delete_edge(eid).unwrap();
assert!(engine.get_edge_by_triple(a, b, "KNOWS").unwrap().is_none());
engine.close().unwrap();
}
#[test]
fn test_get_edge_by_triple_deleted_cross_source() {
let dir = TempDir::new().unwrap();
let engine = open_imm(&dir.path().join("db"));
let a = engine
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let b = engine
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
let eid = engine
.upsert_edge(a, b, "KNOWS", UpsertEdgeOptions::default())
.unwrap();
engine.flush().unwrap();
engine.delete_edge(eid).unwrap();
assert!(engine.get_edge_by_triple(a, b, "KNOWS").unwrap().is_none());
engine.close().unwrap();
}
#[test]
fn test_get_edge_by_triple_after_compaction() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
create_if_missing: true,
edge_uniqueness: true,
wal_sync_mode: WalSyncMode::Immediate,
compact_after_n_flushes: 0,
..Default::default()
};
let engine = DatabaseEngine::open(&dir.path().join("db"), &opts).unwrap();
let a = engine
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let b = engine
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
engine
.upsert_edge(
a,
b,
"KNOWS",
UpsertEdgeOptions {
props: make_props("v", "1"),
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
let eid2 = engine
.upsert_edge(
a,
b,
"KNOWS",
UpsertEdgeOptions {
props: make_props("v", "2"),
weight: 2.0,
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
engine.compact().unwrap();
let edge = engine.get_edge_by_triple(a, b, "KNOWS").unwrap().unwrap();
assert_eq!(edge.id, eid2);
assert_eq!(
edge.props.get("v"),
Some(&PropValue::String("2".to_string()))
);
engine.close().unwrap();
}
#[test]
fn test_get_nodes_bulk() {
let dir = TempDir::new().unwrap();
let engine = open_imm(&dir.path().join("db"));
let a = engine
.upsert_node(
"Person",
"a",
UpsertNodeOptions {
props: make_props("name", "A"),
..Default::default()
},
)
.unwrap();
let b = engine
.upsert_node(
"Person",
"b",
UpsertNodeOptions {
props: make_props("name", "B"),
..Default::default()
},
)
.unwrap();
let c = engine
.upsert_node(
"Person",
"c",
UpsertNodeOptions {
props: make_props("name", "C"),
..Default::default()
},
)
.unwrap();
let results = engine.get_nodes(&[a, b, c]).unwrap();
assert_eq!(results.len(), 3);
assert_eq!(results[0].as_ref().unwrap().key, "a");
assert_eq!(results[1].as_ref().unwrap().key, "b");
assert_eq!(results[2].as_ref().unwrap().key, "c");
engine.close().unwrap();
}
#[test]
fn test_get_nodes_bulk_mixed_found_missing() {
let dir = TempDir::new().unwrap();
let engine = open_imm(&dir.path().join("db"));
let a = engine
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let b = engine
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
engine.delete_node(b).unwrap();
let results = engine.get_nodes(&[a, b, 9999]).unwrap();
assert_eq!(results.len(), 3);
assert!(results[0].is_some());
assert!(results[1].is_none()); assert!(results[2].is_none()); engine.close().unwrap();
}
#[test]
fn test_get_nodes_bulk_cross_source() {
let dir = TempDir::new().unwrap();
let engine = open_imm(&dir.path().join("db"));
let a = engine
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
engine.flush().unwrap();
let b = engine
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
let results = engine.get_nodes(&[a, b]).unwrap();
assert_eq!(results[0].as_ref().unwrap().key, "a");
assert_eq!(results[1].as_ref().unwrap().key, "b");
engine.close().unwrap();
}
#[test]
fn test_get_nodes_bulk_empty() {
let dir = TempDir::new().unwrap();
let engine = open_imm(&dir.path().join("db"));
let results = engine.get_nodes(&[]).unwrap();
assert!(results.is_empty());
engine.close().unwrap();
}
#[test]
fn test_get_edges_bulk() {
let dir = TempDir::new().unwrap();
let engine = open_imm(&dir.path().join("db"));
let a = engine
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let b = engine
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
let c = engine
.upsert_node("Person", "c", UpsertNodeOptions::default())
.unwrap();
let e1 = engine
.upsert_edge(a, b, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
let e2 = engine
.upsert_edge(b, c, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
let results = engine.get_edges(&[e1, e2, 9999]).unwrap();
assert_eq!(results.len(), 3);
assert_eq!(results[0].as_ref().unwrap().from, a);
assert_eq!(results[1].as_ref().unwrap().from, b);
assert!(results[2].is_none());
engine.close().unwrap();
}
#[test]
fn test_get_edges_bulk_cross_source() {
let dir = TempDir::new().unwrap();
let engine = open_imm(&dir.path().join("db"));
let a = engine
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let b = engine
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
let c = engine
.upsert_node("Person", "c", UpsertNodeOptions::default())
.unwrap();
let e1 = engine
.upsert_edge(a, b, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
engine.flush().unwrap();
let e2 = engine
.upsert_edge(b, c, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
let results = engine.get_edges(&[e1, e2]).unwrap();
assert_eq!(results[0].as_ref().unwrap().from, a);
assert_eq!(results[1].as_ref().unwrap().from, b);
engine.close().unwrap();
}
#[test]
fn test_get_nodes_bulk_multi_segment_interleaved() {
let dir = TempDir::new().unwrap();
let engine = open_imm(&dir.path().join("db"));
let a = engine
.upsert_node(
"Person",
"a",
UpsertNodeOptions {
props: make_props("seg", "1"),
..Default::default()
},
)
.unwrap();
let b = engine
.upsert_node(
"Person",
"b",
UpsertNodeOptions {
props: make_props("seg", "1"),
..Default::default()
},
)
.unwrap();
let c = engine
.upsert_node(
"Person",
"c",
UpsertNodeOptions {
props: make_props("seg", "1"),
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
let d = engine
.upsert_node(
"Person",
"d",
UpsertNodeOptions {
props: make_props("seg", "2"),
..Default::default()
},
)
.unwrap();
let e = engine
.upsert_node(
"Person",
"e",
UpsertNodeOptions {
props: make_props("seg", "2"),
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
let results = engine.get_nodes(&[e, a, d, c, b]).unwrap();
assert_eq!(results.len(), 5);
assert_eq!(results[0].as_ref().unwrap().key, "e");
assert_eq!(results[1].as_ref().unwrap().key, "a");
assert_eq!(results[2].as_ref().unwrap().key, "d");
assert_eq!(results[3].as_ref().unwrap().key, "c");
assert_eq!(results[4].as_ref().unwrap().key, "b");
engine.close().unwrap();
}
#[test]
fn test_get_nodes_bulk_tombstone_in_newer_segment() {
let dir = TempDir::new().unwrap();
let engine = open_imm(&dir.path().join("db"));
let a = engine
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let b = engine
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
engine.flush().unwrap(); engine.delete_node(a).unwrap();
let c = engine
.upsert_node("Person", "c", UpsertNodeOptions::default())
.unwrap();
engine.flush().unwrap();
let results = engine.get_nodes(&[a, b, c]).unwrap();
assert!(results[0].is_none()); assert_eq!(results[1].as_ref().unwrap().key, "b");
assert_eq!(results[2].as_ref().unwrap().key, "c");
engine.close().unwrap();
}
#[test]
fn test_get_nodes_bulk_memtable_shadows_segment() {
let dir = TempDir::new().unwrap();
let engine = open_imm(&dir.path().join("db"));
let a = engine
.upsert_node(
"Person",
"a",
UpsertNodeOptions {
props: make_props("v", "old"),
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
engine
.upsert_node(
"Person",
"a",
UpsertNodeOptions {
props: make_props("v", "new"),
weight: 2.0,
..Default::default()
},
)
.unwrap();
let results = engine.get_nodes(&[a]).unwrap();
let node = results[0].as_ref().unwrap();
assert_eq!(
node.props.get("v"),
Some(&PropValue::String("new".to_string()))
);
assert_eq!(node.weight, 2.0);
engine.close().unwrap();
}
#[test]
fn test_get_nodes_bulk_duplicate_ids() {
let dir = TempDir::new().unwrap();
let engine = open_imm(&dir.path().join("db"));
let a = engine
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let results = engine.get_nodes(&[a, a, a]).unwrap();
assert_eq!(results.len(), 3);
assert_eq!(results[0].as_ref().unwrap().key, "a");
assert_eq!(results[1].as_ref().unwrap().key, "a");
assert_eq!(results[2].as_ref().unwrap().key, "a");
engine.close().unwrap();
}
#[test]
fn test_get_nodes_bulk_duplicate_ids_in_segment() {
let dir = TempDir::new().unwrap();
let engine = open_imm(&dir.path().join("db"));
let a = engine
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
engine.flush().unwrap();
let results = engine.get_nodes(&[a, a, a]).unwrap();
assert_eq!(results.len(), 3);
assert_eq!(results[0].as_ref().unwrap().key, "a");
assert_eq!(results[1].as_ref().unwrap().key, "a");
assert_eq!(results[2].as_ref().unwrap().key, "a");
engine.close().unwrap();
}
#[test]
fn test_get_edges_bulk_multi_segment_interleaved() {
let dir = TempDir::new().unwrap();
let engine = open_imm(&dir.path().join("db"));
let a = engine
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let b = engine
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
let c = engine
.upsert_node("Person", "c", UpsertNodeOptions::default())
.unwrap();
let e1 = engine
.upsert_edge(a, b, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
engine.flush().unwrap();
let e2 = engine
.upsert_edge(b, c, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
engine.flush().unwrap();
let results = engine.get_edges(&[e2, e1]).unwrap();
assert_eq!(results[0].as_ref().unwrap().from, b);
assert_eq!(results[1].as_ref().unwrap().from, a);
engine.close().unwrap();
}
#[test]
fn test_get_edges_bulk_tombstone_cross_segment() {
let dir = TempDir::new().unwrap();
let engine = open_imm(&dir.path().join("db"));
let a = engine
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let b = engine
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
let c = engine
.upsert_node("Person", "c", UpsertNodeOptions::default())
.unwrap();
let e1 = engine
.upsert_edge(a, b, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
let e2 = engine
.upsert_edge(b, c, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
engine.flush().unwrap(); engine.delete_edge(e1).unwrap();
engine.flush().unwrap();
let results = engine.get_edges(&[e1, e2]).unwrap();
assert!(results[0].is_none()); assert_eq!(results[1].as_ref().unwrap().from, b);
engine.close().unwrap();
}
#[test]
fn test_get_nodes_bulk_after_compaction() {
let dir = TempDir::new().unwrap();
let engine = open_imm(&dir.path().join("db"));
let a = engine
.upsert_node(
"Person",
"a",
UpsertNodeOptions {
props: make_props("v", "1"),
..Default::default()
},
)
.unwrap();
let b = engine
.upsert_node(
"Person",
"b",
UpsertNodeOptions {
props: make_props("v", "1"),
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
engine
.upsert_node(
"Person",
"a",
UpsertNodeOptions {
props: make_props("v", "2"),
weight: 2.0,
..Default::default()
},
)
.unwrap();
let c = engine
.upsert_node(
"Person",
"c",
UpsertNodeOptions {
props: make_props("v", "1"),
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
engine.compact().unwrap();
let results = engine.get_nodes(&[a, b, c]).unwrap();
assert_eq!(
results[0].as_ref().unwrap().props.get("v"),
Some(&PropValue::String("2".to_string()))
); assert_eq!(results[1].as_ref().unwrap().key, "b");
assert_eq!(results[2].as_ref().unwrap().key, "c");
engine.close().unwrap();
}
#[test]
fn test_get_node_by_key_delete_then_recreate() {
let dir = TempDir::new().unwrap();
let engine = open_imm(&dir.path().join("db"));
let id1 = engine
.upsert_node(
"Person",
"alice",
UpsertNodeOptions {
props: make_props("v", "1"),
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
engine.delete_node(id1).unwrap();
let id2 = engine
.upsert_node(
"Person",
"alice",
UpsertNodeOptions {
props: make_props("v", "2"),
weight: 2.0,
..Default::default()
},
)
.unwrap();
assert_ne!(id1, id2);
let node = engine.get_node_by_key("Person", "alice").unwrap().unwrap();
assert_eq!(node.id, id2);
assert_eq!(
node.props.get("v"),
Some(&PropValue::String("2".to_string()))
);
engine.close().unwrap();
}
#[test]
fn test_get_edge_by_triple_uniqueness_off_returns_latest() {
let dir = TempDir::new().unwrap();
let engine = open_imm(&dir.path().join("db"));
let a = engine
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let b = engine
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
let _e1 = engine
.upsert_edge(
a,
b,
"KNOWS",
UpsertEdgeOptions {
props: make_props("v", "1"),
..Default::default()
},
)
.unwrap();
let e2 = engine
.upsert_edge(
a,
b,
"KNOWS",
UpsertEdgeOptions {
props: make_props("v", "2"),
weight: 2.0,
..Default::default()
},
)
.unwrap();
let edge = engine.get_edge_by_triple(a, b, "KNOWS").unwrap().unwrap();
assert_eq!(edge.id, e2);
assert_eq!(
edge.props.get("v"),
Some(&PropValue::String("2".to_string()))
);
engine.close().unwrap();
}
#[test]
fn test_graph_patch_mixed_ops() {
let dir = TempDir::new().unwrap();
let engine = open_imm(&dir.path().join("db"));
let a = engine
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let b = engine
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
let e1 = engine
.upsert_edge(a, b, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
let patch = GraphPatch {
upsert_nodes: vec![NodeInput {
labels: vec!["Person".to_string()],
key: "c".to_string(),
props: make_props("role", "new"),
weight: 1.0,
dense_vector: None,
sparse_vector: None,
}],
upsert_edges: vec![EdgeInput {
from: a,
to: b,
label: "WORKS_AT".to_string(),
props: BTreeMap::new(),
weight: 0.5,
valid_from: None,
valid_to: None,
}],
invalidate_edges: vec![(e1, 1000)],
delete_node_ids: vec![],
delete_edge_ids: vec![],
};
let result = engine.graph_patch(patch).unwrap();
assert_eq!(result.node_ids.len(), 1);
assert_eq!(result.edge_ids.len(), 1);
let c = engine.get_node(result.node_ids[0]).unwrap().unwrap();
assert_eq!(c.key, "c");
assert_eq!(
c.props.get("role"),
Some(&PropValue::String("new".to_string()))
);
let edge = engine.get_edge(result.edge_ids[0]).unwrap().unwrap();
assert_eq!(edge.from, a);
assert_eq!(edge.to, b);
let e1_after = engine.get_edge(e1).unwrap().unwrap();
assert_eq!(e1_after.valid_to, 1000);
engine.close().unwrap();
}
#[test]
fn test_graph_patch_empty() {
let dir = TempDir::new().unwrap();
let engine = open_imm(&dir.path().join("db"));
let result = engine.graph_patch(GraphPatch::default()).unwrap();
assert!(result.node_ids.is_empty());
assert!(result.edge_ids.is_empty());
engine.close().unwrap();
}
#[test]
fn test_graph_patch_node_dedup() {
let dir = TempDir::new().unwrap();
let engine = open_imm(&dir.path().join("db"));
let existing = engine
.upsert_node(
"Person",
"alice",
UpsertNodeOptions {
props: make_props("v", "1"),
..Default::default()
},
)
.unwrap();
let patch = GraphPatch {
upsert_nodes: vec![
NodeInput {
labels: vec!["Person".to_string()],
key: "alice".to_string(),
props: make_props("v", "2"),
weight: 2.0,
dense_vector: None,
sparse_vector: None,
},
NodeInput {
labels: vec!["Person".to_string()],
key: "alice".to_string(),
props: make_props("v", "3"),
weight: 3.0,
dense_vector: None,
sparse_vector: None,
},
NodeInput {
labels: vec!["Person".to_string()],
key: "bob".to_string(),
props: BTreeMap::new(),
weight: 1.0,
dense_vector: None,
sparse_vector: None,
},
],
..GraphPatch::default()
};
let result = engine.graph_patch(patch).unwrap();
assert_eq!(result.node_ids[0], existing);
assert_eq!(result.node_ids[1], existing);
assert_ne!(result.node_ids[2], existing);
let alice = engine.get_node(existing).unwrap().unwrap();
assert_eq!(
alice.props.get("v"),
Some(&PropValue::String("3".to_string()))
);
engine.close().unwrap();
}
#[test]
fn test_graph_patch_delete_with_cascade() {
let dir = TempDir::new().unwrap();
let engine = open_imm(&dir.path().join("db"));
let a = engine
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let b = engine
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
let c = engine
.upsert_node("Person", "c", UpsertNodeOptions::default())
.unwrap();
let e_ab = engine
.upsert_edge(a, b, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
let e_bc = engine
.upsert_edge(b, c, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
let patch = GraphPatch {
delete_node_ids: vec![b],
..GraphPatch::default()
};
engine.graph_patch(patch).unwrap();
assert!(engine.get_node(b).unwrap().is_none());
assert!(engine.get_edge(e_ab).unwrap().is_none());
assert!(engine.get_edge(e_bc).unwrap().is_none());
assert!(engine.get_node(a).unwrap().is_some());
assert!(engine.get_node(c).unwrap().is_some());
engine.close().unwrap();
}
#[test]
fn test_graph_patch_edge_delete() {
let dir = TempDir::new().unwrap();
let engine = open_imm(&dir.path().join("db"));
let a = engine
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let b = engine
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
let e = engine
.upsert_edge(a, b, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
let patch = GraphPatch {
delete_edge_ids: vec![e],
..GraphPatch::default()
};
engine.graph_patch(patch).unwrap();
assert!(engine.get_edge(e).unwrap().is_none());
assert!(engine.get_node(a).unwrap().is_some());
assert!(engine.get_node(b).unwrap().is_some());
engine.close().unwrap();
}
#[test]
fn test_graph_patch_ordering_upserts_before_deletes() {
let dir = TempDir::new().unwrap();
let engine = open_imm(&dir.path().join("db"));
let a = engine
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let patch = GraphPatch {
upsert_nodes: vec![NodeInput {
labels: vec!["Person".to_string()],
key: "a".to_string(),
props: make_props("v", "updated"),
weight: 2.0,
dense_vector: None,
sparse_vector: None,
}],
delete_node_ids: vec![a],
..GraphPatch::default()
};
let result = engine.graph_patch(patch).unwrap();
assert_eq!(result.node_ids[0], a);
assert!(engine.get_node(a).unwrap().is_none());
engine.close().unwrap();
}
#[test]
fn test_graph_patch_invalidate_nonexistent_edge_skipped() {
let dir = TempDir::new().unwrap();
let engine = open_imm(&dir.path().join("db"));
let patch = GraphPatch {
invalidate_edges: vec![(99999, 5000)],
..GraphPatch::default()
};
let result = engine.graph_patch(patch).unwrap();
assert!(result.node_ids.is_empty());
assert!(result.edge_ids.is_empty());
engine.close().unwrap();
}
#[test]
fn test_graph_patch_survives_wal_replay() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("db");
let (node_id, edge_id, invalidated_eid);
{
let engine = open_imm(&db_path);
let a = engine
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let b = engine
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
invalidated_eid = engine
.upsert_edge(a, b, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
let patch = GraphPatch {
upsert_nodes: vec![NodeInput {
labels: vec!["Person".to_string()],
key: "c".to_string(),
props: make_props("role", "new"),
weight: 1.0,
dense_vector: None,
sparse_vector: None,
}],
upsert_edges: vec![EdgeInput {
from: a,
to: b,
label: "OWNS".to_string(),
props: BTreeMap::new(),
weight: 0.5,
valid_from: None,
valid_to: None,
}],
invalidate_edges: vec![(invalidated_eid, 2000)],
delete_edge_ids: vec![],
delete_node_ids: vec![],
};
let result = engine.graph_patch(patch).unwrap();
node_id = result.node_ids[0];
edge_id = result.edge_ids[0];
engine.close().unwrap();
}
{
let engine = open_imm(&db_path);
let node = engine.get_node(node_id).unwrap().unwrap();
assert_eq!(node.key, "c");
let edge = engine.get_edge(edge_id).unwrap().unwrap();
assert_eq!(edge.label, "OWNS");
let inv_edge = engine.get_edge(invalidated_eid).unwrap().unwrap();
assert_eq!(inv_edge.valid_to, 2000);
engine.close().unwrap();
}
}
#[test]
fn test_graph_patch_vectors_survive_wal_replay() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("db");
let opts = DbOptions {
wal_sync_mode: WalSyncMode::Immediate,
dense_vector: Some(DenseVectorConfig {
dimension: 3,
metric: DenseMetric::Cosine,
hnsw: HnswConfig::default(),
}),
..DbOptions::default()
};
let node_id;
{
let engine = DatabaseEngine::open(&db_path, &opts).unwrap();
let patch = GraphPatch {
upsert_nodes: vec![NodeInput {
labels: vec!["Person".to_string()],
key: "vector-c".to_string(),
props: BTreeMap::new(),
weight: 1.0,
dense_vector: Some(vec![0.1, 0.2, 0.3]),
sparse_vector: Some(vec![(8, 0.0), (2, 1.0), (2, 0.5), (5, 2.0)]),
}],
..GraphPatch::default()
};
let result = engine.graph_patch(patch).unwrap();
node_id = result.node_ids[0];
let node = engine.get_node(node_id).unwrap().unwrap();
assert_eq!(node.dense_vector, Some(vec![0.1, 0.2, 0.3]));
assert_eq!(node.sparse_vector, Some(vec![(2, 1.5), (5, 2.0)]));
engine.close().unwrap();
}
let engine = DatabaseEngine::open(&db_path, &opts).unwrap();
let node = engine.get_node(node_id).unwrap().unwrap();
assert_eq!(node.dense_vector, Some(vec![0.1, 0.2, 0.3]));
assert_eq!(node.sparse_vector, Some(vec![(2, 1.5), (5, 2.0)]));
engine.close().unwrap();
}
#[test]
fn test_graph_patch_two_step_upsert_then_connect() {
let dir = TempDir::new().unwrap();
let engine = open_imm(&dir.path().join("db"));
let patch1 = GraphPatch {
upsert_nodes: vec![
NodeInput {
labels: vec!["Person".to_string()],
key: "x".to_string(),
props: BTreeMap::new(),
weight: 1.0,
dense_vector: None,
sparse_vector: None,
},
NodeInput {
labels: vec!["Person".to_string()],
key: "y".to_string(),
props: BTreeMap::new(),
weight: 1.0,
dense_vector: None,
sparse_vector: None,
},
],
..GraphPatch::default()
};
let r1 = engine.graph_patch(patch1).unwrap();
let x = r1.node_ids[0];
let y = r1.node_ids[1];
let patch2 = GraphPatch {
upsert_edges: vec![EdgeInput {
from: x,
to: y,
label: "KNOWS".to_string(),
props: make_props("rel", "friend"),
weight: 1.0,
valid_from: None,
valid_to: None,
}],
..GraphPatch::default()
};
let r2 = engine.graph_patch(patch2).unwrap();
let edge = engine.get_edge(r2.edge_ids[0]).unwrap().unwrap();
assert_eq!(edge.from, x);
assert_eq!(edge.to, y);
assert_eq!(edge.label, "KNOWS");
let nbrs = engine.neighbors(x, &NeighborOptions::default()).unwrap();
assert_eq!(nbrs.len(), 1);
assert_eq!(nbrs[0].node_id, y);
engine.close().unwrap();
}
#[test]
fn test_graph_patch_after_flush() {
let dir = TempDir::new().unwrap();
let engine = open_imm(&dir.path().join("db"));
let a = engine
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let b = engine
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
let e = engine
.upsert_edge(a, b, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
engine.flush().unwrap();
let patch = GraphPatch {
upsert_nodes: vec![NodeInput {
labels: vec!["Person".to_string()],
key: "a".to_string(),
props: make_props("v", "updated"),
weight: 2.0,
dense_vector: None,
sparse_vector: None,
}],
invalidate_edges: vec![(e, 500)],
..GraphPatch::default()
};
let result = engine.graph_patch(patch).unwrap();
assert_eq!(result.node_ids[0], a);
let node = engine.get_node(a).unwrap().unwrap();
assert_eq!(
node.props.get("v"),
Some(&PropValue::String("updated".to_string()))
);
let edge = engine.get_edge(e).unwrap().unwrap();
assert_eq!(edge.valid_to, 500);
engine.close().unwrap();
}
#[test]
fn test_graph_patch_duplicate_edge_delete_safe() {
let dir = TempDir::new().unwrap();
let engine = open_imm(&dir.path().join("db"));
let a = engine
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let b = engine
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
let e = engine
.upsert_edge(a, b, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
let patch = GraphPatch {
delete_edge_ids: vec![e], delete_node_ids: vec![a], ..GraphPatch::default()
};
engine.graph_patch(patch).unwrap();
assert!(engine.get_edge(e).unwrap().is_none());
assert!(engine.get_node(a).unwrap().is_none());
engine.close().unwrap();
}
#[test]
fn test_graph_patch_invalidate_pre_existing_edge() {
let dir = TempDir::new().unwrap();
let engine = open_imm(&dir.path().join("db"));
let a = engine
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let b = engine
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
let e = engine
.upsert_edge(
a,
b,
"RELATES_TO",
UpsertEdgeOptions {
props: make_props("v", "original"),
..Default::default()
},
)
.unwrap();
let patch = GraphPatch {
upsert_edges: vec![EdgeInput {
from: a,
to: b,
label: "RELATES_TO".to_string(),
props: make_props("v", "updated"),
weight: 2.0,
valid_from: None,
valid_to: None, }],
invalidate_edges: vec![(e, 3000)],
..GraphPatch::default()
};
engine.graph_patch(patch).unwrap();
let edge = engine.get_edge(e).unwrap().unwrap();
assert_eq!(edge.valid_to, 3000);
engine.close().unwrap();
}
#[test]
fn test_delete_node_cascades_segment_edges() {
let dir = TempDir::new().unwrap();
let engine = open_imm(&dir.path().join("db"));
let a = engine
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let b = engine
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
let e = engine
.upsert_edge(a, b, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
engine.flush().unwrap();
engine.delete_node(a).unwrap();
assert!(engine.get_node(a).unwrap().is_none());
assert!(engine.get_edge(e).unwrap().is_none()); assert!(engine.get_node(b).unwrap().is_some()); let nbrs = engine
.neighbors(
b,
&NeighborOptions {
direction: Direction::Incoming,
..Default::default()
},
)
.unwrap();
assert!(nbrs.is_empty());
engine.close().unwrap();
}
#[test]
fn test_delete_node_cascades_mixed_sources() {
let dir = TempDir::new().unwrap();
let engine = open_imm(&dir.path().join("db"));
let a = engine
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let b = engine
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
let c = engine
.upsert_node("Person", "c", UpsertNodeOptions::default())
.unwrap();
let e1 = engine
.upsert_edge(a, b, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
engine.flush().unwrap(); let e2 = engine
.upsert_edge(a, c, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
engine.delete_node(a).unwrap();
assert!(engine.get_edge(e1).unwrap().is_none()); assert!(engine.get_edge(e2).unwrap().is_none()); engine.close().unwrap();
}
#[test]
fn test_delete_node_cascades_incoming_segment_edges() {
let dir = TempDir::new().unwrap();
let engine = open_imm(&dir.path().join("db"));
let a = engine
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let b = engine
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
let e = engine
.upsert_edge(a, b, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
engine.flush().unwrap();
engine.delete_node(b).unwrap();
assert!(engine.get_edge(e).unwrap().is_none());
let nbrs = engine.neighbors(a, &NeighborOptions::default()).unwrap();
assert!(nbrs.is_empty());
engine.close().unwrap();
}
#[test]
fn test_graph_patch_delete_cascades_segment_edges() {
let dir = TempDir::new().unwrap();
let engine = open_imm(&dir.path().join("db"));
let a = engine
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let b = engine
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
let e = engine
.upsert_edge(a, b, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
engine.flush().unwrap();
engine
.graph_patch(GraphPatch {
delete_node_ids: vec![a],
..GraphPatch::default()
})
.unwrap();
assert!(engine.get_node(a).unwrap().is_none());
assert!(engine.get_edge(e).unwrap().is_none());
engine.close().unwrap();
}
#[test]
fn test_prune_empty_policy_rejects() {
let dir = TempDir::new().unwrap();
let engine = open_imm(&dir.path().join("db"));
engine
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let err = engine
.prune(&PrunePolicy {
max_age_ms: None,
max_weight: None,
label: None,
})
.unwrap_err();
assert!(matches!(err, EngineError::InvalidOperation(_)));
assert!(engine.get_node(1).unwrap().is_some());
engine.close().unwrap();
}
#[test]
fn test_prune_label_only_rejects() {
let dir = TempDir::new().unwrap();
let engine = open_imm(&dir.path().join("db"));
engine
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let err = engine
.prune(&PrunePolicy {
max_age_ms: None,
max_weight: None,
label: Some("Person".to_string()),
})
.unwrap_err();
assert!(matches!(err, EngineError::InvalidOperation(_)));
engine.close().unwrap();
}
#[test]
fn test_prune_by_age_only() {
let dir = TempDir::new().unwrap();
let engine = open_imm(&dir.path().join("db"));
let a = engine
.upsert_node("Person", "old", UpsertNodeOptions::default())
.unwrap();
let b = engine
.upsert_node("Person", "new", UpsertNodeOptions::default())
.unwrap();
let old_node = internal_node_record(&engine, a).unwrap().unwrap();
write_internal_wal_op(&engine, &WalOp::UpsertNode(NodeRecord {
updated_at: 1000, ..old_node
}))
.unwrap();
let result = engine
.prune(&PrunePolicy {
max_age_ms: Some(1000),
max_weight: None,
label: None,
})
.unwrap();
assert_eq!(result.nodes_pruned, 1);
assert!(engine.get_node(a).unwrap().is_none()); assert!(engine.get_node(b).unwrap().is_some()); engine.close().unwrap();
}
#[test]
fn test_prune_by_weight_only() {
let dir = TempDir::new().unwrap();
let engine = open_imm(&dir.path().join("db"));
let a = engine
.upsert_node(
"Person",
"low",
UpsertNodeOptions {
weight: 0.1,
..Default::default()
},
)
.unwrap();
let b = engine
.upsert_node(
"Person",
"mid",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
let c = engine
.upsert_node(
"Person",
"high",
UpsertNodeOptions {
weight: 0.9,
..Default::default()
},
)
.unwrap();
let result = engine
.prune(&PrunePolicy {
max_age_ms: None,
max_weight: Some(0.5),
label: None,
})
.unwrap();
assert_eq!(result.nodes_pruned, 2); assert!(engine.get_node(a).unwrap().is_none());
assert!(engine.get_node(b).unwrap().is_none());
assert!(engine.get_node(c).unwrap().is_some()); engine.close().unwrap();
}
#[test]
fn test_prune_combo_age_and_weight() {
let dir = TempDir::new().unwrap();
let engine = open_imm(&dir.path().join("db"));
let a = engine
.upsert_node(
"Person",
"old-low",
UpsertNodeOptions {
weight: 0.1,
..Default::default()
},
)
.unwrap();
let b = engine
.upsert_node(
"Person",
"old-high",
UpsertNodeOptions {
weight: 0.9,
..Default::default()
},
)
.unwrap();
let c = engine
.upsert_node(
"Person",
"new-low",
UpsertNodeOptions {
weight: 0.1,
..Default::default()
},
)
.unwrap();
let node_a = internal_node_record(&engine, a).unwrap().unwrap();
write_internal_wal_op(&engine, &WalOp::UpsertNode(NodeRecord {
updated_at: 1000,
..node_a
}))
.unwrap();
let node_b = internal_node_record(&engine, b).unwrap().unwrap();
write_internal_wal_op(&engine, &WalOp::UpsertNode(NodeRecord {
updated_at: 1000,
..node_b
}))
.unwrap();
let result = engine
.prune(&PrunePolicy {
max_age_ms: Some(1000),
max_weight: Some(0.5),
label: None,
})
.unwrap();
assert_eq!(result.nodes_pruned, 1); assert!(engine.get_node(a).unwrap().is_none()); assert!(engine.get_node(b).unwrap().is_some()); assert!(engine.get_node(c).unwrap().is_some()); engine.close().unwrap();
}
#[test]
fn test_prune_label_scoped() {
let dir = TempDir::new().unwrap();
let engine = open_imm(&dir.path().join("db"));
let a = engine
.upsert_node(
"Person",
"label1-low",
UpsertNodeOptions {
weight: 0.1,
..Default::default()
},
)
.unwrap();
let b = engine
.upsert_node(
"Company",
"label2-low",
UpsertNodeOptions {
weight: 0.1,
..Default::default()
},
)
.unwrap();
let result = engine
.prune(&PrunePolicy {
max_age_ms: None,
max_weight: Some(0.5),
label: Some("Person".to_string()),
})
.unwrap();
assert_eq!(result.nodes_pruned, 1);
assert!(engine.get_node(a).unwrap().is_none()); assert!(engine.get_node(b).unwrap().is_some()); engine.close().unwrap();
}
#[test]
fn test_prune_cascade_deletes_edges() {
let dir = TempDir::new().unwrap();
let engine = open_imm(&dir.path().join("db"));
let a = engine
.upsert_node(
"Person",
"a",
UpsertNodeOptions {
weight: 0.1,
..Default::default()
},
)
.unwrap();
let b = engine
.upsert_node(
"Person",
"b",
UpsertNodeOptions {
weight: 0.9,
..Default::default()
},
)
.unwrap();
let c = engine
.upsert_node(
"Person",
"c",
UpsertNodeOptions {
weight: 0.1,
..Default::default()
},
)
.unwrap();
let e_ab = engine
.upsert_edge(a, b, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
let e_bc = engine
.upsert_edge(b, c, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
let e_ca = engine
.upsert_edge(c, a, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
let result = engine
.prune(&PrunePolicy {
max_age_ms: None,
max_weight: Some(0.5),
label: None,
})
.unwrap();
assert_eq!(result.nodes_pruned, 2); assert_eq!(result.edges_pruned, 3); assert!(engine.get_node(a).unwrap().is_none());
assert!(engine.get_node(c).unwrap().is_none());
assert!(engine.get_node(b).unwrap().is_some()); assert!(engine.get_edge(e_ab).unwrap().is_none());
assert!(engine.get_edge(e_bc).unwrap().is_none());
assert!(engine.get_edge(e_ca).unwrap().is_none());
engine.close().unwrap();
}
#[test]
fn test_prune_shared_edge_dedup() {
let dir = TempDir::new().unwrap();
let engine = open_imm(&dir.path().join("db"));
let a = engine
.upsert_node(
"Person",
"a",
UpsertNodeOptions {
weight: 0.1,
..Default::default()
},
)
.unwrap();
let b = engine
.upsert_node(
"Person",
"b",
UpsertNodeOptions {
weight: 0.1,
..Default::default()
},
)
.unwrap();
let e = engine
.upsert_edge(a, b, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
let result = engine
.prune(&PrunePolicy {
max_age_ms: None,
max_weight: Some(0.5),
label: None,
})
.unwrap();
assert_eq!(result.nodes_pruned, 2);
assert_eq!(result.edges_pruned, 1); assert!(engine.get_edge(e).unwrap().is_none());
engine.close().unwrap();
}
#[test]
fn test_prune_empty_result_no_match() {
let dir = TempDir::new().unwrap();
let engine = open_imm(&dir.path().join("db"));
engine
.upsert_node(
"Person",
"a",
UpsertNodeOptions {
weight: 0.9,
..Default::default()
},
)
.unwrap();
engine
.upsert_node(
"Person",
"b",
UpsertNodeOptions {
weight: 0.8,
..Default::default()
},
)
.unwrap();
let result = engine
.prune(&PrunePolicy {
max_age_ms: None,
max_weight: Some(0.1),
label: None,
})
.unwrap();
assert_eq!(result.nodes_pruned, 0);
assert_eq!(result.edges_pruned, 0);
engine.close().unwrap();
}
#[test]
fn test_prune_after_flush_segment_nodes() {
let dir = TempDir::new().unwrap();
let engine = open_imm(&dir.path().join("db"));
let a = engine
.upsert_node(
"Person",
"seg-a",
UpsertNodeOptions {
weight: 0.1,
..Default::default()
},
)
.unwrap();
let b = engine
.upsert_node(
"Person",
"seg-b",
UpsertNodeOptions {
weight: 0.9,
..Default::default()
},
)
.unwrap();
let e = engine
.upsert_edge(a, b, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
engine.flush().unwrap();
let result = engine
.prune(&PrunePolicy {
max_age_ms: None,
max_weight: Some(0.5),
label: None,
})
.unwrap();
assert_eq!(result.nodes_pruned, 1);
assert_eq!(result.edges_pruned, 1);
assert!(engine.get_node(a).unwrap().is_none());
assert!(engine.get_node(b).unwrap().is_some());
assert!(engine.get_edge(e).unwrap().is_none());
engine.close().unwrap();
}
#[test]
fn test_prune_cross_source_memtable_and_segment() {
let dir = TempDir::new().unwrap();
let engine = open_imm(&dir.path().join("db"));
let a = engine
.upsert_node(
"Person",
"in-seg",
UpsertNodeOptions {
weight: 0.1,
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
let b = engine
.upsert_node(
"Person",
"in-mem",
UpsertNodeOptions {
weight: 0.1,
..Default::default()
},
)
.unwrap();
let e = engine
.upsert_edge(b, a, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
let result = engine
.prune(&PrunePolicy {
max_age_ms: None,
max_weight: Some(0.5),
label: None,
})
.unwrap();
assert_eq!(result.nodes_pruned, 2);
assert_eq!(result.edges_pruned, 1);
assert!(engine.get_node(a).unwrap().is_none());
assert!(engine.get_node(b).unwrap().is_none());
assert!(engine.get_edge(e).unwrap().is_none());
engine.close().unwrap();
}
#[test]
fn test_prune_cascade_edges_in_segment() {
let dir = TempDir::new().unwrap();
let engine = open_imm(&dir.path().join("db"));
let a = engine
.upsert_node(
"Person",
"a",
UpsertNodeOptions {
weight: 0.9,
..Default::default()
},
)
.unwrap();
let b = engine
.upsert_node(
"Person",
"b",
UpsertNodeOptions {
weight: 0.1,
..Default::default()
},
)
.unwrap();
let e = engine
.upsert_edge(a, b, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
engine.flush().unwrap();
engine
.upsert_node(
"Person",
"b",
UpsertNodeOptions {
weight: 0.1,
..Default::default()
},
)
.unwrap();
let result = engine
.prune(&PrunePolicy {
max_age_ms: None,
max_weight: Some(0.5),
label: None,
})
.unwrap();
assert_eq!(result.nodes_pruned, 1); assert_eq!(result.edges_pruned, 1); assert!(engine.get_node(a).unwrap().is_some());
assert!(engine.get_node(b).unwrap().is_none());
assert!(engine.get_edge(e).unwrap().is_none());
engine.close().unwrap();
}
#[test]
fn test_prune_survives_wal_replay() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("db");
let (a, b, e);
{
let engine = open_imm(&db_path);
a = engine
.upsert_node(
"Person",
"a",
UpsertNodeOptions {
weight: 0.1,
..Default::default()
},
)
.unwrap();
b = engine
.upsert_node(
"Person",
"b",
UpsertNodeOptions {
weight: 0.9,
..Default::default()
},
)
.unwrap();
e = engine
.upsert_edge(a, b, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
let result = engine
.prune(&PrunePolicy {
max_age_ms: None,
max_weight: Some(0.5),
label: None,
})
.unwrap();
assert_eq!(result.nodes_pruned, 1);
assert_eq!(result.edges_pruned, 1);
engine.close().unwrap();
}
let engine = open_imm(&db_path);
assert!(engine.get_node(a).unwrap().is_none());
assert!(engine.get_node(b).unwrap().is_some());
assert!(engine.get_edge(e).unwrap().is_none());
engine.close().unwrap();
}
#[test]
fn test_prune_weight_boundary() {
let dir = TempDir::new().unwrap();
let engine = open_imm(&dir.path().join("db"));
let a = engine
.upsert_node(
"Person",
"exact",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
let b = engine
.upsert_node(
"Person",
"above",
UpsertNodeOptions {
weight: 0.500001,
..Default::default()
},
)
.unwrap();
let result = engine
.prune(&PrunePolicy {
max_age_ms: None,
max_weight: Some(0.5),
label: None,
})
.unwrap();
assert_eq!(result.nodes_pruned, 1);
assert!(engine.get_node(a).unwrap().is_none()); assert!(engine.get_node(b).unwrap().is_some()); engine.close().unwrap();
}
#[test]
fn test_prune_already_deleted_node_ignored() {
let dir = TempDir::new().unwrap();
let engine = open_imm(&dir.path().join("db"));
let a = engine
.upsert_node(
"Person",
"a",
UpsertNodeOptions {
weight: 0.1,
..Default::default()
},
)
.unwrap();
engine.delete_node(a).unwrap();
let result = engine
.prune(&PrunePolicy {
max_age_ms: None,
max_weight: Some(0.5),
label: None,
})
.unwrap();
assert_eq!(result.nodes_pruned, 0);
assert_eq!(result.edges_pruned, 0);
engine.close().unwrap();
}
#[test]
fn test_prune_empty_db() {
let dir = TempDir::new().unwrap();
let engine = open_imm(&dir.path().join("db"));
let result = engine
.prune(&PrunePolicy {
max_age_ms: None,
max_weight: Some(0.5),
label: None,
})
.unwrap();
assert_eq!(result.nodes_pruned, 0);
assert_eq!(result.edges_pruned, 0);
engine.close().unwrap();
}
#[test]
fn test_prune_negative_age_rejected() {
let dir = TempDir::new().unwrap();
let engine = open_imm(&dir.path().join("db"));
engine
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let result = engine.prune(&PrunePolicy {
max_age_ms: Some(-100),
max_weight: None,
label: None,
});
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("max_age_ms must be positive"), "got: {}", err);
engine.close().unwrap();
}
#[test]
fn test_prune_zero_age_rejected() {
let dir = TempDir::new().unwrap();
let engine = open_imm(&dir.path().join("db"));
let result = engine.prune(&PrunePolicy {
max_age_ms: Some(0),
max_weight: None,
label: None,
});
assert!(result.is_err());
engine.close().unwrap();
}
#[test]
fn test_prune_label_scoped_with_age() {
let dir = TempDir::new().unwrap();
let engine = open_imm(&dir.path().join("db"));
let a = engine
.upsert_node("Person", "t1-old", UpsertNodeOptions::default())
.unwrap();
let b = engine
.upsert_node("Company", "t2-old", UpsertNodeOptions::default())
.unwrap();
let _c = engine
.upsert_node("Person", "t1-new", UpsertNodeOptions::default())
.unwrap();
let node_a = internal_node_record(&engine, a).unwrap().unwrap();
write_internal_wal_op(&engine, &WalOp::UpsertNode(NodeRecord {
updated_at: 1000,
..node_a
}))
.unwrap();
let node_b = internal_node_record(&engine, b).unwrap().unwrap();
write_internal_wal_op(&engine, &WalOp::UpsertNode(NodeRecord {
updated_at: 1000,
..node_b
}))
.unwrap();
let result = engine
.prune(&PrunePolicy {
max_age_ms: Some(1000),
max_weight: None,
label: Some("Person".to_string()),
})
.unwrap();
assert_eq!(result.nodes_pruned, 1); assert!(engine.get_node(a).unwrap().is_none()); assert!(engine.get_node(b).unwrap().is_some()); engine.close().unwrap();
}
#[test]
fn test_set_and_list_prune_policies() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let mut opts = DbOptions::default();
opts.wal_sync_mode = WalSyncMode::Immediate;
let engine = DatabaseEngine::open(&db_path, &opts).unwrap();
assert!(engine.list_prune_policies().unwrap().is_empty());
let policy = PrunePolicy {
max_age_ms: None,
max_weight: Some(0.5),
label: None,
};
engine
.set_prune_policy("low-weight", policy.clone())
.unwrap();
let list = engine.list_prune_policies().unwrap();
assert_eq!(list.len(), 1);
assert_eq!(list[0].name, "low-weight");
assert_eq!(list[0].policy.max_weight, Some(0.5));
let policy2 = PrunePolicy {
max_age_ms: Some(60_000),
max_weight: None,
label: None,
};
engine.set_prune_policy("low-weight", policy2).unwrap();
let list = engine.list_prune_policies().unwrap();
assert_eq!(list.len(), 1);
assert_eq!(list[0].policy.max_age_ms, Some(60_000));
assert!(list[0].policy.max_weight.is_none());
engine.close().unwrap();
}
#[test]
fn test_remove_prune_policy() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let mut opts = DbOptions::default();
opts.wal_sync_mode = WalSyncMode::Immediate;
let engine = DatabaseEngine::open(&db_path, &opts).unwrap();
engine
.set_prune_policy(
"p1",
PrunePolicy {
max_age_ms: None,
max_weight: Some(0.3),
label: None,
},
)
.unwrap();
assert_eq!(engine.list_prune_policies().unwrap().len(), 1);
assert!(engine.remove_prune_policy("p1").unwrap());
assert!(engine.list_prune_policies().unwrap().is_empty());
assert!(!engine.remove_prune_policy("p1").unwrap());
engine.close().unwrap();
}
#[test]
fn test_prune_policy_validation() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let mut opts = DbOptions::default();
opts.wal_sync_mode = WalSyncMode::Immediate;
let engine = DatabaseEngine::open(&db_path, &opts).unwrap();
let err = engine.set_prune_policy(
"bad",
PrunePolicy {
max_age_ms: None,
max_weight: None,
label: None,
},
);
assert!(err.is_err());
let err = engine.set_prune_policy(
"bad",
PrunePolicy {
max_age_ms: None,
max_weight: None,
label: Some("Person".to_string()),
},
);
assert!(err.is_err());
let err = engine.set_prune_policy(
"bad",
PrunePolicy {
max_age_ms: Some(-1),
max_weight: None,
label: None,
},
);
assert!(err.is_err());
let err = engine.set_prune_policy(
"bad",
PrunePolicy {
max_age_ms: None,
max_weight: Some(f32::NAN),
label: None,
},
);
assert!(err.is_err());
let err = engine.set_prune_policy(
"bad",
PrunePolicy {
max_age_ms: None,
max_weight: Some(-0.1),
label: None,
},
);
assert!(err.is_err());
engine.close().unwrap();
}
#[test]
fn test_prune_policy_survives_close_reopen() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let mut opts = DbOptions::default();
opts.wal_sync_mode = WalSyncMode::Immediate;
{
let engine = DatabaseEngine::open(&db_path, &opts).unwrap();
engine
.set_prune_policy(
"age-rule",
PrunePolicy {
max_age_ms: Some(30_000),
max_weight: None,
label: None,
},
)
.unwrap();
engine
.set_prune_policy(
"weight-rule",
PrunePolicy {
max_age_ms: None,
max_weight: Some(0.1),
label: Some("City".to_string()),
},
)
.unwrap();
engine.close().unwrap();
}
let engine = DatabaseEngine::open(&db_path, &opts).unwrap();
let list = engine.list_prune_policies().unwrap();
assert_eq!(list.len(), 2);
assert_eq!(list[0].name, "age-rule");
assert_eq!(list[0].policy.max_age_ms, Some(30_000));
assert_eq!(list[1].name, "weight-rule");
assert_eq!(list[1].policy.max_weight, Some(0.1));
assert_eq!(list[1].policy.label, Some("City".to_string()));
engine.close().unwrap();
}
#[test]
fn test_compaction_auto_prune_by_weight() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let mut opts = DbOptions::default();
opts.wal_sync_mode = WalSyncMode::Immediate;
opts.compact_after_n_flushes = 0; let engine = DatabaseEngine::open(&db_path, &opts).unwrap();
let low = engine
.upsert_node(
"Person",
"low",
UpsertNodeOptions {
weight: 0.1,
..Default::default()
},
)
.unwrap();
let high = engine
.upsert_node(
"Person",
"high",
UpsertNodeOptions {
weight: 0.9,
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
let low2 = engine
.upsert_node(
"Person",
"low2",
UpsertNodeOptions {
weight: 0.2,
..Default::default()
},
)
.unwrap();
let high2 = engine
.upsert_node(
"Person",
"high2",
UpsertNodeOptions {
weight: 0.8,
..Default::default()
},
)
.unwrap();
engine
.upsert_node(
"Person",
"high",
UpsertNodeOptions {
weight: 0.9,
..Default::default()
},
)
.unwrap(); engine.flush().unwrap();
engine
.set_prune_policy(
"low-weight",
PrunePolicy {
max_age_ms: None,
max_weight: Some(0.5),
label: None,
},
)
.unwrap();
let stats = engine.compact().unwrap().unwrap();
assert_eq!(stats.nodes_auto_pruned, 2);
assert_eq!(stats.nodes_kept, 2); assert!(stats.edges_auto_pruned == 0);
assert!(engine.get_node(low).unwrap().is_none());
assert!(engine.get_node(low2).unwrap().is_none());
assert!(engine.get_node(high).unwrap().is_some());
assert!(engine.get_node(high2).unwrap().is_some());
engine.close().unwrap();
}
#[test]
fn test_compaction_auto_prune_cascade_edges() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let mut opts = DbOptions::default();
opts.wal_sync_mode = WalSyncMode::Immediate;
opts.compact_after_n_flushes = 0;
let engine = DatabaseEngine::open(&db_path, &opts).unwrap();
let a = engine
.upsert_node(
"Person",
"a",
UpsertNodeOptions {
weight: 0.1,
..Default::default()
},
)
.unwrap(); let b = engine
.upsert_node(
"Person",
"b",
UpsertNodeOptions {
weight: 0.9,
..Default::default()
},
)
.unwrap();
let c = engine
.upsert_node(
"Person",
"c",
UpsertNodeOptions {
weight: 0.9,
..Default::default()
},
)
.unwrap();
let e1 = engine
.upsert_edge(a, b, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
let e2 = engine
.upsert_edge(b, c, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
engine.flush().unwrap();
let d = engine
.upsert_node(
"Person",
"d",
UpsertNodeOptions {
weight: 0.8,
..Default::default()
},
)
.unwrap();
engine
.upsert_node(
"Person",
"b",
UpsertNodeOptions {
weight: 0.9,
..Default::default()
},
)
.unwrap(); engine.flush().unwrap();
engine
.set_prune_policy(
"low",
PrunePolicy {
max_age_ms: None,
max_weight: Some(0.5),
label: None,
},
)
.unwrap();
let stats = engine.compact().unwrap().unwrap();
assert_eq!(stats.nodes_auto_pruned, 1); assert_eq!(stats.edges_auto_pruned, 1);
assert!(engine.get_node(a).unwrap().is_none());
assert!(engine.get_node(b).unwrap().is_some());
assert!(engine.get_node(c).unwrap().is_some());
assert!(engine.get_node(d).unwrap().is_some());
assert!(engine.get_edge(e1).unwrap().is_none());
assert!(engine.get_edge(e2).unwrap().is_some());
engine.close().unwrap();
}
#[test]
fn test_compaction_multiple_policies_or_logic() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let mut opts = DbOptions::default();
opts.wal_sync_mode = WalSyncMode::Immediate;
opts.compact_after_n_flushes = 0;
let engine = DatabaseEngine::open(&db_path, &opts).unwrap();
let n1 = engine
.upsert_node(
"Person",
"n1",
UpsertNodeOptions {
weight: 0.1,
..Default::default()
},
)
.unwrap();
let n2 = engine
.upsert_node(
"MissingLabel",
"n2",
UpsertNodeOptions {
weight: 0.9,
..Default::default()
},
)
.unwrap();
let n3 = engine
.upsert_node(
"Person",
"n3",
UpsertNodeOptions {
weight: 0.9,
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
engine
.upsert_node(
"Person",
"n3",
UpsertNodeOptions {
weight: 0.9,
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
engine
.set_prune_policy(
"low-weight",
PrunePolicy {
max_age_ms: None,
max_weight: Some(0.5),
label: None,
},
)
.unwrap();
engine
.set_prune_policy(
"type-99",
PrunePolicy {
max_age_ms: None,
max_weight: Some(999.0),
label: Some("MissingLabel".to_string()),
},
)
.unwrap();
let stats = engine.compact().unwrap().unwrap();
assert_eq!(stats.nodes_auto_pruned, 2);
assert!(engine.get_node(n1).unwrap().is_none());
assert!(engine.get_node(n2).unwrap().is_none());
assert!(engine.get_node(n3).unwrap().is_some());
engine.close().unwrap();
}
#[test]
fn test_compaction_no_policies_no_prune() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let mut opts = DbOptions::default();
opts.wal_sync_mode = WalSyncMode::Immediate;
opts.compact_after_n_flushes = 0;
let engine = DatabaseEngine::open(&db_path, &opts).unwrap();
let a = engine
.upsert_node(
"Person",
"a",
UpsertNodeOptions {
weight: 0.1,
..Default::default()
},
)
.unwrap();
let b = engine
.upsert_node(
"Person",
"b",
UpsertNodeOptions {
weight: 0.9,
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
engine
.upsert_node(
"Person",
"c",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
let stats = engine.compact().unwrap().unwrap();
assert_eq!(stats.nodes_auto_pruned, 0);
assert_eq!(stats.edges_auto_pruned, 0);
assert!(engine.get_node(a).unwrap().is_some());
assert!(engine.get_node(b).unwrap().is_some());
engine.close().unwrap();
}
#[test]
fn test_removed_policy_no_longer_prunes() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let mut opts = DbOptions::default();
opts.wal_sync_mode = WalSyncMode::Immediate;
opts.compact_after_n_flushes = 0;
let engine = DatabaseEngine::open(&db_path, &opts).unwrap();
engine
.set_prune_policy(
"p",
PrunePolicy {
max_age_ms: None,
max_weight: Some(0.5),
label: None,
},
)
.unwrap();
let a = engine
.upsert_node(
"Person",
"a",
UpsertNodeOptions {
weight: 0.1,
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
engine
.upsert_node(
"Person",
"b",
UpsertNodeOptions {
weight: 0.9,
..Default::default()
},
)
.unwrap();
engine
.upsert_node(
"Person",
"a",
UpsertNodeOptions {
weight: 0.1,
..Default::default()
},
)
.unwrap(); engine.flush().unwrap();
engine.remove_prune_policy("p").unwrap();
let stats = engine.compact().unwrap().unwrap();
assert_eq!(stats.nodes_auto_pruned, 0);
assert!(engine.get_node(a).unwrap().is_some());
engine.close().unwrap();
}
#[test]
fn test_compaction_label_scoped_policy() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let mut opts = DbOptions::default();
opts.wal_sync_mode = WalSyncMode::Immediate;
opts.compact_after_n_flushes = 0;
let engine = DatabaseEngine::open(&db_path, &opts).unwrap();
let t1_low = engine
.upsert_node(
"Person",
"t1-low",
UpsertNodeOptions {
weight: 0.1,
..Default::default()
},
)
.unwrap();
let t2_low = engine
.upsert_node(
"Company",
"t2-low",
UpsertNodeOptions {
weight: 0.1,
..Default::default()
},
)
.unwrap();
let t1_high = engine
.upsert_node(
"Person",
"t1-high",
UpsertNodeOptions {
weight: 0.9,
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
engine
.upsert_node(
"Person",
"t1-high",
UpsertNodeOptions {
weight: 0.9,
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
engine
.set_prune_policy(
"label1-low",
PrunePolicy {
max_age_ms: None,
max_weight: Some(0.5),
label: Some("Person".to_string()),
},
)
.unwrap();
let stats = engine.compact().unwrap().unwrap();
assert_eq!(stats.nodes_auto_pruned, 1);
assert!(engine.get_node(t1_low).unwrap().is_none());
assert!(engine.get_node(t2_low).unwrap().is_some()); assert!(engine.get_node(t1_high).unwrap().is_some());
engine.close().unwrap();
}
#[test]
fn test_compaction_prune_stats_in_nodes_removed() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let mut opts = DbOptions::default();
opts.wal_sync_mode = WalSyncMode::Immediate;
opts.compact_after_n_flushes = 0;
let engine = DatabaseEngine::open(&db_path, &opts).unwrap();
for i in 0..10 {
engine
.upsert_node(
"Person",
&format!("n{}", i),
UpsertNodeOptions {
weight: 0.1,
..Default::default()
},
)
.unwrap();
}
engine.flush().unwrap();
for i in 10..20 {
engine
.upsert_node(
"Person",
&format!("n{}", i),
UpsertNodeOptions {
weight: 0.9,
..Default::default()
},
)
.unwrap();
}
engine
.upsert_node(
"Person",
"n0",
UpsertNodeOptions {
weight: 0.1,
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
engine
.set_prune_policy(
"p",
PrunePolicy {
max_age_ms: None,
max_weight: Some(0.5),
label: None,
},
)
.unwrap();
let stats = engine.compact().unwrap().unwrap();
assert_eq!(stats.nodes_auto_pruned, 10);
assert_eq!(stats.nodes_kept, 10);
assert!(stats.nodes_removed >= 10);
engine.close().unwrap();
}
#[test]
fn test_manual_prune_unchanged_by_policies() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let mut opts = DbOptions::default();
opts.wal_sync_mode = WalSyncMode::Immediate;
opts.compact_after_n_flushes = 0;
let engine = DatabaseEngine::open(&db_path, &opts).unwrap();
engine
.set_prune_policy(
"p",
PrunePolicy {
max_age_ms: None,
max_weight: Some(0.0001),
label: None,
},
)
.unwrap();
let a = engine
.upsert_node(
"Person",
"a",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
let b = engine
.upsert_node(
"Person",
"b",
UpsertNodeOptions {
weight: 0.9,
..Default::default()
},
)
.unwrap();
let result = engine
.prune(&PrunePolicy {
max_age_ms: None,
max_weight: Some(0.7),
label: None,
})
.unwrap();
assert_eq!(result.nodes_pruned, 1); assert!(engine.get_node(a).unwrap().is_none());
assert!(engine.get_node(b).unwrap().is_some());
engine.close().unwrap();
}
#[test]
fn test_bg_compaction_applies_prune_policies() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let mut opts = DbOptions::default();
opts.wal_sync_mode = WalSyncMode::Immediate;
opts.compact_after_n_flushes = 2;
let engine = DatabaseEngine::open(&db_path, &opts).unwrap();
engine
.set_prune_policy(
"low",
PrunePolicy {
max_age_ms: None,
max_weight: Some(0.3),
label: None,
},
)
.unwrap();
let low = engine
.upsert_node(
"Person",
"low",
UpsertNodeOptions {
weight: 0.1,
..Default::default()
},
)
.unwrap();
let high = engine
.upsert_node(
"Person",
"high",
UpsertNodeOptions {
weight: 0.9,
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
engine
.upsert_node(
"Person",
"high",
UpsertNodeOptions {
weight: 0.9,
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
engine.wait_for_bg_compact();
assert!(engine.get_node(low).unwrap().is_none());
assert!(engine.get_node(high).unwrap().is_some());
engine.close().unwrap();
}
#[test]
fn test_read_time_policy_get_node() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let mut opts = DbOptions::default();
opts.wal_sync_mode = WalSyncMode::Immediate;
opts.compact_after_n_flushes = 0;
let engine = DatabaseEngine::open(&db_path, &opts).unwrap();
let low = engine
.upsert_node(
"Person",
"low",
UpsertNodeOptions {
weight: 0.2,
..Default::default()
},
)
.unwrap();
let high = engine
.upsert_node(
"Person",
"high",
UpsertNodeOptions {
weight: 0.9,
..Default::default()
},
)
.unwrap();
assert!(engine.get_node(low).unwrap().is_some());
assert!(engine.get_node(high).unwrap().is_some());
engine
.set_prune_policy(
"p",
PrunePolicy {
max_age_ms: None,
max_weight: Some(0.5),
label: None,
},
)
.unwrap();
assert!(engine.get_node(low).unwrap().is_none());
assert!(engine.get_node(high).unwrap().is_some());
engine.close().unwrap();
}
#[test]
fn test_read_time_policy_get_node_by_key() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let mut opts = DbOptions::default();
opts.wal_sync_mode = WalSyncMode::Immediate;
opts.compact_after_n_flushes = 0;
let engine = DatabaseEngine::open(&db_path, &opts).unwrap();
engine
.upsert_node(
"Person",
"low",
UpsertNodeOptions {
weight: 0.2,
..Default::default()
},
)
.unwrap();
engine
.upsert_node(
"Person",
"high",
UpsertNodeOptions {
weight: 0.9,
..Default::default()
},
)
.unwrap();
engine
.set_prune_policy(
"p",
PrunePolicy {
max_age_ms: None,
max_weight: Some(0.5),
label: None,
},
)
.unwrap();
assert!(engine.get_node_by_key("Person", "low").unwrap().is_none());
assert!(engine.get_node_by_key("Person", "high").unwrap().is_some());
engine.close().unwrap();
}
#[test]
fn test_read_time_policy_get_nodes_batch() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let mut opts = DbOptions::default();
opts.wal_sync_mode = WalSyncMode::Immediate;
opts.compact_after_n_flushes = 0;
let engine = DatabaseEngine::open(&db_path, &opts).unwrap();
let a = engine
.upsert_node(
"Person",
"a",
UpsertNodeOptions {
weight: 0.2,
..Default::default()
},
)
.unwrap();
let b = engine
.upsert_node(
"Person",
"b",
UpsertNodeOptions {
weight: 0.9,
..Default::default()
},
)
.unwrap();
let c = engine
.upsert_node(
"Person",
"c",
UpsertNodeOptions {
weight: 0.3,
..Default::default()
},
)
.unwrap();
engine
.set_prune_policy(
"p",
PrunePolicy {
max_age_ms: None,
max_weight: Some(0.5),
label: None,
},
)
.unwrap();
let results = engine.get_nodes(&[a, b, c]).unwrap();
assert!(results[0].is_none());
assert!(results[1].is_some());
assert!(results[2].is_none());
engine.close().unwrap();
}
#[test]
fn test_read_time_policy_get_node_after_flush() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let mut opts = DbOptions::default();
opts.wal_sync_mode = WalSyncMode::Immediate;
opts.compact_after_n_flushes = 0;
let engine = DatabaseEngine::open(&db_path, &opts).unwrap();
let low = engine
.upsert_node(
"Person",
"low",
UpsertNodeOptions {
weight: 0.2,
..Default::default()
},
)
.unwrap();
let high = engine
.upsert_node(
"Person",
"high",
UpsertNodeOptions {
weight: 0.9,
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
engine
.set_prune_policy(
"p",
PrunePolicy {
max_age_ms: None,
max_weight: Some(0.5),
label: None,
},
)
.unwrap();
assert!(engine.get_node(low).unwrap().is_none());
assert!(engine.get_node(high).unwrap().is_some());
engine.close().unwrap();
}
#[test]
fn test_read_time_policy_get_node_by_key_after_flush() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let mut opts = DbOptions::default();
opts.wal_sync_mode = WalSyncMode::Immediate;
opts.compact_after_n_flushes = 0;
let engine = DatabaseEngine::open(&db_path, &opts).unwrap();
engine
.upsert_node(
"Person",
"low",
UpsertNodeOptions {
weight: 0.2,
..Default::default()
},
)
.unwrap();
engine
.upsert_node(
"Person",
"high",
UpsertNodeOptions {
weight: 0.9,
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
engine
.set_prune_policy(
"p",
PrunePolicy {
max_age_ms: None,
max_weight: Some(0.5),
label: None,
},
)
.unwrap();
assert!(engine.get_node_by_key("Person", "low").unwrap().is_none());
assert!(engine.get_node_by_key("Person", "high").unwrap().is_some());
engine.close().unwrap();
}
#[test]
fn test_read_time_policy_upsert_dedup_unaffected() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let mut opts = DbOptions::default();
opts.wal_sync_mode = WalSyncMode::Immediate;
opts.compact_after_n_flushes = 0;
let engine = DatabaseEngine::open(&db_path, &opts).unwrap();
let id1 = engine
.upsert_node(
"Person",
"node-a",
UpsertNodeOptions {
weight: 0.2,
..Default::default()
},
)
.unwrap();
engine
.set_prune_policy(
"p",
PrunePolicy {
max_age_ms: None,
max_weight: Some(0.5),
label: None,
},
)
.unwrap();
assert!(engine.get_node(id1).unwrap().is_none());
let id2 = engine
.upsert_node(
"Person",
"node-a",
UpsertNodeOptions {
weight: 0.8,
..Default::default()
},
)
.unwrap();
assert_eq!(
id1, id2,
"upsert must reuse existing node ID even when policy-excluded"
);
assert!(engine.get_node(id2).unwrap().is_some());
engine.close().unwrap();
}
#[test]
fn test_read_time_policy_upsert_dedup_after_flush() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let mut opts = DbOptions::default();
opts.wal_sync_mode = WalSyncMode::Immediate;
opts.compact_after_n_flushes = 0;
let engine = DatabaseEngine::open(&db_path, &opts).unwrap();
let id1 = engine
.upsert_node(
"Person",
"node-a",
UpsertNodeOptions {
weight: 0.2,
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
engine
.set_prune_policy(
"p",
PrunePolicy {
max_age_ms: None,
max_weight: Some(0.5),
label: None,
},
)
.unwrap();
assert!(engine.get_node(id1).unwrap().is_none());
let id2 = engine
.upsert_node(
"Person",
"node-a",
UpsertNodeOptions {
weight: 0.8,
..Default::default()
},
)
.unwrap();
assert_eq!(id1, id2);
engine.close().unwrap();
}
#[test]
fn test_read_time_policy_add_remove_takes_effect() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let mut opts = DbOptions::default();
opts.wal_sync_mode = WalSyncMode::Immediate;
opts.compact_after_n_flushes = 0;
let engine = DatabaseEngine::open(&db_path, &opts).unwrap();
let id = engine
.upsert_node(
"Person",
"target",
UpsertNodeOptions {
weight: 0.3,
..Default::default()
},
)
.unwrap();
assert!(engine.get_node(id).unwrap().is_some());
engine
.set_prune_policy(
"hide-low",
PrunePolicy {
max_age_ms: None,
max_weight: Some(0.5),
label: None,
},
)
.unwrap();
assert!(engine.get_node(id).unwrap().is_none());
engine.remove_prune_policy("hide-low").unwrap();
assert!(engine.get_node(id).unwrap().is_some());
engine.close().unwrap();
}
#[test]
fn test_read_time_policy_label_scoped() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let mut opts = DbOptions::default();
opts.wal_sync_mode = WalSyncMode::Immediate;
opts.compact_after_n_flushes = 0;
let engine = DatabaseEngine::open(&db_path, &opts).unwrap();
let t1 = engine
.upsert_node(
"Person",
"t1-low",
UpsertNodeOptions {
weight: 0.2,
..Default::default()
},
)
.unwrap();
let t2 = engine
.upsert_node(
"Company",
"t2-low",
UpsertNodeOptions {
weight: 0.2,
..Default::default()
},
)
.unwrap();
engine
.set_prune_policy(
"t1-only",
PrunePolicy {
max_age_ms: None,
max_weight: Some(0.5),
label: Some("Person".to_string()),
},
)
.unwrap();
assert!(engine.get_node(t1).unwrap().is_none());
assert!(engine.get_node(t2).unwrap().is_some());
engine.close().unwrap();
}
#[test]
fn test_read_time_policy_no_policies_zero_overhead() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let mut opts = DbOptions::default();
opts.wal_sync_mode = WalSyncMode::Immediate;
opts.compact_after_n_flushes = 0;
let engine = DatabaseEngine::open(&db_path, &opts).unwrap();
let id = engine
.upsert_node(
"Person",
"node",
UpsertNodeOptions {
weight: 0.1,
..Default::default()
},
)
.unwrap();
assert!(engine.list_prune_policies().unwrap().is_empty());
assert!(engine.get_node(id).unwrap().is_some());
assert!(engine.get_node_by_key("Person", "node").unwrap().is_some());
let batch = engine.get_nodes(&[id]).unwrap();
assert!(batch[0].is_some());
engine.close().unwrap();
}
#[test]
fn test_read_time_policy_multiple_policies_or() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let mut opts = DbOptions::default();
opts.wal_sync_mode = WalSyncMode::Immediate;
opts.compact_after_n_flushes = 0;
let engine = DatabaseEngine::open(&db_path, &opts).unwrap();
let a = engine
.upsert_node(
"Person",
"a",
UpsertNodeOptions {
weight: 0.1,
..Default::default()
},
)
.unwrap(); let b = engine
.upsert_node(
"Company",
"b",
UpsertNodeOptions {
weight: 0.1,
..Default::default()
},
)
.unwrap(); let c = engine
.upsert_node(
"Person",
"c",
UpsertNodeOptions {
weight: 0.9,
..Default::default()
},
)
.unwrap();
engine
.set_prune_policy(
"p1",
PrunePolicy {
max_age_ms: None,
max_weight: Some(0.5),
label: Some("Person".to_string()),
},
)
.unwrap();
engine
.set_prune_policy(
"p2",
PrunePolicy {
max_age_ms: None,
max_weight: Some(0.5),
label: Some("Company".to_string()),
},
)
.unwrap();
assert!(engine.get_node(a).unwrap().is_none());
assert!(engine.get_node(b).unwrap().is_none());
assert!(engine.get_node(c).unwrap().is_some());
engine.close().unwrap();
}
#[test]
fn test_read_time_policy_graph_patch_dedup_unaffected() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let mut opts = DbOptions::default();
opts.wal_sync_mode = WalSyncMode::Immediate;
opts.compact_after_n_flushes = 0;
let engine = DatabaseEngine::open(&db_path, &opts).unwrap();
let id1 = engine
.upsert_node(
"Person",
"node-a",
UpsertNodeOptions {
weight: 0.2,
..Default::default()
},
)
.unwrap();
engine
.set_prune_policy(
"p",
PrunePolicy {
max_age_ms: None,
max_weight: Some(0.5),
label: None,
},
)
.unwrap();
let patch = GraphPatch {
upsert_nodes: vec![NodeInput {
labels: vec!["Person".to_string()],
key: "node-a".to_string(),
props: BTreeMap::new(),
weight: 0.8,
dense_vector: None,
sparse_vector: None,
}],
upsert_edges: Vec::new(),
invalidate_edges: Vec::new(),
delete_node_ids: Vec::new(),
delete_edge_ids: Vec::new(),
};
let result = engine.graph_patch(patch).unwrap();
assert_eq!(
result.node_ids[0], id1,
"graph_patch must reuse existing node ID"
);
engine.close().unwrap();
}
#[test]
fn test_read_time_policy_neighbors() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let mut opts = DbOptions::default();
opts.wal_sync_mode = WalSyncMode::Immediate;
opts.compact_after_n_flushes = 0;
let engine = DatabaseEngine::open(&db_path, &opts).unwrap();
let a = engine
.upsert_node(
"Person",
"a",
UpsertNodeOptions {
weight: 0.9,
..Default::default()
},
)
.unwrap();
let b = engine
.upsert_node(
"Person",
"b",
UpsertNodeOptions {
weight: 0.2,
..Default::default()
},
)
.unwrap(); let c = engine
.upsert_node(
"Person",
"c",
UpsertNodeOptions {
weight: 0.8,
..Default::default()
},
)
.unwrap();
engine
.upsert_edge(a, b, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
engine
.upsert_edge(a, c, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
let result = engine.neighbors(a, &NeighborOptions::default()).unwrap();
assert_eq!(result.len(), 2);
engine
.set_prune_policy(
"p",
PrunePolicy {
max_age_ms: None,
max_weight: Some(0.5),
label: None,
},
)
.unwrap();
let result = engine.neighbors(a, &NeighborOptions::default()).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].node_id, c);
engine.close().unwrap();
}
#[test]
fn test_read_time_policy_neighbors_limit() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let mut opts = DbOptions::default();
opts.wal_sync_mode = WalSyncMode::Immediate;
opts.compact_after_n_flushes = 0;
let engine = DatabaseEngine::open(&db_path, &opts).unwrap();
let hub = engine
.upsert_node(
"Person",
"hub",
UpsertNodeOptions {
weight: 0.9,
..Default::default()
},
)
.unwrap();
let mut visible_ids = Vec::new();
for i in 0..3 {
let id = engine
.upsert_node(
"Person",
&format!("hi-{}", i),
UpsertNodeOptions {
weight: 0.8,
..Default::default()
},
)
.unwrap();
engine
.upsert_edge(hub, id, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
visible_ids.push(id);
}
for i in 0..2 {
let id = engine
.upsert_node(
"Person",
&format!("lo-{}", i),
UpsertNodeOptions {
weight: 0.1,
..Default::default()
},
)
.unwrap();
engine
.upsert_edge(hub, id, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
}
engine
.set_prune_policy(
"p",
PrunePolicy {
max_age_ms: None,
max_weight: Some(0.5),
label: None,
},
)
.unwrap();
let result = engine.neighbors(hub, &NeighborOptions::default()).unwrap();
assert_eq!(result.len(), 3);
let result = engine
.neighbors(
hub,
&NeighborOptions {
limit: Some(2),
..Default::default()
},
)
.unwrap();
assert_eq!(result.len(), 2);
engine.close().unwrap();
}
#[test]
fn test_read_time_policy_traverse_depth_two() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let mut opts = DbOptions::default();
opts.wal_sync_mode = WalSyncMode::Immediate;
opts.compact_after_n_flushes = 0;
let engine = DatabaseEngine::open(&db_path, &opts).unwrap();
let a = engine
.upsert_node(
"Person",
"a",
UpsertNodeOptions {
weight: 0.9,
..Default::default()
},
)
.unwrap();
let b = engine
.upsert_node(
"Person",
"b",
UpsertNodeOptions {
weight: 0.9,
..Default::default()
},
)
.unwrap();
let c = engine
.upsert_node(
"Person",
"c",
UpsertNodeOptions {
weight: 0.2,
..Default::default()
},
)
.unwrap(); let d = engine
.upsert_node(
"Person",
"d",
UpsertNodeOptions {
weight: 0.9,
..Default::default()
},
)
.unwrap();
engine
.upsert_edge(a, b, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
engine
.upsert_edge(b, c, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
engine
.upsert_edge(b, d, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
engine
.set_prune_policy(
"p",
PrunePolicy {
max_age_ms: None,
max_weight: Some(0.5),
label: None,
},
)
.unwrap();
let result = traverse_depth_two_read(&engine, a, Direction::Outgoing, None, None, None);
let result_ids: Vec<u64> = result.iter().map(|e| e.node_id).collect();
assert!(result_ids.contains(&d));
assert!(!result_ids.contains(&c));
engine.close().unwrap();
}
#[test]
fn test_read_time_policy_top_k() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let mut opts = DbOptions::default();
opts.wal_sync_mode = WalSyncMode::Immediate;
opts.compact_after_n_flushes = 0;
let engine = DatabaseEngine::open(&db_path, &opts).unwrap();
let hub = engine
.upsert_node(
"Person",
"hub",
UpsertNodeOptions {
weight: 0.9,
..Default::default()
},
)
.unwrap();
let hi = engine
.upsert_node(
"Person",
"hi",
UpsertNodeOptions {
weight: 0.8,
..Default::default()
},
)
.unwrap();
let lo = engine
.upsert_node(
"Person",
"lo",
UpsertNodeOptions {
weight: 0.2,
..Default::default()
},
)
.unwrap();
engine
.upsert_edge(
hub,
hi,
"RELATES_TO",
UpsertEdgeOptions {
weight: 5.0,
..Default::default()
},
)
.unwrap();
engine
.upsert_edge(
hub,
lo,
"RELATES_TO",
UpsertEdgeOptions {
weight: 10.0,
..Default::default()
},
)
.unwrap();
engine
.set_prune_policy(
"p",
PrunePolicy {
max_age_ms: None,
max_weight: Some(0.5),
label: None,
},
)
.unwrap();
let result = engine
.top_k_neighbors(hub, 2, &TopKOptions::default())
.unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].node_id, hi);
engine.close().unwrap();
}
#[test]
fn test_read_time_policy_extract_subgraph() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let mut opts = DbOptions::default();
opts.wal_sync_mode = WalSyncMode::Immediate;
opts.compact_after_n_flushes = 0;
let engine = DatabaseEngine::open(&db_path, &opts).unwrap();
let a = engine
.upsert_node(
"Person",
"a",
UpsertNodeOptions {
weight: 0.9,
..Default::default()
},
)
.unwrap();
let b = engine
.upsert_node(
"Person",
"b",
UpsertNodeOptions {
weight: 0.8,
..Default::default()
},
)
.unwrap();
let c = engine
.upsert_node(
"Person",
"c",
UpsertNodeOptions {
weight: 0.2,
..Default::default()
},
)
.unwrap();
engine
.upsert_edge(a, b, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
engine
.upsert_edge(a, c, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
engine
.set_prune_policy(
"p",
PrunePolicy {
max_age_ms: None,
max_weight: Some(0.5),
label: None,
},
)
.unwrap();
let sg = engine
.extract_subgraph(a, 1, &SubgraphOptions::default())
.unwrap();
let node_ids: Vec<u64> = sg.nodes.iter().map(|n| n.id).collect();
assert!(node_ids.contains(&a));
assert!(node_ids.contains(&b));
assert!(!node_ids.contains(&c));
engine.close().unwrap();
}
#[test]
fn test_read_time_policy_nodes_by_label_id() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let mut opts = DbOptions::default();
opts.wal_sync_mode = WalSyncMode::Immediate;
opts.compact_after_n_flushes = 0;
let engine = DatabaseEngine::open(&db_path, &opts).unwrap();
let a = engine
.upsert_node(
"Person",
"a",
UpsertNodeOptions {
weight: 0.2,
..Default::default()
},
)
.unwrap();
let b = engine
.upsert_node(
"Person",
"b",
UpsertNodeOptions {
weight: 0.9,
..Default::default()
},
)
.unwrap();
engine
.set_prune_policy(
"p",
PrunePolicy {
max_age_ms: None,
max_weight: Some(0.5),
label: None,
},
)
.unwrap();
let ids = engine.nodes_by_labels("Person").unwrap();
assert!(!ids.contains(&a)); assert!(ids.contains(&b));
engine.close().unwrap();
}
#[test]
fn test_read_time_policy_find_nodes() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let mut opts = DbOptions::default();
opts.wal_sync_mode = WalSyncMode::Immediate;
opts.compact_after_n_flushes = 0;
let engine = DatabaseEngine::open(&db_path, &opts).unwrap();
let mut props = BTreeMap::new();
props.insert("color".to_string(), PropValue::String("red".to_string()));
engine
.upsert_node(
"Person",
"a",
UpsertNodeOptions {
props: props.clone(),
weight: 0.2,
..Default::default()
},
)
.unwrap();
let b = engine
.upsert_node(
"Person",
"b",
UpsertNodeOptions {
props: props.clone(),
weight: 0.9,
..Default::default()
},
)
.unwrap();
engine
.set_prune_policy(
"p",
PrunePolicy {
max_age_ms: None,
max_weight: Some(0.5),
label: None,
},
)
.unwrap();
let ids = engine
.find_nodes("Person", "color", &PropValue::String("red".to_string()))
.unwrap();
assert_eq!(ids.len(), 1);
assert_eq!(ids[0], b);
engine.close().unwrap();
}
#[test]
fn test_read_time_policy_prune_still_works() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let mut opts = DbOptions::default();
opts.wal_sync_mode = WalSyncMode::Immediate;
opts.compact_after_n_flushes = 0;
let engine = DatabaseEngine::open(&db_path, &opts).unwrap();
let a = engine
.upsert_node(
"Person",
"a",
UpsertNodeOptions {
weight: 0.2,
..Default::default()
},
)
.unwrap();
let b = engine
.upsert_node(
"Person",
"b",
UpsertNodeOptions {
weight: 0.9,
..Default::default()
},
)
.unwrap();
engine
.upsert_edge(a, b, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
engine
.set_prune_policy(
"p",
PrunePolicy {
max_age_ms: None,
max_weight: Some(0.5),
label: None,
},
)
.unwrap();
assert!(engine.get_node(a).unwrap().is_none());
let result = engine
.prune(&PrunePolicy {
max_age_ms: None,
max_weight: Some(0.5),
label: None,
})
.unwrap();
assert_eq!(result.nodes_pruned, 1);
assert_eq!(result.edges_pruned, 1);
engine.remove_prune_policy("p").unwrap();
assert!(engine.get_node(a).unwrap().is_none());
engine.close().unwrap();
}
#[test]
fn test_multi_label_manual_prune_policy_membership_and_edge_cascade_once() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let mut opts = DbOptions::default();
opts.wal_sync_mode = WalSyncMode::Immediate;
opts.compact_after_n_flushes = 0;
let engine = DatabaseEngine::open(&db_path, &opts).unwrap();
let prune = engine
.upsert_node(
&["PruneMemberA", "PruneMemberB"],
"prune",
UpsertNodeOptions {
weight: 0.1,
..Default::default()
},
)
.unwrap();
let keep_same_weight = engine
.upsert_node(
"PruneMemberA",
"keep-same-weight",
UpsertNodeOptions {
weight: 0.1,
..Default::default()
},
)
.unwrap();
let keep_other = engine
.upsert_node(
"PruneOther",
"keep-other",
UpsertNodeOptions {
weight: 0.1,
..Default::default()
},
)
.unwrap();
let edge = engine
.upsert_edge(
prune,
keep_other,
"PRUNE_MEMBER_EDGE",
UpsertEdgeOptions::default(),
)
.unwrap();
let result = engine
.prune(&PrunePolicy {
max_age_ms: None,
max_weight: Some(0.5),
label: Some("PruneMemberB".to_string()),
})
.unwrap();
assert_eq!(result.nodes_pruned, 1);
assert_eq!(result.edges_pruned, 1);
assert!(engine.get_node(prune).unwrap().is_none());
assert!(engine.get_edge(edge).unwrap().is_none());
assert!(engine.get_node(keep_same_weight).unwrap().is_some());
assert!(engine.get_node(keep_other).unwrap().is_some());
engine.close().unwrap();
}
#[test]
fn test_multi_label_prune_stale_membership_suppressed_across_sources() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let mut opts = DbOptions::default();
opts.wal_sync_mode = WalSyncMode::Immediate;
opts.compact_after_n_flushes = 0;
{
let engine = DatabaseEngine::open(&db_path, &opts).unwrap();
let policy = PrunePolicy {
max_age_ms: None,
max_weight: Some(0.5),
label: Some("StalePruneLabel".to_string()),
};
engine
.upsert_node(
&["StalePruneLabel", "StaleKeepLabel"],
"active",
UpsertNodeOptions {
weight: 0.1,
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
let active_id = engine
.upsert_node(
"StaleKeepLabel",
"active",
UpsertNodeOptions {
weight: 0.1,
..Default::default()
},
)
.unwrap();
assert_eq!(engine.prune(&policy).unwrap().nodes_pruned, 0);
assert!(engine.get_node(active_id).unwrap().is_some());
engine
.upsert_node(
&["StalePruneLabel", "StaleKeepLabel"],
"immutable",
UpsertNodeOptions {
weight: 0.1,
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
let immutable_id = engine
.upsert_node(
"StaleKeepLabel",
"immutable",
UpsertNodeOptions {
weight: 0.1,
..Default::default()
},
)
.unwrap();
engine.freeze_memtable().unwrap();
assert_eq!(engine.prune(&policy).unwrap().nodes_pruned, 0);
assert!(engine.get_node(immutable_id).unwrap().is_some());
engine
.upsert_node(
&["StalePruneLabel", "StaleKeepLabel"],
"flushed",
UpsertNodeOptions {
weight: 0.1,
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
let flushed_id = engine
.upsert_node(
"StaleKeepLabel",
"flushed",
UpsertNodeOptions {
weight: 0.1,
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
assert_eq!(engine.prune(&policy).unwrap().nodes_pruned, 0);
assert!(engine.get_node(flushed_id).unwrap().is_some());
engine.close().unwrap();
}
{
let engine = DatabaseEngine::open(&db_path, &opts).unwrap();
let policy = PrunePolicy {
max_age_ms: None,
max_weight: Some(0.5),
label: Some("StalePruneLabel".to_string()),
};
assert_eq!(engine.prune(&policy).unwrap().nodes_pruned, 0);
assert!(engine
.get_node_by_key("StaleKeepLabel", "active")
.unwrap()
.is_some());
assert!(engine
.get_node_by_key("StaleKeepLabel", "immutable")
.unwrap()
.is_some());
assert!(engine
.get_node_by_key("StaleKeepLabel", "flushed")
.unwrap()
.is_some());
let stats = engine
.compact()
.unwrap()
.expect("stale prune test must exercise compacted sources");
assert!(stats.segments_merged > 1);
assert_eq!(engine.prune(&policy).unwrap().nodes_pruned, 0);
assert!(engine
.get_nodes_by_labels("StaleKeepLabel")
.unwrap()
.len()
>= 3);
engine.close().unwrap();
}
}
#[test]
fn test_read_time_policy_delete_node_cascade_unaffected() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let mut opts = DbOptions::default();
opts.wal_sync_mode = WalSyncMode::Immediate;
opts.compact_after_n_flushes = 0;
let engine = DatabaseEngine::open(&db_path, &opts).unwrap();
let a = engine
.upsert_node(
"Person",
"a",
UpsertNodeOptions {
weight: 0.9,
..Default::default()
},
)
.unwrap();
let b = engine
.upsert_node(
"Person",
"b",
UpsertNodeOptions {
weight: 0.2,
..Default::default()
},
)
.unwrap(); let edge_id = engine
.upsert_edge(a, b, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
engine
.set_prune_policy(
"p",
PrunePolicy {
max_age_ms: None,
max_weight: Some(0.5),
label: None,
},
)
.unwrap();
engine.delete_node(a).unwrap();
assert!(engine.get_edge(edge_id).unwrap().is_none());
engine.close().unwrap();
}
#[test]
fn test_read_time_policy_neighbors_after_flush() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let mut opts = DbOptions::default();
opts.wal_sync_mode = WalSyncMode::Immediate;
opts.compact_after_n_flushes = 0;
let engine = DatabaseEngine::open(&db_path, &opts).unwrap();
let a = engine
.upsert_node(
"Person",
"a",
UpsertNodeOptions {
weight: 0.9,
..Default::default()
},
)
.unwrap();
let b = engine
.upsert_node(
"Person",
"b",
UpsertNodeOptions {
weight: 0.2,
..Default::default()
},
)
.unwrap();
let c = engine
.upsert_node(
"Person",
"c",
UpsertNodeOptions {
weight: 0.8,
..Default::default()
},
)
.unwrap();
engine
.upsert_edge(a, b, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
engine
.upsert_edge(a, c, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
engine.flush().unwrap();
engine
.set_prune_policy(
"p",
PrunePolicy {
max_age_ms: None,
max_weight: Some(0.5),
label: None,
},
)
.unwrap();
let result = engine.neighbors(a, &NeighborOptions::default()).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].node_id, c);
engine.close().unwrap();
}
#[test]
fn test_close_fast_basic() {
let dir = tempfile::tempdir().unwrap();
let opts = DbOptions {
wal_sync_mode: WalSyncMode::Immediate,
compact_after_n_flushes: 0,
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
let id = engine
.upsert_node("Person", "n1", UpsertNodeOptions::default())
.unwrap();
engine.close_fast().unwrap();
let engine2 = DatabaseEngine::open(dir.path(), &opts).unwrap();
assert!(engine2.get_node(id).unwrap().is_some());
engine2.close().unwrap();
}
#[test]
fn test_close_fast_cancels_bg_compact() {
let dir = tempfile::tempdir().unwrap();
let opts = DbOptions {
wal_sync_mode: WalSyncMode::Immediate,
compact_after_n_flushes: 0, ..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
for i in 0..3 {
for j in 0..100 {
let key = format!("node_{}_{}", i, j);
engine
.upsert_node("Person", &key, UpsertNodeOptions::default())
.unwrap();
}
engine.flush().unwrap();
}
engine.start_bg_compact().unwrap();
assert!(engine.bg_compact_active_for_test());
engine.close_fast().unwrap();
let engine2 = DatabaseEngine::open(dir.path(), &opts).unwrap();
let stats = engine2.stats().unwrap();
assert!(engine2.get_node_by_key("Person", "node_0_0").unwrap().is_some());
assert!(engine2.get_node_by_key("Person", "node_2_99").unwrap().is_some());
assert!(stats.segment_count >= 1); engine2.close().unwrap();
}
#[test]
fn test_close_fast_group_commit() {
let dir = tempfile::tempdir().unwrap();
let opts = DbOptions {
wal_sync_mode: WalSyncMode::GroupCommit {
interval_ms: 10,
soft_trigger_bytes: 4 * 1024 * 1024,
hard_cap_bytes: 16 * 1024 * 1024,
},
compact_after_n_flushes: 0,
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
let id = engine
.upsert_node("Person", "gc_node", UpsertNodeOptions::default())
.unwrap();
engine.close_fast().unwrap();
let engine2 = DatabaseEngine::open(dir.path(), &opts).unwrap();
assert!(engine2.get_node(id).unwrap().is_some());
engine2.close().unwrap();
}
#[test]
fn test_stats_fresh_db() {
let dir = tempfile::tempdir().unwrap();
let opts = DbOptions {
wal_sync_mode: WalSyncMode::Immediate,
compact_after_n_flushes: 0,
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
let stats = engine.stats().unwrap();
assert_eq!(stats.pending_wal_bytes, 0);
assert_eq!(stats.segment_count, 0);
assert_eq!(stats.node_tombstone_count, 0);
assert_eq!(stats.edge_tombstone_count, 0);
assert!(stats.last_compaction_ms.is_none());
assert_eq!(stats.wal_sync_mode, "immediate");
assert_eq!(stats.active_memtable_bytes, 0);
assert_eq!(stats.immutable_memtable_bytes, 0);
assert_eq!(stats.immutable_memtable_count, 0);
assert_eq!(stats.pending_flush_count, 0);
assert_eq!(stats.active_wal_generation_id, 0);
assert_eq!(stats.oldest_retained_wal_generation_id, 0);
engine.close().unwrap();
}
#[test]
fn test_stats_group_commit_sync_mode() {
let dir = tempfile::tempdir().unwrap();
let opts = DbOptions {
wal_sync_mode: WalSyncMode::GroupCommit {
interval_ms: 10,
soft_trigger_bytes: 4 * 1024 * 1024,
hard_cap_bytes: 16 * 1024 * 1024,
},
compact_after_n_flushes: 0,
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
let stats = engine.stats().unwrap();
assert_eq!(stats.wal_sync_mode, "group-commit");
engine.close().unwrap();
}
#[test]
fn test_stats_segments_after_flush() {
let dir = tempfile::tempdir().unwrap();
let opts = DbOptions {
wal_sync_mode: WalSyncMode::Immediate,
compact_after_n_flushes: 0,
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
assert_eq!(engine.stats().unwrap().segment_count, 0);
engine
.upsert_node("Person", "n1", UpsertNodeOptions::default())
.unwrap();
engine.flush().unwrap();
assert_eq!(engine.stats().unwrap().segment_count, 1);
engine
.upsert_node("Person", "n2", UpsertNodeOptions::default())
.unwrap();
engine.flush().unwrap();
assert_eq!(engine.stats().unwrap().segment_count, 2);
engine.close().unwrap();
}
#[test]
fn test_stats_tombstones() {
let dir = tempfile::tempdir().unwrap();
let opts = DbOptions {
wal_sync_mode: WalSyncMode::Immediate,
compact_after_n_flushes: 0,
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
let n1 = engine
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let n2 = engine
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
engine
.upsert_edge(n1, n2, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
assert_eq!(engine.stats().unwrap().node_tombstone_count, 0);
assert_eq!(engine.stats().unwrap().edge_tombstone_count, 0);
engine.delete_node(n1).unwrap();
assert_eq!(engine.stats().unwrap().node_tombstone_count, 1);
assert_eq!(engine.stats().unwrap().edge_tombstone_count, 1);
engine.delete_node(n2).unwrap();
assert_eq!(engine.stats().unwrap().node_tombstone_count, 2);
assert_eq!(engine.stats().unwrap().edge_tombstone_count, 1);
engine.close().unwrap();
}
#[test]
fn test_stats_last_compaction_ms() {
let dir = tempfile::tempdir().unwrap();
let opts = DbOptions {
wal_sync_mode: WalSyncMode::Immediate,
compact_after_n_flushes: 0,
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
assert!(engine.stats().unwrap().last_compaction_ms.is_none());
engine
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
engine.flush().unwrap();
engine
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
engine.flush().unwrap();
let before = now_millis();
engine.compact().unwrap();
let after = now_millis();
let stats = engine.stats().unwrap();
let ts = stats
.last_compaction_ms
.expect("should have compaction timestamp");
assert!(
ts >= before && ts <= after,
"timestamp should be between before and after"
);
assert_eq!(stats.segment_count, 1);
engine.close().unwrap();
}
#[test]
fn test_stats_last_compaction_ms_bg() {
let dir = tempfile::tempdir().unwrap();
let opts = DbOptions {
wal_sync_mode: WalSyncMode::Immediate,
compact_after_n_flushes: 0,
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
engine
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
engine.flush().unwrap();
engine
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
engine.flush().unwrap();
let before = now_millis();
engine.start_bg_compact().unwrap();
engine.wait_for_bg_compact();
let after = now_millis();
let stats = engine.stats().unwrap();
let ts = stats
.last_compaction_ms
.expect("should have bg compaction timestamp");
assert!(ts >= before && ts <= after);
engine.close().unwrap();
}
#[test]
fn test_stats_pending_wal_bytes_group_commit() {
let dir = tempfile::tempdir().unwrap();
let opts = DbOptions {
wal_sync_mode: WalSyncMode::GroupCommit {
interval_ms: 5_000, soft_trigger_bytes: 100 * 1024 * 1024, hard_cap_bytes: 200 * 1024 * 1024,
},
compact_after_n_flushes: 0,
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
assert_eq!(engine.stats().unwrap().pending_wal_bytes, 0);
engine
.upsert_node("Person", "buffered", UpsertNodeOptions::default())
.unwrap();
let stats = engine.stats().unwrap();
assert!(stats.pending_wal_bytes > 0, "should have buffered bytes");
engine.close().unwrap();
}
#[test]
fn test_stats_immutable_memtable_fields() {
let dir = tempfile::tempdir().unwrap();
let opts = DbOptions {
wal_sync_mode: WalSyncMode::Immediate,
compact_after_n_flushes: 0,
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
engine
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
engine
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
let stats = engine.stats().unwrap();
assert!(
stats.active_memtable_bytes > 0,
"active memtable should have data"
);
assert_eq!(stats.immutable_memtable_count, 0);
assert_eq!(stats.immutable_memtable_bytes, 0);
assert_eq!(stats.active_wal_generation_id, 0);
engine.freeze_memtable().unwrap();
let stats = engine.stats().unwrap();
assert_eq!(stats.immutable_memtable_count, 1);
assert!(
stats.immutable_memtable_bytes > 0,
"immutable memtable should have bytes after freeze"
);
assert_eq!(
stats.active_wal_generation_id, 1,
"active WAL gen should advance after freeze"
);
assert_eq!(
stats.oldest_retained_wal_generation_id, 0,
"oldest retained gen should be the frozen gen"
);
engine
.upsert_node("Person", "c", UpsertNodeOptions::default())
.unwrap();
engine.freeze_memtable().unwrap();
let stats = engine.stats().unwrap();
assert_eq!(stats.immutable_memtable_count, 2);
assert_eq!(stats.active_wal_generation_id, 2);
assert_eq!(stats.oldest_retained_wal_generation_id, 0);
engine.flush().unwrap();
let stats = engine.stats().unwrap();
assert_eq!(stats.immutable_memtable_count, 0);
assert_eq!(stats.immutable_memtable_bytes, 0);
assert_eq!(stats.pending_flush_count, 0);
assert!(stats.segment_count >= 1);
assert_eq!(
stats.oldest_retained_wal_generation_id,
stats.active_wal_generation_id
);
engine.close().unwrap();
}
#[test]
fn test_v3_planner_basic_winner_selection() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
compact_after_n_flushes: 0,
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
engine
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
engine
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
engine
.upsert_node("Person", "c", UpsertNodeOptions::default())
.unwrap();
engine.flush().unwrap();
engine
.upsert_node(
"Person",
"a",
UpsertNodeOptions {
weight: 2.0,
..Default::default()
},
)
.unwrap();
engine
.upsert_node("Person", "d", UpsertNodeOptions::default())
.unwrap();
engine.flush().unwrap();
assert_eq!(engine.segments_for_test().len(), 2);
let stats = engine.compact().unwrap().unwrap();
assert_eq!(stats.segments_merged, 2);
assert_eq!(stats.nodes_kept, 4);
let n = engine.get_node_by_key("Person", "a").unwrap().unwrap();
assert_eq!(n.weight, 2.0);
engine.close().unwrap();
}
#[test]
fn test_v3_planner_tombstone_handling() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
compact_after_n_flushes: 0,
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
let id1 = engine
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let id2 = engine
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
engine.flush().unwrap();
engine.delete_node(id1).unwrap();
engine.flush().unwrap();
let stats = engine.compact().unwrap().unwrap();
assert_eq!(stats.nodes_kept, 1);
assert_eq!(stats.nodes_removed, 1);
assert!(engine.get_node(id1).unwrap().is_none());
assert!(engine.get_node(id2).unwrap().is_some());
engine.close().unwrap();
}
#[test]
fn test_v3_planner_edge_cascade_on_tombstone() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
compact_after_n_flushes: 0,
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
let n1 = engine
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let n2 = engine
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
let n3 = engine
.upsert_node("Person", "c", UpsertNodeOptions::default())
.unwrap();
let e1 = engine
.upsert_edge(n1, n2, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
let e2 = engine
.upsert_edge(n2, n3, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
engine.flush().unwrap();
engine.delete_node(n2).unwrap();
engine.flush().unwrap();
let stats = engine.compact().unwrap().unwrap();
assert_eq!(stats.nodes_kept, 2); assert_eq!(stats.edges_kept, 0);
assert!(engine.get_edge(e1).unwrap().is_none());
assert!(engine.get_edge(e2).unwrap().is_none());
engine.close().unwrap();
}
#[test]
fn test_v3_planner_prune_policy_from_metadata() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
compact_after_n_flushes: 0,
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
engine
.upsert_node(
"Person",
"low_weight",
UpsertNodeOptions {
weight: 0.1,
..Default::default()
},
)
.unwrap();
engine
.upsert_node(
"Person",
"high_weight",
UpsertNodeOptions {
weight: 5.0,
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
engine
.upsert_node(
"Person",
"high_weight",
UpsertNodeOptions {
weight: 5.0,
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
engine
.set_prune_policy(
"low_weight",
PrunePolicy {
max_age_ms: None,
max_weight: Some(0.5),
label: None,
},
)
.unwrap();
let stats = engine.compact().unwrap().unwrap();
assert_eq!(stats.nodes_kept, 1);
assert_eq!(stats.nodes_auto_pruned, 1);
let node = engine.get_node_by_key("Person", "high_weight").unwrap();
assert!(node.is_some());
let node = engine.get_node_by_key("Person", "low_weight").unwrap();
assert!(node.is_none());
engine.close().unwrap();
}
#[test]
fn test_v3_planner_prune_policy_edge_cascade() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
compact_after_n_flushes: 0,
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
let n1 = engine
.upsert_node(
"Person",
"keep",
UpsertNodeOptions {
weight: 5.0,
..Default::default()
},
)
.unwrap();
let n2 = engine
.upsert_node(
"Person",
"prune_me",
UpsertNodeOptions {
weight: 0.1,
..Default::default()
},
)
.unwrap();
let n3 = engine
.upsert_node(
"Person",
"also_keep",
UpsertNodeOptions {
weight: 5.0,
..Default::default()
},
)
.unwrap();
let _e1 = engine
.upsert_edge(n1, n2, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
let e2 = engine
.upsert_edge(n1, n3, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
engine.flush().unwrap();
engine
.upsert_node(
"Person",
"keep",
UpsertNodeOptions {
weight: 5.0,
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
engine
.set_prune_policy(
"low",
PrunePolicy {
max_age_ms: None,
max_weight: Some(0.5),
label: None,
},
)
.unwrap();
let stats = engine.compact().unwrap().unwrap();
assert_eq!(stats.nodes_auto_pruned, 1);
assert_eq!(stats.edges_auto_pruned, 1); assert_eq!(stats.edges_kept, 1);
assert!(engine.get_edge(e2).unwrap().is_some());
engine.close().unwrap();
}
#[test]
fn test_v3_planner_prune_policy_or_semantics() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
compact_after_n_flushes: 0,
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
engine
.upsert_node(
"Person",
"t1_low",
UpsertNodeOptions {
weight: 0.1,
..Default::default()
},
)
.unwrap();
engine
.upsert_node(
"Person",
"t1_high",
UpsertNodeOptions {
weight: 5.0,
..Default::default()
},
)
.unwrap();
engine
.upsert_node(
"Company",
"t2_low",
UpsertNodeOptions {
weight: 0.1,
..Default::default()
},
)
.unwrap();
engine
.upsert_node(
"Article",
"t3_safe",
UpsertNodeOptions {
weight: 5.0,
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
engine
.upsert_node(
"Person",
"t1_high",
UpsertNodeOptions {
weight: 5.0,
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
engine
.set_prune_policy(
"label1_low",
PrunePolicy {
max_age_ms: None,
max_weight: Some(0.5),
label: Some("Person".to_string()),
},
)
.unwrap();
engine
.set_prune_policy(
"label2_low",
PrunePolicy {
max_age_ms: None,
max_weight: Some(0.5),
label: Some("Company".to_string()),
},
)
.unwrap();
let stats = engine.compact().unwrap().unwrap();
assert_eq!(stats.nodes_auto_pruned, 2); assert_eq!(stats.nodes_kept, 2);
engine.close().unwrap();
}
#[test]
fn test_v3_planner_overlapping_multi_segment() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
compact_after_n_flushes: 0,
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
for i in 0..50 {
engine
.upsert_node("Person", &format!("node_{}", i), UpsertNodeOptions::default())
.unwrap();
}
engine.flush().unwrap();
for i in 10..30 {
engine
.upsert_node(
"Person",
&format!("node_{}", i),
UpsertNodeOptions {
weight: 2.0,
..Default::default()
},
)
.unwrap();
}
engine.flush().unwrap();
for i in 40..50 {
let n = engine
.get_node_by_key("Person", &format!("node_{}", i))
.unwrap()
.unwrap();
engine.delete_node(n.id).unwrap();
}
engine.flush().unwrap();
let stats = engine.compact().unwrap().unwrap();
assert_eq!(stats.nodes_kept, 40); assert_eq!(stats.segments_merged, 3);
for i in 10..30 {
let n = engine
.get_node_by_key("Person", &format!("node_{}", i))
.unwrap()
.unwrap();
assert_eq!(n.weight, 2.0, "node_{} should have updated weight", i);
}
for i in 40..50 {
let n = engine.get_node_by_key("Person", &format!("node_{}", i)).unwrap();
assert!(n.is_none(), "node_{} should be deleted", i);
}
engine.close().unwrap();
}
#[test]
fn test_v3_compact_preserves_edges_across_segments() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
compact_after_n_flushes: 0,
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
let n1 = engine
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let n2 = engine
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
engine.flush().unwrap();
let e1 = engine
.upsert_edge(n1, n2, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
engine.flush().unwrap();
let stats = engine.compact().unwrap().unwrap();
assert_eq!(stats.nodes_kept, 2);
assert_eq!(stats.edges_kept, 1);
let edge = engine.get_edge(e1).unwrap().unwrap();
assert_eq!(edge.from, n1);
assert_eq!(edge.to, n2);
let nbrs = engine
.neighbors(
n1,
&NeighborOptions {
limit: Some(100),
..Default::default()
},
)
.unwrap();
assert_eq!(nbrs.len(), 1);
assert_eq!(nbrs[0].node_id, n2);
engine.close().unwrap();
}
#[test]
fn test_v3_compact_reopen_durability() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
compact_after_n_flushes: 0,
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
for i in 0..100 {
engine
.upsert_node("Person", &format!("n{}", i), UpsertNodeOptions::default())
.unwrap();
}
engine.flush().unwrap();
for i in 50..100 {
engine
.upsert_node(
"Person",
&format!("n{}", i),
UpsertNodeOptions {
weight: 2.0,
..Default::default()
},
)
.unwrap();
}
engine.flush().unwrap();
engine.compact().unwrap();
engine.close().unwrap();
let engine2 = DatabaseEngine::open(dir.path(), &opts).unwrap();
assert_eq!(engine2.segments_for_test().len(), 1);
for i in 0..100 {
let n = engine2.get_node_by_key("Person", &format!("n{}", i)).unwrap();
assert!(n.is_some(), "node n{} should exist after reopen", i);
let n = n.unwrap();
let expected_weight = if i >= 50 { 2.0 } else { 1.0 };
assert_eq!(n.weight, expected_weight, "n{} weight", i);
}
engine2.close().unwrap();
}
#[test]
fn test_vector_segment_flush_reopen_mixed_nodes() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
dense_vector: Some(DenseVectorConfig {
dimension: 3,
metric: DenseMetric::Cosine,
hnsw: HnswConfig::default(),
}),
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
let vector_node = engine
.upsert_node(
"Person",
"vector-node",
UpsertNodeOptions {
weight: 0.5,
dense_vector: Some(vec![0.1, 0.2, 0.3]),
sparse_vector: Some(vec![(3, 1.25), (8, 0.5)]),
..Default::default()
},
)
.unwrap();
let plain_node = engine
.upsert_node(
"Person",
"plain-node",
UpsertNodeOptions {
weight: 0.25,
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
engine.close().unwrap();
let reopened = DatabaseEngine::open(dir.path(), &opts).unwrap();
let vector_node = reopened.get_node(vector_node).unwrap().unwrap();
assert_eq!(vector_node.dense_vector, Some(vec![0.1, 0.2, 0.3]));
assert_eq!(vector_node.sparse_vector, Some(vec![(3, 1.25), (8, 0.5)]));
let plain_node = reopened.get_node(plain_node).unwrap().unwrap();
assert!(plain_node.dense_vector.is_none());
assert!(plain_node.sparse_vector.is_none());
reopened.close().unwrap();
}
#[test]
fn test_plain_segment_flush_reopen_v6_fast_path() {
let dir = TempDir::new().unwrap();
let engine = DatabaseEngine::open(dir.path(), &DbOptions::default()).unwrap();
let node_id = engine
.upsert_node(
"Person",
"plain-node",
UpsertNodeOptions {
weight: 0.25,
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
engine.close().unwrap();
let reopened = DatabaseEngine::open(dir.path(), &DbOptions::default()).unwrap();
let node = reopened.get_node(node_id).unwrap().unwrap();
assert!(node.dense_vector.is_none());
assert!(node.sparse_vector.is_none());
let seg_dir =
crate::segment_writer::segment_dir(dir.path(), reopened.segments_for_test()[0].segment_id);
let manifest = crate::segment_components::decode_manifest_envelope(
&std::fs::read(
seg_dir.join(crate::segment_components::SEGMENT_COMPONENT_MANIFEST_FILENAME),
)
.unwrap(),
)
.unwrap();
assert!(manifest.components.iter().all(|record| {
!matches!(
record.kind,
crate::segment_components::SegmentComponentKind::NodeVectorMetadata
| crate::segment_components::SegmentComponentKind::NodeDenseVectorBlob
| crate::segment_components::SegmentComponentKind::NodeSparseVectorBlob
)
}));
reopened.close().unwrap();
}
#[test]
fn test_vector_segments_survive_standard_compaction_reopen() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
compact_after_n_flushes: 0,
dense_vector: Some(DenseVectorConfig {
dimension: 3,
metric: DenseMetric::Cosine,
hnsw: HnswConfig::default(),
}),
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
let node_id = engine
.upsert_node(
"Person",
"vector-node",
UpsertNodeOptions {
weight: 0.5,
dense_vector: Some(vec![0.1, 0.2, 0.3]),
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
engine
.upsert_node(
"Person",
"vector-node",
UpsertNodeOptions {
weight: 0.75,
dense_vector: Some(vec![0.4, 0.5, 0.6]),
sparse_vector: Some(vec![(4, 2.0)]),
..Default::default()
},
)
.unwrap();
let sparse_only = engine
.upsert_node(
"Person",
"sparse-only",
UpsertNodeOptions {
weight: 0.9,
sparse_vector: Some(vec![(7, 1.0), (9, 0.5)]),
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
engine.compact().unwrap().unwrap();
engine.close().unwrap();
let reopened = DatabaseEngine::open(dir.path(), &opts).unwrap();
assert_eq!(reopened.segments_for_test().len(), 1);
let node = reopened.get_node(node_id).unwrap().unwrap();
assert_eq!(node.weight, 0.75);
assert_eq!(node.dense_vector, Some(vec![0.4, 0.5, 0.6]));
assert_eq!(node.sparse_vector, Some(vec![(4, 2.0)]));
let sparse_only = reopened.get_node(sparse_only).unwrap().unwrap();
assert!(sparse_only.dense_vector.is_none());
assert_eq!(sparse_only.sparse_vector, Some(vec![(7, 1.0), (9, 0.5)]));
reopened.close().unwrap();
}
#[test]
fn test_standard_compaction_clears_stale_vector_payloads() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
compact_after_n_flushes: 0,
dense_vector: Some(DenseVectorConfig {
dimension: 3,
metric: DenseMetric::Cosine,
hnsw: HnswConfig::default(),
}),
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
let node_id = engine
.upsert_node(
"Person",
"vector-node",
UpsertNodeOptions {
weight: 0.5,
dense_vector: Some(vec![0.1, 0.2, 0.3]),
sparse_vector: Some(vec![(4, 1.0)]),
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
engine
.upsert_node(
"Person",
"vector-node",
UpsertNodeOptions {
weight: 0.9,
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
engine.compact().unwrap().unwrap();
engine.close().unwrap();
let reopened = DatabaseEngine::open(dir.path(), &opts).unwrap();
let node = reopened.get_node(node_id).unwrap().unwrap();
assert!(node.dense_vector.is_none());
assert!(node.sparse_vector.is_none());
let seg_dir =
crate::segment_writer::segment_dir(dir.path(), reopened.segments_for_test()[0].segment_id);
let manifest = crate::segment_components::decode_manifest_envelope(
&std::fs::read(
seg_dir.join(crate::segment_components::SEGMENT_COMPONENT_MANIFEST_FILENAME),
)
.unwrap(),
)
.unwrap();
assert!(manifest.components.iter().all(|record| {
!matches!(
record.kind,
crate::segment_components::SegmentComponentKind::NodeVectorMetadata
| crate::segment_components::SegmentComponentKind::NodeDenseVectorBlob
| crate::segment_components::SegmentComponentKind::NodeSparseVectorBlob
)
}));
reopened.close().unwrap();
}
#[test]
fn test_vector_segments_survive_fast_merge_reopen() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
dense_vector: Some(DenseVectorConfig {
dimension: 2,
metric: DenseMetric::Cosine,
hnsw: HnswConfig::default(),
}),
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
let dense_node = engine
.upsert_node(
"Person",
"dense",
UpsertNodeOptions {
weight: 0.5,
dense_vector: Some(vec![1.0, 2.0]),
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
let sparse_node = engine
.upsert_node(
"Person",
"sparse",
UpsertNodeOptions {
weight: 0.7,
sparse_vector: Some(vec![(5, 1.5)]),
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
engine.compact().unwrap().unwrap();
engine.close().unwrap();
let reopened = DatabaseEngine::open(dir.path(), &opts).unwrap();
assert_eq!(reopened.segments_for_test().len(), 1);
let dense = reopened.get_node(dense_node).unwrap().unwrap();
assert_eq!(dense.dense_vector, Some(vec![1.0, 2.0]));
assert!(dense.sparse_vector.is_none());
let sparse = reopened.get_node(sparse_node).unwrap().unwrap();
assert!(sparse.dense_vector.is_none());
assert_eq!(sparse.sparse_vector, Some(vec![(5, 1.5)]));
reopened.close().unwrap();
}
fn dense_search_request(
query: Vec<f32>,
k: usize,
label_filter: Option<Vec<&str>>,
ef_search: Option<usize>,
) -> VectorSearchRequest {
dense_search_request_with_mode(query, k, label_filter, LabelMatchMode::Any, ef_search)
}
fn dense_search_request_with_mode(
query: Vec<f32>,
k: usize,
label_filter: Option<Vec<&str>>,
label_match_mode: LabelMatchMode,
ef_search: Option<usize>,
) -> VectorSearchRequest {
VectorSearchRequest {
mode: VectorSearchMode::Dense,
dense_query: Some(query),
sparse_query: None,
k,
label_filter: label_filter
.as_ref()
.map(|labels| read_node_label_filter(labels, label_match_mode)),
ef_search,
scope: None,
dense_weight: None,
sparse_weight: None,
fusion_mode: None,
}
}
fn sparse_search_request(
query: Vec<(u32, f32)>,
k: usize,
label_filter: Option<Vec<&str>>,
) -> VectorSearchRequest {
sparse_search_request_with_mode(query, k, label_filter, LabelMatchMode::Any)
}
fn sparse_search_request_with_mode(
query: Vec<(u32, f32)>,
k: usize,
label_filter: Option<Vec<&str>>,
label_match_mode: LabelMatchMode,
) -> VectorSearchRequest {
VectorSearchRequest {
mode: VectorSearchMode::Sparse,
dense_query: None,
sparse_query: Some(query),
k,
label_filter: label_filter
.as_ref()
.map(|labels| read_node_label_filter(labels, label_match_mode)),
ef_search: None,
scope: None,
dense_weight: None,
sparse_weight: None,
fusion_mode: None,
}
}
fn vector_search_scope(
start_node_id: u64,
max_depth: u32,
direction: Direction,
edge_label_filter: Option<Vec<&str>>,
at_epoch: Option<i64>,
) -> VectorSearchScope {
VectorSearchScope {
start_node_id,
max_depth,
direction,
edge_label_filter: edge_label_filter.map(|edge_labels| read_filter_names(&edge_labels)),
at_epoch,
}
}
fn scoped_dense_search_request(
query: Vec<f32>,
k: usize,
label_filter: Option<Vec<&str>>,
ef_search: Option<usize>,
scope: VectorSearchScope,
) -> VectorSearchRequest {
scoped_dense_search_request_with_mode(
query,
k,
label_filter,
LabelMatchMode::Any,
ef_search,
scope,
)
}
fn scoped_dense_search_request_with_mode(
query: Vec<f32>,
k: usize,
label_filter: Option<Vec<&str>>,
label_match_mode: LabelMatchMode,
ef_search: Option<usize>,
scope: VectorSearchScope,
) -> VectorSearchRequest {
VectorSearchRequest {
mode: VectorSearchMode::Dense,
dense_query: Some(query),
sparse_query: None,
k,
label_filter: label_filter
.as_ref()
.map(|labels| read_node_label_filter(labels, label_match_mode)),
ef_search,
scope: Some(scope),
dense_weight: None,
sparse_weight: None,
fusion_mode: None,
}
}
fn scoped_sparse_search_request(
query: Vec<(u32, f32)>,
k: usize,
label_filter: Option<Vec<&str>>,
scope: VectorSearchScope,
) -> VectorSearchRequest {
scoped_sparse_search_request_with_mode(query, k, label_filter, LabelMatchMode::Any, scope)
}
fn scoped_sparse_search_request_with_mode(
query: Vec<(u32, f32)>,
k: usize,
label_filter: Option<Vec<&str>>,
label_match_mode: LabelMatchMode,
scope: VectorSearchScope,
) -> VectorSearchRequest {
VectorSearchRequest {
mode: VectorSearchMode::Sparse,
dense_query: None,
sparse_query: Some(query),
k,
label_filter: label_filter
.as_ref()
.map(|labels| read_node_label_filter(labels, label_match_mode)),
ef_search: None,
scope: Some(scope),
dense_weight: None,
sparse_weight: None,
fusion_mode: None,
}
}
fn benchmark_percentile(values: &[f64], percentile: f64) -> f64 {
if values.is_empty() {
return 0.0;
}
let index = ((values.len() - 1) as f64 * percentile).round() as usize;
values[index.min(values.len() - 1)]
}
fn splitmix64(mut x: u64) -> u64 {
x = x.wrapping_add(0x9E3779B97F4A7C15);
let mut z = x;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB);
z ^ (z >> 31)
}
fn benchmark_sparse_unique_dimensions(seed: u64, dimension_count: u32, nnz: usize) -> Vec<u32> {
let mut dims = Vec::with_capacity(nnz);
let mut state = seed;
while dims.len() < nnz {
state = splitmix64(state);
let dimension_id = (state % dimension_count as u64) as u32;
if !dims.contains(&dimension_id) {
dims.push(dimension_id);
}
}
dims.sort_unstable();
dims
}
fn benchmark_clustered_sparse_vector(
dimension_count: u32,
cluster: usize,
member: usize,
cluster_count: usize,
nnz: usize,
) -> Vec<(u32, f32)> {
let anchor_seed = ((cluster as u64) << 32) ^ cluster_count as u64 ^ 0xA5A5_5A5A;
let anchor_dims = benchmark_sparse_unique_dimensions(anchor_seed, dimension_count, nnz.min(6));
let noise_dims = benchmark_sparse_unique_dimensions(
((cluster as u64) << 32) ^ member as u64 ^ 0x9E37_79B9,
dimension_count,
nnz.saturating_sub(anchor_dims.len()),
);
let mut values = Vec::with_capacity(nnz);
for (index, dimension_id) in anchor_dims.into_iter().enumerate() {
values.push((dimension_id, 1.2 - index as f32 * 0.12));
}
for (index, dimension_id) in noise_dims.into_iter().enumerate() {
values.push((dimension_id, 0.35 - index as f32 * 0.02));
}
values.sort_unstable_by_key(|&(dimension_id, _)| dimension_id);
values
}
fn benchmark_clustered_sparse_query(
dimension_count: u32,
cluster: usize,
query_idx: usize,
cluster_count: usize,
nnz: usize,
) -> Vec<(u32, f32)> {
let mut query = benchmark_clustered_sparse_vector(
dimension_count,
cluster,
query_idx + 100_000,
cluster_count,
nnz,
);
if query_idx % 2 == 1 {
let adjacent = benchmark_clustered_sparse_vector(
dimension_count,
(cluster + 1) % cluster_count,
query_idx + 200_000,
cluster_count,
nnz,
);
for (index, (_, weight)) in query.iter_mut().enumerate() {
*weight *= if index < 4 { 0.72 } else { 0.88 };
}
for (dimension_id, weight) in adjacent.into_iter().take(4) {
if let Some((_, existing_weight)) = query
.iter_mut()
.find(|(existing_dim, _)| *existing_dim == dimension_id)
{
*existing_weight += weight * 0.28;
} else {
query.push((dimension_id, weight * 0.28));
}
}
query.sort_unstable_by_key(|&(dimension_id, _)| dimension_id);
}
query
}
fn benchmark_uniform_sparse_vector(dimension_count: u32, seed: u64, nnz: usize) -> Vec<(u32, f32)> {
benchmark_sparse_unique_dimensions(seed, dimension_count, nnz)
.into_iter()
.enumerate()
.map(|(index, dimension_id)| {
let weight_seed = splitmix64(seed ^ ((index as u64 + 1) * 0x9E37_79B9));
let weight = 0.2 + ((weight_seed >> 40) as f32 / 16_777_215.0) * 1.1;
(dimension_id, weight)
})
.collect()
}
fn benchmark_scale_sparse_vector(values: &[(u32, f32)], scale: f32) -> Vec<(u32, f32)> {
values
.iter()
.map(|&(dimension_id, weight)| (dimension_id, weight * scale))
.collect()
}
fn benchmark_clustered_sparse_inputs(
cluster_count: usize,
points_per_cluster: usize,
dimension_count: u32,
nnz: usize,
) -> Vec<NodeInput> {
(0..cluster_count)
.flat_map(|cluster| {
(0..points_per_cluster).map(move |member| NodeInput {
labels: vec!["Person".to_string()],
key: format!("sc{cluster}_n{member}"),
props: BTreeMap::new(),
weight: 1.0,
dense_vector: None,
sparse_vector: Some(benchmark_clustered_sparse_vector(
dimension_count,
cluster,
member,
cluster_count,
nnz,
)),
})
})
.collect()
}
fn benchmark_uniform_sparse_inputs(
count: usize,
dimension_count: u32,
nnz: usize,
) -> Vec<NodeInput> {
(0..count)
.map(|index| NodeInput {
labels: vec!["Person".to_string()],
key: format!("su{index}"),
props: BTreeMap::new(),
weight: 1.0,
dense_vector: None,
sparse_vector: Some(benchmark_uniform_sparse_vector(
dimension_count,
(index as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15),
nnz,
)),
})
.collect()
}
fn benchmark_sparse_multisegment_inputs_a(
count: usize,
dimension_count: u32,
cluster_count: usize,
nnz: usize,
) -> Vec<NodeInput> {
let mut inputs = Vec::with_capacity(count * 3);
for i in 0..count {
inputs.push(NodeInput {
labels: vec!["Person".to_string()],
key: format!("shared_{i}"),
props: BTreeMap::new(),
weight: 1.0,
dense_vector: None,
sparse_vector: Some(benchmark_clustered_sparse_vector(
dimension_count,
3,
i,
cluster_count,
nnz,
)),
});
inputs.push(NodeInput {
labels: vec!["Person".to_string()],
key: format!("stable_a_{i}"),
props: BTreeMap::new(),
weight: 1.0,
dense_vector: None,
sparse_vector: Some(benchmark_clustered_sparse_vector(
dimension_count,
7,
i,
cluster_count,
nnz,
)),
});
inputs.push(NodeInput {
labels: vec!["Company".to_string()],
key: format!("other_label_{i}"),
props: BTreeMap::new(),
weight: 1.0,
dense_vector: None,
sparse_vector: Some(benchmark_clustered_sparse_vector(
dimension_count,
3,
i + 10_000,
cluster_count,
nnz,
)),
});
}
inputs
}
fn benchmark_sparse_multisegment_inputs_b(
count: usize,
dimension_count: u32,
cluster_count: usize,
nnz: usize,
) -> Vec<NodeInput> {
let mut inputs = Vec::with_capacity(count * 2);
for i in 0..count {
let shared =
benchmark_clustered_sparse_vector(dimension_count, 3, i + 50_000, cluster_count, nnz);
inputs.push(NodeInput {
labels: vec!["Person".to_string()],
key: format!("shared_{i}"),
props: BTreeMap::new(),
weight: 1.0,
dense_vector: None,
sparse_vector: Some(benchmark_scale_sparse_vector(&shared, 1.15)),
});
inputs.push(NodeInput {
labels: vec!["Person".to_string()],
key: format!("stable_b_{i}"),
props: BTreeMap::new(),
weight: 1.0,
dense_vector: None,
sparse_vector: Some(benchmark_clustered_sparse_vector(
dimension_count,
11,
i,
cluster_count,
nnz,
)),
});
}
inputs
}
fn benchmark_sparse_overlap_segment_inputs(
segment_index: usize,
count: usize,
dimension_count: u32,
cluster_count: usize,
nnz: usize,
) -> Vec<NodeInput> {
let mut inputs = Vec::with_capacity(count * 2);
for i in 0..count {
let shared = benchmark_clustered_sparse_vector(
dimension_count,
5,
segment_index * 10_000 + i,
cluster_count,
nnz,
);
inputs.push(NodeInput {
labels: vec!["Person".to_string()],
key: format!("shared_{i}"),
props: BTreeMap::new(),
weight: 1.0,
dense_vector: None,
sparse_vector: Some(benchmark_scale_sparse_vector(
&shared,
1.0 + segment_index as f32 * 0.08,
)),
});
inputs.push(NodeInput {
labels: vec!["Person".to_string()],
key: format!("stable_{segment_index}_{i}"),
props: BTreeMap::new(),
weight: 1.0,
dense_vector: None,
sparse_vector: Some(benchmark_clustered_sparse_vector(
dimension_count,
9 + segment_index,
i,
cluster_count,
nnz,
)),
});
}
inputs
}
fn assert_vector_hits_match(actual: &[VectorHit], expected: &[VectorHit]) {
assert_eq!(actual.len(), expected.len());
for (actual_hit, expected_hit) in actual.iter().zip(expected.iter()) {
assert_eq!(actual_hit.node_id, expected_hit.node_id);
assert!(
(actual_hit.score - expected_hit.score).abs() < 1e-6,
"score mismatch for node {}: actual={} expected={}",
actual_hit.node_id,
actual_hit.score,
expected_hit.score
);
}
}
fn rewrite_segment_component_payload_for_test(path: &Path, rewrite: impl FnOnce(&mut [u8])) {
use std::io::{Seek, SeekFrom, Write};
let data = std::fs::read(path).unwrap();
let range = if data.len() >= crate::segment_components::COMPONENT_IDENTITY_HEADER_LEN
&& data[0..crate::segment_components::COMPONENT_IDENTITY_HEADER_MAGIC.len()]
== crate::segment_components::COMPONENT_IDENTITY_HEADER_MAGIC
{
let header = crate::segment_components::decode_identity_header(&data).unwrap();
let start = header.payload_offset as usize;
let end = start + header.payload_len as usize;
start..end
} else {
0..data.len()
};
let mut payload = data[range.clone()].to_vec();
rewrite(&mut payload);
let mut file = std::fs::OpenOptions::new()
.write(true)
.open(path)
.unwrap();
file.seek(SeekFrom::Start(range.start as u64)).unwrap();
file.write_all(&payload).unwrap();
file.sync_all().unwrap();
}
#[test]
fn test_vector_search_dense_rejects_missing_query_and_wrong_dimension() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
dense_vector: Some(DenseVectorConfig {
dimension: 3,
metric: DenseMetric::Cosine,
hnsw: HnswConfig::default(),
}),
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
let err = engine
.vector_search(&VectorSearchRequest {
mode: VectorSearchMode::Dense,
dense_query: None,
sparse_query: None,
k: 5,
label_filter: None,
ef_search: None,
scope: None,
dense_weight: None,
sparse_weight: None,
fusion_mode: None,
})
.unwrap_err();
assert!(err.to_string().contains("requires dense_query"));
let err = engine
.vector_search(&dense_search_request(vec![0.1, 0.2], 5, None, None))
.unwrap_err();
assert!(err
.to_string()
.contains("does not match configured dimension"));
let err = engine
.vector_search(&dense_search_request(vec![0.1, 0.2, 0.3], 5, None, Some(0)))
.unwrap_err();
assert!(err.to_string().contains("ef_search must be > 0"));
}
#[test]
fn test_vector_search_dense_empty_when_unconfigured_or_no_vectors() {
let dir = TempDir::new().unwrap();
let engine = DatabaseEngine::open(dir.path(), &DbOptions::default()).unwrap();
assert!(engine
.vector_search(&dense_search_request(vec![1.0, 0.0], 5, None, None))
.unwrap()
.is_empty());
engine.close().unwrap();
let dir = TempDir::new().unwrap();
let opts = DbOptions {
dense_vector: Some(DenseVectorConfig {
dimension: 2,
metric: DenseMetric::Cosine,
hnsw: HnswConfig::default(),
}),
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
engine
.upsert_node(
"Person",
"plain",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
assert!(engine
.vector_search(&dense_search_request(vec![1.0, 0.0], 5, None, None))
.unwrap()
.is_empty());
engine.close().unwrap();
}
#[test]
fn test_vector_search_rejects_unimplemented_modes() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
dense_vector: Some(DenseVectorConfig {
dimension: 2,
metric: DenseMetric::Cosine,
hnsw: HnswConfig::default(),
}),
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
let result = engine
.vector_search(&VectorSearchRequest {
mode: VectorSearchMode::Dense,
dense_query: Some(vec![1.0, 0.0]),
sparse_query: None,
k: 5,
label_filter: None,
ef_search: None,
scope: Some(VectorSearchScope {
start_node_id: 1,
max_depth: 1,
direction: Direction::Outgoing,
edge_label_filter: None,
at_epoch: None,
}),
dense_weight: None,
sparse_weight: None,
fusion_mode: None,
})
.unwrap();
assert!(result.is_empty());
let err = engine
.vector_search(&VectorSearchRequest {
mode: VectorSearchMode::Sparse,
dense_query: None,
sparse_query: None,
k: 5,
label_filter: None,
ef_search: None,
scope: None,
dense_weight: None,
sparse_weight: None,
fusion_mode: None,
})
.unwrap_err();
assert!(err.to_string().contains("requires sparse_query"));
let err = engine
.vector_search(&VectorSearchRequest {
mode: VectorSearchMode::Hybrid,
dense_query: None,
sparse_query: None,
k: 5,
label_filter: None,
ef_search: None,
scope: None,
dense_weight: None,
sparse_weight: None,
fusion_mode: None,
})
.unwrap_err();
assert!(err.to_string().contains("requires at least one"));
}
#[test]
fn test_vector_search_sparse_empty_when_no_sparse_vectors() {
let dir = TempDir::new().unwrap();
let engine = DatabaseEngine::open(dir.path(), &DbOptions::default()).unwrap();
assert!(engine
.vector_search(&sparse_search_request(vec![(1, 1.0)], 5, None))
.unwrap()
.is_empty());
engine.close().unwrap();
let dir = TempDir::new().unwrap();
let engine = DatabaseEngine::open(dir.path(), &DbOptions::default()).unwrap();
engine
.upsert_node(
"Person",
"plain",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
assert!(engine
.vector_search(&sparse_search_request(vec![(1, 1.0)], 5, None))
.unwrap()
.is_empty());
engine.close().unwrap();
}
#[test]
fn test_vector_search_sparse_rejects_negative_query_weights() {
let dir = TempDir::new().unwrap();
let engine = DatabaseEngine::open(dir.path(), &DbOptions::default()).unwrap();
let err = engine
.vector_search(&sparse_search_request(vec![(1, -1.0)], 5, None))
.unwrap_err();
assert!(err
.to_string()
.contains("sparse vector weights must be non-negative"));
engine.close().unwrap();
}
#[test]
fn test_upsert_node_rejects_negative_sparse_weights() {
let dir = TempDir::new().unwrap();
let engine = DatabaseEngine::open(dir.path(), &DbOptions::default()).unwrap();
let err = engine
.upsert_node(
"Person",
"bad-sparse",
UpsertNodeOptions {
weight: 0.5,
sparse_vector: Some(vec![(1, -0.5)]),
..Default::default()
},
)
.unwrap_err();
assert!(err
.to_string()
.contains("sparse vector weights must be non-negative"));
}
#[test]
fn test_vector_search_sparse_exact_ranking_and_query_canonicalization() {
let dir = TempDir::new().unwrap();
let engine = DatabaseEngine::open(dir.path(), &DbOptions::default()).unwrap();
let segment_best = engine
.upsert_node(
"Person",
"segment-best",
UpsertNodeOptions {
weight: 0.5,
sparse_vector: Some(vec![(1, 2.0), (4, 1.0)]),
..Default::default()
},
)
.unwrap();
let segment_mid = engine
.upsert_node(
"Person",
"segment-mid",
UpsertNodeOptions {
weight: 0.5,
sparse_vector: Some(vec![(1, 1.0), (7, 2.0)]),
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
let memtable_low = engine
.upsert_node(
"Person",
"memtable-low",
UpsertNodeOptions {
weight: 0.5,
sparse_vector: Some(vec![(1, 0.5), (4, 0.5)]),
..Default::default()
},
)
.unwrap();
let hits = engine
.vector_search(&sparse_search_request(
vec![(4, 1.0), (1, 1.0), (1, 2.0), (9, 0.0)],
3,
None,
))
.unwrap();
let returned_ids: Vec<u64> = hits.iter().map(|hit| hit.node_id).collect();
assert_eq!(returned_ids, vec![segment_best, segment_mid, memtable_low]);
assert!(hits[0].score > hits[1].score);
assert!(hits[1].score > hits[2].score);
}
#[test]
fn test_vector_search_sparse_zero_overlap_returns_empty() {
let dir = TempDir::new().unwrap();
let engine = DatabaseEngine::open(dir.path(), &DbOptions::default()).unwrap();
engine
.upsert_node(
"Person",
"a",
UpsertNodeOptions {
weight: 0.5,
sparse_vector: Some(vec![(4, 1.0), (7, 2.0)]),
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
let hits = engine
.vector_search(&sparse_search_request(vec![(2, 1.0)], 5, None))
.unwrap();
assert!(hits.is_empty());
}
#[test]
fn test_vector_search_sparse_label_filter_deleted_and_policy_exclusion() {
let dir = TempDir::new().unwrap();
let engine = DatabaseEngine::open(dir.path(), &DbOptions::default()).unwrap();
let deleted = engine
.upsert_node(
"Person",
"deleted",
UpsertNodeOptions {
weight: 0.9,
sparse_vector: Some(vec![(3, 2.0)]),
..Default::default()
},
)
.unwrap();
let kept = engine
.upsert_node(
"Person",
"kept",
UpsertNodeOptions {
weight: 0.9,
sparse_vector: Some(vec![(3, 1.5)]),
..Default::default()
},
)
.unwrap();
let pruned = engine
.upsert_node(
"Person",
"pruned",
UpsertNodeOptions {
weight: 0.1,
sparse_vector: Some(vec![(3, 3.0)]),
..Default::default()
},
)
.unwrap();
let other_label = engine
.upsert_node(
"Company",
"other-label",
UpsertNodeOptions {
weight: 0.9,
sparse_vector: Some(vec![(3, 4.0)]),
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
engine.delete_node(deleted).unwrap();
engine
.set_prune_policy(
"hide-low",
PrunePolicy {
max_age_ms: None,
max_weight: Some(0.5),
label: None,
},
)
.unwrap();
let hits = engine
.vector_search(&sparse_search_request(vec![(3, 1.0)], 5, Some(vec!["Person"])))
.unwrap();
let returned_ids: Vec<u64> = hits.iter().map(|hit| hit.node_id).collect();
assert_eq!(returned_ids, vec![kept]);
assert!(!returned_ids.contains(&deleted));
assert!(!returned_ids.contains(&pruned));
assert!(!returned_ids.contains(&other_label));
}
#[test]
fn test_vector_search_sparse_label_filter_supports_single_any_all_multi_label() {
let dir = TempDir::new().unwrap();
let engine = DatabaseEngine::open(dir.path(), &DbOptions::default()).unwrap();
let person_employee = engine
.upsert_node(
&["Person", "Employee"],
"person-employee",
UpsertNodeOptions {
sparse_vector: Some(vec![(3, 3.0)]),
..Default::default()
},
)
.unwrap();
let employee = engine
.upsert_node(
"Employee",
"employee",
UpsertNodeOptions {
sparse_vector: Some(vec![(3, 2.0)]),
..Default::default()
},
)
.unwrap();
let person = engine
.upsert_node(
"Person",
"person",
UpsertNodeOptions {
sparse_vector: Some(vec![(3, 1.0)]),
..Default::default()
},
)
.unwrap();
let company = engine
.upsert_node(
"Company",
"company",
UpsertNodeOptions {
sparse_vector: Some(vec![(3, 4.0)]),
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
let single = engine
.vector_search(&sparse_search_request(vec![(3, 1.0)], 10, Some(vec!["Person"])))
.unwrap();
assert_eq!(
single.iter().map(|hit| hit.node_id).collect::<Vec<_>>(),
vec![person_employee, person]
);
let any = engine
.vector_search(&sparse_search_request_with_mode(
vec![(3, 1.0)],
10,
Some(vec!["Person", "Employee"]),
LabelMatchMode::Any,
))
.unwrap();
assert_eq!(
any.iter().map(|hit| hit.node_id).collect::<Vec<_>>(),
vec![person_employee, employee, person]
);
assert!(!any.iter().any(|hit| hit.node_id == company));
let all = engine
.vector_search(&sparse_search_request_with_mode(
vec![(3, 1.0)],
10,
Some(vec!["Person", "Employee"]),
LabelMatchMode::All,
))
.unwrap();
assert_eq!(all.len(), 1);
assert_eq!(all[0].node_id, person_employee);
engine.close().unwrap();
}
#[test]
fn test_vector_search_sparse_combines_shadowing_tombstones_label_filter_and_policy() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
compact_after_n_flushes: 0,
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
let stale_shared = engine
.upsert_node(
"Person",
"shared",
UpsertNodeOptions {
weight: 0.9,
sparse_vector: Some(vec![(3, 1.0)]),
..Default::default()
},
)
.unwrap();
let deleted = engine
.upsert_node(
"Person",
"deleted",
UpsertNodeOptions {
weight: 0.9,
sparse_vector: Some(vec![(3, 2.0)]),
..Default::default()
},
)
.unwrap();
let pruned = engine
.upsert_node(
"Person",
"pruned",
UpsertNodeOptions {
weight: 0.1,
sparse_vector: Some(vec![(3, 3.0)]),
..Default::default()
},
)
.unwrap();
let other_label = engine
.upsert_node(
"Company",
"other-label",
UpsertNodeOptions {
weight: 0.9,
sparse_vector: Some(vec![(3, 4.0)]),
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
let stable = engine
.upsert_node(
"Person",
"stable",
UpsertNodeOptions {
weight: 0.9,
sparse_vector: Some(vec![(3, 0.8)]),
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
engine.delete_node(deleted).unwrap();
engine
.set_prune_policy(
"hide-low",
PrunePolicy {
max_age_ms: None,
max_weight: Some(0.5),
label: None,
},
)
.unwrap();
let fresh_shared = engine
.upsert_node(
"Person",
"shared",
UpsertNodeOptions {
weight: 0.9,
sparse_vector: Some(vec![(3, 5.0)]),
..Default::default()
},
)
.unwrap();
assert_eq!(stale_shared, fresh_shared);
let hits = engine
.vector_search(&sparse_search_request(vec![(3, 1.0)], 5, Some(vec!["Person"])))
.unwrap();
let returned_ids: Vec<u64> = hits.iter().map(|hit| hit.node_id).collect();
assert_eq!(returned_ids, vec![fresh_shared, stable]);
assert!(!returned_ids.contains(&deleted));
assert!(!returned_ids.contains(&pruned));
assert!(!returned_ids.contains(&other_label));
}
#[test]
fn test_vector_search_sparse_flush_and_reopen_parity() {
let dir = TempDir::new().unwrap();
let engine = DatabaseEngine::open(dir.path(), &DbOptions::default()).unwrap();
let a = engine
.upsert_node(
"Person",
"a",
UpsertNodeOptions {
weight: 0.5,
sparse_vector: Some(vec![(2, 1.0), (5, 0.5)]),
..Default::default()
},
)
.unwrap();
let b = engine
.upsert_node(
"Person",
"b",
UpsertNodeOptions {
weight: 0.5,
sparse_vector: Some(vec![(2, 0.5), (9, 2.0)]),
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
let request = sparse_search_request(vec![(2, 2.0), (5, 1.0)], 2, None);
let before = engine.vector_search(&request).unwrap();
assert_eq!(
before.iter().map(|hit| hit.node_id).collect::<Vec<_>>(),
vec![a, b]
);
engine.close().unwrap();
let reopened = DatabaseEngine::open(dir.path(), &DbOptions::default()).unwrap();
let after = reopened.vector_search(&request).unwrap();
assert_vector_hits_match(&after, &before);
reopened.close().unwrap();
}
#[test]
fn test_vector_search_sparse_missing_postings_uses_exact_segment_fallback() {
let dir = TempDir::new().unwrap();
let engine = DatabaseEngine::open(dir.path(), &DbOptions::default()).unwrap();
let best = engine
.upsert_node(
"Person",
"best",
UpsertNodeOptions {
sparse_vector: Some(vec![(2, 1.0), (7, 0.5)]),
..Default::default()
},
)
.unwrap();
let second = engine
.upsert_node(
"Person",
"second",
UpsertNodeOptions {
sparse_vector: Some(vec![(2, 0.5)]),
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
let segment_id = engine.segments_for_test()[0].segment_id;
let seg_dir = crate::segment_writer::segment_dir(dir.path(), segment_id);
std::fs::remove_file(seg_dir.join(crate::sparse_postings::SPARSE_POSTING_INDEX_FILENAME))
.unwrap();
std::fs::remove_file(seg_dir.join(crate::sparse_postings::SPARSE_POSTINGS_FILENAME)).unwrap();
engine
.reopen_segment_reader_and_rebuild_sources_for_test(segment_id)
.unwrap();
let hits = engine
.vector_search(&sparse_search_request(vec![(2, 1.0)], 2, None))
.unwrap();
assert_eq!(
hits.iter().map(|hit| hit.node_id).collect::<Vec<_>>(),
vec![best, second]
);
assert!((hits[0].score - 1.0).abs() < 1e-6);
assert!((hits[1].score - 0.5).abs() < 1e-6);
}
#[test]
fn test_vector_search_sparse_invalid_postings_uses_exact_segment_fallback() {
let dir = TempDir::new().unwrap();
let engine = DatabaseEngine::open(dir.path(), &DbOptions::default()).unwrap();
let best = engine
.upsert_node(
"Person",
"best",
UpsertNodeOptions {
sparse_vector: Some(vec![(2, 1.0), (7, 0.5)]),
..Default::default()
},
)
.unwrap();
let second = engine
.upsert_node(
"Person",
"second",
UpsertNodeOptions {
sparse_vector: Some(vec![(2, 0.5)]),
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
let segment_id = engine.segments_for_test()[0].segment_id;
let seg_dir = crate::segment_writer::segment_dir(dir.path(), segment_id);
rewrite_segment_component_payload_for_test(
&seg_dir.join(crate::sparse_postings::SPARSE_POSTING_INDEX_FILENAME),
|index| {
index[20..24].copy_from_slice(&1u32.to_le_bytes());
index[28..36].copy_from_slice(&12u64.to_le_bytes());
},
);
engine
.reopen_segment_reader_and_rebuild_sources_for_test(segment_id)
.unwrap();
let segment = engine.segments_for_test()[0].clone();
assert!(segment.sparse_postings_available());
let hits = engine
.vector_search(&sparse_search_request(vec![(2, 1.0)], 2, None))
.unwrap();
assert_eq!(
hits.iter().map(|hit| hit.node_id).collect::<Vec<_>>(),
vec![best, second]
);
assert!((hits[0].score - 1.0).abs() < 1e-6);
assert!((hits[1].score - 0.5).abs() < 1e-6);
assert!(!segment.sparse_postings_available());
assert!(matches!(
segment.optional_component_availability_for_test(
crate::segment_components::SegmentComponentKind::SparsePostingIndex
),
crate::segment_components::ComponentAvailability::CorruptIdentity { .. }
));
}
#[test]
fn test_vector_search_sparse_runtime_posting_error_latches_and_falls_back() {
let dir = TempDir::new().unwrap();
let engine = DatabaseEngine::open(dir.path(), &DbOptions::default()).unwrap();
let best = engine
.upsert_node(
"Person",
"best",
UpsertNodeOptions {
sparse_vector: Some(vec![(2, 1.0), (7, 0.5)]),
..Default::default()
},
)
.unwrap();
let second = engine
.upsert_node(
"Person",
"second",
UpsertNodeOptions {
sparse_vector: Some(vec![(2, 0.5)]),
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
let segment_id = engine.segments_for_test()[0].segment_id;
let seg_dir = crate::segment_writer::segment_dir(dir.path(), segment_id);
rewrite_segment_component_payload_for_test(
&seg_dir.join(crate::sparse_postings::SPARSE_POSTINGS_FILENAME),
|postings| {
postings[8..12].copy_from_slice(&(-1.0f32).to_le_bytes());
},
);
engine
.reopen_segment_reader_and_rebuild_sources_for_test(segment_id)
.unwrap();
let segment = engine.segments_for_test()[0].clone();
assert!(segment.sparse_postings_available());
let hits = engine
.vector_search(&sparse_search_request(vec![(2, 1.0)], 2, None))
.unwrap();
assert_eq!(
hits.iter().map(|hit| hit.node_id).collect::<Vec<_>>(),
vec![best, second]
);
assert!(!segment.sparse_postings_available());
assert!(matches!(
segment.optional_component_availability_for_test(
crate::segment_components::SegmentComponentKind::SparsePostingIndex
),
crate::segment_components::ComponentAvailability::CorruptIdentity { .. }
));
}
#[test]
fn test_vector_search_hybrid_flush_and_reopen_parity() {
let (dir, engine, _ids) = setup_hybrid_db();
let dense_request = dense_search_request(vec![1.0, 0.0, 0.0, 0.0], 5, None, None);
let sparse_request = sparse_search_request(vec![(0, 1.0), (1, 0.5), (2, 0.3)], 5, None);
let hybrid_request = hybrid_search_request(
Some(vec![1.0, 0.0, 0.0, 0.0]),
Some(vec![(0, 1.0), (1, 0.5), (2, 0.3)]),
5,
Some(FusionMode::WeightedRankFusion),
Some(1.0),
Some(1.0),
);
let dense_before = engine.vector_search(&dense_request).unwrap();
let sparse_before = engine.vector_search(&sparse_request).unwrap();
let hybrid_before = engine.vector_search(&hybrid_request).unwrap();
engine.close().unwrap();
let reopened = DatabaseEngine::open(
dir.path(),
&DbOptions {
dense_vector: Some(DenseVectorConfig {
dimension: 4,
metric: DenseMetric::Cosine,
hnsw: HnswConfig::default(),
}),
..DbOptions::default()
},
)
.unwrap();
let dense_after = reopened.vector_search(&dense_request).unwrap();
let sparse_after = reopened.vector_search(&sparse_request).unwrap();
let hybrid_after = reopened.vector_search(&hybrid_request).unwrap();
assert_vector_hits_match(&dense_after, &dense_before);
assert_vector_hits_match(&sparse_after, &sparse_before);
assert_vector_hits_match(&hybrid_after, &hybrid_before);
reopened.close().unwrap();
}
#[test]
fn test_vector_search_sparse_newer_segment_shadows_older_segment_candidate() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
compact_after_n_flushes: 0,
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
let stale = engine
.upsert_node(
"Person",
"shared",
UpsertNodeOptions {
weight: 0.5,
sparse_vector: Some(vec![(3, 1.0)]),
..Default::default()
},
)
.unwrap();
let stable = engine
.upsert_node(
"Person",
"stable",
UpsertNodeOptions {
weight: 0.5,
sparse_vector: Some(vec![(3, 0.8)]),
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
let fresh = engine
.upsert_node(
"Person",
"shared",
UpsertNodeOptions {
weight: 0.5,
sparse_vector: Some(vec![(3, 3.0)]),
..Default::default()
},
)
.unwrap();
assert_eq!(stale, fresh);
engine.flush().unwrap();
let hits = engine
.vector_search(&sparse_search_request(vec![(3, 1.0)], 2, None))
.unwrap();
assert_eq!(hits.len(), 2);
assert_eq!(hits[0].node_id, fresh);
assert_eq!(hits[1].node_id, stable);
assert!((hits[0].score - 3.0).abs() < 1e-6);
}
#[test]
fn test_vector_search_sparse_newer_non_match_hides_older_match() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
compact_after_n_flushes: 0,
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
let stale = engine
.upsert_node(
"Person",
"shared",
UpsertNodeOptions {
weight: 0.5,
sparse_vector: Some(vec![(3, 1.0)]),
..Default::default()
},
)
.unwrap();
let stable = engine
.upsert_node(
"Person",
"stable",
UpsertNodeOptions {
weight: 0.5,
sparse_vector: Some(vec![(3, 0.75)]),
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
let fresh = engine
.upsert_node(
"Person",
"shared",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
assert_eq!(stale, fresh);
engine.flush().unwrap();
let hits = engine
.vector_search(&sparse_search_request(vec![(3, 1.0)], 5, None))
.unwrap();
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].node_id, stable);
}
#[test]
fn test_vector_search_sparse_standard_compaction_parity() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
compact_after_n_flushes: 0,
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
let stale = engine
.upsert_node(
"Person",
"shared",
UpsertNodeOptions {
weight: 0.5,
sparse_vector: Some(vec![(2, 0.5), (5, 1.0)]),
..Default::default()
},
)
.unwrap();
let stable = engine
.upsert_node(
"Person",
"stable",
UpsertNodeOptions {
weight: 0.5,
sparse_vector: Some(vec![(2, 1.0), (9, 0.5)]),
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
let fresh = engine
.upsert_node(
"Person",
"shared",
UpsertNodeOptions {
weight: 0.9,
sparse_vector: Some(vec![(2, 2.0), (5, 1.5)]),
..Default::default()
},
)
.unwrap();
let deleted = engine
.upsert_node(
"Company",
"deleted",
UpsertNodeOptions {
weight: 0.4,
sparse_vector: Some(vec![(2, 3.0)]),
..Default::default()
},
)
.unwrap();
assert_eq!(stale, fresh);
engine.flush().unwrap();
engine.delete_node(deleted).unwrap();
let request = sparse_search_request(vec![(2, 1.0), (5, 1.0)], 2, Some(vec!["Person"]));
let before = engine.vector_search(&request).unwrap();
assert_eq!(before.len(), 2);
assert_eq!(before[0].node_id, fresh);
assert_eq!(before[1].node_id, stable);
engine.compact().unwrap().unwrap();
let after_compact = engine.vector_search(&request).unwrap();
assert_vector_hits_match(&after_compact, &before);
engine.close().unwrap();
let reopened = DatabaseEngine::open(dir.path(), &opts).unwrap();
let after_reopen = reopened.vector_search(&request).unwrap();
assert_vector_hits_match(&after_reopen, &before);
reopened.close().unwrap();
}
#[test]
fn test_vector_search_sparse_fast_merge_compaction_parity() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
compact_after_n_flushes: 0,
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
let a = engine
.upsert_node(
"Person",
"a",
UpsertNodeOptions {
weight: 0.5,
sparse_vector: Some(vec![(3, 1.0), (10, 0.5)]),
..Default::default()
},
)
.unwrap();
let b = engine
.upsert_node(
"Person",
"b",
UpsertNodeOptions {
weight: 0.5,
sparse_vector: Some(vec![(3, 0.8), (11, 0.25)]),
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
let c = engine
.upsert_node(
"Person",
"c",
UpsertNodeOptions {
weight: 0.5,
sparse_vector: Some(vec![(10, 1.0), (12, 0.4)]),
..Default::default()
},
)
.unwrap();
let d = engine
.upsert_node(
"Person",
"d",
UpsertNodeOptions {
weight: 0.5,
sparse_vector: Some(vec![(12, 1.0)]),
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
let request = sparse_search_request(vec![(3, 1.0), (10, 0.75)], 3, None);
let before = engine.vector_search(&request).unwrap();
let before_ids: Vec<u64> = before.iter().map(|hit| hit.node_id).collect();
assert_eq!(before_ids, vec![a, b, c]);
assert!(!before_ids.contains(&d));
engine.compact().unwrap().unwrap();
let after_compact = engine.vector_search(&request).unwrap();
assert_vector_hits_match(&after_compact, &before);
engine.close().unwrap();
let reopened = DatabaseEngine::open(dir.path(), &opts).unwrap();
let after_reopen = reopened.vector_search(&request).unwrap();
assert_vector_hits_match(&after_reopen, &before);
reopened.close().unwrap();
}
#[test]
fn test_vector_search_sparse_background_compaction_parity_with_mixed_vectors() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
dense_vector: Some(DenseVectorConfig {
dimension: 2,
metric: DenseMetric::Cosine,
hnsw: HnswConfig::default(),
}),
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
for segment in 0..2 {
for index in 0..16 {
let key = format!("s{segment}_n{index}");
let write = if index % 2 == 0 {
UpsertNodeOptions {
weight: 0.5,
dense_vector: Some(vec![1.0, 0.0]),
sparse_vector: Some(vec![(3, 1.0), (10, 0.25 + index as f32 * 0.01)]),
..Default::default()
}
} else {
UpsertNodeOptions {
weight: 0.5,
sparse_vector: Some(vec![(3, 0.8), (11, 0.2 + index as f32 * 0.01)]),
..Default::default()
}
};
engine.upsert_node("Person", &key, write).unwrap();
}
engine.flush().unwrap();
}
let request = sparse_search_request(vec![(3, 1.0), (10, 0.5)], 5, None);
let before = engine.vector_search(&request).unwrap();
engine.start_bg_compact().unwrap();
engine.wait_for_bg_compact().expect("bg compaction");
let after_bg = engine.vector_search(&request).unwrap();
assert_vector_hits_match(&after_bg, &before);
engine.close().unwrap();
let reopened = DatabaseEngine::open(dir.path(), &opts).unwrap();
let after_reopen = reopened.vector_search(&request).unwrap();
assert_vector_hits_match(&after_reopen, &before);
reopened.close().unwrap();
}
#[test]
#[ignore = "benchmark-style sparse exact timing harness for clustered data"]
fn benchmark_vector_search_sparse_clustered_9216x12of4096() {
let dir = TempDir::new().unwrap();
let engine = DatabaseEngine::open(dir.path(), &DbOptions::default()).unwrap();
let cluster_count = 24usize;
let points_per_cluster = 384usize;
let dimension_count = 4096u32;
let nnz = 12usize;
let inputs =
benchmark_clustered_sparse_inputs(cluster_count, points_per_cluster, dimension_count, nnz);
let flush_started = std::time::Instant::now();
engine.batch_upsert_nodes(inputs).unwrap();
engine.flush().unwrap();
let flush_ms = flush_started.elapsed().as_secs_f64() * 1_000.0;
let mut search_micros = Vec::with_capacity(48);
for query_idx in 0..48usize {
let query = benchmark_clustered_sparse_query(
dimension_count,
query_idx % cluster_count,
query_idx,
cluster_count,
nnz,
);
let started = std::time::Instant::now();
let hits = engine
.vector_search(&sparse_search_request(query, 10, None))
.unwrap();
search_micros.push(started.elapsed().as_secs_f64() * 1_000_000.0);
assert!(!hits.is_empty());
}
search_micros.sort_unstable_by(|left, right| left.total_cmp(right));
println!(
"sparse_clustered_exact dataset=9216x12of4096 queries=48 k=10 flush_ms={:.2} search_p50_us={:.2} search_p95_us={:.2}",
flush_ms,
benchmark_percentile(&search_micros, 0.50),
benchmark_percentile(&search_micros, 0.95),
);
}
#[test]
#[ignore = "benchmark-style sparse exact timing harness for uniform data"]
fn benchmark_vector_search_sparse_uniform_9216x12of4096() {
let dir = TempDir::new().unwrap();
let engine = DatabaseEngine::open(dir.path(), &DbOptions::default()).unwrap();
let dimension_count = 4096u32;
let nnz = 12usize;
let inputs = benchmark_uniform_sparse_inputs(9_216, dimension_count, nnz);
let flush_started = std::time::Instant::now();
engine.batch_upsert_nodes(inputs).unwrap();
engine.flush().unwrap();
let flush_ms = flush_started.elapsed().as_secs_f64() * 1_000.0;
let mut search_micros = Vec::with_capacity(48);
for query_idx in 0..48usize {
let query = benchmark_uniform_sparse_vector(
dimension_count,
0xDEAD_BEEF_CAFE_BABE ^ query_idx as u64,
nnz,
);
let started = std::time::Instant::now();
let hits = engine
.vector_search(&sparse_search_request(query, 10, None))
.unwrap();
search_micros.push(started.elapsed().as_secs_f64() * 1_000_000.0);
if query_idx == 0 {
assert!(hits.len() <= 10);
}
}
search_micros.sort_unstable_by(|left, right| left.total_cmp(right));
println!(
"sparse_uniform_exact dataset=9216x12of4096 queries=48 k=10 flush_ms={:.2} search_p50_us={:.2} search_p95_us={:.2}",
flush_ms,
benchmark_percentile(&search_micros, 0.50),
benchmark_percentile(&search_micros, 0.95),
);
}
#[test]
#[ignore = "benchmark-style sparse exact timing harness for multisegment visibility"]
fn benchmark_vector_search_sparse_multisegment_filtered() {
let dir = TempDir::new().unwrap();
let engine = DatabaseEngine::open(
dir.path(),
&DbOptions {
compact_after_n_flushes: 0,
..DbOptions::default()
},
)
.unwrap();
let cluster_count = 24usize;
let dimension_count = 4096u32;
let nnz = 12usize;
let mut flush_ms = 0.0;
let inputs_a =
benchmark_sparse_multisegment_inputs_a(1_536, dimension_count, cluster_count, nnz);
let inputs_b =
benchmark_sparse_multisegment_inputs_b(1_536, dimension_count, cluster_count, nnz);
let started = std::time::Instant::now();
engine.batch_upsert_nodes(inputs_a).unwrap();
engine.flush().unwrap();
flush_ms += started.elapsed().as_secs_f64() * 1_000.0;
let started = std::time::Instant::now();
engine.batch_upsert_nodes(inputs_b).unwrap();
engine.flush().unwrap();
flush_ms += started.elapsed().as_secs_f64() * 1_000.0;
let mut search_micros = Vec::with_capacity(48);
for query_idx in 0..48usize {
let query =
benchmark_clustered_sparse_query(dimension_count, 3, query_idx, cluster_count, nnz);
let started = std::time::Instant::now();
let hits = engine
.vector_search(&sparse_search_request(query, 10, Some(vec!["Person"])))
.unwrap();
search_micros.push(started.elapsed().as_secs_f64() * 1_000_000.0);
assert!(hits.iter().all(|hit| hit.node_id > 0));
}
search_micros.sort_unstable_by(|left, right| left.total_cmp(right));
println!(
"sparse_multisegment_filtered dataset=2x1536_shared_plus_stable queries=48 k=10 flush_total_ms={:.2} search_p50_us={:.2} search_p95_us={:.2}",
flush_ms,
benchmark_percentile(&search_micros, 0.50),
benchmark_percentile(&search_micros, 0.95),
);
}
#[test]
#[ignore = "benchmark-style sparse build timing harness for flush and compaction"]
fn benchmark_sparse_flush_and_compaction_overlap() {
let dir = TempDir::new().unwrap();
let engine = DatabaseEngine::open(
dir.path(),
&DbOptions {
compact_after_n_flushes: 0,
..DbOptions::default()
},
)
.unwrap();
let cluster_count = 24usize;
let dimension_count = 4096u32;
let nnz = 12usize;
let mut flush_durations_ms = Vec::new();
for segment_index in 0..3usize {
let inputs = benchmark_sparse_overlap_segment_inputs(
segment_index,
1_024,
dimension_count,
cluster_count,
nnz,
);
engine.batch_upsert_nodes(inputs).unwrap();
let started = std::time::Instant::now();
engine.flush().unwrap();
flush_durations_ms.push(started.elapsed().as_secs_f64() * 1_000.0);
}
let request = sparse_search_request(
benchmark_clustered_sparse_query(dimension_count, 5, 7, cluster_count, nnz),
10,
Some(vec!["Person"]),
);
let before = engine.vector_search(&request).unwrap();
let compact_started = std::time::Instant::now();
let stats = engine
.compact()
.unwrap()
.expect("sparse compaction should run");
let compact_ms = compact_started.elapsed().as_secs_f64() * 1_000.0;
let after = engine.vector_search(&request).unwrap();
assert_vector_hits_match(&after, &before);
flush_durations_ms.sort_unstable_by(|left, right| left.total_cmp(right));
println!(
"sparse_build_overlap segments=3x2048 flush_p50_ms={:.2} flush_p95_ms={:.2} compact_ms={:.2} nodes_removed={} edges_removed={}",
benchmark_percentile(&flush_durations_ms, 0.50),
benchmark_percentile(&flush_durations_ms, 0.95),
compact_ms,
stats.nodes_removed,
stats.edges_removed,
);
}
#[test]
fn test_vector_search_dense_memtable_shadows_segment_and_collapses_duplicates() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
dense_vector: Some(DenseVectorConfig {
dimension: 2,
metric: DenseMetric::Cosine,
hnsw: HnswConfig::default(),
}),
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
let alpha = engine
.upsert_node(
"Person",
"alpha",
UpsertNodeOptions {
weight: 0.5,
dense_vector: Some(vec![0.0, 1.0]),
..Default::default()
},
)
.unwrap();
let beta = engine
.upsert_node(
"Person",
"beta",
UpsertNodeOptions {
weight: 0.4,
dense_vector: Some(vec![1.0, 0.0]),
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
let alpha_updated = engine
.upsert_node(
"Person",
"alpha",
UpsertNodeOptions {
weight: 0.7,
dense_vector: Some(vec![1.0, 1.0]),
..Default::default()
},
)
.unwrap();
assert_eq!(alpha, alpha_updated);
let hits = engine
.vector_search(&dense_search_request(vec![1.0, 1.0], 3, None, None))
.unwrap();
assert_eq!(hits.iter().filter(|hit| hit.node_id == alpha).count(), 1);
assert_eq!(hits[0].node_id, alpha);
assert_eq!(hits[1].node_id, beta);
assert!(hits[0].score > hits[1].score);
}
#[test]
fn test_vector_search_dense_newer_segment_shadows_older_segment() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
dense_vector: Some(DenseVectorConfig {
dimension: 2,
metric: DenseMetric::Cosine,
hnsw: HnswConfig::default(),
}),
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
let alpha = engine
.upsert_node(
"Person",
"alpha",
UpsertNodeOptions {
weight: 0.5,
dense_vector: Some(vec![0.0, 1.0]),
..Default::default()
},
)
.unwrap();
let beta = engine
.upsert_node(
"Person",
"beta",
UpsertNodeOptions {
weight: 0.4,
dense_vector: Some(vec![0.8, 0.2]),
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
engine
.upsert_node(
"Person",
"alpha",
UpsertNodeOptions {
weight: 0.9,
dense_vector: Some(vec![1.0, 0.0]),
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
let hits = engine
.vector_search(&dense_search_request(vec![1.0, 0.0], 2, None, None))
.unwrap();
assert_eq!(hits.len(), 2);
assert_eq!(hits[0].node_id, alpha);
assert_eq!(hits[1].node_id, beta);
assert!((hits[0].score - 1.0).abs() < 1e-6);
}
#[test]
fn test_vector_search_dense_label_filter_and_deleted_node_exclusion() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
dense_vector: Some(DenseVectorConfig {
dimension: 2,
metric: DenseMetric::Cosine,
hnsw: HnswConfig::default(),
}),
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
let deleted = engine
.upsert_node(
"Person",
"deleted",
UpsertNodeOptions {
weight: 0.5,
dense_vector: Some(vec![1.0, 0.0]),
..Default::default()
},
)
.unwrap();
let kept = engine
.upsert_node(
"Person",
"kept",
UpsertNodeOptions {
weight: 0.4,
dense_vector: Some(vec![0.9, 0.1]),
..Default::default()
},
)
.unwrap();
let other_label = engine
.upsert_node(
"Company",
"other-label",
UpsertNodeOptions {
weight: 0.3,
dense_vector: Some(vec![1.0, 0.0]),
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
engine.delete_node(deleted).unwrap();
let hits = engine
.vector_search(&dense_search_request(
vec![1.0, 0.0],
3,
Some(vec!["Person"]),
None,
))
.unwrap();
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].node_id, kept);
assert!(hits.iter().all(|hit| hit.node_id != deleted));
assert!(hits.iter().all(|hit| hit.node_id != other_label));
}
#[test]
fn test_vector_search_dense_label_filter_supports_single_any_all_multi_label() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
dense_vector: Some(DenseVectorConfig {
dimension: 2,
metric: DenseMetric::Cosine,
hnsw: HnswConfig::default(),
}),
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
let person_employee = engine
.upsert_node(
&["Person", "Employee"],
"person-employee",
UpsertNodeOptions {
dense_vector: Some(vec![1.0, 0.0]),
..Default::default()
},
)
.unwrap();
let person = engine
.upsert_node(
"Person",
"person",
UpsertNodeOptions {
dense_vector: Some(vec![0.8, 0.2]),
..Default::default()
},
)
.unwrap();
let employee = engine
.upsert_node(
"Employee",
"employee",
UpsertNodeOptions {
dense_vector: Some(vec![0.7, 0.3]),
..Default::default()
},
)
.unwrap();
let company = engine
.upsert_node(
"Company",
"company",
UpsertNodeOptions {
dense_vector: Some(vec![1.0, 0.0]),
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
let single = engine
.vector_search(&dense_search_request(
vec![1.0, 0.0],
10,
Some(vec!["Person"]),
Some(8),
))
.unwrap();
assert_eq!(
single.iter().map(|hit| hit.node_id).collect::<Vec<_>>(),
vec![person_employee, person]
);
let any = engine
.vector_search(&dense_search_request_with_mode(
vec![1.0, 0.0],
10,
Some(vec!["Person", "Employee"]),
LabelMatchMode::Any,
Some(8),
))
.unwrap();
assert_eq!(
any.iter().map(|hit| hit.node_id).collect::<Vec<_>>(),
vec![person_employee, person, employee]
);
assert!(!any.iter().any(|hit| hit.node_id == company));
let all = engine
.vector_search(&dense_search_request_with_mode(
vec![1.0, 0.0],
10,
Some(vec!["Person", "Employee"]),
LabelMatchMode::All,
Some(8),
))
.unwrap();
assert_eq!(all.len(), 1);
assert_eq!(all[0].node_id, person_employee);
engine.close().unwrap();
}
#[test]
fn test_vector_search_scoped_label_filter_supports_any_all_multi_label() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
dense_vector: Some(DenseVectorConfig {
dimension: 2,
metric: DenseMetric::Cosine,
hnsw: HnswConfig::default(),
}),
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
let start = engine
.upsert_node("Anchor", "scope-label-start", UpsertNodeOptions::default())
.unwrap();
let person_employee = engine
.upsert_node(
&["Person", "Employee"],
"scope-person-employee",
UpsertNodeOptions {
dense_vector: Some(vec![1.0, 0.0]),
sparse_vector: Some(vec![(3, 4.0)]),
..Default::default()
},
)
.unwrap();
let employee = engine
.upsert_node(
"Employee",
"scope-employee",
UpsertNodeOptions {
dense_vector: Some(vec![0.8, 0.2]),
sparse_vector: Some(vec![(3, 3.0)]),
..Default::default()
},
)
.unwrap();
let person = engine
.upsert_node(
"Person",
"scope-person",
UpsertNodeOptions {
dense_vector: Some(vec![0.7, 0.3]),
sparse_vector: Some(vec![(3, 2.0)]),
..Default::default()
},
)
.unwrap();
let company = engine
.upsert_node(
"Company",
"scope-company",
UpsertNodeOptions {
dense_vector: Some(vec![1.0, 0.0]),
sparse_vector: Some(vec![(3, 5.0)]),
..Default::default()
},
)
.unwrap();
let unreachable = engine
.upsert_node(
&["Person", "Employee"],
"scope-label-unreachable",
UpsertNodeOptions {
dense_vector: Some(vec![1.0, 0.0]),
sparse_vector: Some(vec![(3, 6.0)]),
..Default::default()
},
)
.unwrap();
let wrong_edge = engine
.upsert_node(
&["Person", "Employee"],
"scope-label-wrong-edge",
UpsertNodeOptions {
dense_vector: Some(vec![1.0, 0.0]),
sparse_vector: Some(vec![(3, 7.0)]),
..Default::default()
},
)
.unwrap();
for node_id in [person_employee, employee, person, company] {
engine
.upsert_edge(start, node_id, "KNOWS", UpsertEdgeOptions::default())
.unwrap();
}
engine
.upsert_edge(start, wrong_edge, "REPORTS_TO", UpsertEdgeOptions::default())
.unwrap();
engine.flush().unwrap();
let scope = vector_search_scope(start, 1, Direction::Outgoing, Some(vec!["KNOWS"]), None);
let dense_any = engine
.vector_search(&scoped_dense_search_request_with_mode(
vec![1.0, 0.0],
10,
Some(vec!["Person", "Employee"]),
LabelMatchMode::Any,
Some(8),
scope.clone(),
))
.unwrap();
assert_eq!(
dense_any.iter().map(|hit| hit.node_id).collect::<Vec<_>>(),
vec![person_employee, employee, person]
);
assert!(!dense_any.iter().any(|hit| hit.node_id == company));
assert!(!dense_any.iter().any(|hit| hit.node_id == unreachable));
assert!(!dense_any.iter().any(|hit| hit.node_id == wrong_edge));
let dense_all = engine
.vector_search(&scoped_dense_search_request_with_mode(
vec![1.0, 0.0],
10,
Some(vec!["Person", "Employee"]),
LabelMatchMode::All,
Some(8),
scope.clone(),
))
.unwrap();
assert_eq!(dense_all.len(), 1);
assert_eq!(dense_all[0].node_id, person_employee);
let sparse_any = engine
.vector_search(&scoped_sparse_search_request_with_mode(
vec![(3, 1.0)],
10,
Some(vec!["Person", "Employee"]),
LabelMatchMode::Any,
scope.clone(),
))
.unwrap();
assert_eq!(
sparse_any.iter().map(|hit| hit.node_id).collect::<Vec<_>>(),
vec![person_employee, employee, person]
);
assert!(!sparse_any.iter().any(|hit| hit.node_id == company));
assert!(!sparse_any.iter().any(|hit| hit.node_id == unreachable));
assert!(!sparse_any.iter().any(|hit| hit.node_id == wrong_edge));
let sparse_all = engine
.vector_search(&scoped_sparse_search_request_with_mode(
vec![(3, 1.0)],
10,
Some(vec!["Person", "Employee"]),
LabelMatchMode::All,
scope,
))
.unwrap();
assert_eq!(sparse_all.len(), 1);
assert_eq!(sparse_all[0].node_id, person_employee);
engine.close().unwrap();
}
#[test]
fn test_vector_search_dense_combines_shadowing_tombstones_label_filter_and_policy() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
dense_vector: Some(DenseVectorConfig {
dimension: 2,
metric: DenseMetric::Cosine,
hnsw: HnswConfig::default(),
}),
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
let shadowed = engine
.upsert_node(
"Person",
"shadowed",
UpsertNodeOptions {
weight: 0.9,
dense_vector: Some(vec![0.0, 1.0]),
..Default::default()
},
)
.unwrap();
let kept_a = engine
.upsert_node(
"Person",
"kept-a",
UpsertNodeOptions {
weight: 0.9,
dense_vector: Some(vec![0.9, 0.1]),
..Default::default()
},
)
.unwrap();
let kept_b = engine
.upsert_node(
"Person",
"kept-b",
UpsertNodeOptions {
weight: 0.8,
dense_vector: Some(vec![0.8, 0.2]),
..Default::default()
},
)
.unwrap();
let deleted = engine
.upsert_node(
"Person",
"deleted",
UpsertNodeOptions {
weight: 0.95,
dense_vector: Some(vec![0.98, 0.02]),
..Default::default()
},
)
.unwrap();
let pruned = engine
.upsert_node(
"Person",
"pruned",
UpsertNodeOptions {
weight: 0.1,
dense_vector: Some(vec![0.97, 0.03]),
..Default::default()
},
)
.unwrap();
let other_label = engine
.upsert_node(
"Company",
"other-label",
UpsertNodeOptions {
weight: 0.95,
dense_vector: Some(vec![1.0, 0.0]),
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
engine.delete_node(deleted).unwrap();
engine
.set_prune_policy(
"hide-low",
PrunePolicy {
max_age_ms: None,
max_weight: Some(0.5),
label: None,
},
)
.unwrap();
let shadowed_updated = engine
.upsert_node(
"Person",
"shadowed",
UpsertNodeOptions {
weight: 0.95,
dense_vector: Some(vec![1.0, 0.0]),
..Default::default()
},
)
.unwrap();
assert_eq!(shadowed, shadowed_updated);
let hits = engine
.vector_search(&dense_search_request(
vec![1.0, 0.0],
3,
Some(vec!["Person"]),
Some(8),
))
.unwrap();
let returned_ids: Vec<u64> = hits.iter().map(|hit| hit.node_id).collect();
assert_eq!(returned_ids, vec![shadowed, kept_a, kept_b]);
assert!(!returned_ids.contains(&deleted));
assert!(!returned_ids.contains(&pruned));
assert!(!returned_ids.contains(&other_label));
}
#[test]
fn test_vector_search_dense_overfetch_recovers_visible_k() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
dense_vector: Some(DenseVectorConfig {
dimension: 2,
metric: DenseMetric::Cosine,
hnsw: HnswConfig {
m: 8,
ef_construction: 64,
},
}),
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
let mut stale_ids = Vec::new();
for index in 0..9 {
let node_id = engine
.upsert_node(
"Person",
&format!("stale-{index}"),
UpsertNodeOptions {
weight: 0.5,
dense_vector: Some(vec![1.0 - index as f32 * 0.001, index as f32 * 0.001]),
..Default::default()
},
)
.unwrap();
stale_ids.push(node_id);
}
let visible_a = engine
.upsert_node(
"Person",
"visible-a",
UpsertNodeOptions {
weight: 0.5,
dense_vector: Some(vec![0.85, 0.15]),
..Default::default()
},
)
.unwrap();
let visible_b = engine
.upsert_node(
"Person",
"visible-b",
UpsertNodeOptions {
weight: 0.5,
dense_vector: Some(vec![0.75, 0.25]),
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
for index in 0..stale_ids.len() {
engine
.upsert_node(
"Person",
&format!("stale-{index}"),
UpsertNodeOptions {
weight: 0.8,
..Default::default()
},
)
.unwrap();
}
let hits = engine
.vector_search(&dense_search_request(vec![1.0, 0.0], 2, None, Some(1)))
.unwrap();
let returned_ids: Vec<u64> = hits.iter().map(|hit| hit.node_id).collect();
assert_eq!(returned_ids, vec![visible_a, visible_b]);
for stale_id in stale_ids {
assert!(!returned_ids.contains(&stale_id));
}
}
#[test]
fn test_vector_search_dense_exhausts_segments_before_returning_top_k() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
dense_vector: Some(DenseVectorConfig {
dimension: 2,
metric: DenseMetric::Cosine,
hnsw: HnswConfig {
m: 8,
ef_construction: 64,
},
}),
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
let weaker = engine
.upsert_node(
"Person",
"older-weaker",
UpsertNodeOptions {
weight: 0.5,
dense_vector: Some(vec![0.7, 0.3]),
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
for index in 0..12 {
engine
.upsert_node(
"Person",
&format!("shadowed-{index}"),
UpsertNodeOptions {
weight: 0.5,
dense_vector: Some(vec![1.0 - index as f32 * 0.001, index as f32 * 0.001]),
..Default::default()
},
)
.unwrap();
}
let hidden_better = engine
.upsert_node(
"Person",
"hidden-better",
UpsertNodeOptions {
weight: 0.5,
dense_vector: Some(vec![0.9, 0.1]),
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
for index in 0..12 {
engine
.upsert_node(
"Person",
&format!("shadowed-{index}"),
UpsertNodeOptions {
weight: 0.8,
..Default::default()
},
)
.unwrap();
}
let hits = engine
.vector_search(&dense_search_request(vec![1.0, 0.0], 1, None, None))
.unwrap();
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].node_id, hidden_better);
assert_ne!(hits[0].node_id, weaker);
}
#[test]
fn test_vector_search_dense_default_ef_search_matches_explicit_default() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
dense_vector: Some(DenseVectorConfig {
dimension: 2,
metric: DenseMetric::Cosine,
hnsw: HnswConfig::default(),
}),
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
for index in 0..128 {
engine
.upsert_node(
"Person",
&format!("n{index}"),
UpsertNodeOptions {
weight: 0.5,
dense_vector: Some(vec![1.0 - index as f32 * 0.004, index as f32 * 0.004]),
..Default::default()
},
)
.unwrap();
}
engine.flush().unwrap();
let query = vec![0.83, 0.17];
let implicit = engine
.vector_search(&dense_search_request(query.clone(), 10, None, None))
.unwrap();
let explicit = engine
.vector_search(&dense_search_request(
query,
10,
None,
Some(DEFAULT_DENSE_EF_SEARCH),
))
.unwrap();
assert_vector_hits_match(&implicit, &explicit);
}
#[test]
fn test_vector_search_dense_supports_euclidean_and_dot_product_metrics() {
let euclidean_dir = TempDir::new().unwrap();
let euclidean_opts = DbOptions {
dense_vector: Some(DenseVectorConfig {
dimension: 2,
metric: DenseMetric::Euclidean,
hnsw: HnswConfig::default(),
}),
..DbOptions::default()
};
let euclidean = DatabaseEngine::open(euclidean_dir.path(), &euclidean_opts).unwrap();
let near = euclidean
.upsert_node(
"Person",
"near",
UpsertNodeOptions {
weight: 0.5,
dense_vector: Some(vec![0.1, 0.1]),
..Default::default()
},
)
.unwrap();
let far = euclidean
.upsert_node(
"Person",
"far",
UpsertNodeOptions {
weight: 0.5,
dense_vector: Some(vec![2.0, 2.0]),
..Default::default()
},
)
.unwrap();
euclidean.flush().unwrap();
let hits = euclidean
.vector_search(&dense_search_request(vec![0.0, 0.0], 2, None, None))
.unwrap();
assert_eq!(
hits.iter().map(|hit| hit.node_id).collect::<Vec<_>>(),
vec![near, far]
);
assert!(hits[0].score > hits[1].score);
euclidean.close().unwrap();
let dot_dir = TempDir::new().unwrap();
let dot_opts = DbOptions {
dense_vector: Some(DenseVectorConfig {
dimension: 2,
metric: DenseMetric::DotProduct,
hnsw: HnswConfig::default(),
}),
..DbOptions::default()
};
let dot = DatabaseEngine::open(dot_dir.path(), &dot_opts).unwrap();
let lower = dot
.upsert_node(
"Person",
"lower",
UpsertNodeOptions {
weight: 0.5,
dense_vector: Some(vec![2.0, 1.0]),
..Default::default()
},
)
.unwrap();
let higher = dot
.upsert_node(
"Person",
"higher",
UpsertNodeOptions {
weight: 0.5,
dense_vector: Some(vec![0.0, 3.0]),
..Default::default()
},
)
.unwrap();
dot.flush().unwrap();
let hits = dot
.vector_search(&dense_search_request(vec![1.0, 2.0], 2, None, None))
.unwrap();
assert_eq!(
hits.iter().map(|hit| hit.node_id).collect::<Vec<_>>(),
vec![higher, lower]
);
assert!(hits[0].score > hits[1].score);
}
#[test]
fn test_vector_search_dense_small_graph_matches_exact_oracle() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
dense_vector: Some(DenseVectorConfig {
dimension: 2,
metric: DenseMetric::Cosine,
hnsw: HnswConfig::default(),
}),
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
let n1 = engine
.upsert_node(
"Person",
"n1",
UpsertNodeOptions {
weight: 0.5,
dense_vector: Some(vec![1.0, 0.0]),
..Default::default()
},
)
.unwrap();
let n2 = engine
.upsert_node(
"Person",
"n2",
UpsertNodeOptions {
weight: 0.5,
dense_vector: Some(vec![0.8, 0.2]),
..Default::default()
},
)
.unwrap();
let n3 = engine
.upsert_node(
"Person",
"n3",
UpsertNodeOptions {
weight: 0.5,
dense_vector: Some(vec![0.0, 1.0]),
..Default::default()
},
)
.unwrap();
let n4 = engine
.upsert_node(
"Person",
"n4",
UpsertNodeOptions {
weight: 0.5,
dense_vector: Some(vec![0.6, 0.4]),
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
let query = vec![1.0, 0.0];
let hits = engine
.vector_search(&dense_search_request(query.clone(), 3, None, None))
.unwrap();
let mut expected = vec![
VectorHit {
node_id: n1,
score: crate::dense_hnsw::dense_score(DenseMetric::Cosine, &query, &[1.0, 0.0]),
},
VectorHit {
node_id: n2,
score: crate::dense_hnsw::dense_score(DenseMetric::Cosine, &query, &[0.8, 0.2]),
},
VectorHit {
node_id: n3,
score: crate::dense_hnsw::dense_score(DenseMetric::Cosine, &query, &[0.0, 1.0]),
},
VectorHit {
node_id: n4,
score: crate::dense_hnsw::dense_score(DenseMetric::Cosine, &query, &[0.6, 0.4]),
},
];
expected.sort_unstable_by(|left, right| {
right
.score
.total_cmp(&left.score)
.then_with(|| left.node_id.cmp(&right.node_id))
});
expected.truncate(3);
assert_eq!(hits, expected);
}
#[test]
fn test_vector_search_dense_missing_hnsw_uses_exact_segment_fallback() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
dense_vector: Some(DenseVectorConfig {
dimension: 2,
metric: DenseMetric::Cosine,
hnsw: HnswConfig::default(),
}),
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
let best = engine
.upsert_node(
"Person",
"best",
UpsertNodeOptions {
dense_vector: Some(vec![1.0, 0.0]),
..Default::default()
},
)
.unwrap();
let second = engine
.upsert_node(
"Person",
"second",
UpsertNodeOptions {
dense_vector: Some(vec![0.8, 0.2]),
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
let segment_id = engine.segments_for_test()[0].segment_id;
let seg_dir = crate::segment_writer::segment_dir(dir.path(), segment_id);
std::fs::remove_file(seg_dir.join(crate::dense_hnsw::DENSE_HNSW_META_FILENAME)).unwrap();
std::fs::remove_file(seg_dir.join(crate::dense_hnsw::DENSE_HNSW_GRAPH_FILENAME)).unwrap();
engine
.reopen_segment_reader_and_rebuild_sources_for_test(segment_id)
.unwrap();
let hits = engine
.vector_search(&dense_search_request(vec![1.0, 0.0], 2, None, None))
.unwrap();
assert_eq!(
hits.iter().map(|hit| hit.node_id).collect::<Vec<_>>(),
vec![best, second]
);
}
#[test]
fn test_vector_search_dense_invalid_hnsw_uses_exact_segment_fallback() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
dense_vector: Some(DenseVectorConfig {
dimension: 2,
metric: DenseMetric::Cosine,
hnsw: HnswConfig::default(),
}),
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
let best = engine
.upsert_node(
"Person",
"best",
UpsertNodeOptions {
dense_vector: Some(vec![1.0, 0.0]),
..Default::default()
},
)
.unwrap();
let second = engine
.upsert_node(
"Person",
"second",
UpsertNodeOptions {
dense_vector: Some(vec![0.7, 0.3]),
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
let segment_id = engine.segments_for_test()[0].segment_id;
let seg_dir = crate::segment_writer::segment_dir(dir.path(), segment_id);
rewrite_segment_component_payload_for_test(
&seg_dir.join(crate::dense_hnsw::DENSE_HNSW_META_FILENAME),
|meta| {
meta[22..24].copy_from_slice(&(opts.dense_vector.as_ref().unwrap().hnsw.m + 1).to_le_bytes());
},
);
engine
.reopen_segment_reader_and_rebuild_sources_for_test(segment_id)
.unwrap();
let hits = engine
.vector_search(&dense_search_request(vec![1.0, 0.0], 2, None, None))
.unwrap();
assert_eq!(
hits.iter().map(|hit| hit.node_id).collect::<Vec<_>>(),
vec![best, second]
);
}
#[test]
fn test_vector_search_dense_runtime_hnsw_error_latches_and_falls_back() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
dense_vector: Some(DenseVectorConfig {
dimension: 2,
metric: DenseMetric::Cosine,
hnsw: HnswConfig::default(),
}),
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
let best = engine
.upsert_node(
"Person",
"best",
UpsertNodeOptions {
dense_vector: Some(vec![1.0, 0.0]),
..Default::default()
},
)
.unwrap();
let second = engine
.upsert_node(
"Person",
"second",
UpsertNodeOptions {
dense_vector: Some(vec![0.7, 0.3]),
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
let segment_id = engine.segments_for_test()[0].segment_id;
let seg_dir = crate::segment_writer::segment_dir(dir.path(), segment_id);
rewrite_segment_component_payload_for_test(
&seg_dir.join(crate::dense_hnsw::DENSE_HNSW_META_FILENAME),
|meta| {
let first_dense_offset = 36 + 8;
meta[first_dense_offset..first_dense_offset + 8]
.copy_from_slice(&u64::MAX.to_le_bytes());
},
);
engine
.reopen_segment_reader_and_rebuild_sources_for_test(segment_id)
.unwrap();
let segment = engine.segments_for_test()[0].clone();
assert!(segment.dense_hnsw_header().is_some());
let hits = engine
.vector_search(&dense_search_request(vec![1.0, 0.0], 2, None, None))
.unwrap();
assert_eq!(
hits.iter().map(|hit| hit.node_id).collect::<Vec<_>>(),
vec![best, second]
);
assert!(segment.dense_hnsw_header().is_none());
assert!(matches!(
segment.optional_component_availability_for_test(
crate::segment_components::SegmentComponentKind::DenseHnswMetadata
),
crate::segment_components::ComponentAvailability::CorruptIdentity { .. }
));
}
#[test]
fn test_vector_search_dense_standard_compaction_parity() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
compact_after_n_flushes: 0,
dense_vector: Some(DenseVectorConfig {
dimension: 2,
metric: DenseMetric::Cosine,
hnsw: HnswConfig::default(),
}),
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
let stale = engine
.upsert_node(
"Person",
"shared",
UpsertNodeOptions {
weight: 0.5,
dense_vector: Some(vec![0.7, 0.3]),
..Default::default()
},
)
.unwrap();
let stable = engine
.upsert_node(
"Person",
"stable",
UpsertNodeOptions {
weight: 0.5,
dense_vector: Some(vec![0.6, 0.4]),
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
let fresh = engine
.upsert_node(
"Person",
"shared",
UpsertNodeOptions {
weight: 0.8,
dense_vector: Some(vec![0.95, 0.05]),
..Default::default()
},
)
.unwrap();
let deleted = engine
.upsert_node(
"Company",
"deleted",
UpsertNodeOptions {
weight: 0.4,
dense_vector: Some(vec![0.9, 0.1]),
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
engine.delete_node(deleted).unwrap();
let request = dense_search_request(vec![1.0, 0.0], 2, Some(vec!["Person"]), Some(8));
let before = engine.vector_search(&request).unwrap();
assert_eq!(before.len(), 2);
assert_eq!(before[0].node_id, fresh);
assert_eq!(before[1].node_id, stable);
assert_eq!(stale, fresh);
engine.compact().unwrap().unwrap();
let after_compact = engine.vector_search(&request).unwrap();
assert_vector_hits_match(&after_compact, &before);
engine.close().unwrap();
let reopened = DatabaseEngine::open(dir.path(), &opts).unwrap();
let after_reopen = reopened.vector_search(&request).unwrap();
assert_vector_hits_match(&after_reopen, &before);
reopened.close().unwrap();
}
#[test]
fn test_vector_search_dense_fast_merge_compaction_parity() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
compact_after_n_flushes: 0,
dense_vector: Some(DenseVectorConfig {
dimension: 2,
metric: DenseMetric::Cosine,
hnsw: HnswConfig::default(),
}),
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
let a = engine
.upsert_node(
"Person",
"a",
UpsertNodeOptions {
weight: 0.5,
dense_vector: Some(vec![1.0, 0.0]),
..Default::default()
},
)
.unwrap();
let b = engine
.upsert_node(
"Person",
"b",
UpsertNodeOptions {
weight: 0.5,
dense_vector: Some(vec![0.8, 0.2]),
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
let c = engine
.upsert_node(
"Person",
"c",
UpsertNodeOptions {
weight: 0.5,
dense_vector: Some(vec![0.6, 0.4]),
..Default::default()
},
)
.unwrap();
let d = engine
.upsert_node(
"Person",
"d",
UpsertNodeOptions {
weight: 0.5,
dense_vector: Some(vec![0.2, 0.8]),
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
let request = dense_search_request(vec![1.0, 0.0], 3, None, Some(8));
let before = engine.vector_search(&request).unwrap();
let before_ids: Vec<u64> = before.iter().map(|hit| hit.node_id).collect();
assert_eq!(before_ids, vec![a, b, c]);
assert!(!before_ids.contains(&d));
engine.compact().unwrap().unwrap();
let after_compact = engine.vector_search(&request).unwrap();
assert_vector_hits_match(&after_compact, &before);
engine.close().unwrap();
let reopened = DatabaseEngine::open(dir.path(), &opts).unwrap();
let after_reopen = reopened.vector_search(&request).unwrap();
assert_vector_hits_match(&after_reopen, &before);
reopened.close().unwrap();
}
#[test]
fn test_vector_search_dense_background_compaction_parity() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
dense_vector: Some(DenseVectorConfig {
dimension: 2,
metric: DenseMetric::Cosine,
hnsw: HnswConfig::default(),
}),
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
for segment in 0..2 {
for index in 0..16 {
engine
.upsert_node(
"Person",
&format!("s{segment}_n{index}"),
UpsertNodeOptions {
weight: 0.5,
dense_vector: Some(vec![
1.0 - (segment * 16 + index) as f32 * 0.01,
(segment * 16 + index) as f32 * 0.01,
]),
..Default::default()
},
)
.unwrap();
}
engine.flush().unwrap();
}
let request = dense_search_request(vec![1.0, 0.0], 5, None, Some(8));
let before = engine.vector_search(&request).unwrap();
engine.start_bg_compact().unwrap();
engine.wait_for_bg_compact().expect("bg compaction");
let after_bg = engine.vector_search(&request).unwrap();
assert_vector_hits_match(&after_bg, &before);
engine.close().unwrap();
let reopened = DatabaseEngine::open(dir.path(), &opts).unwrap();
let after_reopen = reopened.vector_search(&request).unwrap();
assert_vector_hits_match(&after_reopen, &before);
reopened.close().unwrap();
}
#[test]
fn test_vector_search_dense_scope_combines_start_edge_filters_temporal_policy_and_shadowing() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
dense_vector: Some(DenseVectorConfig {
dimension: 2,
metric: DenseMetric::Cosine,
hnsw: HnswConfig::default(),
}),
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
let start = engine
.upsert_node(
"Person",
"scope-start",
UpsertNodeOptions {
weight: 0.9,
dense_vector: Some(vec![0.70, 0.30]),
..Default::default()
},
)
.unwrap();
let keep = engine
.upsert_node(
"Person",
"scope-keep",
UpsertNodeOptions {
weight: 0.9,
dense_vector: Some(vec![0.96, 0.04]),
..Default::default()
},
)
.unwrap();
let shadowed = engine
.upsert_node(
"Person",
"scope-shadowed",
UpsertNodeOptions {
weight: 0.9,
dense_vector: Some(vec![0.20, 0.80]),
..Default::default()
},
)
.unwrap();
let deleted = engine
.upsert_node(
"Person",
"scope-deleted",
UpsertNodeOptions {
weight: 0.9,
dense_vector: Some(vec![0.99, 0.01]),
..Default::default()
},
)
.unwrap();
let pruned = engine
.upsert_node(
"Person",
"scope-pruned",
UpsertNodeOptions {
weight: 0.1,
dense_vector: Some(vec![0.98, 0.02]),
..Default::default()
},
)
.unwrap();
let other_label = engine
.upsert_node(
"Company",
"scope-other-label",
UpsertNodeOptions {
weight: 0.95,
dense_vector: Some(vec![1.0, 0.0]),
..Default::default()
},
)
.unwrap();
let wrong_edge_label = engine
.upsert_node(
"Person",
"scope-wrong-edge",
UpsertNodeOptions {
weight: 0.9,
dense_vector: Some(vec![0.97, 0.03]),
..Default::default()
},
)
.unwrap();
let temporal_old = engine
.upsert_node(
"Person",
"scope-temporal-old",
UpsertNodeOptions {
weight: 0.9,
dense_vector: Some(vec![0.95, 0.05]),
..Default::default()
},
)
.unwrap();
let temporal_live = engine
.upsert_node(
"Person",
"scope-temporal-live",
UpsertNodeOptions {
weight: 0.9,
dense_vector: Some(vec![0.92, 0.08]),
..Default::default()
},
)
.unwrap();
let unreachable = engine
.upsert_node(
"Person",
"scope-unreachable",
UpsertNodeOptions {
weight: 0.95,
dense_vector: Some(vec![1.0, 0.0]),
..Default::default()
},
)
.unwrap();
for &node_id in &[keep, shadowed, deleted, pruned, other_label] {
engine
.upsert_edge(
start,
node_id,
"KNOWS",
UpsertEdgeOptions {
valid_from: Some(0),
valid_to: Some(10_000),
..Default::default()
},
)
.unwrap();
}
engine
.upsert_edge(
start,
wrong_edge_label,
"REPORTS_TO",
UpsertEdgeOptions {
valid_from: Some(0),
valid_to: Some(10_000),
..Default::default()
},
)
.unwrap();
engine
.upsert_edge(
start,
temporal_old,
"KNOWS",
UpsertEdgeOptions {
valid_from: Some(0),
valid_to: Some(4_000),
..Default::default()
},
)
.unwrap();
engine
.upsert_edge(
start,
temporal_live,
"KNOWS",
UpsertEdgeOptions {
valid_from: Some(4_500),
valid_to: Some(9_000),
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
let shadowed_updated = engine
.upsert_node(
"Person",
"scope-shadowed",
UpsertNodeOptions {
weight: 0.95,
dense_vector: Some(vec![0.995, 0.005]),
..Default::default()
},
)
.unwrap();
assert_eq!(shadowed, shadowed_updated);
engine.delete_node(deleted).unwrap();
engine
.set_prune_policy(
"hide-low-weight",
PrunePolicy {
max_age_ms: None,
max_weight: Some(0.5),
label: None,
},
)
.unwrap();
let request = scoped_dense_search_request(
vec![1.0, 0.0],
10,
Some(vec!["Person"]),
Some(8),
vector_search_scope(start, 1, Direction::Outgoing, Some(vec!["KNOWS"]), Some(5_000)),
);
let hits = engine.vector_search(&request).unwrap();
let returned_ids: Vec<u64> = hits.iter().map(|hit| hit.node_id).collect();
assert_eq!(returned_ids, vec![shadowed, keep, temporal_live, start]);
assert!(returned_ids.iter().all(|id| *id != deleted));
assert!(returned_ids.iter().all(|id| *id != pruned));
assert!(returned_ids.iter().all(|id| *id != other_label));
assert!(returned_ids.iter().all(|id| *id != wrong_edge_label));
assert!(returned_ids.iter().all(|id| *id != temporal_old));
assert!(returned_ids.iter().all(|id| *id != unreachable));
}
#[test]
fn test_vector_search_dense_scope_large_reachable_set_excludes_better_unreachable_nodes() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
dense_vector: Some(DenseVectorConfig {
dimension: 2,
metric: DenseMetric::Cosine,
hnsw: HnswConfig {
m: 8,
ef_construction: 64,
},
}),
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
let start = engine
.upsert_node("Person", "dense-scope-start", UpsertNodeOptions::default())
.unwrap();
let mut reachable_ids = Vec::new();
for index in 0..2055 {
let node_id = engine
.upsert_node(
"Person",
&format!("dense-scope-r-{index}"),
UpsertNodeOptions {
dense_vector: Some(vec![1.0 - index as f32 * 0.0001, index as f32 * 0.0001]),
..Default::default()
},
)
.unwrap();
engine
.upsert_edge(start, node_id, "KNOWS", UpsertEdgeOptions::default())
.unwrap();
reachable_ids.push(node_id);
}
let mut unreachable_ids = Vec::new();
for index in 0..16 {
let node_id = engine
.upsert_node(
"Person",
&format!("dense-scope-u-{index}"),
UpsertNodeOptions {
dense_vector: Some(vec![1.0, index as f32 * 0.00001]),
..Default::default()
},
)
.unwrap();
unreachable_ids.push(node_id);
}
engine.flush().unwrap();
let request = scoped_dense_search_request(
vec![1.0, 0.0],
5,
None,
Some(32),
vector_search_scope(start, 1, Direction::Outgoing, Some(vec!["KNOWS"]), None),
);
let hits = engine.vector_search(&request).unwrap();
let returned_ids: Vec<u64> = hits.iter().map(|hit| hit.node_id).collect();
assert_eq!(returned_ids, reachable_ids[..5].to_vec());
for unreachable_id in unreachable_ids {
assert!(!returned_ids.contains(&unreachable_id));
}
}
#[test]
fn test_vector_search_dense_scope_compaction_and_reopen_parity() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
compact_after_n_flushes: 0,
dense_vector: Some(DenseVectorConfig {
dimension: 2,
metric: DenseMetric::Cosine,
hnsw: HnswConfig::default(),
}),
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
let start = engine
.upsert_node(
"Person",
"dense-scope-parity-start",
UpsertNodeOptions {
weight: 0.8,
dense_vector: Some(vec![0.55, 0.45]),
..Default::default()
},
)
.unwrap();
let stable = engine
.upsert_node(
"Person",
"dense-scope-stable",
UpsertNodeOptions {
weight: 0.9,
dense_vector: Some(vec![0.9, 0.1]),
..Default::default()
},
)
.unwrap();
let shared_old = engine
.upsert_node(
"Person",
"dense-scope-shared",
UpsertNodeOptions {
weight: 0.8,
dense_vector: Some(vec![0.3, 0.7]),
..Default::default()
},
)
.unwrap();
let deleted = engine
.upsert_node(
"Person",
"dense-scope-deleted",
UpsertNodeOptions {
weight: 0.95,
dense_vector: Some(vec![0.95, 0.05]),
..Default::default()
},
)
.unwrap();
for &node_id in &[stable, shared_old, deleted] {
engine
.upsert_edge(start, node_id, "KNOWS", UpsertEdgeOptions::default())
.unwrap();
}
engine.flush().unwrap();
let shared_new = engine
.upsert_node(
"Person",
"dense-scope-shared",
UpsertNodeOptions {
weight: 0.95,
dense_vector: Some(vec![0.99, 0.01]),
..Default::default()
},
)
.unwrap();
assert_eq!(shared_old, shared_new);
engine.delete_node(deleted).unwrap();
engine.flush().unwrap();
let request = scoped_dense_search_request(
vec![1.0, 0.0],
3,
Some(vec!["Person"]),
Some(8),
vector_search_scope(start, 1, Direction::Outgoing, Some(vec!["KNOWS"]), None),
);
let before = engine.vector_search(&request).unwrap();
let before_ids: Vec<u64> = before.iter().map(|hit| hit.node_id).collect();
assert_eq!(before_ids, vec![shared_old, stable, start]);
engine.compact().unwrap().unwrap();
let after_compact = engine.vector_search(&request).unwrap();
assert_vector_hits_match(&after_compact, &before);
engine.close().unwrap();
let reopened = DatabaseEngine::open(dir.path(), &opts).unwrap();
let after_reopen = reopened.vector_search(&request).unwrap();
assert_vector_hits_match(&after_reopen, &before);
reopened.close().unwrap();
}
#[test]
fn test_vector_search_dense_four_segment_visibility_and_ordering() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
compact_after_n_flushes: 0,
dense_vector: Some(DenseVectorConfig {
dimension: 2,
metric: DenseMetric::Cosine,
hnsw: HnswConfig::default(),
}),
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
let a = engine
.upsert_node(
"Person",
"node-a",
UpsertNodeOptions {
weight: 0.9,
dense_vector: Some(vec![1.0, 0.0]),
..Default::default()
},
)
.unwrap();
let b = engine
.upsert_node(
"Person",
"node-b",
UpsertNodeOptions {
weight: 0.9,
dense_vector: Some(vec![0.8, 0.2]),
..Default::default()
},
)
.unwrap();
let c = engine
.upsert_node(
"Person",
"node-c",
UpsertNodeOptions {
weight: 0.9,
dense_vector: Some(vec![0.6, 0.4]),
..Default::default()
},
)
.unwrap();
let d = engine
.upsert_node(
"Person",
"node-d",
UpsertNodeOptions {
weight: 0.9,
dense_vector: Some(vec![0.3, 0.7]),
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
let a2 = engine
.upsert_node(
"Person",
"node-a",
UpsertNodeOptions {
weight: 0.9,
dense_vector: Some(vec![0.5, 0.5]),
..Default::default()
},
)
.unwrap();
assert_eq!(a, a2);
let e = engine
.upsert_node(
"Person",
"node-e",
UpsertNodeOptions {
weight: 0.9,
dense_vector: Some(vec![0.9, 0.1]),
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
engine.delete_node(b).unwrap();
let f = engine
.upsert_node(
"Person",
"node-f",
UpsertNodeOptions {
weight: 0.9,
dense_vector: Some(vec![0.95, 0.05]),
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
let c2 = engine
.upsert_node(
"Person",
"node-c",
UpsertNodeOptions {
weight: 0.9,
dense_vector: Some(vec![0.1, 0.9]),
..Default::default()
},
)
.unwrap();
assert_eq!(c, c2);
let _g = engine
.upsert_node(
"Company",
"node-g",
UpsertNodeOptions {
weight: 0.9,
dense_vector: Some(vec![0.7, 0.3]),
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
let query = vec![1.0, 0.0];
let hits = engine
.vector_search(&dense_search_request(query.clone(), 5, Some(vec!["Person"]), None))
.unwrap();
let mut expected: Vec<(u64, f32)> = vec![
(
f,
crate::dense_hnsw::dense_score(DenseMetric::Cosine, &query, &[0.95, 0.05]),
),
(
e,
crate::dense_hnsw::dense_score(DenseMetric::Cosine, &query, &[0.9, 0.1]),
),
(
a,
crate::dense_hnsw::dense_score(DenseMetric::Cosine, &query, &[0.5, 0.5]),
),
(
d,
crate::dense_hnsw::dense_score(DenseMetric::Cosine, &query, &[0.3, 0.7]),
),
(
c,
crate::dense_hnsw::dense_score(DenseMetric::Cosine, &query, &[0.1, 0.9]),
),
];
expected.sort_by(|(id_a, score_a), (id_b, score_b)| {
score_b.total_cmp(score_a).then_with(|| id_a.cmp(id_b))
});
assert_eq!(
hits.len(),
5,
"expected 5 results (B deleted, G filtered), got {}",
hits.len()
);
for (i, (expected_id, expected_score)) in expected.iter().enumerate() {
assert_eq!(
hits[i].node_id, *expected_id,
"hit[{i}] node_id mismatch: got {}, expected {}",
hits[i].node_id, expected_id
);
assert!(
(hits[i].score - expected_score).abs() < 1e-6,
"hit[{i}] score mismatch: got {}, expected {}",
hits[i].score,
expected_score
);
}
}
#[test]
fn test_vector_search_sparse_four_segment_visibility_and_ordering() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
compact_after_n_flushes: 0,
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
let a = engine
.upsert_node(
"Person",
"node-a",
UpsertNodeOptions {
weight: 0.9,
sparse_vector: Some(vec![(3, 1.0)]),
..Default::default()
},
)
.unwrap();
let _b = engine
.upsert_node(
"Company",
"node-b",
UpsertNodeOptions {
weight: 0.9,
sparse_vector: Some(vec![(3, 2.0)]),
..Default::default()
},
)
.unwrap();
let c = engine
.upsert_node(
"Person",
"node-c",
UpsertNodeOptions {
weight: 0.9,
sparse_vector: Some(vec![(3, 3.0)]),
..Default::default()
},
)
.unwrap();
let d = engine
.upsert_node(
"Person",
"node-d",
UpsertNodeOptions {
weight: 0.9,
sparse_vector: Some(vec![(3, 4.0)]),
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
let a2 = engine
.upsert_node(
"Person",
"node-a",
UpsertNodeOptions {
weight: 0.9,
sparse_vector: Some(vec![(3, 10.0)]),
..Default::default()
},
)
.unwrap();
assert_eq!(a, a2);
let e = engine
.upsert_node(
"Person",
"node-e",
UpsertNodeOptions {
weight: 0.9,
sparse_vector: Some(vec![(3, 0.5)]),
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
engine.delete_node(c).unwrap();
let f = engine
.upsert_node(
"Person",
"node-f",
UpsertNodeOptions {
weight: 0.9,
sparse_vector: Some(vec![(3, 1.5)]),
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
let d2 = engine
.upsert_node(
"Person",
"node-d",
UpsertNodeOptions {
weight: 0.9,
sparse_vector: Some(vec![(99, 5.0)]),
..Default::default()
},
)
.unwrap();
assert_eq!(d, d2);
let g = engine
.upsert_node(
"Person",
"node-g",
UpsertNodeOptions {
weight: 0.9,
sparse_vector: Some(vec![(3, 0.8)]),
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
let hits = engine
.vector_search(&sparse_search_request(vec![(3, 1.0)], 10, Some(vec!["Person"])))
.unwrap();
let expected: Vec<(u64, f32)> = vec![(a, 10.0), (f, 1.5), (g, 0.8), (e, 0.5)];
assert_eq!(
hits.len(),
expected.len(),
"expected {} results (B filtered, C deleted, D non-overlapping), got {}",
expected.len(),
hits.len()
);
for (i, (expected_id, expected_score)) in expected.iter().enumerate() {
assert_eq!(
hits[i].node_id, *expected_id,
"hit[{i}] node_id mismatch: got {}, expected {}",
hits[i].node_id, expected_id
);
assert!(
(hits[i].score - expected_score).abs() < 1e-6,
"hit[{i}] score mismatch: got {}, expected {}",
hits[i].score,
expected_score
);
}
}
#[test]
fn test_vector_search_sparse_scope_combines_start_edge_filters_temporal_policy_and_shadowing() {
let dir = TempDir::new().unwrap();
let engine = DatabaseEngine::open(dir.path(), &DbOptions::default()).unwrap();
let start = engine
.upsert_node(
"Person",
"scope-start",
UpsertNodeOptions {
weight: 0.9,
sparse_vector: Some(vec![(3, 0.4)]),
..Default::default()
},
)
.unwrap();
let keep = engine
.upsert_node(
"Person",
"scope-keep",
UpsertNodeOptions {
weight: 0.9,
sparse_vector: Some(vec![(3, 1.5)]),
..Default::default()
},
)
.unwrap();
let shadowed = engine
.upsert_node(
"Person",
"scope-shadowed",
UpsertNodeOptions {
weight: 0.9,
sparse_vector: Some(vec![(4, 2.0)]),
..Default::default()
},
)
.unwrap();
let deleted = engine
.upsert_node(
"Person",
"scope-deleted",
UpsertNodeOptions {
weight: 0.9,
sparse_vector: Some(vec![(3, 2.5)]),
..Default::default()
},
)
.unwrap();
let pruned = engine
.upsert_node(
"Person",
"scope-pruned",
UpsertNodeOptions {
weight: 0.1,
sparse_vector: Some(vec![(3, 3.0)]),
..Default::default()
},
)
.unwrap();
let other_label = engine
.upsert_node(
"Company",
"scope-other-label",
UpsertNodeOptions {
weight: 0.95,
sparse_vector: Some(vec![(3, 4.0)]),
..Default::default()
},
)
.unwrap();
let wrong_edge_label = engine
.upsert_node(
"Person",
"scope-wrong-edge",
UpsertNodeOptions {
weight: 0.9,
sparse_vector: Some(vec![(3, 2.1)]),
..Default::default()
},
)
.unwrap();
let temporal_old = engine
.upsert_node(
"Person",
"scope-temporal-old",
UpsertNodeOptions {
weight: 0.9,
sparse_vector: Some(vec![(3, 1.9)]),
..Default::default()
},
)
.unwrap();
let temporal_live = engine
.upsert_node(
"Person",
"scope-temporal-live",
UpsertNodeOptions {
weight: 0.9,
sparse_vector: Some(vec![(3, 1.2)]),
..Default::default()
},
)
.unwrap();
let unreachable = engine
.upsert_node(
"Person",
"scope-unreachable",
UpsertNodeOptions {
weight: 0.95,
sparse_vector: Some(vec![(3, 5.0)]),
..Default::default()
},
)
.unwrap();
for &node_id in &[keep, shadowed, deleted, pruned, other_label] {
engine
.upsert_edge(
start,
node_id,
"KNOWS",
UpsertEdgeOptions {
valid_from: Some(0),
valid_to: Some(10_000),
..Default::default()
},
)
.unwrap();
}
engine
.upsert_edge(
start,
wrong_edge_label,
"REPORTS_TO",
UpsertEdgeOptions {
valid_from: Some(0),
valid_to: Some(10_000),
..Default::default()
},
)
.unwrap();
engine
.upsert_edge(
start,
temporal_old,
"KNOWS",
UpsertEdgeOptions {
valid_from: Some(0),
valid_to: Some(4_000),
..Default::default()
},
)
.unwrap();
engine
.upsert_edge(
start,
temporal_live,
"KNOWS",
UpsertEdgeOptions {
valid_from: Some(4_500),
valid_to: Some(9_000),
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
let shadowed_updated = engine
.upsert_node(
"Person",
"scope-shadowed",
UpsertNodeOptions {
weight: 0.95,
sparse_vector: Some(vec![(3, 2.0)]),
..Default::default()
},
)
.unwrap();
assert_eq!(shadowed, shadowed_updated);
engine.delete_node(deleted).unwrap();
engine
.set_prune_policy(
"hide-low-weight",
PrunePolicy {
max_age_ms: None,
max_weight: Some(0.5),
label: None,
},
)
.unwrap();
let request = scoped_sparse_search_request(
vec![(3, 1.0)],
10,
Some(vec!["Person"]),
vector_search_scope(start, 1, Direction::Outgoing, Some(vec!["KNOWS"]), Some(5_000)),
);
let hits = engine.vector_search(&request).unwrap();
let returned_ids: Vec<u64> = hits.iter().map(|hit| hit.node_id).collect();
assert_eq!(returned_ids, vec![shadowed, keep, temporal_live, start]);
assert!(returned_ids.iter().all(|id| *id != deleted));
assert!(returned_ids.iter().all(|id| *id != pruned));
assert!(returned_ids.iter().all(|id| *id != other_label));
assert!(returned_ids.iter().all(|id| *id != wrong_edge_label));
assert!(returned_ids.iter().all(|id| *id != temporal_old));
assert!(returned_ids.iter().all(|id| *id != unreachable));
}
#[test]
fn test_vector_search_sparse_scope_large_reachable_set_excludes_better_unreachable_nodes() {
let dir = TempDir::new().unwrap();
let engine = DatabaseEngine::open(dir.path(), &DbOptions::default()).unwrap();
let start = engine
.upsert_node("Person", "sparse-scope-start", UpsertNodeOptions::default())
.unwrap();
let mut reachable_ids = Vec::new();
for index in 0..2055 {
let node_id = engine
.upsert_node(
"Person",
&format!("sparse-scope-r-{index}"),
UpsertNodeOptions {
sparse_vector: Some(vec![(1, 1.0 - index as f32 * 0.0001)]),
..Default::default()
},
)
.unwrap();
engine
.upsert_edge(start, node_id, "KNOWS", UpsertEdgeOptions::default())
.unwrap();
reachable_ids.push(node_id);
}
let mut unreachable_ids = Vec::new();
for index in 0..16 {
let node_id = engine
.upsert_node(
"Person",
&format!("sparse-scope-u-{index}"),
UpsertNodeOptions {
sparse_vector: Some(vec![(1, 2.0 + index as f32)]),
..Default::default()
},
)
.unwrap();
unreachable_ids.push(node_id);
}
engine.flush().unwrap();
let request = scoped_sparse_search_request(
vec![(1, 1.0)],
5,
None,
vector_search_scope(start, 1, Direction::Outgoing, Some(vec!["KNOWS"]), None),
);
let hits = engine.vector_search(&request).unwrap();
let returned_ids: Vec<u64> = hits.iter().map(|hit| hit.node_id).collect();
assert_eq!(returned_ids, reachable_ids[..5].to_vec());
for unreachable_id in unreachable_ids {
assert!(!returned_ids.contains(&unreachable_id));
}
}
#[test]
fn test_vector_search_sparse_scope_compaction_and_reopen_parity() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
compact_after_n_flushes: 0,
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
let start = engine
.upsert_node(
"Person",
"sparse-scope-parity-start",
UpsertNodeOptions {
weight: 0.8,
sparse_vector: Some(vec![(3, 0.5)]),
..Default::default()
},
)
.unwrap();
let stable = engine
.upsert_node(
"Person",
"sparse-scope-stable",
UpsertNodeOptions {
weight: 0.9,
sparse_vector: Some(vec![(3, 1.4)]),
..Default::default()
},
)
.unwrap();
let shared_old = engine
.upsert_node(
"Person",
"sparse-scope-shared",
UpsertNodeOptions {
weight: 0.8,
sparse_vector: Some(vec![(4, 1.0)]),
..Default::default()
},
)
.unwrap();
let deleted = engine
.upsert_node(
"Person",
"sparse-scope-deleted",
UpsertNodeOptions {
weight: 0.95,
sparse_vector: Some(vec![(3, 1.6)]),
..Default::default()
},
)
.unwrap();
for &node_id in &[stable, shared_old, deleted] {
engine
.upsert_edge(start, node_id, "KNOWS", UpsertEdgeOptions::default())
.unwrap();
}
engine.flush().unwrap();
let shared_new = engine
.upsert_node(
"Person",
"sparse-scope-shared",
UpsertNodeOptions {
weight: 0.95,
sparse_vector: Some(vec![(3, 2.0)]),
..Default::default()
},
)
.unwrap();
assert_eq!(shared_old, shared_new);
engine.delete_node(deleted).unwrap();
engine.flush().unwrap();
let request = scoped_sparse_search_request(
vec![(3, 1.0)],
3,
Some(vec!["Person"]),
vector_search_scope(start, 1, Direction::Outgoing, Some(vec!["KNOWS"]), None),
);
let before = engine.vector_search(&request).unwrap();
let before_ids: Vec<u64> = before.iter().map(|hit| hit.node_id).collect();
assert_eq!(before_ids, vec![shared_old, stable, start]);
engine.compact().unwrap().unwrap();
let after_compact = engine.vector_search(&request).unwrap();
assert_vector_hits_match(&after_compact, &before);
engine.close().unwrap();
let reopened = DatabaseEngine::open(dir.path(), &opts).unwrap();
let after_reopen = reopened.vector_search(&request).unwrap();
assert_vector_hits_match(&after_reopen, &before);
reopened.close().unwrap();
}
#[test]
fn test_vector_search_scope_matches_unscoped_results_filtered_by_reachable_ids() {
fn filter_hits_by_scope(
hits: &[VectorHit],
reachable_ids: &std::collections::HashSet<u64>,
k: usize,
) -> Vec<VectorHit> {
let mut filtered: Vec<VectorHit> = hits
.iter()
.filter(|hit| reachable_ids.contains(&hit.node_id))
.cloned()
.collect();
filtered.truncate(k);
filtered
}
let dir = TempDir::new().unwrap();
let opts = DbOptions {
dense_vector: Some(DenseVectorConfig {
dimension: 2,
metric: DenseMetric::Cosine,
hnsw: HnswConfig::default(),
}),
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
let start = engine
.upsert_node(
"Person",
"scope-oracle-start",
UpsertNodeOptions {
weight: 0.9,
dense_vector: Some(vec![0.60, 0.40]),
sparse_vector: Some(vec![(1, 0.5)]),
..Default::default()
},
)
.unwrap();
let reachable_a = engine
.upsert_node(
"Person",
"scope-oracle-a",
UpsertNodeOptions {
weight: 0.9,
dense_vector: Some(vec![0.98, 0.02]),
sparse_vector: Some(vec![(1, 1.5)]),
..Default::default()
},
)
.unwrap();
let reachable_b_old = engine
.upsert_node(
"Person",
"scope-oracle-b",
UpsertNodeOptions {
weight: 0.8,
dense_vector: Some(vec![0.20, 0.80]),
sparse_vector: Some(vec![(2, 2.0)]),
..Default::default()
},
)
.unwrap();
let reachable_deleted = engine
.upsert_node(
"Person",
"scope-oracle-deleted",
UpsertNodeOptions {
weight: 0.9,
dense_vector: Some(vec![0.97, 0.03]),
sparse_vector: Some(vec![(1, 2.0)]),
..Default::default()
},
)
.unwrap();
let reachable_other_label = engine
.upsert_node(
"Company",
"scope-oracle-company",
UpsertNodeOptions {
weight: 0.9,
dense_vector: Some(vec![0.96, 0.04]),
sparse_vector: Some(vec![(1, 1.8)]),
..Default::default()
},
)
.unwrap();
let unreachable = engine
.upsert_node(
"Person",
"scope-oracle-unreachable",
UpsertNodeOptions {
weight: 0.9,
dense_vector: Some(vec![1.0, 0.0]),
sparse_vector: Some(vec![(1, 3.0)]),
..Default::default()
},
)
.unwrap();
engine
.upsert_edge(start, reachable_a, "KNOWS", UpsertEdgeOptions::default())
.unwrap();
engine
.upsert_edge(
reachable_a,
reachable_b_old,
"KNOWS",
UpsertEdgeOptions::default(),
)
.unwrap();
engine
.upsert_edge(start, reachable_deleted, "KNOWS", UpsertEdgeOptions::default())
.unwrap();
engine
.upsert_edge(
start,
reachable_other_label,
"KNOWS",
UpsertEdgeOptions::default(),
)
.unwrap();
engine.flush().unwrap();
let reachable_b_new = engine
.upsert_node(
"Person",
"scope-oracle-b",
UpsertNodeOptions {
weight: 0.95,
dense_vector: Some(vec![0.94, 0.06]),
sparse_vector: Some(vec![(1, 1.2)]),
..Default::default()
},
)
.unwrap();
assert_eq!(reachable_b_old, reachable_b_new);
engine.delete_node(reachable_deleted).unwrap();
assert!(engine.get_node(unreachable).unwrap().is_some());
let scope = vector_search_scope(start, 2, Direction::Outgoing, Some(vec!["KNOWS"]), None);
let reachable_ids: std::collections::HashSet<u64> = engine
.traverse(
start,
2,
&TraverseOptions {
min_depth: 0,
edge_label_filter: Some(vec!["KNOWS".to_string()]),
..Default::default()
},
)
.unwrap()
.items
.into_iter()
.map(|hit| hit.node_id)
.collect();
assert_eq!(
reachable_ids,
std::collections::HashSet::from([
start,
reachable_a,
reachable_b_old,
reachable_other_label
])
);
let dense_scoped = engine
.vector_search(&scoped_dense_search_request(
vec![1.0, 0.0],
3,
Some(vec!["Person"]),
Some(8),
scope.clone(),
))
.unwrap();
let dense_unscoped = engine
.vector_search(&dense_search_request(
vec![1.0, 0.0],
16,
Some(vec!["Person"]),
Some(8),
))
.unwrap();
let dense_expected = filter_hits_by_scope(&dense_unscoped, &reachable_ids, 3);
assert_vector_hits_match(&dense_scoped, &dense_expected);
assert!(dense_scoped.iter().all(|hit| hit.node_id != unreachable));
assert!(dense_scoped
.iter()
.all(|hit| hit.node_id != reachable_deleted));
let sparse_scoped = engine
.vector_search(&scoped_sparse_search_request(
vec![(1, 1.0)],
3,
Some(vec!["Person"]),
scope,
))
.unwrap();
let sparse_unscoped = engine
.vector_search(&sparse_search_request(vec![(1, 1.0)], 16, Some(vec!["Person"])))
.unwrap();
let sparse_expected = filter_hits_by_scope(&sparse_unscoped, &reachable_ids, 3);
assert_vector_hits_match(&sparse_scoped, &sparse_expected);
assert!(sparse_scoped.iter().all(|hit| hit.node_id != unreachable));
assert!(sparse_scoped
.iter()
.all(|hit| hit.node_id != reachable_deleted));
}
#[test]
fn test_v3_matches_any_prune_policy_meta() {
let label = |label_id| NodeLabelSet::single(label_id).unwrap();
let labels = |label_ids: &[u32]| NodeLabelSet::from_canonical_ids(label_ids).unwrap();
let policy_weight = ResolvedPrunePolicy {
max_age_ms: None,
max_weight: Some(0.5),
label_id: None,
};
let policy_label_scoped = ResolvedPrunePolicy {
max_age_ms: None,
max_weight: Some(0.5),
label_id: Some(1),
};
let policy_age = ResolvedPrunePolicy {
max_age_ms: Some(1000),
max_weight: None,
label_id: None,
};
let now = 10_000i64;
assert!(matches_any_prune_policy_meta(
&label(1),
now,
0.1,
std::slice::from_ref(&policy_weight),
now
));
assert!(matches_any_prune_policy_meta(
&label(1),
now,
0.5,
std::slice::from_ref(&policy_weight),
now
));
assert!(!matches_any_prune_policy_meta(
&label(1),
now,
0.6,
std::slice::from_ref(&policy_weight),
now
));
assert!(matches_any_prune_policy_meta(
&label(1),
now,
0.1,
std::slice::from_ref(&policy_label_scoped),
now
));
assert!(!matches_any_prune_policy_meta(
&label(2),
now,
0.1,
std::slice::from_ref(&policy_label_scoped),
now
)); assert!(matches_any_prune_policy_meta(
&labels(&[1, 2]),
now,
0.1,
std::slice::from_ref(&policy_label_scoped),
now
));
assert!(matches_any_prune_policy_meta(
&label(1),
8000,
1.0,
std::slice::from_ref(&policy_age),
now
)); assert!(!matches_any_prune_policy_meta(
&label(1),
9500,
1.0,
std::slice::from_ref(&policy_age),
now
));
let policies = vec![policy_label_scoped.clone(), policy_age.clone()];
assert!(matches_any_prune_policy_meta(
&label(1),
now,
0.1,
&policies,
now
));
assert!(matches_any_prune_policy_meta(
&label(5),
8000,
1.0,
&policies,
now
));
assert!(!matches_any_prune_policy_meta(
&label(5),
now,
1.0,
&policies,
now
));
let policy_combo = ResolvedPrunePolicy {
max_age_ms: Some(1000),
max_weight: Some(0.5),
label_id: None,
};
assert!(matches_any_prune_policy_meta(
&label(1),
8000,
0.1,
std::slice::from_ref(&policy_combo),
now
));
assert!(!matches_any_prune_policy_meta(
&label(1),
8000,
1.0,
std::slice::from_ref(&policy_combo),
now
));
assert!(!matches_any_prune_policy_meta(
&label(1),
9500,
0.1,
std::slice::from_ref(&policy_combo),
now
));
assert!(!matches_any_prune_policy_meta(&label(1), 0, 0.0, &[], now));
}
fn make_compaction_test_node(
id: u64,
label_ids: &[u32],
key: &str,
updated_at: i64,
weight: f32,
) -> NodeRecord {
NodeRecord {
id,
label_ids: NodeLabelSet::from_canonical_ids(label_ids).unwrap(),
key: key.to_string(),
props: BTreeMap::new(),
created_at: 1000,
updated_at,
weight,
dense_vector: None,
sparse_vector: None,
last_write_seq: 0,
}
}
fn make_compaction_test_node_with_props(
id: u64,
label_ids: &[u32],
key: &str,
props: BTreeMap<String, PropValue>,
updated_at: i64,
weight: f32,
) -> NodeRecord {
NodeRecord {
props,
..make_compaction_test_node(id, label_ids, key, updated_at, weight)
}
}
fn write_compaction_test_segment(
seg_dir: &std::path::Path,
segment_id: u64,
ops: Vec<WalOp>,
) -> std::sync::Arc<SegmentReader> {
write_compaction_test_segment_with_secondary_indexes(seg_dir, segment_id, ops, &[])
}
fn write_compaction_test_segment_with_secondary_indexes(
seg_dir: &std::path::Path,
segment_id: u64,
ops: Vec<WalOp>,
secondary_indexes: &[SecondaryIndexManifestEntry],
) -> std::sync::Arc<SegmentReader> {
let mt = Memtable::new();
for entry in secondary_indexes {
mt.register_secondary_index(entry);
}
for (idx, op) in ops.iter().enumerate() {
mt.apply_op(op, (idx + 1) as u64);
}
let degree_overlay = crate::degree_cache::DegreeOverlaySnapshot::empty();
let info = crate::segment_writer::write_segment_with_degree_overlay_and_secondary_indexes(
seg_dir,
segment_id,
&mt,
None,
degree_overlay.as_ref(),
secondary_indexes,
)
.unwrap();
std::sync::Arc::new(
SegmentReader::open_with_info(seg_dir, &info, None, secondary_indexes).unwrap(),
)
}
fn compact_test_segments(
out_dir: &std::path::Path,
out_segment_id: u64,
segments: Vec<std::sync::Arc<SegmentReader>>,
prune_policies: &[ResolvedPrunePolicy],
) -> (SegmentInfo, u64, u64, SegmentReader) {
compact_test_segments_with_secondary_indexes(
out_dir,
out_segment_id,
segments,
prune_policies,
&[],
)
}
fn compact_test_segments_with_secondary_indexes(
out_dir: &std::path::Path,
out_segment_id: u64,
segments: Vec<std::sync::Arc<SegmentReader>>,
prune_policies: &[ResolvedPrunePolicy],
secondary_indexes: &[SecondaryIndexManifestEntry],
) -> (SegmentInfo, u64, u64, SegmentReader) {
let has_tombstones = segments.iter().any(|segment| segment.has_tombstones());
let cancel = std::sync::atomic::AtomicBool::new(false);
let (info, nodes_auto_pruned, edges_auto_pruned, _report) = bg_standard_merge(
&segments,
out_dir,
out_segment_id,
has_tombstones,
prune_policies,
None,
secondary_indexes,
&cancel,
)
.unwrap();
let reader = SegmentReader::open_with_info(out_dir, &info, None, secondary_indexes).unwrap();
(info, nodes_auto_pruned, edges_auto_pruned, reader)
}
#[test]
fn test_multi_label_v3_compaction_tombstone_and_replacement_memberships() {
let dir = TempDir::new().unwrap();
let older_dir = dir.path().join("seg_0001");
let newer_dir = dir.path().join("seg_0002");
let out_dir = dir.path().join("seg_0003");
let older = write_compaction_test_segment(
&older_dir,
1,
vec![
WalOp::UpsertNode(make_compaction_test_node(1, &[1, 2], "survive", 100, 1.0)),
WalOp::UpsertNode(make_compaction_test_node(2, &[1, 2], "delete", 100, 1.0)),
WalOp::UpsertNode(make_compaction_test_node(3, &[1, 2], "replace", 100, 1.0)),
],
);
let newer = write_compaction_test_segment(
&newer_dir,
2,
vec![
WalOp::DeleteNode {
id: 2,
deleted_at: 200,
},
WalOp::UpsertNode(make_compaction_test_node(3, &[2, 3], "replace", 300, 1.0)),
],
);
let (info, nodes_auto_pruned, edges_auto_pruned, reader) =
compact_test_segments(&out_dir, 3, vec![newer, older], &[]);
assert_eq!(nodes_auto_pruned, 0);
assert_eq!(edges_auto_pruned, 0);
assert_eq!(info.node_count, 2);
assert_eq!(
reader.get_node(1).unwrap().unwrap().label_ids.as_slice(),
&[1, 2]
);
assert!(reader.get_node(2).unwrap().is_none());
assert_eq!(
reader.get_node(3).unwrap().unwrap().label_ids.as_slice(),
&[2, 3]
);
assert_eq!(
reader.node_by_key(1, "survive").unwrap().map(|node| node.id),
Some(1)
);
assert_eq!(
reader.node_by_key(2, "survive").unwrap().map(|node| node.id),
Some(1)
);
assert!(reader.node_by_key(1, "delete").unwrap().is_none());
assert!(reader.node_by_key(2, "delete").unwrap().is_none());
assert!(reader.node_by_key(1, "replace").unwrap().is_none());
assert_eq!(
reader.node_by_key(2, "replace").unwrap().map(|node| node.id),
Some(3)
);
assert_eq!(
reader.node_by_key(3, "replace").unwrap().map(|node| node.id),
Some(3)
);
assert_eq!(reader.nodes_by_label_id(1).unwrap(), vec![1]);
assert_eq!(reader.nodes_by_label_id(2).unwrap(), vec![1, 3]);
assert_eq!(reader.nodes_by_label_id(3).unwrap(), vec![3]);
assert!(reader.nodes_by_time_range(1, 300, 300).unwrap().is_empty());
assert_eq!(reader.nodes_by_time_range(2, 300, 300).unwrap(), vec![3]);
assert_eq!(reader.nodes_by_time_range(3, 300, 300).unwrap(), vec![3]);
let reopened = SegmentReader::open_with_info(&out_dir, &info, None, &[]).unwrap();
assert_eq!(
reopened.get_node(3).unwrap().unwrap().label_ids.as_slice(),
&[2, 3]
);
assert_eq!(reopened.nodes_by_label_id(2).unwrap(), vec![1, 3]);
assert_eq!(
reopened
.node_by_key(3, "replace")
.unwrap()
.map(|node| node.id),
Some(3)
);
}
#[test]
fn test_multi_label_prune_policy_compaction_cascades_edges_by_membership() {
let dir = TempDir::new().unwrap();
let source_dir = dir.path().join("seg_0001");
let out_dir = dir.path().join("seg_0002");
let source = write_compaction_test_segment(
&source_dir,
1,
vec![
WalOp::UpsertNode(make_compaction_test_node(1, &[1, 5], "prune", 100, 0.1)),
WalOp::UpsertNode(make_compaction_test_node(2, &[1], "keep", 100, 1.0)),
WalOp::UpsertEdge(EdgeRecord {
id: 10,
from: 1,
to: 2,
label_id: 10,
props: BTreeMap::new(),
created_at: 100,
updated_at: 100,
weight: 1.0,
valid_from: 0,
valid_to: i64::MAX,
last_write_seq: 0,
}),
],
);
let policies = [ResolvedPrunePolicy {
max_age_ms: None,
max_weight: Some(0.5),
label_id: Some(5),
}];
let (info, nodes_auto_pruned, edges_auto_pruned, reader) =
compact_test_segments(&out_dir, 2, vec![source], &policies);
assert_eq!(nodes_auto_pruned, 1);
assert_eq!(edges_auto_pruned, 1);
assert_eq!(info.node_count, 1);
assert_eq!(info.edge_count, 0);
assert!(reader.get_node(1).unwrap().is_none());
assert_eq!(
reader.get_node(2).unwrap().unwrap().label_ids.as_slice(),
&[1]
);
assert!(reader.get_edge(10).unwrap().is_none());
assert!(reader.node_by_key(5, "prune").unwrap().is_none());
assert_eq!(reader.nodes_by_label_id(1).unwrap(), vec![2]);
}
#[test]
fn test_multi_label_compaction_rebuilds_declared_sidecars_via_targeted_decode() {
let dir = TempDir::new().unwrap();
let source_dir = dir.path().join("seg_0001");
let out_dir = dir.path().join("seg_0002");
let eq_entry = SecondaryIndexManifestEntry {
index_id: 301,
target: SecondaryIndexTarget::NodeProperty {
label_id: 2,
prop_key: "color".to_string(),
},
kind: SecondaryIndexKind::Equality,
state: SecondaryIndexState::Ready,
last_error: None,
};
let range_entry = SecondaryIndexManifestEntry {
index_id: 302,
target: SecondaryIndexTarget::NodeProperty {
label_id: 3,
prop_key: "score".to_string(),
},
kind: SecondaryIndexKind::Range,
state: SecondaryIndexState::Ready,
last_error: None,
};
let indexes = vec![eq_entry.clone(), range_entry.clone()];
let source = write_compaction_test_segment_with_secondary_indexes(
&source_dir,
1,
vec![
WalOp::UpsertNode(make_compaction_test_node_with_props(
1,
&[1, 2],
"eq-only",
BTreeMap::from([
("color".to_string(), PropValue::String("red".to_string())),
("score".to_string(), PropValue::Int(10)),
]),
100,
1.0,
)),
WalOp::UpsertNode(make_compaction_test_node_with_props(
2,
&[2, 3],
"both",
BTreeMap::from([
("color".to_string(), PropValue::String("red".to_string())),
("score".to_string(), PropValue::Int(20)),
]),
200,
1.0,
)),
WalOp::UpsertNode(make_compaction_test_node_with_props(
3,
&[3],
"range-only",
BTreeMap::from([
("color".to_string(), PropValue::String("blue".to_string())),
("score".to_string(), PropValue::Int(30)),
]),
300,
1.0,
)),
],
&indexes,
);
let eq_sidecar =
crate::segment_writer::node_prop_eq_sidecar_path(&source_dir, eq_entry.index_id);
let range_sidecar =
crate::segment_writer::node_prop_range_sidecar_path(&source_dir, range_entry.index_id);
std::fs::remove_file(&eq_sidecar).unwrap();
std::fs::remove_file(&range_sidecar).unwrap();
let (_info, nodes_auto_pruned, edges_auto_pruned, reader) =
compact_test_segments_with_secondary_indexes(&out_dir, 2, vec![source], &[], &indexes);
assert_eq!(nodes_auto_pruned, 0);
assert_eq!(edges_auto_pruned, 0);
let red_hash = hash_prop_equality_key(&PropValue::String("red".to_string()));
assert_eq!(
reader
.find_nodes_by_secondary_eq_index(eq_entry.index_id, red_hash)
.unwrap(),
vec![1, 2]
);
let score_20 = numeric_range_sort_key_for_value(&PropValue::Int(20)).unwrap();
let score_30 = numeric_range_sort_key_for_value(&PropValue::Int(30)).unwrap();
assert_eq!(
reader
.find_nodes_by_secondary_range_index_if_present(
range_entry.index_id,
None,
None,
None,
)
.unwrap(),
Some(vec![(score_20, 2), (score_30, 3)])
);
}
#[test]
fn test_multi_label_compaction_drops_stale_declared_sidecar_memberships() {
let dir = TempDir::new().unwrap();
let older_dir = dir.path().join("seg_0001");
let newer_dir = dir.path().join("seg_0002");
let out_dir = dir.path().join("seg_0003");
let eq_entry = SecondaryIndexManifestEntry {
index_id: 401,
target: SecondaryIndexTarget::NodeProperty {
label_id: 2,
prop_key: "color".to_string(),
},
kind: SecondaryIndexKind::Equality,
state: SecondaryIndexState::Ready,
last_error: None,
};
let range_entry = SecondaryIndexManifestEntry {
index_id: 402,
target: SecondaryIndexTarget::NodeProperty {
label_id: 2,
prop_key: "score".to_string(),
},
kind: SecondaryIndexKind::Range,
state: SecondaryIndexState::Ready,
last_error: None,
};
let indexes = vec![eq_entry.clone(), range_entry.clone()];
let older = write_compaction_test_segment_with_secondary_indexes(
&older_dir,
1,
vec![
WalOp::UpsertNode(make_compaction_test_node_with_props(
1,
&[1, 2],
"replacement",
BTreeMap::from([
("color".to_string(), PropValue::String("red".to_string())),
("score".to_string(), PropValue::Int(10)),
]),
100,
1.0,
)),
WalOp::UpsertNode(make_compaction_test_node_with_props(
2,
&[2, 3],
"deleted",
BTreeMap::from([
("color".to_string(), PropValue::String("red".to_string())),
("score".to_string(), PropValue::Int(20)),
]),
100,
1.0,
)),
],
&indexes,
);
let newer = write_compaction_test_segment_with_secondary_indexes(
&newer_dir,
2,
vec![
WalOp::UpsertNode(make_compaction_test_node_with_props(
1,
&[4],
"replacement",
BTreeMap::from([
("color".to_string(), PropValue::String("red".to_string())),
("score".to_string(), PropValue::Int(10)),
]),
300,
1.0,
)),
WalOp::DeleteNode {
id: 2,
deleted_at: 300,
},
],
&indexes,
);
let (info, nodes_auto_pruned, edges_auto_pruned, reader) =
compact_test_segments_with_secondary_indexes(
&out_dir,
3,
vec![newer, older],
&[],
&indexes,
);
assert_eq!(nodes_auto_pruned, 0);
assert_eq!(edges_auto_pruned, 0);
assert_eq!(info.node_count, 1);
assert_eq!(
reader.get_node(1).unwrap().unwrap().label_ids.as_slice(),
&[4]
);
assert!(reader.get_node(2).unwrap().is_none());
let red_hash = hash_prop_equality_key(&PropValue::String("red".to_string()));
assert_eq!(
reader
.find_nodes_by_secondary_eq_index(eq_entry.index_id, red_hash)
.unwrap(),
Vec::<u64>::new()
);
assert_eq!(
reader
.find_nodes_by_secondary_range_index_if_present(
range_entry.index_id,
None,
None,
None,
)
.unwrap(),
Some(Vec::new())
);
assert!(reader.node_by_key(2, "replacement").unwrap().is_none());
assert_eq!(
reader.node_by_key(4, "replacement").unwrap().map(|node| node.id),
Some(1)
);
}
#[test]
fn test_v3_tombstone_overlap_stress() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("db");
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
for round in 0..5u64 {
for i in 0..20u64 {
let key = format!("node_{}", i);
let mut props = BTreeMap::new();
props.insert("round".into(), PropValue::Int(round as i64));
db.upsert_node(
"Person",
&key,
UpsertNodeOptions {
props,
weight: 0.5,
..Default::default()
},
)
.unwrap();
}
for i in 0..19u64 {
db.upsert_edge(i + 1, i + 2, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
}
db.flush().unwrap();
}
for i in [3u64, 7, 11, 15] {
db.delete_node(i).unwrap();
}
db.flush().unwrap();
let stats = db.compact().unwrap().expect("compaction should run");
assert!(
stats.segments_merged >= 2,
"should merge at least 2 segments"
);
for i in 1..=20u64 {
let node = db.get_node(i).unwrap();
if [3, 7, 11, 15].contains(&i) {
assert!(node.is_none(), "node {} should be deleted", i);
} else {
let n = node.unwrap_or_else(|| panic!("node {} should exist", i));
assert_eq!(n.props.get("round"), Some(&PropValue::Int(4)));
}
}
let out_3 = db.neighbors(3, &NeighborOptions::default()).unwrap();
assert!(out_3.is_empty(), "deleted node 3 should have no neighbors");
}
#[test]
fn test_v3_policy_or_and_semantics() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("db");
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
for i in 0..10u64 {
let w = if i < 5 { 0.1 } else { 0.8 };
let mut props = BTreeMap::new();
props.insert("name".into(), PropValue::String(format!("t1_{}", i)));
db.upsert_node(
"Person",
&format!("t1_{}", i),
UpsertNodeOptions {
props,
weight: w,
..Default::default()
},
)
.unwrap();
}
for i in 0..10u64 {
let mut props = BTreeMap::new();
props.insert("name".into(), PropValue::String(format!("t2_{}", i)));
db.upsert_node(
"Company",
&format!("t2_{}", i),
UpsertNodeOptions {
props,
weight: 0.5,
..Default::default()
},
)
.unwrap();
}
db.flush().unwrap();
db.upsert_node(
"Person",
"t1_0",
UpsertNodeOptions {
weight: 0.1,
..Default::default()
},
)
.unwrap();
db.flush().unwrap();
db.set_prune_policy(
"low-weight-t1",
PrunePolicy {
max_age_ms: None,
max_weight: Some(0.3),
label: Some("Person".to_string()),
},
)
.unwrap();
let stats = db.compact().unwrap().expect("compaction");
for i in 0..10u64 {
assert!(
db.get_node_by_key("Company", &format!("t2_{}", i))
.unwrap()
.is_some(),
"Company-labeled node t2_{} should survive",
i
);
}
for i in 5..10u64 {
assert!(
db.get_node_by_key("Person", &format!("t1_{}", i))
.unwrap()
.is_some(),
"Person-labeled node t1_{} (w=0.8) should survive",
i
);
}
assert!(stats.nodes_auto_pruned > 0);
}
#[test]
fn test_v3_edge_cascade_high_fanout() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("db");
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let hub = db
.upsert_node(
"Person",
"hub",
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
let mut spoke_ids = Vec::new();
for i in 0..100u64 {
let spoke = db
.upsert_node(
"Person",
&format!("spoke_{}", i),
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
spoke_ids.push(spoke);
db.upsert_edge(hub, spoke, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
db.upsert_edge(spoke, hub, "WORKS_AT", UpsertEdgeOptions::default())
.unwrap();
}
db.flush().unwrap();
db.delete_node(hub).unwrap();
db.flush().unwrap();
db.compact().unwrap();
assert!(db.get_node(hub).unwrap().is_none());
let hub_out = db
.neighbors(
hub,
&NeighborOptions {
direction: Direction::Both,
..Default::default()
},
)
.unwrap();
assert!(hub_out.is_empty(), "hub should have no neighbors");
for &spoke in &spoke_ids {
assert!(
db.get_node(spoke).unwrap().is_some(),
"spoke {} should exist",
spoke
);
let nbrs = db
.neighbors(
spoke,
&NeighborOptions {
direction: Direction::Both,
..Default::default()
},
)
.unwrap();
for ne in &nbrs {
assert_ne!(ne.node_id, hub, "no neighbor should be hub");
}
}
}
#[test]
fn test_v3_deterministic_output() {
let dir1 = TempDir::new().unwrap();
let dir2 = TempDir::new().unwrap();
let p1 = dir1.path().join("db");
let p2 = dir2.path().join("db");
for p in [&p1, &p2] {
let db = DatabaseEngine::open(p, &DbOptions::default()).unwrap();
for i in 0..50u64 {
let mut props = BTreeMap::new();
props.insert("idx".into(), PropValue::Int(i as i64));
db.upsert_node(
["Person", "Company", "Article"][i as usize % 3],
&format!("n_{}", i),
UpsertNodeOptions {
props,
weight: 0.5,
..Default::default()
},
)
.unwrap();
}
for i in 0..30u64 {
db.upsert_edge(
i + 1,
(i + 5) % 50 + 1,
["RELATES_TO", "WORKS_AT"][i as usize % 2],
UpsertEdgeOptions::default(),
)
.unwrap();
}
db.flush().unwrap();
for i in 0..20u64 {
let mut props = BTreeMap::new();
props.insert("idx".into(), PropValue::Int(i as i64 + 100));
db.upsert_node(
["Person", "Company", "Article"][i as usize % 3],
&format!("n_{}", i),
UpsertNodeOptions {
props,
weight: 0.5,
..Default::default()
},
)
.unwrap();
}
db.flush().unwrap();
db.compact().unwrap();
db.close().unwrap();
}
let db1 = DatabaseEngine::open(&p1, &DbOptions::default()).unwrap();
let db2 = DatabaseEngine::open(&p2, &DbOptions::default()).unwrap();
for i in 0..50u64 {
let n1 = db1.get_node(i + 1).unwrap();
let n2 = db2.get_node(i + 1).unwrap();
assert_eq!(
n1.is_some(),
n2.is_some(),
"node {} existence mismatch",
i + 1
);
if let (Some(a), Some(b)) = (n1, n2) {
assert_eq!(a.props, b.props, "node {} props mismatch", i + 1);
assert_eq!(a.labels, b.labels, "node {} labels mismatch", i + 1);
assert_eq!(a.key, b.key, "node {} key mismatch", i + 1);
}
}
}
#[test]
fn test_v3_index_parity() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("db");
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let mut props1 = BTreeMap::new();
props1.insert("color".into(), PropValue::String("red".into()));
props1.insert("score".into(), PropValue::Float(0.8));
let mut props2 = BTreeMap::new();
props2.insert("color".into(), PropValue::String("blue".into()));
props2.insert("score".into(), PropValue::Float(0.6));
for i in 0..10u64 {
let props = if i % 2 == 0 {
props1.clone()
} else {
props2.clone()
};
db.upsert_node(
["Person", "Company"][i as usize % 2],
&format!("key_{}", i),
UpsertNodeOptions {
props,
weight: 0.5,
..Default::default()
},
)
.unwrap();
}
for i in 0..8u64 {
db.upsert_edge(
i + 1,
i + 2,
["RELATES_TO", "WORKS_AT", "LIKES"][i as usize % 3],
UpsertEdgeOptions::default(),
)
.unwrap();
}
db.flush().unwrap();
for i in 0..5u64 {
let mut props = BTreeMap::new();
props.insert("color".into(), PropValue::String("green".into()));
props.insert("updated".into(), PropValue::Bool(true));
db.upsert_node(
["Person", "Company"][i as usize % 2],
&format!("key_{}", i),
UpsertNodeOptions {
props,
weight: 0.5,
..Default::default()
},
)
.unwrap();
}
for i in 5..10u64 {
db.upsert_edge(i + 1, 1, "WORKS_AT", UpsertEdgeOptions::default())
.unwrap();
}
db.flush().unwrap();
let pre_nodes: Vec<_> = (1..=10).map(|i| db.get_node(i).unwrap()).collect();
let pre_key_lookups: Vec<_> = (0..10u32)
.map(|i| {
db.get_node_by_key(["Person", "Company"][i as usize % 2], &format!("key_{}", i))
.unwrap()
})
.collect();
let pre_neighbors: Vec<_> = (1..=10)
.map(|i| {
db.neighbors(
i,
&NeighborOptions {
direction: Direction::Both,
..Default::default()
},
)
.unwrap()
})
.collect();
let pre_label1 = db.nodes_by_labels("Person").unwrap();
let pre_label2 = db.nodes_by_labels("Company").unwrap();
let pre_find = db
.find_nodes("Person", "color", &PropValue::String("green".into()))
.unwrap();
db.compact().unwrap();
for (i, pre_node) in pre_nodes.iter().enumerate().take(10) {
let post = db.get_node(i as u64 + 1).unwrap();
assert_eq!(
pre_node.as_ref().map(|n| (&n.props, &n.labels)),
post.as_ref().map(|n| (&n.props, &n.labels)),
"get_node({}) mismatch",
i + 1
);
}
for i in 0..10u32 {
let post = db
.get_node_by_key(["Person", "Company"][i as usize % 2], &format!("key_{}", i))
.unwrap();
assert_eq!(
pre_key_lookups[i as usize].as_ref().map(|n| n.id),
post.as_ref().map(|n| n.id),
"get_node_by_key({}) mismatch",
i
);
}
for (i, pre_neighbor) in pre_neighbors.iter().enumerate().take(10) {
let post = db
.neighbors(
i as u64 + 1,
&NeighborOptions {
direction: Direction::Both,
..Default::default()
},
)
.unwrap();
let pre_ids: NodeIdSet = pre_neighbor.iter().map(|ne| ne.edge_id).collect();
let post_ids: NodeIdSet = post.iter().map(|ne| ne.edge_id).collect();
assert_eq!(pre_ids, post_ids, "neighbors({}) edge set mismatch", i + 1);
}
let post_label1 = db.nodes_by_labels("Person").unwrap();
assert_eq!(
pre_label1.len(),
post_label1.len(),
"nodes_by_labels(Person) mismatch"
);
let post_label2 = db.nodes_by_labels("Company").unwrap();
assert_eq!(
pre_label2.len(),
post_label2.len(),
"nodes_by_labels(Company) mismatch"
);
let post_find = db
.find_nodes("Person", "color", &PropValue::String("green".into()))
.unwrap();
let pre_find_ids: NodeIdSet = pre_find.iter().copied().collect();
let post_find_ids: NodeIdSet = post_find.iter().copied().collect();
assert_eq!(pre_find_ids, post_find_ids, "find_nodes mismatch");
}
#[test]
fn test_v3_mixed_workload_stress() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("db");
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
for i in 0..30u64 {
db.upsert_node(
"Person",
&format!("mix_{}", i),
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
}
for i in 0..20u64 {
db.upsert_edge(i + 1, i + 2, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
}
db.flush().unwrap();
for i in [5u64, 10, 15, 20, 25] {
db.delete_node(i).unwrap();
}
for i in 0..10u64 {
let mut props = BTreeMap::new();
props.insert("updated".into(), PropValue::Bool(true));
db.upsert_node(
"Person",
&format!("mix_{}", i),
UpsertNodeOptions {
props,
weight: 0.5,
..Default::default()
},
)
.unwrap();
}
db.flush().unwrap();
db.compact().unwrap();
for i in 0..5u64 {
let mut props = BTreeMap::new();
props.insert("round3".into(), PropValue::Int(3));
db.upsert_node(
"Person",
&format!("mix_{}", i),
UpsertNodeOptions {
props,
weight: 0.5,
..Default::default()
},
)
.unwrap();
}
for i in 30..40u64 {
db.upsert_node(
"Company",
&format!("new_{}", i),
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
}
db.flush().unwrap();
let stats = db.compact().unwrap().expect("compaction");
assert!(stats.segments_merged >= 2);
for i in [5u64, 10, 15, 20, 25] {
assert!(
db.get_node(i).unwrap().is_none(),
"node {} should be deleted",
i
);
}
for i in 0..5u64 {
let n = db
.get_node_by_key("Person", &format!("mix_{}", i))
.unwrap()
.unwrap();
assert_eq!(n.props.get("round3"), Some(&PropValue::Int(3)));
}
for i in 30..40u64 {
assert!(
db.get_node_by_key("Company", &format!("new_{}", i))
.unwrap()
.is_some(),
"new node new_{} should exist",
i
);
}
}
#[test]
fn test_v3_cross_segment_edges() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("db");
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
for i in 0..20u64 {
db.upsert_node(
"Person",
&format!("cs_{}", i),
UpsertNodeOptions {
weight: 0.5,
..Default::default()
},
)
.unwrap();
}
db.flush().unwrap();
for i in 0..19u64 {
db.upsert_edge(i + 1, i + 2, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
}
db.upsert_edge(1, 10, "WORKS_AT", UpsertEdgeOptions::default())
.unwrap();
db.upsert_edge(5, 15, "WORKS_AT", UpsertEdgeOptions::default())
.unwrap();
db.upsert_edge(10, 20, "WORKS_AT", UpsertEdgeOptions::default())
.unwrap();
db.flush().unwrap();
db.compact().unwrap();
let out_1 = db.neighbors(1, &NeighborOptions::default()).unwrap();
assert!(
out_1.len() >= 2,
"node 1 should have at least 2 outgoing edges"
);
let out_5 = db.neighbors(5, &NeighborOptions::default()).unwrap();
assert!(out_5.len() >= 2, "node 5 should have outgoing to 6 and 15");
let in_10 = db
.neighbors(
10,
&NeighborOptions {
direction: Direction::Incoming,
..Default::default()
},
)
.unwrap();
assert!(
in_10.iter().any(|ne| ne.node_id == 1),
"node 10 should have incoming from node 1"
);
}
#[test]
fn test_v3_reopen_durability() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("db");
{
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
for i in 0..50u64 {
let mut props = BTreeMap::new();
props.insert("name".into(), PropValue::String(format!("durable_{}", i)));
db.upsert_node(
["Person", "Company", "Article"][i as usize % 3],
&format!("dur_{}", i),
UpsertNodeOptions {
props,
weight: 0.5,
..Default::default()
},
)
.unwrap();
}
for i in 0..40u64 {
db.upsert_edge(
i + 1,
(i + 3) % 50 + 1,
["RELATES_TO", "WORKS_AT"][i as usize % 2],
UpsertEdgeOptions::default(),
)
.unwrap();
}
db.flush().unwrap();
for i in 0..20u64 {
let mut props = BTreeMap::new();
props.insert("name".into(), PropValue::String(format!("updated_{}", i)));
db.upsert_node(
["Person", "Company", "Article"][i as usize % 3],
&format!("dur_{}", i),
UpsertNodeOptions {
props,
weight: 0.5,
..Default::default()
},
)
.unwrap();
}
for i in [5u64, 15, 25, 35, 45] {
db.delete_node(i).unwrap();
}
db.flush().unwrap();
db.compact().unwrap();
db.close().unwrap();
}
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
for i in 0..50u64 {
let id = i + 1;
if [5, 15, 25, 35, 45].contains(&id) {
assert!(
db.get_node(id).unwrap().is_none(),
"node {} should be deleted",
id
);
} else {
let n = db
.get_node(id)
.unwrap()
.unwrap_or_else(|| panic!("node {} should exist", id));
if i < 20 {
assert_eq!(
n.props.get("name"),
Some(&PropValue::String(format!("updated_{}", i)))
);
} else {
assert_eq!(
n.props.get("name"),
Some(&PropValue::String(format!("durable_{}", i)))
);
}
}
}
assert!(
db.get_node_by_key("Person", "dur_0").unwrap().is_some(),
"key lookup should work"
);
let nbrs = db.neighbors(1, &NeighborOptions::default()).unwrap();
assert!(!nbrs.is_empty(), "adjacency should work after reopen");
let label1 = db.nodes_by_labels("Person").unwrap();
assert!(!label1.is_empty(), "label query should work after reopen");
}
#[test]
fn test_v3_fast_merge_index_parity() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("db");
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
for seg in 0..3u64 {
let base = seg * 10;
for i in 0..10u64 {
let mut props = BTreeMap::new();
props.insert("seg".into(), PropValue::Int(seg as i64));
db.upsert_node(
["Person", "Company"][seg as usize % 2],
&format!("fm_{}_{}", seg, i),
UpsertNodeOptions {
props,
weight: 0.5,
..Default::default()
},
)
.unwrap();
}
for i in 0..9u64 {
db.upsert_edge(base + i + 1, base + i + 2, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
}
db.flush().unwrap();
}
let pre_nodes: Vec<_> = (1..=30).filter_map(|i| db.get_node(i).unwrap()).collect();
let pre_key_0 = db.get_node_by_key("Person", "fm_0_0").unwrap();
let pre_nbrs_1 = db.neighbors(1, &NeighborOptions::default()).unwrap();
let pre_label1 = db.nodes_by_labels("Person").unwrap();
db.compact().unwrap();
let post_nodes: Vec<_> = (1..=30).filter_map(|i| db.get_node(i).unwrap()).collect();
assert_eq!(pre_nodes.len(), post_nodes.len());
for (pre, post) in pre_nodes.iter().zip(post_nodes.iter()) {
assert_eq!(pre.id, post.id);
assert_eq!(pre.props, post.props);
assert_eq!(pre.labels, post.labels);
}
let post_key_0 = db.get_node_by_key("Person", "fm_0_0").unwrap();
assert_eq!(
pre_key_0.map(|n| n.id),
post_key_0.map(|n| n.id),
"key lookup mismatch after fast-merge"
);
let post_nbrs_1 = db.neighbors(1, &NeighborOptions::default()).unwrap();
assert_eq!(pre_nbrs_1.len(), post_nbrs_1.len(), "neighbors mismatch");
let post_label1 = db.nodes_by_labels("Person").unwrap();
assert_eq!(pre_label1.len(), post_label1.len(), "label query mismatch");
}
fn time_node(id: u64, label_id: u32, key: &str, updated_at: i64) -> NodeRecord {
NodeRecord {
id,
label_ids: NodeLabelSet::single(label_id).unwrap(),
key: key.to_string(),
props: BTreeMap::new(),
created_at: updated_at - 100,
updated_at,
weight: 0.5,
dense_vector: None,
sparse_vector: None,
last_write_seq: 0,
}
}
#[test]
fn test_time_range_memtable_only() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
write_internal_wal_op(&db, &WalOp::UpsertNode(time_node(1, 1, "a", 1000)))
.unwrap();
write_internal_wal_op(&db, &WalOp::UpsertNode(time_node(2, 1, "b", 2000)))
.unwrap();
write_internal_wal_op(&db, &WalOp::UpsertNode(time_node(3, 1, "c", 3000)))
.unwrap();
write_internal_wal_op(&db, &WalOp::UpsertNode(time_node(4, 2, "d", 2500)))
.unwrap();
let r = db.find_nodes_by_time_range("Person", 1000, 3000).unwrap();
assert_eq!(r, vec![1, 2, 3]);
let r = db.find_nodes_by_time_range("Person", 1500, 2500).unwrap();
assert_eq!(r, vec![2]);
let r = db.find_nodes_by_time_range("Company", 2000, 3000).unwrap();
assert_eq!(r, vec![4]);
let r = db.find_nodes_by_time_range("Person", 4000, 5000).unwrap();
assert!(r.is_empty());
let r = db.find_nodes_by_time_range("Person", 0, i64::MAX).unwrap();
assert_eq!(r, vec![1, 2, 3]);
db.close().unwrap();
}
#[test]
fn test_time_range_across_flush() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
write_internal_wal_op(&db, &WalOp::UpsertNode(time_node(1, 1, "a", 1000)))
.unwrap();
write_internal_wal_op(&db, &WalOp::UpsertNode(time_node(2, 1, "b", 2000)))
.unwrap();
db.flush().unwrap();
write_internal_wal_op(&db, &WalOp::UpsertNode(time_node(3, 1, "c", 3000)))
.unwrap();
write_internal_wal_op(&db, &WalOp::UpsertNode(time_node(4, 1, "d", 1500)))
.unwrap();
let r = db.find_nodes_by_time_range("Person", 1000, 2000).unwrap();
assert_eq!(r, vec![1, 2, 4]);
db.close().unwrap();
}
#[test]
fn test_time_range_survives_compaction() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
write_internal_wal_op(&db, &WalOp::UpsertNode(time_node(1, 1, "a", 1000)))
.unwrap();
write_internal_wal_op(&db, &WalOp::UpsertNode(time_node(2, 1, "b", 2000)))
.unwrap();
db.flush().unwrap();
write_internal_wal_op(&db, &WalOp::UpsertNode(time_node(3, 1, "c", 3000)))
.unwrap();
write_internal_wal_op(&db, &WalOp::UpsertNode(time_node(4, 1, "d", 4000)))
.unwrap();
db.flush().unwrap();
db.compact().unwrap();
let r = db.find_nodes_by_time_range("Person", 1500, 3500).unwrap();
assert_eq!(r, vec![2, 3]);
let r = db.find_nodes_by_time_range("Person", 0, i64::MAX).unwrap();
assert_eq!(r, vec![1, 2, 3, 4]);
db.close().unwrap();
}
#[test]
fn test_time_range_respects_tombstones() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
write_internal_wal_op(&db, &WalOp::UpsertNode(time_node(1, 1, "a", 1000)))
.unwrap();
write_internal_wal_op(&db, &WalOp::UpsertNode(time_node(2, 1, "b", 2000)))
.unwrap();
write_internal_wal_op(&db, &WalOp::UpsertNode(time_node(3, 1, "c", 3000)))
.unwrap();
db.flush().unwrap();
db.delete_node(2).unwrap();
let r = db.find_nodes_by_time_range("Person", 0, i64::MAX).unwrap();
assert_eq!(r, vec![1, 3]);
db.close().unwrap();
}
#[test]
fn test_time_range_boundary_conditions() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
write_internal_wal_op(&db, &WalOp::UpsertNode(time_node(1, 1, "a", 1000)))
.unwrap();
write_internal_wal_op(&db, &WalOp::UpsertNode(time_node(2, 1, "b", 2000)))
.unwrap();
write_internal_wal_op(&db, &WalOp::UpsertNode(time_node(3, 1, "c", 3000)))
.unwrap();
let r = db.find_nodes_by_time_range("Person", 1000, 1000).unwrap();
assert_eq!(r, vec![1], "single-point range at lower bound");
let r = db.find_nodes_by_time_range("Person", 3000, 3000).unwrap();
assert_eq!(r, vec![3], "single-point range at upper bound");
let r = db.find_nodes_by_time_range("Person", 2000, 2000).unwrap();
assert_eq!(r, vec![2], "single-point range in middle");
let r = db.find_nodes_by_time_range("Person", 1500, 1500).unwrap();
assert!(r.is_empty(), "no nodes at this exact time");
let r = db.find_nodes_by_time_range("Person", 3000, 1000).unwrap();
assert!(r.is_empty(), "inverted range returns empty");
db.close().unwrap();
}
#[test]
fn test_time_range_paged() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
for i in 1..=10u64 {
write_internal_wal_op(&db, &WalOp::UpsertNode(time_node(
i,
1,
&format!("n{}", i),
i as i64 * 1000,
)))
.unwrap();
}
db.flush().unwrap();
let page1 = db
.find_nodes_by_time_range_paged("Person",
1000,
10000,
&PageRequest {
limit: Some(3),
after: None,
},
)
.unwrap();
assert_eq!(page1.items, vec![1, 2, 3]);
assert!(page1.next_cursor.is_some());
let page2 = db
.find_nodes_by_time_range_paged("Person",
1000,
10000,
&PageRequest {
limit: Some(3),
after: page1.next_cursor,
},
)
.unwrap();
assert_eq!(page2.items, vec![4, 5, 6]);
let page3 = db
.find_nodes_by_time_range_paged("Person",
1000,
10000,
&PageRequest {
limit: Some(3),
after: page2.next_cursor,
},
)
.unwrap();
assert_eq!(page3.items, vec![7, 8, 9]);
let page4 = db
.find_nodes_by_time_range_paged("Person",
1000,
10000,
&PageRequest {
limit: Some(3),
after: page3.next_cursor,
},
)
.unwrap();
assert_eq!(page4.items, vec![10]);
assert!(page4.next_cursor.is_none());
db.close().unwrap();
}
#[test]
fn test_time_range_upsert_updates_index() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
write_internal_wal_op(&db, &WalOp::UpsertNode(time_node(1, 1, "a", 1000)))
.unwrap();
let r = db.find_nodes_by_time_range("Person", 900, 1100).unwrap();
assert_eq!(r, vec![1]);
write_internal_wal_op(&db, &WalOp::UpsertNode(time_node(1, 1, "a", 5000)))
.unwrap();
let r = db.find_nodes_by_time_range("Person", 900, 1100).unwrap();
assert!(r.is_empty(), "node should not appear at old timestamp");
let r = db.find_nodes_by_time_range("Person", 4900, 5100).unwrap();
assert_eq!(r, vec![1], "node should appear at new timestamp");
db.close().unwrap();
}
#[test]
fn test_time_range_survives_reopen() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
{
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
write_internal_wal_op(&db, &WalOp::UpsertNode(time_node(1, 1, "a", 1000)))
.unwrap();
write_internal_wal_op(&db, &WalOp::UpsertNode(time_node(2, 1, "b", 2000)))
.unwrap();
write_internal_wal_op(&db, &WalOp::UpsertNode(time_node(3, 1, "c", 3000)))
.unwrap();
db.flush().unwrap();
db.close().unwrap();
}
{
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let r = db.find_nodes_by_time_range("Person", 1500, 2500).unwrap();
assert_eq!(r, vec![2]);
let r = db.find_nodes_by_time_range("Person", 0, i64::MAX).unwrap();
assert_eq!(r, vec![1, 2, 3]);
db.close().unwrap();
}
}
#[test]
fn test_time_range_dedup_across_sources() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
write_internal_wal_op(&db, &WalOp::UpsertNode(time_node(1, 1, "a", 1000)))
.unwrap();
db.flush().unwrap();
write_internal_wal_op(&db, &WalOp::UpsertNode(time_node(1, 1, "a", 1500)))
.unwrap();
let r = db.find_nodes_by_time_range("Person", 0, 2000).unwrap();
assert_eq!(r, vec![1]);
db.close().unwrap();
}
#[test]
fn test_time_range_with_prune_policy() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
write_internal_wal_op(&db, &WalOp::UpsertNode(time_node(1, 1, "a", 1000)))
.unwrap();
write_internal_wal_op(&db, &WalOp::UpsertNode(time_node(2, 1, "b", 2000)))
.unwrap();
write_internal_wal_op(&db, &WalOp::UpsertNode(NodeRecord {
id: 3,
label_ids: NodeLabelSet::single(1).unwrap(),
key: "c".to_string(),
props: BTreeMap::new(),
created_at: 2900,
updated_at: 3000,
weight: 0.001,
dense_vector: None,
sparse_vector: None,
last_write_seq: 0,
}))
.unwrap();
db.set_prune_policy(
"low_weight",
PrunePolicy {
max_age_ms: None,
max_weight: Some(0.01),
label: None,
},
)
.unwrap();
let r = db.find_nodes_by_time_range("Person", 0, i64::MAX).unwrap();
assert_eq!(r, vec![1, 2]);
db.close().unwrap();
}
#[test]
fn test_time_range_stale_segment_suppressed_by_newer_version() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
write_internal_wal_op(&db, &WalOp::UpsertNode(time_node(1, 1, "a", 1000)))
.unwrap();
db.flush().unwrap();
write_internal_wal_op(&db, &WalOp::UpsertNode(time_node(1, 1, "a", 5000)))
.unwrap();
let r = db.find_nodes_by_time_range("Person", 500, 2000).unwrap();
assert!(
r.is_empty(),
"stale segment entry must be suppressed by newer version"
);
let r = db.find_nodes_by_time_range("Person", 4000, 6000).unwrap();
assert_eq!(r, vec![1]);
db.close().unwrap();
}
#[test]
fn test_time_range_stale_segment_suppressed_across_segments() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
write_internal_wal_op(&db, &WalOp::UpsertNode(time_node(1, 1, "a", 1000)))
.unwrap();
db.flush().unwrap();
write_internal_wal_op(&db, &WalOp::UpsertNode(time_node(1, 1, "a", 5000)))
.unwrap();
db.flush().unwrap();
let r = db.find_nodes_by_time_range("Person", 500, 2000).unwrap();
assert!(
r.is_empty(),
"stale segment entry must be suppressed by newer segment version"
);
db.compact().unwrap();
let r = db.find_nodes_by_time_range("Person", 500, 2000).unwrap();
assert!(
r.is_empty(),
"stale entry must be suppressed after compaction too"
);
let r = db.find_nodes_by_time_range("Person", 4000, 6000).unwrap();
assert_eq!(r, vec![1]);
db.close().unwrap();
}
#[test]
fn test_time_range_paged_stale_suppressed() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
write_internal_wal_op(&db, &WalOp::UpsertNode(time_node(1, 1, "a", 1000)))
.unwrap();
write_internal_wal_op(&db, &WalOp::UpsertNode(time_node(2, 1, "b", 2000)))
.unwrap();
write_internal_wal_op(&db, &WalOp::UpsertNode(time_node(3, 1, "c", 3000)))
.unwrap();
db.flush().unwrap();
write_internal_wal_op(&db, &WalOp::UpsertNode(time_node(2, 1, "b", 9000)))
.unwrap();
let r = db
.find_nodes_by_time_range_paged("Person",
500,
4000,
&PageRequest {
limit: Some(10),
after: None,
},
)
.unwrap();
assert_eq!(r.items, vec![1, 3]);
db.close().unwrap();
}
#[test]
fn test_time_range_paged_policy_refills_past_sparse_filtered_window() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let mut visible_ids = Vec::new();
for i in 0..17u64 {
let weight = if i < 12 { 0.1 } else { 1.0 };
write_internal_wal_op(&db, &WalOp::UpsertNode(NodeRecord {
id: i + 1,
label_ids: NodeLabelSet::single(1).unwrap(),
key: format!("n{}", i),
props: BTreeMap::new(),
created_at: 1000 + i as i64,
updated_at: 1000 + i as i64,
weight,
dense_vector: None,
sparse_vector: None,
last_write_seq: 0,
}))
.unwrap();
if weight > 0.5 {
visible_ids.push(i + 1);
}
}
db.set_prune_policy(
"low_weight",
PrunePolicy {
max_age_ms: None,
max_weight: Some(0.5),
label: None,
},
)
.unwrap();
let page1 = db
.find_nodes_by_time_range_paged("Person",
1000,
2000,
&PageRequest {
limit: Some(3),
after: None,
},
)
.unwrap();
assert_eq!(page1.items, visible_ids[..3].to_vec());
assert!(page1.next_cursor.is_some());
let page2 = db
.find_nodes_by_time_range_paged("Person",
1000,
2000,
&PageRequest {
limit: Some(3),
after: page1.next_cursor,
},
)
.unwrap();
assert_eq!(page2.items, visible_ids[3..].to_vec());
assert!(page2.next_cursor.is_none());
db.close().unwrap();
}
#[test]
fn test_time_range_paged_cursor_requires_extra_verified_match() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
for i in 0..6u64 {
let weight = if i == 0 { 1.0 } else { 0.1 };
write_internal_wal_op(&db, &WalOp::UpsertNode(NodeRecord {
id: i + 1,
label_ids: NodeLabelSet::single(1).unwrap(),
key: format!("n{}", i),
props: BTreeMap::new(),
created_at: 900 + i as i64,
updated_at: 1000 + i as i64,
weight,
dense_vector: None,
sparse_vector: None,
last_write_seq: 0,
}))
.unwrap();
}
db.set_prune_policy(
"low_weight",
PrunePolicy {
max_age_ms: None,
max_weight: Some(0.5),
label: None,
},
)
.unwrap();
let page = db
.find_nodes_by_time_range_paged("Person",
1000,
2000,
&PageRequest {
limit: Some(1),
after: None,
},
)
.unwrap();
assert_eq!(page.items, vec![1]);
assert!(
page.next_cursor.is_none(),
"time range pagination must not report a next page unless another verified node exists"
);
db.close().unwrap();
}
#[test]
fn test_find_nodes_by_time_range_cursor_no_false_next_after_stale_memberships() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let db = DatabaseEngine::open(&db_path, &DbOptions::default()).unwrap();
let node = |id: u64, label_ids: &[u32], key: &str, updated_at: i64| NodeRecord {
id,
label_ids: NodeLabelSet::from_canonical_ids(label_ids).unwrap(),
key: key.to_string(),
props: BTreeMap::new(),
created_at: updated_at - 100,
updated_at,
weight: 1.0,
dense_vector: None,
sparse_vector: None,
last_write_seq: 0,
};
write_internal_wal_op(&db, &WalOp::UpsertNode(node(1, &[2], "keep", 1000)))
.unwrap();
for id in 2..=6 {
write_internal_wal_op(
&db,
&WalOp::UpsertNode(node(id, &[2, 3], &format!("stale-{id}"), 1000 + id as i64)),
)
.unwrap();
}
db.flush().unwrap();
for id in 2..=6 {
write_internal_wal_op(
&db,
&WalOp::UpsertNode(node(id, &[3], &format!("stale-{id}"), 2000 + id as i64)),
)
.unwrap();
}
let page = db
.find_nodes_by_time_range_paged(
"Company",
1000,
1010,
&PageRequest {
limit: Some(1),
after: None,
},
)
.unwrap();
assert_eq!(page.items, vec![1]);
assert!(
page.next_cursor.is_none(),
"time range pagination must not report a next page for stale label memberships"
);
db.close().unwrap();
}
fn approx_ppr_opts() -> PprOptions {
PprOptions {
algorithm: PprAlgorithm::ApproxForwardPush,
approx_residual_tolerance: 1e-6,
..PprOptions::default()
}
}
#[test]
fn test_ppr_empty_seeds() {
let dir = TempDir::new().unwrap();
let db = open_imm(dir.path());
let result = db
.personalized_pagerank(&[], &PprOptions::default())
.unwrap();
assert!(result.scores.is_empty());
assert_eq!(result.iterations, 0);
assert!(result.converged);
db.close().unwrap();
}
#[test]
fn test_ppr_single_node_no_edges() {
let dir = TempDir::new().unwrap();
let db = open_imm(dir.path());
let n1 = db
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let result = db
.personalized_pagerank(&[n1], &PprOptions::default())
.unwrap();
assert_eq!(result.scores.len(), 1);
assert_eq!(result.scores[0].0, n1);
assert!(
(result.scores[0].1 - 1.0).abs() < 1e-6,
"single dangling node should have rank ~1.0"
);
assert!(result.converged);
db.close().unwrap();
}
#[test]
fn test_ppr_simple_chain() {
let dir = TempDir::new().unwrap();
let db = open_imm(dir.path());
let a = db
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let b = db
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
let c = db
.upsert_node("Person", "c", UpsertNodeOptions::default())
.unwrap();
db.upsert_edge(a, b, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
db.upsert_edge(b, c, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
let opts = PprOptions {
max_iterations: 100,
..PprOptions::default()
};
let result = db.personalized_pagerank(&[a], &opts).unwrap();
assert!(result.converged);
assert!(result.scores.len() >= 2);
let score_a = result
.scores
.iter()
.find(|s| s.0 == a)
.map(|s| s.1)
.unwrap_or(0.0);
let score_b = result
.scores
.iter()
.find(|s| s.0 == b)
.map(|s| s.1)
.unwrap_or(0.0);
let score_c = result
.scores
.iter()
.find(|s| s.0 == c)
.map(|s| s.1)
.unwrap_or(0.0);
assert!(score_a > score_b, "seed A should rank higher than B");
assert!(score_b > score_c, "B should rank higher than C");
db.close().unwrap();
}
#[test]
fn test_ppr_cycle_converges() {
let dir = TempDir::new().unwrap();
let db = open_imm(dir.path());
let a = db
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let b = db
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
db.upsert_edge(a, b, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
db.upsert_edge(b, a, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
let opts = PprOptions {
max_iterations: 100,
..PprOptions::default()
};
let result = db.personalized_pagerank(&[a], &opts).unwrap();
assert!(result.converged);
assert_eq!(result.scores.len(), 2);
let score_a = result.scores.iter().find(|s| s.0 == a).unwrap().1;
let score_b = result.scores.iter().find(|s| s.0 == b).unwrap().1;
assert!(score_a > score_b, "seed should rank higher due to teleport");
assert!(
(score_a + score_b - 1.0).abs() < 1e-4,
"total rank should sum to ~1.0"
);
db.close().unwrap();
}
#[test]
fn test_ppr_weighted_edges() {
let dir = TempDir::new().unwrap();
let db = open_imm(dir.path());
let a = db
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let b = db
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
let c = db
.upsert_node("Person", "c", UpsertNodeOptions::default())
.unwrap();
db.upsert_edge(a, b, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
db.upsert_edge(
a,
c,
"RELATES_TO",
UpsertEdgeOptions {
weight: 9.0,
..Default::default()
},
)
.unwrap();
let opts = PprOptions {
max_iterations: 100,
..PprOptions::default()
};
let result = db.personalized_pagerank(&[a], &opts).unwrap();
assert!(result.converged);
let score_b = result
.scores
.iter()
.find(|s| s.0 == b)
.map(|s| s.1)
.unwrap_or(0.0);
let score_c = result
.scores
.iter()
.find(|s| s.0 == c)
.map(|s| s.1)
.unwrap_or(0.0);
assert!(
score_c > score_b * 3.0,
"heavily-weighted C ({score_c}) should rank much higher than B ({score_b})"
);
db.close().unwrap();
}
#[test]
fn test_ppr_edge_label_filter() {
let dir = TempDir::new().unwrap();
let db = open_imm(dir.path());
let a = db
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let b = db
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
let c = db
.upsert_node("Person", "c", UpsertNodeOptions::default())
.unwrap();
db.upsert_edge(a, b, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
db.upsert_edge(a, c, "WORKS_AT", UpsertEdgeOptions::default())
.unwrap();
let opts = PprOptions {
edge_label_filter: Some(vec!["RELATES_TO".to_string()]),
max_iterations: 100,
..PprOptions::default()
};
let result = db.personalized_pagerank(&[a], &opts).unwrap();
assert!(result.converged);
let score_b = result
.scores
.iter()
.find(|s| s.0 == b)
.map(|s| s.1)
.unwrap_or(0.0);
let score_c = result
.scores
.iter()
.find(|s| s.0 == c)
.map(|s| s.1)
.unwrap_or(0.0);
assert!(score_b > 0.0, "B should receive rank via type-1 edge");
assert_eq!(
score_c, 0.0,
"C should receive no rank (type-2 edge filtered out)"
);
db.close().unwrap();
}
#[test]
fn test_ppr_max_results() {
let dir = TempDir::new().unwrap();
let db = open_imm(dir.path());
let center = db
.upsert_node("Person", "center", UpsertNodeOptions::default())
.unwrap();
for i in 0..10 {
let n = db
.upsert_node("Person", &format!("n{i}"), UpsertNodeOptions::default())
.unwrap();
db.upsert_edge(center, n, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
}
let opts = PprOptions {
max_results: Some(3),
..PprOptions::default()
};
let result = db.personalized_pagerank(&[center], &opts).unwrap();
assert!(result.scores.len() <= 3, "max_results should cap output");
db.close().unwrap();
}
#[test]
fn test_ppr_multiple_seeds() {
let dir = TempDir::new().unwrap();
let db = open_imm(dir.path());
let a = db
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let b = db
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
let c = db
.upsert_node("Person", "c", UpsertNodeOptions::default())
.unwrap();
db.upsert_edge(a, c, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
db.upsert_edge(b, c, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
let opts = PprOptions {
max_iterations: 100,
..PprOptions::default()
};
let result = db.personalized_pagerank(&[a, b], &opts).unwrap();
assert!(result.converged);
let score_c = result
.scores
.iter()
.find(|s| s.0 == c)
.map(|s| s.1)
.unwrap_or(0.0);
assert!(score_c > 0.0, "C should receive rank from both seeds");
db.close().unwrap();
}
#[test]
fn test_ppr_respects_deleted_nodes() {
let dir = TempDir::new().unwrap();
let db = open_imm(dir.path());
let a = db
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let b = db
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
let c = db
.upsert_node("Person", "c", UpsertNodeOptions::default())
.unwrap();
db.upsert_edge(a, b, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
db.upsert_edge(b, c, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
db.delete_node(b).unwrap();
let opts = PprOptions {
max_iterations: 100,
..PprOptions::default()
};
let result = db.personalized_pagerank(&[a], &opts).unwrap();
assert!(result.converged);
let score_b = result
.scores
.iter()
.find(|s| s.0 == b)
.map(|s| s.1)
.unwrap_or(0.0);
let score_c = result
.scores
.iter()
.find(|s| s.0 == c)
.map(|s| s.1)
.unwrap_or(0.0);
assert_eq!(score_b, 0.0, "deleted node B should not appear in results");
assert_eq!(score_c, 0.0, "C unreachable after B deleted");
db.close().unwrap();
}
#[test]
fn test_ppr_deleted_seed_returns_empty() {
let dir = TempDir::new().unwrap();
let db = open_imm(dir.path());
let a = db
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
db.delete_node(a).unwrap();
let result = db
.personalized_pagerank(&[a], &PprOptions::default())
.unwrap();
assert!(
result.scores.is_empty(),
"deleted seed must not appear in PPR results"
);
assert!(result.converged);
assert_eq!(result.iterations, 0);
db.close().unwrap();
}
#[test]
fn test_ppr_nonexistent_seed_returns_empty() {
let dir = TempDir::new().unwrap();
let db = open_imm(dir.path());
let result = db
.personalized_pagerank(&[999], &PprOptions::default())
.unwrap();
assert!(result.scores.is_empty());
assert!(result.converged);
assert_eq!(result.iterations, 0);
db.close().unwrap();
}
#[test]
fn test_ppr_across_flush() {
let dir = TempDir::new().unwrap();
let db = open_imm(dir.path());
let a = db
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let b = db
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
db.upsert_edge(a, b, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
db.flush().unwrap();
let c = db
.upsert_node("Person", "c", UpsertNodeOptions::default())
.unwrap();
db.upsert_edge(b, c, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
let opts = PprOptions {
max_iterations: 100,
..PprOptions::default()
};
let result = db.personalized_pagerank(&[a], &opts).unwrap();
assert!(result.converged);
assert!(
result.scores.len() >= 3,
"should find nodes across memtable + segment"
);
db.close().unwrap();
}
#[test]
fn test_ppr_survives_compaction() {
let dir = TempDir::new().unwrap();
let db = open_imm(dir.path());
let a = db
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let b = db
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
let c = db
.upsert_node("Person", "c", UpsertNodeOptions::default())
.unwrap();
db.upsert_edge(a, b, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
db.upsert_edge(b, c, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
db.flush().unwrap();
db.upsert_edge(a, c, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
db.flush().unwrap();
db.compact().unwrap();
let opts = PprOptions {
max_iterations: 100,
..PprOptions::default()
};
let result = db.personalized_pagerank(&[a], &opts).unwrap();
assert!(result.converged);
assert!(result.scores.len() >= 3);
db.close().unwrap();
}
#[test]
fn test_ppr_duplicate_seeds() {
let dir = TempDir::new().unwrap();
let db = open_imm(dir.path());
let a = db
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let b = db
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
db.upsert_edge(a, b, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
let r1 = db
.personalized_pagerank(&[a], &PprOptions::default())
.unwrap();
let r2 = db
.personalized_pagerank(&[a, a, a], &PprOptions::default())
.unwrap();
assert_eq!(r1.scores.len(), r2.scores.len());
for (s1, s2) in r1.scores.iter().zip(r2.scores.iter()) {
assert_eq!(s1.0, s2.0);
assert!((s1.1 - s2.1).abs() < 1e-10);
}
db.close().unwrap();
}
#[test]
fn test_ppr_known_values() {
let dir = TempDir::new().unwrap();
let db = open_imm(dir.path());
let a = db
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let b = db
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
let c = db
.upsert_node("Person", "c", UpsertNodeOptions::default())
.unwrap();
db.upsert_edge(a, b, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
db.upsert_edge(b, c, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
db.upsert_edge(c, a, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
let d = 0.85_f64;
let opts = PprOptions {
damping_factor: d,
epsilon: 1e-10,
max_iterations: 200,
..PprOptions::default()
};
let result = db.personalized_pagerank(&[a], &opts).unwrap();
assert!(result.converged);
let score_a = result.scores.iter().find(|s| s.0 == a).unwrap().1;
let score_b = result.scores.iter().find(|s| s.0 == b).unwrap().1;
let score_c = result.scores.iter().find(|s| s.0 == c).unwrap().1;
let expected_a = (1.0 - d) / (1.0 - d.powi(3));
let expected_b = d * expected_a;
let expected_c = d * d * expected_a;
assert!(
(score_a - expected_a).abs() < 1e-6,
"A: got {score_a}, expected {expected_a}"
);
assert!(
(score_b - expected_b).abs() < 1e-6,
"B: got {score_b}, expected {expected_b}"
);
assert!(
(score_c - expected_c).abs() < 1e-6,
"C: got {score_c}, expected {expected_c}"
);
assert!(
(score_a + score_b + score_c - 1.0).abs() < 1e-6,
"total should sum to 1.0"
);
db.close().unwrap();
}
#[test]
fn test_ppr_approx_small_graph_matches_exact_order() {
let dir = TempDir::new().unwrap();
let db = open_imm(dir.path());
let a = db
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let b = db
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
let c = db
.upsert_node("Person", "c", UpsertNodeOptions::default())
.unwrap();
db.upsert_edge(a, b, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
db.upsert_edge(
a,
c,
"RELATES_TO",
UpsertEdgeOptions {
weight: 2.0,
..Default::default()
},
)
.unwrap();
db.upsert_edge(b, c, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
let exact = db
.personalized_pagerank(
&[a],
&PprOptions {
max_iterations: 200,
epsilon: 1e-10,
..PprOptions::default()
},
)
.unwrap();
let approx = db
.personalized_pagerank(
&[a],
&PprOptions {
approx_residual_tolerance: 1e-8,
..approx_ppr_opts()
},
)
.unwrap();
let exact_ids: Vec<u64> = exact.scores.iter().map(|(id, _)| *id).collect();
let approx_ids: Vec<u64> = approx.scores.iter().map(|(id, _)| *id).collect();
let exact_scores: std::collections::BTreeMap<u64, f64> = exact.scores.iter().copied().collect();
let approx_scores: std::collections::BTreeMap<u64, f64> =
approx.scores.iter().copied().collect();
assert_eq!(approx.algorithm, PprAlgorithm::ApproxForwardPush);
assert!(approx.approx.is_some());
assert_eq!(approx_ids, exact_ids);
let l1_error: f64 = exact_scores
.iter()
.map(|(&node_id, &exact_score)| {
let approx_score = approx_scores.get(&node_id).copied().unwrap_or(0.0);
let abs_err = (exact_score - approx_score).abs();
assert!(
abs_err < 1e-5,
"node {} exact={} approx={} abs_err={}",
node_id,
exact_score,
approx_score,
abs_err
);
abs_err
})
.sum();
assert!(l1_error < 1e-5, "L1 error too large: {}", l1_error);
db.close().unwrap();
}
#[test]
fn test_ppr_approx_empty_seeds() {
let dir = TempDir::new().unwrap();
let db = open_imm(dir.path());
let result = db.personalized_pagerank(&[], &approx_ppr_opts()).unwrap();
assert!(result.scores.is_empty());
assert_eq!(result.algorithm, PprAlgorithm::ApproxForwardPush);
let approx = result.approx.expect("approx metadata should be present");
assert_eq!(approx.pushes, 0);
assert_eq!(approx.max_remaining_residual, 0.0);
db.close().unwrap();
}
#[test]
fn test_ppr_approx_filters_deleted_seeds() {
let dir = TempDir::new().unwrap();
let db = open_imm(dir.path());
let a = db
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let b = db
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
db.upsert_edge(a, b, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
db.delete_node(a).unwrap();
let result = db.personalized_pagerank(&[a], &approx_ppr_opts()).unwrap();
assert!(result.scores.is_empty());
assert_eq!(result.algorithm, PprAlgorithm::ApproxForwardPush);
db.close().unwrap();
}
#[test]
fn test_ppr_approx_edge_label_filter() {
let dir = TempDir::new().unwrap();
let db = open_imm(dir.path());
let a = db
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let b = db
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
let c = db
.upsert_node("Person", "c", UpsertNodeOptions::default())
.unwrap();
db.upsert_edge(a, b, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
db.upsert_edge(a, c, "WORKS_AT", UpsertEdgeOptions::default())
.unwrap();
let result = db
.personalized_pagerank(
&[a],
&PprOptions {
edge_label_filter: Some(vec!["RELATES_TO".to_string()]),
..approx_ppr_opts()
},
)
.unwrap();
let score_b = result
.scores
.iter()
.find(|s| s.0 == b)
.map(|s| s.1)
.unwrap_or(0.0);
let score_c = result
.scores
.iter()
.find(|s| s.0 == c)
.map(|s| s.1)
.unwrap_or(0.0);
assert!(score_b > 0.0);
assert_eq!(score_c, 0.0);
db.close().unwrap();
}
#[test]
fn test_ppr_approx_respects_deleted_nodes() {
let dir = TempDir::new().unwrap();
let db = open_imm(dir.path());
let a = db
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let b = db
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
let c = db
.upsert_node("Person", "c", UpsertNodeOptions::default())
.unwrap();
db.upsert_edge(a, b, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
db.upsert_edge(b, c, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
db.delete_node(b).unwrap();
let result = db.personalized_pagerank(&[a], &approx_ppr_opts()).unwrap();
let score_b = result
.scores
.iter()
.find(|s| s.0 == b)
.map(|s| s.1)
.unwrap_or(0.0);
let score_c = result
.scores
.iter()
.find(|s| s.0 == c)
.map(|s| s.1)
.unwrap_or(0.0);
assert_eq!(score_b, 0.0);
assert_eq!(score_c, 0.0);
db.close().unwrap();
}
#[test]
fn test_ppr_approx_across_flush() {
let dir = TempDir::new().unwrap();
let db = open_imm(dir.path());
let a = db
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let b = db
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
db.upsert_edge(a, b, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
db.flush().unwrap();
let c = db
.upsert_node("Person", "c", UpsertNodeOptions::default())
.unwrap();
db.upsert_edge(b, c, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
let result = db.personalized_pagerank(&[a], &approx_ppr_opts()).unwrap();
assert_eq!(result.algorithm, PprAlgorithm::ApproxForwardPush);
assert!(result.approx.is_some());
assert!(result.scores.iter().any(|(id, _)| *id == a));
assert!(result.scores.iter().any(|(id, _)| *id == b));
assert!(result.scores.iter().any(|(id, _)| *id == c));
db.close().unwrap();
}
#[test]
fn test_ppr_approx_survives_compaction() {
let dir = TempDir::new().unwrap();
let db = open_imm(dir.path());
let a = db
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let b = db
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
let c = db
.upsert_node("Person", "c", UpsertNodeOptions::default())
.unwrap();
db.upsert_edge(a, b, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
db.upsert_edge(b, c, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
db.flush().unwrap();
db.upsert_edge(a, c, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
db.flush().unwrap();
db.compact().unwrap();
let result = db.personalized_pagerank(&[a], &approx_ppr_opts()).unwrap();
assert_eq!(result.algorithm, PprAlgorithm::ApproxForwardPush);
assert!(result.scores.iter().any(|(id, _)| *id == a));
assert!(result.scores.iter().any(|(id, _)| *id == b));
assert!(result.scores.iter().any(|(id, _)| *id == c));
db.close().unwrap();
}
#[test]
fn test_export_empty_db() {
let dir = TempDir::new().unwrap();
let db = open_imm(dir.path());
let result = db.export_adjacency(&ExportOptions::default()).unwrap();
assert!(result.node_ids.is_empty());
assert!(result.node_labels.is_empty());
assert!(result.node_label_indexes.is_empty());
assert!(result.edges.is_empty());
db.close().unwrap();
}
#[test]
fn test_export_nodes_only() {
let dir = TempDir::new().unwrap();
let db = open_imm(dir.path());
let a = db
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let b = db
.upsert_node("Company", "b", UpsertNodeOptions::default())
.unwrap();
let result = db.export_adjacency(&ExportOptions::default()).unwrap();
assert_eq!(result.node_ids, vec![a, b]);
assert_eq!(result.node_labels, vec!["Person", "Company"]);
assert_eq!(result.node_label_indexes, vec![vec![0], vec![1]]);
assert!(result.edges.is_empty());
let filtered = db
.export_adjacency(&ExportOptions {
node_label_filter: Some(read_node_label_filter(&["Company"], LabelMatchMode::Any)),
..Default::default()
})
.unwrap();
assert_eq!(filtered.node_ids, vec![b]);
assert_eq!(filtered.node_labels, vec!["Company"]);
assert_eq!(filtered.node_label_indexes, vec![vec![0]]);
assert!(filtered.edges.is_empty());
db.close().unwrap();
}
#[test]
fn test_export_node_labels_are_deterministic_for_multi_label_nodes() {
let dir = TempDir::new().unwrap();
let db = open_imm(dir.path());
db.ensure_node_label("Person").unwrap();
db.ensure_node_label("Researcher").unwrap();
db.ensure_node_label("Company").unwrap();
let ids = db
.batch_upsert_nodes(vec![
NodeInput {
labels: vec!["Researcher".to_string(), "Person".to_string()],
key: "alice".to_string(),
props: BTreeMap::new(),
weight: 1.0,
dense_vector: None,
sparse_vector: None,
},
NodeInput {
labels: vec!["Company".to_string(), "Person".to_string()],
key: "ando".to_string(),
props: BTreeMap::new(),
weight: 1.0,
dense_vector: None,
sparse_vector: None,
},
])
.unwrap();
db.upsert_edge(ids[0], ids[1], "WORKS_AT", UpsertEdgeOptions::default())
.unwrap();
let result = db.export_adjacency(&ExportOptions::default()).unwrap();
assert_eq!(result.node_ids, ids);
assert_eq!(
result.node_labels,
vec![
"Person".to_string(),
"Researcher".to_string(),
"Company".to_string()
]
);
assert_eq!(result.node_label_indexes, vec![vec![0, 1], vec![0, 2]]);
assert_eq!(result.edges.len(), 1);
assert_eq!(result.edges[0].from, ids[0]);
assert_eq!(result.edges[0].to, ids[1]);
db.close().unwrap();
}
#[test]
fn test_export_node_labels_stay_deterministic_after_flush_compact_reopen() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("testdb");
let db = open_imm(&db_path);
db.ensure_node_label("Person").unwrap();
db.ensure_node_label("Researcher").unwrap();
db.ensure_node_label("Company").unwrap();
db.ensure_node_label("Mentor").unwrap();
db.ensure_node_label("Reviewer").unwrap();
let ids = db
.batch_upsert_nodes(vec![
NodeInput {
labels: vec!["Person".to_string(), "Researcher".to_string()],
key: "alice".to_string(),
props: BTreeMap::new(),
weight: 1.0,
dense_vector: None,
sparse_vector: None,
},
NodeInput {
labels: vec!["Person".to_string(), "Company".to_string()],
key: "ando".to_string(),
props: BTreeMap::new(),
weight: 1.0,
dense_vector: None,
sparse_vector: None,
},
])
.unwrap();
db.flush().unwrap();
assert!(db.add_node_label(ids[0], "Mentor").unwrap());
assert!(db.remove_node_label(ids[1], "Company").unwrap());
assert!(db.add_node_label(ids[1], "Reviewer").unwrap());
db.flush().unwrap();
db.compact().unwrap();
db.close().unwrap();
let reopened = open_imm(&db_path);
let result = reopened.export_adjacency(&ExportOptions::default()).unwrap();
assert_eq!(result.node_ids, ids);
assert_eq!(
result.node_labels,
vec![
"Person".to_string(),
"Researcher".to_string(),
"Mentor".to_string(),
"Reviewer".to_string()
]
);
assert_eq!(result.node_label_indexes, vec![vec![0, 1, 2], vec![0, 3]]);
reopened.close().unwrap();
}
#[test]
fn test_export_full_graph() {
let dir = TempDir::new().unwrap();
let db = open_imm(dir.path());
let a = db
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let b = db
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
let c = db
.upsert_node("Person", "c", UpsertNodeOptions::default())
.unwrap();
db.upsert_edge(
a,
b,
"RELATES_TO",
UpsertEdgeOptions {
weight: 2.0,
..Default::default()
},
)
.unwrap();
db.upsert_edge(
b,
c,
"RELATES_TO",
UpsertEdgeOptions {
weight: 3.0,
..Default::default()
},
)
.unwrap();
db.upsert_edge(c, a, "WORKS_AT", UpsertEdgeOptions::default())
.unwrap();
let opts = ExportOptions {
include_weights: true,
..Default::default()
};
let result = db.export_adjacency(&opts).unwrap();
assert_eq!(result.node_ids.len(), 3);
assert_eq!(result.edges.len(), 3);
let ab = result
.edges
.iter()
.find(|e| e.from == a && e.to == b)
.unwrap();
assert_eq!(
result.edge_labels[ab.edge_label_index as usize],
"RELATES_TO".to_string()
);
assert!((ab.weight.unwrap() - 2.0).abs() < 1e-6);
let ca = result
.edges
.iter()
.find(|e| e.from == c && e.to == a)
.unwrap();
assert_eq!(
result.edge_labels[ca.edge_label_index as usize],
"WORKS_AT".to_string()
);
db.close().unwrap();
}
#[test]
fn test_export_node_label_filter() {
let dir = TempDir::new().unwrap();
let db = open_imm(dir.path());
let a = db
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let b = db
.upsert_node("Company", "b", UpsertNodeOptions::default())
.unwrap();
let c = db
.upsert_node("Person", "c", UpsertNodeOptions::default())
.unwrap();
let d = db
.upsert_node(
&["Person", "Employee"],
"d",
UpsertNodeOptions::default(),
)
.unwrap();
db.upsert_edge(a, b, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
db.upsert_edge(a, c, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
db.upsert_edge(a, d, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
let opts = ExportOptions {
node_label_filter: Some(read_node_label_filter(&["Person"], LabelMatchMode::Any)),
include_weights: true,
..Default::default()
};
let result = db.export_adjacency(&opts).unwrap();
assert_eq!(result.node_ids.len(), 3);
assert!(result.node_ids.contains(&a));
assert!(result.node_ids.contains(&c));
assert!(result.node_ids.contains(&d));
assert_eq!(result.edges.len(), 2);
assert!(result.edges.iter().any(|edge| edge.from == a && edge.to == c));
assert!(result.edges.iter().any(|edge| edge.from == a && edge.to == d));
let all_opts = ExportOptions {
node_label_filter: Some(read_node_label_filter(
&["Person", "Employee"],
LabelMatchMode::All,
)),
include_weights: true,
..Default::default()
};
let all_result = db.export_adjacency(&all_opts).unwrap();
assert_eq!(all_result.node_ids, vec![d]);
assert!(all_result.edges.is_empty());
db.close().unwrap();
}
#[test]
fn test_export_edge_label_filter() {
let dir = TempDir::new().unwrap();
let db = open_imm(dir.path());
let a = db
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let b = db
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
db.upsert_edge(a, b, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
db.upsert_edge(a, b, "WORKS_AT", UpsertEdgeOptions::default())
.unwrap();
let opts = ExportOptions {
edge_label_filter: Some(vec!["WORKS_AT".to_string()]),
include_weights: true,
..Default::default()
};
let result = db.export_adjacency(&opts).unwrap();
assert_eq!(result.edges.len(), 1);
assert_eq!(
result.edge_labels[result.edges[0].edge_label_index as usize],
"WORKS_AT".to_string()
);
db.close().unwrap();
}
#[test]
fn test_export_include_weights_false() {
let dir = TempDir::new().unwrap();
let db = open_imm(dir.path());
let a = db
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let b = db
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
db.upsert_edge(
a,
b,
"RELATES_TO",
UpsertEdgeOptions {
weight: 5.0,
..Default::default()
},
)
.unwrap();
let opts = ExportOptions {
include_weights: false,
..Default::default()
};
let result = db.export_adjacency(&opts).unwrap();
assert_eq!(result.edges.len(), 1);
assert_eq!(result.edges[0].weight, None);
db.close().unwrap();
}
#[test]
fn test_export_respects_tombstones() {
let dir = TempDir::new().unwrap();
let db = open_imm(dir.path());
let a = db
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let b = db
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
let c = db
.upsert_node("Person", "c", UpsertNodeOptions::default())
.unwrap();
db.upsert_edge(a, b, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
db.upsert_edge(a, c, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
db.delete_node(b).unwrap();
let result = db
.export_adjacency(&ExportOptions {
include_weights: true,
..Default::default()
})
.unwrap();
assert_eq!(result.node_ids.len(), 2); assert!(!result.node_ids.contains(&b));
assert_eq!(result.edges.len(), 1);
assert_eq!(result.edges[0].to, c);
db.close().unwrap();
}
#[test]
fn test_export_across_flush() {
let dir = TempDir::new().unwrap();
let db = open_imm(dir.path());
let a = db
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let b = db
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
db.upsert_edge(a, b, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
db.flush().unwrap();
let c = db
.upsert_node("Person", "c", UpsertNodeOptions::default())
.unwrap();
db.upsert_edge(
b,
c,
"RELATES_TO",
UpsertEdgeOptions {
weight: 2.0,
..Default::default()
},
)
.unwrap();
let result = db
.export_adjacency(&ExportOptions {
include_weights: true,
..Default::default()
})
.unwrap();
assert_eq!(result.node_ids.len(), 3);
assert_eq!(result.edges.len(), 2);
db.close().unwrap();
}
#[test]
fn test_export_survives_compaction() {
let dir = TempDir::new().unwrap();
let db = open_imm(dir.path());
let a = db
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let b = db
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
db.upsert_edge(a, b, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
db.flush().unwrap();
let c = db
.upsert_node("Person", "c", UpsertNodeOptions::default())
.unwrap();
db.upsert_edge(b, c, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
db.flush().unwrap();
db.compact().unwrap();
let result = db
.export_adjacency(&ExportOptions {
include_weights: true,
..Default::default()
})
.unwrap();
assert_eq!(result.node_ids.len(), 3);
assert_eq!(result.edges.len(), 2);
db.close().unwrap();
}
#[test]
fn test_export_node_ids_sorted() {
let dir = TempDir::new().unwrap();
let db = open_imm(dir.path());
for i in 0..10 {
db.upsert_node("Person", &format!("n{i}"), UpsertNodeOptions::default())
.unwrap();
}
let result = db.export_adjacency(&ExportOptions::default()).unwrap();
assert_eq!(result.node_ids.len(), 10);
for i in 1..result.node_ids.len() {
assert!(
result.node_ids[i] > result.node_ids[i - 1],
"node_ids must be sorted"
);
}
db.close().unwrap();
}
#[test]
fn test_export_combined_filters() {
let dir = TempDir::new().unwrap();
let db = open_imm(dir.path());
let a = db
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let b = db
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
let c = db
.upsert_node("Company", "c", UpsertNodeOptions::default())
.unwrap();
db.upsert_edge(a, b, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
db.upsert_edge(
a,
b,
"WORKS_AT",
UpsertEdgeOptions {
weight: 2.0,
..Default::default()
},
)
.unwrap();
db.upsert_edge(
a,
c,
"RELATES_TO",
UpsertEdgeOptions {
weight: 3.0,
..Default::default()
},
)
.unwrap();
let opts = ExportOptions {
node_label_filter: Some(read_node_label_filter(&["Person"], LabelMatchMode::Any)),
edge_label_filter: Some(vec!["RELATES_TO".to_string()]),
include_weights: true,
};
let result = db.export_adjacency(&opts).unwrap();
assert_eq!(result.node_ids.len(), 2); assert_eq!(result.edges.len(), 1);
assert_eq!(result.edges[0].from, a);
assert_eq!(result.edges[0].to, b);
assert_eq!(
result.edge_labels[result.edges[0].edge_label_index as usize],
"RELATES_TO".to_string()
);
assert_eq!(result.edges[0].weight, Some(1.0));
db.close().unwrap();
}
#[test]
fn test_ppr_low_damping_seed_dominates() {
let dir = TempDir::new().unwrap();
let db = open_imm(&dir.path().join("db"));
let a = db
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let b = db
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
let c = db
.upsert_node("Person", "c", UpsertNodeOptions::default())
.unwrap();
db.upsert_edge(a, b, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
db.upsert_edge(b, c, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
let result = db
.personalized_pagerank(
&[a],
&PprOptions {
damping_factor: 0.01,
max_iterations: 100,
..PprOptions::default()
},
)
.unwrap();
let seed_score = result
.scores
.iter()
.find(|s| s.0 == a)
.map(|s| s.1)
.unwrap();
assert!(
seed_score > 0.95,
"seed should have >95% rank with damping=0.01, got {}",
seed_score
);
db.close().unwrap();
}
#[test]
fn test_ppr_high_damping_spreads_rank() {
let dir = TempDir::new().unwrap();
let db = open_imm(&dir.path().join("db"));
let a = db
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let b = db
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
let c = db
.upsert_node("Person", "c", UpsertNodeOptions::default())
.unwrap();
db.upsert_edge(a, b, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
db.upsert_edge(b, c, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
db.upsert_edge(c, a, "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
let result = db
.personalized_pagerank(
&[a],
&PprOptions {
damping_factor: 0.99,
max_iterations: 200,
epsilon: 1e-8,
..PprOptions::default()
},
)
.unwrap();
let seed_score = result
.scores
.iter()
.find(|s| s.0 == a)
.map(|s| s.1)
.unwrap();
assert!(
seed_score < 0.60,
"seed should have <60% rank with damping=0.99, got {}",
seed_score
);
db.close().unwrap();
}
fn hybrid_search_request(
dense_query: Option<Vec<f32>>,
sparse_query: Option<Vec<(u32, f32)>>,
k: usize,
fusion_mode: Option<FusionMode>,
dense_weight: Option<f32>,
sparse_weight: Option<f32>,
) -> VectorSearchRequest {
VectorSearchRequest {
mode: VectorSearchMode::Hybrid,
dense_query,
sparse_query,
k,
label_filter: None,
ef_search: None,
scope: None,
dense_weight,
sparse_weight,
fusion_mode,
}
}
fn setup_hybrid_db() -> (TempDir, DatabaseEngine, [u64; 5]) {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
dense_vector: Some(DenseVectorConfig {
dimension: 4,
metric: DenseMetric::Cosine,
hnsw: HnswConfig::default(),
}),
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
let id1 = engine
.upsert_node(
"Person",
"n1",
UpsertNodeOptions {
dense_vector: Some(vec![0.95, 0.05, 0.05, 0.05]),
sparse_vector: Some(vec![(0, 0.2), (1, 0.1)]),
..Default::default()
},
)
.unwrap();
let id2 = engine
.upsert_node(
"Person",
"n2",
UpsertNodeOptions {
dense_vector: Some(vec![0.3, 0.5, 0.5, 0.5]),
sparse_vector: Some(vec![(0, 0.9), (1, 0.8), (2, 0.7)]),
..Default::default()
},
)
.unwrap();
let id3 = engine
.upsert_node(
"Person",
"n3",
UpsertNodeOptions {
dense_vector: Some(vec![0.85, 0.1, 0.1, 0.1]),
sparse_vector: Some(vec![(0, 0.7), (1, 0.6)]),
..Default::default()
},
)
.unwrap();
let id4 = engine
.upsert_node(
"Person",
"n4",
UpsertNodeOptions {
dense_vector: Some(vec![0.6, 0.3, 0.3, 0.3]),
sparse_vector: Some(vec![(0, 0.5), (2, 0.3)]),
..Default::default()
},
)
.unwrap();
let id5 = engine
.upsert_node(
"Person",
"n5",
UpsertNodeOptions {
dense_vector: Some(vec![0.1, 0.4, 0.6, 0.6]),
sparse_vector: Some(vec![(1, 0.1)]),
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
(dir, engine, [id1, id2, id3, id4, id5])
}
#[test]
fn test_hybrid_degeneration_dense_only() {
let (_dir, engine, _ids) = setup_hybrid_db();
let query = vec![1.0, 0.0, 0.0, 0.0];
let dense_results = engine
.vector_search(&dense_search_request(query.clone(), 5, None, None))
.unwrap();
let hybrid_results = engine
.vector_search(&hybrid_search_request(
Some(query),
None,
5,
None,
None,
None,
))
.unwrap();
assert_eq!(dense_results.len(), hybrid_results.len());
for (d, h) in dense_results.iter().zip(hybrid_results.iter()) {
assert_eq!(d.node_id, h.node_id);
assert!((d.score - h.score).abs() < 1e-6);
}
}
#[test]
fn test_hybrid_degeneration_sparse_only() {
let (_dir, engine, _ids) = setup_hybrid_db();
let query = vec![(0, 1.0), (1, 0.5), (2, 0.3)];
let sparse_results = engine
.vector_search(&sparse_search_request(query.clone(), 5, None))
.unwrap();
let hybrid_results = engine
.vector_search(&hybrid_search_request(
None,
Some(query),
5,
None,
None,
None,
))
.unwrap();
assert_eq!(sparse_results.len(), hybrid_results.len());
for (s, h) in sparse_results.iter().zip(hybrid_results.iter()) {
assert_eq!(s.node_id, h.node_id);
assert!((s.score - h.score).abs() < 1e-6);
}
}
#[test]
fn test_hybrid_missing_both_queries_errors() {
let (_dir, engine, _ids) = setup_hybrid_db();
let err = engine
.vector_search(&hybrid_search_request(None, None, 5, None, None, None))
.unwrap_err();
assert!(err.to_string().contains("requires at least one"));
}
#[test]
fn test_hybrid_k_zero_returns_empty() {
let (_dir, engine, _ids) = setup_hybrid_db();
let results = engine
.vector_search(&hybrid_search_request(
Some(vec![1.0, 0.0, 0.0, 0.0]),
Some(vec![(0, 1.0)]),
0,
None,
None,
None,
))
.unwrap();
assert!(results.is_empty());
}
#[test]
fn test_hybrid_weighted_rank_fusion_ordering() {
let (_dir, engine, ids) = setup_hybrid_db();
let dense_query = vec![1.0, 0.0, 0.0, 0.0];
let sparse_query = vec![(0, 1.0), (1, 0.5), (2, 0.3)];
let results = engine
.vector_search(&hybrid_search_request(
Some(dense_query),
Some(sparse_query),
5,
Some(FusionMode::WeightedRankFusion),
Some(1.0),
Some(1.0),
))
.unwrap();
assert_eq!(results.len(), 5);
assert_eq!(results[0].node_id, ids[2], "balanced node should be first");
let result_ids: Vec<u64> = results.iter().map(|h| h.node_id).collect();
assert!(result_ids.contains(&ids[0]));
assert!(result_ids.contains(&ids[1]));
assert!(result_ids.contains(&ids[3]));
assert!(result_ids.contains(&ids[4]));
}
#[test]
fn test_hybrid_weighted_rank_fusion_custom_weights() {
let (_dir, engine, ids) = setup_hybrid_db();
let dense_query = vec![1.0, 0.0, 0.0, 0.0];
let sparse_query = vec![(0, 1.0), (1, 0.5), (2, 0.3)];
let results = engine
.vector_search(&hybrid_search_request(
Some(dense_query.clone()),
Some(sparse_query.clone()),
5,
Some(FusionMode::WeightedRankFusion),
Some(5.0),
Some(1.0),
))
.unwrap();
assert_eq!(
results[0].node_id, ids[0],
"heavy dense weight should promote dense #1"
);
let results = engine
.vector_search(&hybrid_search_request(
Some(dense_query),
Some(sparse_query),
5,
Some(FusionMode::WeightedRankFusion),
Some(1.0),
Some(5.0),
))
.unwrap();
assert_eq!(
results[0].node_id, ids[1],
"heavy sparse weight should promote sparse #1"
);
}
#[test]
fn test_hybrid_reciprocal_rank_fusion_ordering() {
let (_dir, engine, ids) = setup_hybrid_db();
let dense_query = vec![1.0, 0.0, 0.0, 0.0];
let sparse_query = vec![(0, 1.0), (1, 0.5), (2, 0.3)];
let results_default = engine
.vector_search(&hybrid_search_request(
Some(dense_query.clone()),
Some(sparse_query.clone()),
5,
Some(FusionMode::ReciprocalRankFusion),
None,
None,
))
.unwrap();
let results_weighted = engine
.vector_search(&hybrid_search_request(
Some(dense_query),
Some(sparse_query),
5,
Some(FusionMode::ReciprocalRankFusion),
Some(99.0),
Some(0.01),
))
.unwrap();
assert_eq!(results_default.len(), results_weighted.len());
for (d, w) in results_default.iter().zip(results_weighted.iter()) {
assert_eq!(d.node_id, w.node_id, "RRF should ignore weights");
assert!((d.score - w.score).abs() < 1e-6, "RRF scores should match");
}
assert_eq!(results_default[0].node_id, ids[2]);
}
#[test]
fn test_hybrid_weighted_score_fusion_ordering() {
let (_dir, engine, _ids) = setup_hybrid_db();
let dense_query = vec![1.0, 0.0, 0.0, 0.0];
let sparse_query = vec![(0, 1.0), (1, 0.5), (2, 0.3)];
let results = engine
.vector_search(&hybrid_search_request(
Some(dense_query),
Some(sparse_query),
5,
Some(FusionMode::WeightedScoreFusion),
Some(1.0),
Some(1.0),
))
.unwrap();
assert_eq!(results.len(), 5);
for hit in &results {
assert!(
hit.score >= 0.0,
"fused score should be non-negative for node {}",
hit.node_id
);
}
for w in results.windows(2) {
assert!(
w[0].score >= w[1].score,
"results should be sorted descending: {} >= {}",
w[0].score,
w[1].score
);
}
}
#[test]
fn test_hybrid_weighted_score_fusion_equal_dense_scores() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
dense_vector: Some(DenseVectorConfig {
dimension: 2,
metric: DenseMetric::Cosine,
hnsw: HnswConfig::default(),
}),
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
let id_a = engine
.upsert_node(
"Person",
"a",
UpsertNodeOptions {
dense_vector: Some(vec![1.0, 0.0]),
sparse_vector: Some(vec![(0, 0.3)]),
..Default::default()
},
)
.unwrap();
let id_b = engine
.upsert_node(
"Person",
"b",
UpsertNodeOptions {
dense_vector: Some(vec![1.0, 0.0]),
sparse_vector: Some(vec![(0, 0.9)]),
..Default::default()
},
)
.unwrap();
let id_c = engine
.upsert_node(
"Person",
"c",
UpsertNodeOptions {
dense_vector: Some(vec![1.0, 0.0]),
sparse_vector: Some(vec![(0, 0.6)]),
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
let results = engine
.vector_search(&hybrid_search_request(
Some(vec![1.0, 0.0]),
Some(vec![(0, 1.0)]),
3,
Some(FusionMode::WeightedScoreFusion),
Some(1.0),
Some(1.0),
))
.unwrap();
assert_eq!(results.len(), 3);
assert_eq!(results[0].node_id, id_b);
assert_eq!(results[1].node_id, id_c);
assert_eq!(results[2].node_id, id_a);
engine.close().unwrap();
}
#[test]
fn test_hybrid_partial_overlap() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
dense_vector: Some(DenseVectorConfig {
dimension: 2,
metric: DenseMetric::Cosine,
hnsw: HnswConfig::default(),
}),
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
engine
.upsert_node(
"Person",
"dense_only",
UpsertNodeOptions {
dense_vector: Some(vec![1.0, 0.0]),
..Default::default()
},
)
.unwrap();
engine
.upsert_node(
"Person",
"sparse_only",
UpsertNodeOptions {
sparse_vector: Some(vec![(0, 1.0)]),
..Default::default()
},
)
.unwrap();
engine
.upsert_node(
"Person",
"both",
UpsertNodeOptions {
dense_vector: Some(vec![0.7, 0.7]),
sparse_vector: Some(vec![(0, 0.5)]),
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
let results = engine
.vector_search(&hybrid_search_request(
Some(vec![1.0, 0.0]),
Some(vec![(0, 1.0)]),
10,
Some(FusionMode::WeightedRankFusion),
Some(1.0),
Some(1.0),
))
.unwrap();
let ids: Vec<u64> = results.iter().map(|h| h.node_id).collect();
assert!(ids.contains(&1), "dense-only node should appear");
assert!(ids.contains(&2), "sparse-only node should appear");
assert!(ids.contains(&3), "both-modality node should appear");
engine.close().unwrap();
}
#[test]
fn test_hybrid_with_scope() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
dense_vector: Some(DenseVectorConfig {
dimension: 2,
metric: DenseMetric::Cosine,
hnsw: HnswConfig::default(),
}),
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
let mut ids = Vec::new();
for i in 0..4u64 {
let id = engine
.upsert_node(
"Person",
&format!("n{}", i),
UpsertNodeOptions {
dense_vector: Some(vec![1.0, 0.0]),
sparse_vector: Some(vec![(0, (i + 1) as f32 * 0.3)]),
..Default::default()
},
)
.unwrap();
ids.push(id);
}
engine
.upsert_edge(ids[0], ids[1], "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
engine
.upsert_edge(ids[1], ids[2], "RELATES_TO", UpsertEdgeOptions::default())
.unwrap();
engine.flush().unwrap();
let mut req = hybrid_search_request(
Some(vec![1.0, 0.0]),
Some(vec![(0, 1.0)]),
10,
None,
None,
None,
);
req.scope = Some(VectorSearchScope {
start_node_id: ids[0],
max_depth: 1,
direction: Direction::Outgoing,
edge_label_filter: None,
at_epoch: None,
});
let results = engine.vector_search(&req).unwrap();
let result_ids: Vec<u64> = results.iter().map(|h| h.node_id).collect();
assert!(
result_ids.contains(&ids[0]),
"start node should be in scope"
);
assert!(
result_ids.contains(&ids[1]),
"depth-1 neighbor should be in scope"
);
assert!(
!result_ids.contains(&ids[2]),
"depth-2 node should be out of scope"
);
assert!(
!result_ids.contains(&ids[3]),
"disconnected node should be out of scope"
);
engine.close().unwrap();
}
#[test]
fn test_hybrid_label_filter() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
dense_vector: Some(DenseVectorConfig {
dimension: 2,
metric: DenseMetric::Cosine,
hnsw: HnswConfig::default(),
}),
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
let id_article = engine
.upsert_node(
"Person",
"a",
UpsertNodeOptions {
dense_vector: Some(vec![1.0, 0.0]),
sparse_vector: Some(vec![(0, 1.0)]),
..Default::default()
},
)
.unwrap();
engine
.upsert_node(
"Company",
"b",
UpsertNodeOptions {
dense_vector: Some(vec![0.9, 0.1]),
sparse_vector: Some(vec![(0, 0.9)]),
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
let mut req = hybrid_search_request(
Some(vec![1.0, 0.0]),
Some(vec![(0, 1.0)]),
10,
None,
None,
None,
);
req.label_filter = Some(read_node_label_filter(&["Person"], LabelMatchMode::Any));
let results = engine.vector_search(&req).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].node_id, id_article);
engine.close().unwrap();
}
#[test]
fn test_hybrid_default_fusion_mode() {
let (_dir, engine, _ids) = setup_hybrid_db();
let dense_query = vec![1.0, 0.0, 0.0, 0.0];
let sparse_query = vec![(0, 1.0), (1, 0.5), (2, 0.3)];
let results_default = engine
.vector_search(&hybrid_search_request(
Some(dense_query.clone()),
Some(sparse_query.clone()),
5,
None,
Some(1.0),
Some(1.0),
))
.unwrap();
let results_explicit = engine
.vector_search(&hybrid_search_request(
Some(dense_query),
Some(sparse_query),
5,
Some(FusionMode::WeightedRankFusion),
Some(1.0),
Some(1.0),
))
.unwrap();
assert_eq!(results_default.len(), results_explicit.len());
for (d, e) in results_default.iter().zip(results_explicit.iter()) {
assert_eq!(d.node_id, e.node_id, "default should match explicit WRF");
assert!((d.score - e.score).abs() < 1e-6);
}
}
#[test]
fn test_hybrid_accuracy_oracle_20_nodes() {
let dir = TempDir::new().unwrap();
let opts = DbOptions {
dense_vector: Some(DenseVectorConfig {
dimension: 8,
metric: DenseMetric::Cosine,
hnsw: HnswConfig::default(),
}),
..DbOptions::default()
};
let engine = DatabaseEngine::open(dir.path(), &opts).unwrap();
struct TestNode {
key: &'static str,
dense: [f32; 8],
sparse: Vec<(u32, f32)>,
}
let nodes = [
TestNode {
key: "a1",
dense: [0.98, 0.10, 0.05, 0.05, 0.05, 0.05, 0.05, 0.05],
sparse: vec![(0, 0.1)],
},
TestNode {
key: "a2",
dense: [0.92, 0.20, 0.10, 0.10, 0.10, 0.10, 0.10, 0.10],
sparse: vec![(0, 0.15), (1, 0.05)],
},
TestNode {
key: "a3",
dense: [0.88, 0.25, 0.15, 0.10, 0.10, 0.05, 0.05, 0.05],
sparse: vec![(2, 0.2)],
},
TestNode {
key: "a4",
dense: [0.80, 0.30, 0.20, 0.15, 0.15, 0.10, 0.10, 0.10],
sparse: vec![(0, 0.05), (3, 0.1)],
},
TestNode {
key: "b1",
dense: [0.20, 0.50, 0.50, 0.40, 0.30, 0.20, 0.20, 0.20],
sparse: vec![(0, 0.9), (1, 0.8), (2, 0.7)],
},
TestNode {
key: "b2",
dense: [0.25, 0.45, 0.45, 0.45, 0.35, 0.25, 0.25, 0.15],
sparse: vec![(0, 0.8), (1, 0.7), (2, 0.5)],
},
TestNode {
key: "b3",
dense: [0.30, 0.40, 0.40, 0.40, 0.30, 0.25, 0.25, 0.25],
sparse: vec![(0, 0.7), (1, 0.5), (2, 0.3)],
},
TestNode {
key: "b4",
dense: [0.15, 0.55, 0.50, 0.35, 0.30, 0.25, 0.20, 0.20],
sparse: vec![(0, 0.6), (2, 0.8)],
},
TestNode {
key: "c1",
dense: [0.85, 0.20, 0.15, 0.15, 0.15, 0.15, 0.15, 0.15],
sparse: vec![(0, 0.6), (1, 0.5), (2, 0.4)],
},
TestNode {
key: "c2",
dense: [0.75, 0.30, 0.25, 0.20, 0.20, 0.15, 0.15, 0.15],
sparse: vec![(0, 0.5), (1, 0.6)],
},
TestNode {
key: "c3",
dense: [0.70, 0.35, 0.30, 0.25, 0.20, 0.15, 0.10, 0.10],
sparse: vec![(0, 0.7), (1, 0.3), (2, 0.2)],
},
TestNode {
key: "c4",
dense: [0.78, 0.28, 0.22, 0.18, 0.18, 0.14, 0.12, 0.12],
sparse: vec![(0, 0.55), (1, 0.45), (2, 0.35)],
},
TestNode {
key: "d1",
dense: [0.40, 0.40, 0.35, 0.35, 0.30, 0.30, 0.25, 0.25],
sparse: vec![(0, 0.2), (3, 0.1)],
},
TestNode {
key: "d2",
dense: [0.35, 0.35, 0.35, 0.35, 0.35, 0.30, 0.30, 0.25],
sparse: vec![(1, 0.2), (2, 0.15)],
},
TestNode {
key: "d3",
dense: [0.45, 0.38, 0.32, 0.30, 0.28, 0.28, 0.25, 0.20],
sparse: vec![(0, 0.3), (1, 0.1)],
},
TestNode {
key: "d4",
dense: [0.38, 0.38, 0.36, 0.34, 0.30, 0.28, 0.26, 0.24],
sparse: vec![(2, 0.25)],
},
TestNode {
key: "e1",
dense: [0.50, 0.35, 0.30, 0.30, 0.30, 0.25, 0.25, 0.25],
sparse: vec![(1, 0.9)],
},
TestNode {
key: "e2",
dense: [0.55, 0.35, 0.28, 0.28, 0.28, 0.25, 0.25, 0.20],
sparse: vec![(2, 0.95)],
},
TestNode {
key: "e3",
dense: [0.48, 0.38, 0.32, 0.30, 0.28, 0.26, 0.24, 0.22],
sparse: vec![(0, 0.4), (1, 0.4)],
},
TestNode {
key: "e4",
dense: [0.52, 0.36, 0.30, 0.28, 0.26, 0.24, 0.24, 0.22],
sparse: vec![(0, 0.3), (2, 0.4)],
},
];
let mut ids: Vec<(String, u64)> = Vec::new();
for node in &nodes {
let norm = node.dense.iter().map(|v| v * v).sum::<f32>().sqrt();
let dense_norm: Vec<f32> = node.dense.iter().map(|v| v / norm).collect();
let id = engine
.upsert_node(
"Person",
node.key,
UpsertNodeOptions {
dense_vector: Some(dense_norm),
sparse_vector: Some(node.sparse.clone()),
..Default::default()
},
)
.unwrap();
ids.push((node.key.to_string(), id));
}
engine.flush().unwrap();
let dense_query_raw = [1.0f32, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
let dq_norm = dense_query_raw.iter().map(|v| v * v).sum::<f32>().sqrt();
let dense_query: Vec<f32> = dense_query_raw.iter().map(|v| v / dq_norm).collect();
let sparse_query: Vec<(u32, f32)> = vec![(0, 1.0), (1, 0.8), (2, 0.5)];
struct OracleEntry {
node_id: u64,
dense_score: f32,
sparse_score: f32,
}
let mut oracle: Vec<OracleEntry> = Vec::new();
for (i, node) in nodes.iter().enumerate() {
let norm = node.dense.iter().map(|v| v * v).sum::<f32>().sqrt();
let dense_norm: Vec<f32> = node.dense.iter().map(|v| v / norm).collect();
let dense_score: f32 = dense_norm
.iter()
.zip(dense_query.iter())
.map(|(a, b)| a * b)
.sum();
let sparse_score: f32 = node
.sparse
.iter()
.map(|(dim, val)| {
sparse_query
.iter()
.find(|(qd, _)| *qd == *dim)
.map(|(_, qv)| qv * val)
.unwrap_or(0.0)
})
.sum();
oracle.push(OracleEntry {
node_id: ids[i].1,
dense_score,
sparse_score,
});
}
let mut dense_rank: Vec<(u64, usize)> = {
let mut sorted: Vec<_> = oracle.iter().collect();
sorted.sort_by(|a, b| b.dense_score.total_cmp(&a.dense_score));
sorted
.iter()
.enumerate()
.map(|(rank, e)| (e.node_id, rank + 1))
.collect()
};
dense_rank.sort_by_key(|(id, _)| *id);
let mut sparse_rank: Vec<(u64, usize)> = {
let mut sorted: Vec<_> = oracle.iter().collect();
sorted.sort_by(|a, b| b.sparse_score.total_cmp(&a.sparse_score));
sorted
.iter()
.enumerate()
.map(|(rank, e)| (e.node_id, rank + 1))
.collect()
};
sparse_rank.sort_by_key(|(id, _)| *id);
let rrf_k = 60.0f64;
let wrf_score = |node_id: u64, wd: f64, ws: f64| -> f64 {
let dr = dense_rank.iter().find(|(id, _)| *id == node_id).unwrap().1 as f64;
let sr = sparse_rank.iter().find(|(id, _)| *id == node_id).unwrap().1 as f64;
wd / (rrf_k + dr) + ws / (rrf_k + sr)
};
let k = 10usize;
let mut oracle_wrf: Vec<(u64, f64)> = oracle
.iter()
.map(|e| (e.node_id, wrf_score(e.node_id, 1.0, 1.0)))
.collect();
oracle_wrf.sort_by(|a, b| b.1.total_cmp(&a.1));
let oracle_top_k_wrf: Vec<u64> = oracle_wrf.iter().take(k).map(|(id, _)| *id).collect();
let results = engine
.vector_search(&hybrid_search_request(
Some(dense_query.clone()),
Some(sparse_query.clone()),
k,
Some(FusionMode::WeightedRankFusion),
Some(1.0),
Some(1.0),
))
.unwrap();
let result_ids: Vec<u64> = results.iter().map(|h| h.node_id).collect();
assert_eq!(
result_ids, oracle_top_k_wrf,
"WRF equal-weight top-{k} should match oracle.\n engine: {result_ids:?}\n oracle: {oracle_top_k_wrf:?}"
);
let mut oracle_dense_heavy: Vec<(u64, f64)> = oracle
.iter()
.map(|e| (e.node_id, wrf_score(e.node_id, 5.0, 1.0)))
.collect();
oracle_dense_heavy.sort_by(|a, b| b.1.total_cmp(&a.1));
let oracle_top_k_dh: Vec<u64> = oracle_dense_heavy
.iter()
.take(k)
.map(|(id, _)| *id)
.collect();
let results_dh = engine
.vector_search(&hybrid_search_request(
Some(dense_query.clone()),
Some(sparse_query.clone()),
k,
Some(FusionMode::WeightedRankFusion),
Some(5.0),
Some(1.0),
))
.unwrap();
let result_ids_dh: Vec<u64> = results_dh.iter().map(|h| h.node_id).collect();
assert_eq!(
result_ids_dh, oracle_top_k_dh,
"WRF dense-heavy top-{k} should match oracle.\n engine: {result_ids_dh:?}\n oracle: {oracle_top_k_dh:?}"
);
let a1_id = ids.iter().find(|(k, _)| k == "a1").unwrap().1;
let a1_pos = result_ids_dh.iter().position(|id| *id == a1_id);
assert!(
a1_pos.is_some() && a1_pos.unwrap() < 3,
"a1 (dense rank #1) should be top-3 with 5:1 dense weight, found at {:?}",
a1_pos
);
let mut oracle_sparse_heavy: Vec<(u64, f64)> = oracle
.iter()
.map(|e| (e.node_id, wrf_score(e.node_id, 1.0, 5.0)))
.collect();
oracle_sparse_heavy.sort_by(|a, b| b.1.total_cmp(&a.1));
let oracle_top_k_sh: Vec<u64> = oracle_sparse_heavy
.iter()
.take(k)
.map(|(id, _)| *id)
.collect();
let results_sh = engine
.vector_search(&hybrid_search_request(
Some(dense_query.clone()),
Some(sparse_query.clone()),
k,
Some(FusionMode::WeightedRankFusion),
Some(1.0),
Some(5.0),
))
.unwrap();
let result_ids_sh: Vec<u64> = results_sh.iter().map(|h| h.node_id).collect();
assert_eq!(
result_ids_sh, oracle_top_k_sh,
"WRF sparse-heavy top-{k} should match oracle.\n engine: {result_ids_sh:?}\n oracle: {oracle_top_k_sh:?}"
);
let b1_id = ids.iter().find(|(k, _)| k == "b1").unwrap().1;
let b1_pos = result_ids_sh.iter().position(|id| *id == b1_id);
assert!(
b1_pos.is_some() && b1_pos.unwrap() < 3,
"b1 (sparse rank #1) should be top-3 with 1:5 sparse weight, found at {:?}",
b1_pos
);
let c1_id = ids.iter().find(|(k, _)| k == "c1").unwrap().1;
let c4_id = ids.iter().find(|(k, _)| k == "c4").unwrap().1;
let c1_pos = result_ids.iter().position(|id| *id == c1_id);
let c4_pos = result_ids.iter().position(|id| *id == c4_id);
assert!(
c1_pos.is_some() && c1_pos.unwrap() < 5,
"c1 (balanced) should be top-5 with equal weights, found at {:?}",
c1_pos
);
assert!(
c4_pos.is_some() && c4_pos.unwrap() < 5,
"c4 (balanced) should be top-5 with equal weights, found at {:?}",
c4_pos
);
let d_ids: Vec<u64> = ids
.iter()
.filter(|(k, _)| k.starts_with('d'))
.map(|(_, id)| *id)
.collect();
let top5: &[u64] = &result_ids[..5];
for d_id in &d_ids {
assert!(
!top5.contains(d_id),
"mediocre d-group node {} should not be in top 5",
d_id
);
}
}
#[test]
fn test_get_nodes_by_keys_basic() {
let dir = TempDir::new().unwrap();
let engine = open_imm(&dir.path().join("db"));
engine
.upsert_node(
"Person",
"alice",
UpsertNodeOptions {
props: make_props("name", "A"),
..Default::default()
},
)
.unwrap();
engine
.upsert_node(
"Person",
"bob",
UpsertNodeOptions {
props: make_props("name", "B"),
..Default::default()
},
)
.unwrap();
engine
.upsert_node(
"Company",
"charlie",
UpsertNodeOptions {
props: make_props("name", "C"),
..Default::default()
},
)
.unwrap();
let keys: Vec<(&str, &str)> = vec![("Person", "alice"), ("Person", "bob"), ("Company", "charlie")];
let results = engine.get_nodes_by_keys(&read_node_key_queries(&keys)).unwrap();
assert_eq!(results.len(), 3);
assert_eq!(results[0].as_ref().unwrap().key, "alice");
assert_eq!(results[1].as_ref().unwrap().key, "bob");
assert_eq!(results[2].as_ref().unwrap().key, "charlie");
engine.close().unwrap();
}
#[test]
fn test_get_nodes_by_keys_mixed_found_missing() {
let dir = TempDir::new().unwrap();
let engine = open_imm(&dir.path().join("db"));
engine
.upsert_node("Person", "alice", UpsertNodeOptions::default())
.unwrap();
let b = engine
.upsert_node("Person", "bob", UpsertNodeOptions::default())
.unwrap();
engine.delete_node(b).unwrap();
let keys: Vec<(&str, &str)> = vec![("Person", "alice"), ("Person", "bob"), ("Person", "nonexistent")];
let results = engine.get_nodes_by_keys(&read_node_key_queries(&keys)).unwrap();
assert_eq!(results.len(), 3);
assert!(results[0].is_some());
assert!(results[1].is_none()); assert!(results[2].is_none()); engine.close().unwrap();
}
#[test]
fn test_get_nodes_by_keys_cross_source() {
let dir = TempDir::new().unwrap();
let engine = open_imm(&dir.path().join("db"));
engine
.upsert_node("Person", "alice", UpsertNodeOptions::default())
.unwrap();
engine.flush().unwrap();
engine
.upsert_node("Person", "bob", UpsertNodeOptions::default())
.unwrap();
let keys: Vec<(&str, &str)> = vec![("Person", "alice"), ("Person", "bob")];
let results = engine.get_nodes_by_keys(&read_node_key_queries(&keys)).unwrap();
assert_eq!(results[0].as_ref().unwrap().key, "alice");
assert_eq!(results[1].as_ref().unwrap().key, "bob");
engine.close().unwrap();
}
#[test]
fn test_get_nodes_by_keys_empty() {
let dir = TempDir::new().unwrap();
let engine = open_imm(&dir.path().join("db"));
let keys: Vec<(&str, &str)> = vec![];
let results = engine.get_nodes_by_keys(&read_node_key_queries(&keys)).unwrap();
assert!(results.is_empty());
engine.close().unwrap();
}
#[test]
fn test_get_nodes_by_keys_multi_segment() {
let dir = TempDir::new().unwrap();
let engine = open_imm(&dir.path().join("db"));
engine
.upsert_node(
"Person",
"a",
UpsertNodeOptions {
props: make_props("seg", "1"),
..Default::default()
},
)
.unwrap();
engine
.upsert_node(
"Person",
"b",
UpsertNodeOptions {
props: make_props("seg", "1"),
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
engine
.upsert_node(
"Person",
"c",
UpsertNodeOptions {
props: make_props("seg", "2"),
..Default::default()
},
)
.unwrap();
engine
.upsert_node(
"Person",
"d",
UpsertNodeOptions {
props: make_props("seg", "2"),
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
let keys: Vec<(&str, &str)> = vec![("Person", "d"), ("Person", "a"), ("Person", "c"), ("Person", "b")];
let results = engine.get_nodes_by_keys(&read_node_key_queries(&keys)).unwrap();
assert_eq!(results.len(), 4);
assert_eq!(results[0].as_ref().unwrap().key, "d");
assert_eq!(results[1].as_ref().unwrap().key, "a");
assert_eq!(results[2].as_ref().unwrap().key, "c");
assert_eq!(results[3].as_ref().unwrap().key, "b");
engine.close().unwrap();
}
#[test]
fn test_get_nodes_by_keys_tombstone_in_newer_segment() {
let dir = TempDir::new().unwrap();
let engine = open_imm(&dir.path().join("db"));
let a = engine
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
engine
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
engine.flush().unwrap(); engine.delete_node(a).unwrap();
engine
.upsert_node("Person", "c", UpsertNodeOptions::default())
.unwrap();
engine.flush().unwrap();
let keys: Vec<(&str, &str)> = vec![("Person", "a"), ("Person", "b"), ("Person", "c")];
let results = engine.get_nodes_by_keys(&read_node_key_queries(&keys)).unwrap();
assert!(results[0].is_none()); assert_eq!(results[1].as_ref().unwrap().key, "b");
assert_eq!(results[2].as_ref().unwrap().key, "c");
engine.close().unwrap();
}
#[test]
fn test_get_nodes_by_keys_memtable_shadows_segment() {
let dir = TempDir::new().unwrap();
let engine = open_imm(&dir.path().join("db"));
engine
.upsert_node(
"Person",
"a",
UpsertNodeOptions {
props: make_props("v", "old"),
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
engine
.upsert_node(
"Person",
"a",
UpsertNodeOptions {
props: make_props("v", "new"),
weight: 2.0,
..Default::default()
},
)
.unwrap();
let keys: Vec<(&str, &str)> = vec![("Person", "a")];
let results = engine.get_nodes_by_keys(&read_node_key_queries(&keys)).unwrap();
let node = results[0].as_ref().unwrap();
assert_eq!(
node.props.get("v"),
Some(&PropValue::String("new".to_string()))
);
assert_eq!(node.weight, 2.0);
engine.close().unwrap();
}
#[test]
fn test_get_nodes_by_keys_duplicate_keys() {
let dir = TempDir::new().unwrap();
let engine = open_imm(&dir.path().join("db"));
engine
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
let keys: Vec<(&str, &str)> = vec![("Person", "a"), ("Person", "a"), ("Person", "a")];
let results = engine.get_nodes_by_keys(&read_node_key_queries(&keys)).unwrap();
assert_eq!(results.len(), 3);
assert_eq!(results[0].as_ref().unwrap().key, "a");
assert_eq!(results[1].as_ref().unwrap().key, "a");
assert_eq!(results[2].as_ref().unwrap().key, "a");
engine.close().unwrap();
}
#[test]
fn test_get_nodes_by_keys_after_compaction() {
let dir = TempDir::new().unwrap();
let engine = DatabaseEngine::open(
&dir.path().join("db"),
&DbOptions {
create_if_missing: true,
wal_sync_mode: WalSyncMode::Immediate,
compact_after_n_flushes: 2,
..Default::default()
},
)
.unwrap();
engine
.upsert_node("Person", "a", UpsertNodeOptions::default())
.unwrap();
engine
.upsert_node("Person", "b", UpsertNodeOptions::default())
.unwrap();
engine.flush().unwrap();
engine
.upsert_node("Person", "c", UpsertNodeOptions::default())
.unwrap();
engine.flush().unwrap();
let keys: Vec<(&str, &str)> = vec![("Person", "a"), ("Person", "b"), ("Person", "c")];
let results = engine.get_nodes_by_keys(&read_node_key_queries(&keys)).unwrap();
assert_eq!(results.len(), 3);
assert!(results.iter().all(|r| r.is_some()));
engine.close().unwrap();
}
#[test]
fn test_get_nodes_by_keys_delete_then_recreate() {
let dir = TempDir::new().unwrap();
let engine = open_imm(&dir.path().join("db"));
let old_id = engine
.upsert_node(
"Person",
"a",
UpsertNodeOptions {
props: make_props("v", "old"),
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
engine.delete_node(old_id).unwrap();
let new_id = engine
.upsert_node(
"Person",
"a",
UpsertNodeOptions {
props: make_props("v", "new"),
..Default::default()
},
)
.unwrap();
assert_ne!(old_id, new_id);
let keys: Vec<(&str, &str)> = vec![("Person", "a")];
let results = engine.get_nodes_by_keys(&read_node_key_queries(&keys)).unwrap();
let node = results[0].as_ref().unwrap();
assert_eq!(node.id, new_id);
assert_eq!(
node.props.get("v"),
Some(&PropValue::String("new".to_string()))
);
engine.close().unwrap();
}
#[test]
fn test_get_nodes_by_keys_different_label_ids() {
let dir = TempDir::new().unwrap();
let engine = open_imm(&dir.path().join("db"));
engine
.upsert_node(
"Person",
"x",
UpsertNodeOptions {
props: make_props("t", "1"),
..Default::default()
},
)
.unwrap();
engine
.upsert_node(
"Company",
"x",
UpsertNodeOptions {
props: make_props("t", "2"),
..Default::default()
},
)
.unwrap();
let keys: Vec<(&str, &str)> = vec![("Person", "x"), ("Company", "x")];
let results = engine.get_nodes_by_keys(&read_node_key_queries(&keys)).unwrap();
assert_eq!(results.len(), 2);
let n1 = results[0].as_ref().unwrap();
let n2 = results[1].as_ref().unwrap();
assert_eq!(n1.labels.as_slice(), ["Person"]);
assert_eq!(n2.labels.as_slice(), ["Company"]);
assert_eq!(n1.props.get("t"), Some(&PropValue::String("1".to_string())));
assert_eq!(n2.props.get("t"), Some(&PropValue::String("2".to_string())));
engine.close().unwrap();
}
#[test]
fn test_get_nodes_by_keys_policy_filtering() {
let dir = TempDir::new().unwrap();
let engine = DatabaseEngine::open(
&dir.path().join("db"),
&DbOptions {
create_if_missing: true,
wal_sync_mode: WalSyncMode::Immediate,
compact_after_n_flushes: 0,
..Default::default()
},
)
.unwrap();
engine
.upsert_node(
"Person",
"low",
UpsertNodeOptions {
weight: 0.05,
..Default::default()
},
)
.unwrap();
engine
.upsert_node(
"Person",
"high",
UpsertNodeOptions {
weight: 5.0,
..Default::default()
},
)
.unwrap();
engine
.set_prune_policy(
"low_weight",
PrunePolicy {
label: Some("Person".to_string()),
max_age_ms: None,
max_weight: Some(0.1),
},
)
.unwrap();
let keys: Vec<(&str, &str)> = vec![("Person", "low"), ("Person", "high")];
let results = engine.get_nodes_by_keys(&read_node_key_queries(&keys)).unwrap();
assert!(results[0].is_none()); assert!(results[1].is_some());
engine.close().unwrap();
}
#[test]
fn test_get_nodes_by_keys_tombstone_prevents_fallthrough() {
let dir = TempDir::new().unwrap();
let engine = open_imm(&dir.path().join("db"));
let old_id = engine
.upsert_node(
"Person",
"a",
UpsertNodeOptions {
props: make_props("v", "seg1"),
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
engine
.upsert_node(
"Person",
"a",
UpsertNodeOptions {
props: make_props("v", "seg2"),
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
engine.delete_node(old_id).unwrap();
let keys: Vec<(&str, &str)> = vec![("Person", "a")];
let results = engine.get_nodes_by_keys(&read_node_key_queries(&keys)).unwrap();
assert!(
results[0].is_none(),
"tombstoned node must not fall through to older segment"
);
engine.close().unwrap();
}
#[test]
fn test_get_nodes_by_keys_immutable_tombstone_shadows_older_immutable() {
let dir = TempDir::new().unwrap();
let engine = DatabaseEngine::open(&dir.path().join("db"), &DbOptions::default()).unwrap();
let id = engine
.upsert_node("Person", "doomed", UpsertNodeOptions::default())
.unwrap();
engine.freeze_memtable().unwrap();
engine.delete_node(id).unwrap();
engine.freeze_memtable().unwrap();
assert!(
engine.get_node_by_key("Person", "doomed").unwrap().is_none(),
"scalar get_node_by_key must hide node tombstoned in newer immutable"
);
let keys: Vec<(&str, &str)> = vec![("Person", "doomed")];
let results = engine.get_nodes_by_keys(&read_node_key_queries(&keys)).unwrap();
assert!(
results[0].is_none(),
"batch get_nodes_by_keys must hide node tombstoned in newer immutable"
);
engine.close().unwrap();
}
#[test]
fn test_get_nodes_by_keys_delete_recreate_delete_cross_segment() {
let dir = TempDir::new().unwrap();
let engine = open_imm(&dir.path().join("db"));
let old_id = engine
.upsert_node(
"Person",
"a",
UpsertNodeOptions {
props: make_props("v", "old"),
..Default::default()
},
)
.unwrap();
engine.flush().unwrap();
engine.delete_node(old_id).unwrap();
let new_id = engine
.upsert_node(
"Person",
"a",
UpsertNodeOptions {
props: make_props("v", "new"),
..Default::default()
},
)
.unwrap();
assert_ne!(old_id, new_id);
engine.flush().unwrap();
engine.delete_node(new_id).unwrap();
engine.flush().unwrap();
assert!(
engine.get_node_by_key("Person", "a").unwrap().is_none(),
"scalar must return None for delete-recreate-delete across segments"
);
let keys: Vec<(&str, &str)> = vec![("Person", "a")];
let results = engine.get_nodes_by_keys(&read_node_key_queries(&keys)).unwrap();
assert!(
results[0].is_none(),
"batch must return None for delete-recreate-delete across segments"
);
engine.close().unwrap();
}