1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
//! Resident graph-index cache (Phase 5 of the efficiency epic).
//!
//! Materializing a `ProjectIndex` from the property graph (open SQLite + query
//! files/symbols/edges) on *every* query that touches the graph (symbol
//! lookups, related hints, impact) is wasteful. This keeps the materialized
//! index resident in RAM keyed by project root, invalidated by the on-disk
//! `graph.meta.json` fingerprint so a background rebuild is picked up
//! immediately (no TTL wait). Callers that need an owned value get a cheap
//! in-memory clone instead of a SQLite round-trip.
use std::collections::HashMap;
use std::sync::{Arc, Mutex, OnceLock};
use std::time::{Instant, SystemTime};
/// Max distinct project roots kept resident. A daemon touching many roots/branches/
/// worktrees would otherwise retain every ProjectIndex (MBs each) forever. LRU-evict
/// beyond this; an evicted root pays one materialization (SQLite query) on its next
/// query — bounded and self-healing.
const MAX_ROOTS: usize = 8;
use crate::core::graph_index::ProjectIndex;
/// `(mtime, size)` fingerprint of the on-disk graph store. Size pairs with mtime
/// to catch same-second rebuilds that coarse (1–2 s) filesystem mtime would
/// otherwise hide — cheap, no file read.
#[derive(Clone, Copy, PartialEq, Eq, Default)]
struct Fingerprint {
mtime: Option<SystemTime>,
size: u64,
}
struct Entry {
index: Arc<ProjectIndex>,
fingerprint: Fingerprint,
last_access: Instant,
}
static CACHE: OnceLock<Mutex<HashMap<String, Entry>>> = OnceLock::new();
fn cache() -> &'static Mutex<HashMap<String, Entry>> {
CACHE.get_or_init(|| Mutex::new(HashMap::new()))
}
/// `(mtime, size)` of the persisted property graph, if any.
///
/// Since #696 C4 the property graph is the sole store; every mirror rewrites
/// `graph.meta.json` (fresh `built_at` + node/edge counts) so its `(mtime, size)`
/// shifts on each rebuild — a reliable, read-free invalidation signal. The
/// `graph.db` file is the fallback when no meta has been stamped yet.
fn index_fingerprint(project_root: &str) -> Fingerprint {
let Some(dir) = ProjectIndex::index_dir(project_root) else {
return Fingerprint::default();
};
for name in ["graph.meta.json", "graph.db"] {
if let Ok(meta) = std::fs::metadata(dir.join(name)) {
return Fingerprint {
mtime: meta.modified().ok(),
size: meta.len(),
};
}
}
Fingerprint::default()
}
/// Returns the resident `ProjectIndex` for `project_root`, loading from disk
/// only when absent or when the on-disk index file changed. `None` when no
/// non-empty index exists on disk.
pub(crate) fn get_cached(project_root: &str) -> Option<Arc<ProjectIndex>> {
let fingerprint = index_fingerprint(project_root);
{
let mut map = cache()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(entry) = map.get_mut(project_root)
&& entry.fingerprint == fingerprint
{
entry.last_access = Instant::now();
return Some(Arc::clone(&entry.index));
}
}
let idx = ProjectIndex::load(project_root).filter(|i| !i.files.is_empty())?;
let arc = Arc::new(idx);
let mut map = cache()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
// LRU-evict before inserting a *new* root so the cap holds. Re-inserting an
// existing root (fingerprint changed) just overwrites and doesn't grow the map.
if !map.contains_key(project_root)
&& map.len() >= MAX_ROOTS
&& let Some(lru_key) = map
.iter()
.min_by_key(|(_, e)| e.last_access)
.map(|(k, _)| k.clone())
{
map.remove(&lru_key);
}
map.insert(
project_root.to_string(),
Entry {
index: Arc::clone(&arc),
fingerprint,
last_access: Instant::now(),
},
);
Some(arc)
}
/// Drops the cached graph index for a root (or all roots when `None`).
pub(crate) fn invalidate(project_root: Option<&str>) {
let mut map = cache()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
match project_root {
Some(root) => {
map.remove(root);
}
None => map.clear(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn returns_none_without_index() {
let tmp = tempfile::tempdir().unwrap();
invalidate(None);
assert!(get_cached(tmp.path().to_str().unwrap()).is_none());
}
}