Skip to main content

lean_ctx/core/
bm25_cache.rs

1use std::path::{Path, PathBuf};
2use std::sync::Arc;
3use std::time::{Instant, SystemTime};
4
5use super::bm25_index::BM25Index;
6
7const DEFAULT_TTL_SECS: u64 = 60;
8
9/// Cheap content fingerprint of the persisted index file: `(mtime, size)`.
10///
11/// mtime alone is not enough — many filesystems only resolve mtime to 1–2 s, so
12/// a background rebuild that lands in the same tick as the load would be missed.
13/// Pairing it with the file size catches those same-second rewrites without the
14/// cost of hashing a multi-MB index file on every per-query freshness check.
15#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
16pub struct IndexFingerprint {
17    mtime: Option<SystemTime>,
18    size: u64,
19}
20
21pub struct Bm25CacheEntry {
22    pub root: PathBuf,
23    pub index: Arc<BM25Index>,
24    pub loaded_at: Instant,
25    /// Fingerprint of the persisted index file when this entry was loaded.
26    pub fingerprint: IndexFingerprint,
27}
28
29impl Bm25CacheEntry {
30    pub fn is_fresh(&self) -> bool {
31        if self.loaded_at.elapsed().as_secs() >= ttl_secs() {
32            return false;
33        }
34        // Precise invalidation: if a background rebuild changed the index file
35        // on disk, the resident copy is stale even within the TTL window.
36        index_fingerprint(&self.root) == self.fingerprint
37    }
38}
39
40/// `(mtime, size)` fingerprint of the persisted BM25 index file for `root`.
41pub fn index_fingerprint(root: &Path) -> IndexFingerprint {
42    match std::fs::metadata(BM25Index::index_file_path(root)) {
43        Ok(m) => IndexFingerprint {
44            mtime: m.modified().ok(),
45            size: m.len(),
46        },
47        Err(_) => IndexFingerprint::default(),
48    }
49}
50
51fn ttl_secs() -> u64 {
52    std::env::var("LEAN_CTX_BM25_CACHE_TTL")
53        .ok()
54        .and_then(|v| v.parse().ok())
55        .unwrap_or(DEFAULT_TTL_SECS)
56}
57
58pub type SharedBm25Cache = std::sync::Arc<std::sync::Mutex<Option<Bm25CacheEntry>>>;
59
60/// Get the BM25 index from cache if available and fresh, otherwise load/build,
61/// cache it, and return. Uses Arc to avoid cloning the entire index.
62pub fn get_or_load(cache: &SharedBm25Cache, root: &Path) -> Arc<BM25Index> {
63    {
64        let guard = cache
65            .lock()
66            .unwrap_or_else(std::sync::PoisonError::into_inner);
67        if let Some(ref entry) = *guard
68            && entry.root == root
69            && entry.is_fresh()
70        {
71            return Arc::clone(&entry.index);
72        }
73    }
74
75    let index = Arc::new(BM25Index::load_or_build_fast(root));
76
77    let mut guard = cache
78        .lock()
79        .unwrap_or_else(std::sync::PoisonError::into_inner);
80    *guard = Some(Bm25CacheEntry {
81        root: root.to_path_buf(),
82        index: Arc::clone(&index),
83        loaded_at: Instant::now(),
84        fingerprint: index_fingerprint(root),
85    });
86
87    index
88}
89
90/// The cached index for `root`, fresh or stale, without triggering a refresh.
91pub fn peek(cache: &SharedBm25Cache, root: &Path) -> Option<Arc<BM25Index>> {
92    let guard = cache
93        .lock()
94        .unwrap_or_else(std::sync::PoisonError::into_inner);
95    guard
96        .as_ref()
97        .filter(|entry| entry.root == root)
98        .map(|entry| Arc::clone(&entry.index))
99}
100
101/// Roots whose background refresh is running. A stale entry is served to
102/// every caller until the refresh lands, so without this each call in that
103/// window started one more full reload of the same index.
104static REFRESHING: std::sync::Mutex<Vec<PathBuf>> = std::sync::Mutex::new(Vec::new());
105
106/// Exclusive right to refresh one root; released on drop, also after a panic.
107struct RefreshClaim(PathBuf);
108
109impl RefreshClaim {
110    fn try_claim(root: &Path) -> Option<Self> {
111        let mut running = REFRESHING
112            .lock()
113            .unwrap_or_else(std::sync::PoisonError::into_inner);
114        if running.iter().any(|claimed| claimed == root) {
115            return None;
116        }
117        running.push(root.to_path_buf());
118        Some(Self(root.to_path_buf()))
119    }
120}
121
122impl Drop for RefreshClaim {
123    fn drop(&mut self) {
124        REFRESHING
125            .lock()
126            .unwrap_or_else(std::sync::PoisonError::into_inner)
127            .retain(|claimed| *claimed != self.0);
128    }
129}
130
131/// Get index from cache (fresh or stale), triggering background rebuild if stale.
132/// At most one rebuild per root runs at a time.
133/// Returns None only if no cache entry exists at all.
134pub fn get_or_background(cache: &SharedBm25Cache, root: &Path) -> Option<Arc<BM25Index>> {
135    let guard = cache
136        .lock()
137        .unwrap_or_else(std::sync::PoisonError::into_inner);
138    let entry = guard.as_ref()?;
139    if entry.root != root {
140        return None;
141    }
142
143    let idx = Arc::clone(&entry.index);
144
145    if !entry.is_fresh()
146        && let Some(claim) = RefreshClaim::try_claim(root)
147    {
148        let root_str = root.to_string_lossy().to_string();
149        let cache_clone = cache.clone();
150        let root_clone = root.to_path_buf();
151        std::thread::spawn(move || {
152            let _claim = claim;
153            // Isolate panics (corrupt index file, FS race): a panic here must not
154            // kill the worker silently — the stale index keeps serving and the
155            // next call retries the refresh.
156            let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
157                let rebuilt = BM25Index::load_or_build(&root_clone);
158                let rebuilt_fp = index_fingerprint(&root_clone);
159                let mut g = cache_clone
160                    .lock()
161                    .unwrap_or_else(std::sync::PoisonError::into_inner);
162                *g = Some(Bm25CacheEntry {
163                    root: root_clone,
164                    index: Arc::new(rebuilt),
165                    loaded_at: Instant::now(),
166                    fingerprint: rebuilt_fp,
167                });
168            }));
169            if result.is_ok() {
170                tracing::debug!("[bm25_cache: background refresh done for {root_str}]");
171            } else {
172                tracing::warn!(
173                    "[bm25_cache: background refresh panicked for {root_str}; serving stale index]"
174                );
175            }
176        });
177    }
178
179    Some(idx)
180}
181
182/// Drops the cached BM25 index, freeing its heap memory.
183/// The index will be rebuilt from disk on the next search.
184pub fn unload(cache: &SharedBm25Cache) {
185    let mut guard = cache
186        .lock()
187        .unwrap_or_else(std::sync::PoisonError::into_inner);
188    if guard.is_some() {
189        *guard = None;
190        tracing::info!("[bm25_cache] unloaded index to free memory");
191    }
192}
193
194/// Returns the approximate heap memory used by the cached BM25 index, or 0.
195pub fn memory_usage(cache: &SharedBm25Cache) -> usize {
196    let guard = cache
197        .lock()
198        .unwrap_or_else(std::sync::PoisonError::into_inner);
199    guard.as_ref().map_or(0, |e| e.index.memory_usage_bytes())
200}
201
202/// Trims the RESIDENT cached index for `root` so each chunk keeps only its first
203/// `keep_lines` lines of `content`, reclaiming the RAM held by full source
204/// bodies once the embedding pass has consumed them.
205///
206/// Call this ONLY after embeddings for the current fingerprint are built and
207/// persisted (see `ctx_semantic_search::ensure_embeddings`). The on-disk index
208/// is untouched, so a reload restores full bodies; the resident `content_truncated`
209/// flag guards a later embedding pass against re-embedding the trimmed bodies.
210///
211/// Truncation happens in place via `Arc::get_mut`, so it is a no-op (and costs
212/// nothing) whenever another owner still holds the Arc — e.g. the search handler
213/// has not yet dropped its clone, or a background refresh is in flight. The next
214/// search call retries against the then-sole-owner cache entry. Returns the bytes
215/// reclaimed (0 if skipped).
216pub fn shrink_resident_to_snippet(
217    cache: &SharedBm25Cache,
218    root: &Path,
219    keep_lines: usize,
220) -> usize {
221    let mut guard = cache
222        .lock()
223        .unwrap_or_else(std::sync::PoisonError::into_inner);
224    let Some(entry) = guard.as_mut() else {
225        return 0;
226    };
227    if entry.root != root || entry.index.content_truncated {
228        return 0;
229    }
230    // Only mutate when the cache is the sole owner — cloning a multi-MB index
231    // just to trim it would defeat the purpose.
232    let Some(index) = Arc::get_mut(&mut entry.index) else {
233        tracing::debug!(
234            "[bm25_cache] resident index still shared; skipping content shrink for now"
235        );
236        return 0;
237    };
238    let before = index.memory_usage_bytes();
239    index.shrink_resident_content_to_snippet(keep_lines);
240    before.saturating_sub(index.memory_usage_bytes())
241}
242
243#[cfg(test)]
244pub mod tests {
245    use super::*;
246    use std::sync::Arc;
247
248    #[test]
249    fn fresh_cache_returns_same_instance() {
250        let cache: SharedBm25Cache = Arc::new(std::sync::Mutex::new(None));
251        let tmp = tempfile::tempdir().unwrap();
252        let root = tmp.path();
253        std::fs::write(root.join("main.rs"), "fn main() {}\n").unwrap();
254
255        let idx1 = get_or_load(&cache, root);
256        assert!(idx1.doc_count > 0);
257
258        let idx2 = get_or_load(&cache, root);
259        assert_eq!(idx1.doc_count, idx2.doc_count);
260    }
261
262    #[test]
263    fn different_root_invalidates() {
264        let cache: SharedBm25Cache = Arc::new(std::sync::Mutex::new(None));
265        let tmp1 = tempfile::tempdir().unwrap();
266        let tmp2 = tempfile::tempdir().unwrap();
267        std::fs::write(tmp1.path().join("a.rs"), "fn a() {}\n").unwrap();
268        std::fs::write(tmp2.path().join("b.rs"), "fn b() {}\n").unwrap();
269
270        let _ = get_or_load(&cache, tmp1.path());
271        let idx2 = get_or_load(&cache, tmp2.path());
272
273        let guard = cache
274            .lock()
275            .unwrap_or_else(std::sync::PoisonError::into_inner);
276        let entry = guard.as_ref().unwrap();
277        assert_eq!(entry.root, tmp2.path());
278        assert_eq!(entry.index.doc_count, idx2.doc_count);
279    }
280
281    #[test]
282    fn get_or_background_returns_none_on_empty() {
283        let cache: SharedBm25Cache = Arc::new(std::sync::Mutex::new(None));
284        let tmp = tempfile::tempdir().unwrap();
285        assert!(get_or_background(&cache, tmp.path()).is_none());
286    }
287
288    #[test]
289    fn one_refresh_per_root_runs_at_a_time() {
290        let tmp = tempfile::tempdir().unwrap();
291        let other = tempfile::tempdir().unwrap();
292        let claim = RefreshClaim::try_claim(tmp.path()).expect("first claim");
293        assert!(
294            RefreshClaim::try_claim(tmp.path()).is_none(),
295            "a second refresh of the same root must not start"
296        );
297        let other_claim = RefreshClaim::try_claim(other.path()).expect("another root");
298        drop(claim);
299        assert!(
300            RefreshClaim::try_claim(tmp.path()).is_some(),
301            "a finished refresh releases its root"
302        );
303        drop(other_claim);
304    }
305
306    #[test]
307    fn fingerprint_default_when_index_file_absent() {
308        let tmp = tempfile::tempdir().unwrap();
309        // No persisted index file → default (None, 0) fingerprint.
310        assert_eq!(index_fingerprint(tmp.path()), IndexFingerprint::default());
311    }
312
313    #[test]
314    fn fingerprint_detects_size_change_under_equal_mtime() {
315        // Two fingerprints with the same mtime but different size must differ,
316        // proving size catches same-second rewrites that mtime alone misses.
317        let mtime = Some(SystemTime::UNIX_EPOCH);
318        let a = IndexFingerprint { mtime, size: 100 };
319        let b = IndexFingerprint { mtime, size: 200 };
320        assert_ne!(a, b);
321        assert_eq!(a, IndexFingerprint { mtime, size: 100 });
322    }
323}