use std::time::{Duration, Instant};
use topodb::*;
fn wait_for_count(db: &Db, scopes: &ScopeSet, id: NodeId, want_at_least: u64) -> AccessStats {
let deadline = Instant::now() + Duration::from_secs(5);
loop {
if let Some(stats) = db.access_stats(scopes, id).unwrap() {
if stats.access_count >= want_at_least {
return stats;
}
}
assert!(
Instant::now() < deadline,
"counter never reached {want_at_least}"
);
std::thread::sleep(Duration::from_millis(20));
}
}
#[test]
fn reads_bump_counters_asynchronously() {
let dir = tempfile::tempdir().unwrap();
let db = Db::open(dir.path().join("t.redb")).unwrap();
let s = ScopeId::new();
let scopes = ScopeSet::of(&[s]);
let id = NodeId::new();
db.submit(vec![Op::CreateNode {
id,
scope: Scope::Id(s),
label: "M".into(),
props: Default::default(),
}])
.unwrap();
assert_eq!(
db.access_stats(&scopes, id).unwrap(),
Some(AccessStats::default())
);
let _ = db.node(&scopes, id);
let _ = db.node(&scopes, id);
let stats = wait_for_count(&db, &scopes, id, 2);
assert!(stats.last_accessed_at > 0);
}
#[test]
fn nodes_by_label_unbumped_reads_without_bumping() {
let dir = tempfile::tempdir().unwrap();
let db = Db::open(dir.path().join("t.redb")).unwrap();
let s = ScopeId::new();
let scopes = ScopeSet::of(&[s]);
let untouched = NodeId::new();
let fence = NodeId::new();
for id in [untouched, fence] {
db.submit(vec![Op::CreateNode {
id,
scope: Scope::Id(s),
label: "M".into(),
props: Default::default(),
}])
.unwrap();
}
let hits = db.nodes_by_label_unbumped(&scopes, "M");
assert_eq!(hits.len(), 2, "sees both nodes");
let _ = db.node(&scopes, fence);
wait_for_count(&db, &scopes, fence, 1);
assert_eq!(
db.access_stats(&scopes, untouched).unwrap(),
Some(AccessStats::default()),
"an unbumped scan must leave the access counters untouched"
);
}
#[test]
fn counters_are_outside_log_feed_and_replay() {
let dir = tempfile::tempdir().unwrap();
let db = Db::open(dir.path().join("t.redb")).unwrap();
let rx = db.subscribe(16);
let s = ScopeId::new();
let scopes = ScopeSet::of(&[s]);
let id = NodeId::new();
db.submit(vec![Op::CreateNode {
id,
scope: Scope::Id(s),
label: "M".into(),
props: Default::default(),
}])
.unwrap();
let _ = rx.recv().unwrap();
let _ = db.node(&scopes, id);
let stats = wait_for_count(&db, &scopes, id, 1);
assert!(rx.try_recv().is_err());
assert_eq!(db.ops_since(1).unwrap().len(), 1);
db.rebuild_state_from_ops().unwrap();
assert_eq!(db.access_stats(&scopes, id).unwrap(), Some(stats));
}
#[test]
fn stats_respect_scope_and_reads_of_stats_do_not_bump() {
let dir = tempfile::tempdir().unwrap();
let db = Db::open(dir.path().join("t.redb")).unwrap();
let s = ScopeId::new();
let scopes = ScopeSet::of(&[s]);
let id = NodeId::new();
db.submit(vec![Op::CreateNode {
id,
scope: Scope::Id(s),
label: "M".into(),
props: Default::default(),
}])
.unwrap();
assert_eq!(
db.access_stats(&ScopeSet::of(&[ScopeId::new()]), id)
.unwrap(),
None
);
for _ in 0..5 {
let _ = db.access_stats(&scopes, id).unwrap();
}
std::thread::sleep(Duration::from_millis(300));
assert_eq!(
db.access_stats(&scopes, id).unwrap(),
Some(AccessStats::default())
);
}
#[test]
fn nodes_by_prop_bumps_results() {
let dir = tempfile::tempdir().unwrap();
let spec = IndexSpec {
equality: vec![PropIndex {
label: "M".into(),
prop: "k".into(),
}],
text: vec![],
};
let db = Db::open_with(dir.path().join("t.redb"), spec).unwrap();
let s = ScopeId::new();
let scopes = ScopeSet::of(&[s]);
let id = NodeId::new();
let mut props = Props::new();
props.insert("k".into(), PropValue::Str("x".into()));
db.submit(vec![Op::CreateNode {
id,
scope: Scope::Id(s),
label: "M".into(),
props,
}])
.unwrap();
let hits = db
.nodes_by_prop(&scopes, "M", "k", &PropValue::Str("x".into()))
.unwrap();
assert_eq!(hits.len(), 1);
let stats = wait_for_count(&db, &scopes, id, 1);
assert!(stats.access_count >= 1);
}
#[test]
fn removed_node_stats_are_none_despite_orphan_row() {
let dir = tempfile::tempdir().unwrap();
let db = Db::open(dir.path().join("t.redb")).unwrap();
let s = ScopeId::new();
let scopes = ScopeSet::of(&[s]);
let id = NodeId::new();
db.submit(vec![Op::CreateNode {
id,
scope: Scope::Id(s),
label: "M".into(),
props: Default::default(),
}])
.unwrap();
let _ = db.node(&scopes, id);
let _ = wait_for_count(&db, &scopes, id, 1); db.submit(vec![Op::RemoveNode { id }]).unwrap();
assert_eq!(
db.access_stats(&scopes, id).unwrap(),
None,
"gate on node existence, not row existence"
);
}
#[test]
fn multi_hit_prop_lookup_bumps_every_returned_node() {
let dir = tempfile::tempdir().unwrap();
let spec = IndexSpec {
equality: vec![PropIndex {
label: "M".into(),
prop: "k".into(),
}],
text: vec![],
};
let db = Db::open_with(dir.path().join("t.redb"), spec).unwrap();
let s = ScopeId::new();
let scopes = ScopeSet::of(&[s]);
let ids: Vec<NodeId> = (0..3).map(|_| NodeId::new()).collect();
for id in &ids {
let mut props = Props::new();
props.insert("k".into(), PropValue::Str("same".into()));
db.submit(vec![Op::CreateNode {
id: *id,
scope: Scope::Id(s),
label: "M".into(),
props,
}])
.unwrap();
}
assert_eq!(
db.nodes_by_prop(&scopes, "M", "k", &PropValue::Str("same".into()))
.unwrap()
.len(),
3
);
for id in &ids {
let stats = wait_for_count(&db, &scopes, *id, 1);
assert!(
stats.access_count >= 1,
"every hit bumps, not just the first"
);
}
}
#[test]
fn rejected_prop_lookup_does_not_bump() {
let dir = tempfile::tempdir().unwrap();
let db = Db::open(dir.path().join("t.redb")).unwrap(); let s = ScopeId::new();
let scopes = ScopeSet::of(&[s]);
let id = NodeId::new();
db.submit(vec![Op::CreateNode {
id,
scope: Scope::Id(s),
label: "M".into(),
props: Default::default(),
}])
.unwrap();
assert!(db
.nodes_by_prop(&scopes, "M", "k", &PropValue::Int(1))
.is_err());
std::thread::sleep(std::time::Duration::from_millis(300));
assert_eq!(
db.access_stats(&scopes, id).unwrap(),
Some(AccessStats::default())
);
}
#[test]
fn float_range_scan_does_not_bump() {
let dir = tempfile::tempdir().unwrap();
let db = Db::open(dir.path().join("t.redb")).unwrap();
let s = ScopeId::new();
let scopes = ScopeSet::of(&[s]);
let id = NodeId::new();
let mut props = Props::new();
props.insert("importance".into(), PropValue::Float(0.5));
db.submit(vec![Op::CreateNode {
id,
scope: Scope::Id(s),
label: "M".into(),
props,
}])
.unwrap();
assert_eq!(
db.nodes_by_float_range(&scopes, "importance", 0.0, 1.0)
.len(),
1
);
std::thread::sleep(std::time::Duration::from_millis(300)); assert_eq!(
db.access_stats(&scopes, id).unwrap(),
Some(AccessStats::default())
);
}
#[test]
fn search_text_unbumped_leaves_access_counters_untouched() {
let dir = tempfile::tempdir().unwrap();
let spec = IndexSpec {
equality: vec![],
text: vec![PropIndex {
label: "Memory".into(),
prop: "content".into(),
}],
};
let db = Db::open_with(dir.path().join("t.redb"), spec).unwrap();
let s = ScopeId::new();
let scopes = ScopeSet::of(&[s]);
let id = NodeId::new();
let mut props = Props::new();
props.insert(
"content".into(),
PropValue::Str("rust database engine architecture".into()),
);
db.submit(vec![Op::CreateNode {
id,
scope: Scope::Id(s),
label: "Memory".into(),
props,
}])
.unwrap();
let hits = db.search_text(&scopes, "database", 10).unwrap();
assert_eq!(hits.len(), 1, "search_text must find the node");
assert_eq!(hits[0].0.id, id);
let bumped_score = hits[0].1;
let bumped_stats = wait_for_count(&db, &scopes, id, 1);
assert!(
bumped_stats.access_count >= 1,
"search_text must bump access count"
);
let count_after_bumped = bumped_stats.access_count;
let unbumped_hits = db.search_text_unbumped(&scopes, "database", 10).unwrap();
assert_eq!(
unbumped_hits.len(),
1,
"search_text_unbumped must find the node"
);
assert_eq!(unbumped_hits[0].0.id, id);
assert_eq!(
unbumped_hits[0].1, bumped_score,
"unbumped must return the same score as bumped (same BM25, only bump differs)"
);
std::thread::sleep(Duration::from_millis(300));
let final_stats = db.access_stats(&scopes, id).unwrap();
assert_eq!(
final_stats,
Some(AccessStats {
access_count: count_after_bumped,
last_accessed_at: bumped_stats.last_accessed_at,
}),
"search_text_unbumped must not bump access counters"
);
}