Skip to main content

lean_ctx/core/
graph_cache.rs

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