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 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 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
101static REFRESHING: std::sync::Mutex<Vec<PathBuf>> = std::sync::Mutex::new(Vec::new());
105
106struct 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
131pub 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 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
182pub 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
194pub 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
202pub 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 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 assert_eq!(index_fingerprint(tmp.path()), IndexFingerprint::default());
311 }
312
313 #[test]
314 fn fingerprint_detects_size_change_under_equal_mtime() {
315 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}