lean_ctx/core/
bm25_cache.rs1use 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#[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 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 index_fingerprint(&self.root) == self.fingerprint
37 }
38}
39
40pub(crate) 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
60pub 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
90pub fn get_or_background(cache: &SharedBm25Cache, root: &Path) -> Option<Arc<BM25Index>> {
93 let guard = cache
94 .lock()
95 .unwrap_or_else(std::sync::PoisonError::into_inner);
96 let entry = guard.as_ref()?;
97 if entry.root != root {
98 return None;
99 }
100
101 let idx = Arc::clone(&entry.index);
102
103 if !entry.is_fresh() {
104 let root_str = root.to_string_lossy().to_string();
105 let cache_clone = cache.clone();
106 let root_clone = root.to_path_buf();
107 std::thread::spawn(move || {
108 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
112 let rebuilt = BM25Index::load_or_build(&root_clone);
113 let rebuilt_fp = index_fingerprint(&root_clone);
114 let mut g = cache_clone
115 .lock()
116 .unwrap_or_else(std::sync::PoisonError::into_inner);
117 *g = Some(Bm25CacheEntry {
118 root: root_clone,
119 index: Arc::new(rebuilt),
120 loaded_at: Instant::now(),
121 fingerprint: rebuilt_fp,
122 });
123 }));
124 if result.is_ok() {
125 tracing::debug!("[bm25_cache: background refresh done for {root_str}]");
126 } else {
127 tracing::warn!(
128 "[bm25_cache: background refresh panicked for {root_str}; serving stale index]"
129 );
130 }
131 });
132 }
133
134 Some(idx)
135}
136
137pub fn unload(cache: &SharedBm25Cache) {
140 let mut guard = cache
141 .lock()
142 .unwrap_or_else(std::sync::PoisonError::into_inner);
143 if guard.is_some() {
144 *guard = None;
145 tracing::info!("[bm25_cache] unloaded index to free memory");
146 }
147}
148
149pub fn memory_usage(cache: &SharedBm25Cache) -> usize {
151 let guard = cache
152 .lock()
153 .unwrap_or_else(std::sync::PoisonError::into_inner);
154 guard.as_ref().map_or(0, |e| e.index.memory_usage_bytes())
155}
156
157pub fn shrink_resident_to_snippet(
172 cache: &SharedBm25Cache,
173 root: &Path,
174 keep_lines: usize,
175) -> usize {
176 let mut guard = cache
177 .lock()
178 .unwrap_or_else(std::sync::PoisonError::into_inner);
179 let Some(entry) = guard.as_mut() else {
180 return 0;
181 };
182 if entry.root != root || entry.index.content_truncated {
183 return 0;
184 }
185 let Some(index) = Arc::get_mut(&mut entry.index) else {
188 tracing::debug!(
189 "[bm25_cache] resident index still shared; skipping content shrink for now"
190 );
191 return 0;
192 };
193 let before = index.memory_usage_bytes();
194 index.shrink_resident_content_to_snippet(keep_lines);
195 before.saturating_sub(index.memory_usage_bytes())
196}
197
198#[cfg(test)]
199mod tests {
200 use super::*;
201 use std::sync::Arc;
202
203 #[test]
204 fn fresh_cache_returns_same_instance() {
205 let cache: SharedBm25Cache = Arc::new(std::sync::Mutex::new(None));
206 let tmp = tempfile::tempdir().unwrap();
207 let root = tmp.path();
208 std::fs::write(root.join("main.rs"), "fn main() {}\n").unwrap();
209
210 let idx1 = get_or_load(&cache, root);
211 assert!(idx1.doc_count > 0);
212
213 let idx2 = get_or_load(&cache, root);
214 assert_eq!(idx1.doc_count, idx2.doc_count);
215 }
216
217 #[test]
218 fn different_root_invalidates() {
219 let cache: SharedBm25Cache = Arc::new(std::sync::Mutex::new(None));
220 let tmp1 = tempfile::tempdir().unwrap();
221 let tmp2 = tempfile::tempdir().unwrap();
222 std::fs::write(tmp1.path().join("a.rs"), "fn a() {}\n").unwrap();
223 std::fs::write(tmp2.path().join("b.rs"), "fn b() {}\n").unwrap();
224
225 let _ = get_or_load(&cache, tmp1.path());
226 let idx2 = get_or_load(&cache, tmp2.path());
227
228 let guard = cache.lock().unwrap();
229 let entry = guard.as_ref().unwrap();
230 assert_eq!(entry.root, tmp2.path());
231 assert_eq!(entry.index.doc_count, idx2.doc_count);
232 }
233
234 #[test]
235 fn get_or_background_returns_none_on_empty() {
236 let cache: SharedBm25Cache = Arc::new(std::sync::Mutex::new(None));
237 let tmp = tempfile::tempdir().unwrap();
238 assert!(get_or_background(&cache, tmp.path()).is_none());
239 }
240
241 #[test]
242 fn fingerprint_default_when_index_file_absent() {
243 let tmp = tempfile::tempdir().unwrap();
244 assert_eq!(index_fingerprint(tmp.path()), IndexFingerprint::default());
246 }
247
248 #[test]
249 fn fingerprint_detects_size_change_under_equal_mtime() {
250 let mtime = Some(SystemTime::UNIX_EPOCH);
253 let a = IndexFingerprint { mtime, size: 100 };
254 let b = IndexFingerprint { mtime, size: 200 };
255 assert_ne!(a, b);
256 assert_eq!(a, IndexFingerprint { mtime, size: 100 });
257 }
258}