1use std::fs::Metadata;
26use std::path::{Path, PathBuf};
27use std::sync::{Arc, Mutex, OnceLock};
28use std::time::UNIX_EPOCH;
29
30use lru::LruCache;
31
32const DEFAULT_BUDGET_MB: usize = 128;
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub struct FileState {
39 pub mtime_ms: u64,
40 pub size_bytes: u64,
41}
42
43impl FileState {
44 pub fn from_metadata(meta: &Metadata) -> Option<Self> {
48 let mtime_ms = meta
49 .modified()
50 .ok()
51 .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
52 .map(|d| d.as_millis() as u64)?;
53 Some(Self {
54 mtime_ms,
55 size_bytes: meta.len(),
56 })
57 }
58
59 pub fn from_path(path: &Path) -> Option<Self> {
61 Self::from_metadata(&path.metadata().ok()?)
62 }
63}
64
65struct Entry {
66 state: FileState,
67 content: Arc<str>,
68}
69
70struct Cache {
71 map: LruCache<PathBuf, Entry>,
79 total_bytes: usize,
80 budget_bytes: usize,
81 hits: u64,
82 misses: u64,
83 inserts: u64,
84 evictions: u64,
85}
86
87impl Cache {
88 fn new(budget_bytes: usize) -> Self {
89 Self {
90 map: LruCache::unbounded(),
91 total_bytes: 0,
92 budget_bytes,
93 hits: 0,
94 misses: 0,
95 inserts: 0,
96 evictions: 0,
97 }
98 }
99
100 fn remove_entry(&mut self, path: &Path) {
101 if let Some(old) = self.map.pop(path) {
102 self.total_bytes = self.total_bytes.saturating_sub(old.content.len());
103 }
104 }
105
106 fn evict_to_budget(&mut self) {
108 while self.total_bytes > self.budget_bytes {
109 let Some((_, victim)) = self.map.pop_lru() else {
110 break;
111 };
112 self.total_bytes = self.total_bytes.saturating_sub(victim.content.len());
113 self.evictions += 1;
114 }
115 }
116}
117
118static CACHE: OnceLock<Mutex<Cache>> = OnceLock::new();
119
120fn budget_bytes() -> usize {
121 let mb = std::env::var("LEAN_CTX_CONTENT_CACHE_MB")
122 .ok()
123 .and_then(|v| v.trim().parse::<usize>().ok())
124 .unwrap_or(DEFAULT_BUDGET_MB);
125 mb.saturating_mul(1024 * 1024)
126}
127
128fn disabled() -> bool {
129 std::env::var("LEAN_CTX_DISABLE_CONTENT_CACHE")
132 .is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
133 || budget_bytes() == 0
134}
135
136fn cache() -> &'static Mutex<Cache> {
137 CACHE.get_or_init(|| Mutex::new(Cache::new(budget_bytes())))
138}
139
140fn lock() -> std::sync::MutexGuard<'static, Cache> {
141 cache()
142 .lock()
143 .unwrap_or_else(std::sync::PoisonError::into_inner)
144}
145
146pub fn get(path: &Path, current: FileState) -> Option<Arc<str>> {
151 if disabled() {
152 return None;
153 }
154 let mut c = lock();
155 let Some(entry) = c.map.peek(path) else {
158 c.misses += 1;
159 crate::core::telemetry::global_metrics().record_cache(false);
160 return None;
161 };
162 if entry.state != current {
163 c.remove_entry(path);
165 c.misses += 1;
166 crate::core::telemetry::global_metrics().record_cache(false);
167 return None;
168 }
169 c.hits += 1;
170 crate::core::telemetry::global_metrics().record_cache(true);
171 let entry = c.map.get(path)?;
175 Some(Arc::clone(&entry.content))
176}
177
178pub fn insert(
187 path: &Path,
188 state: FileState,
189 content: Arc<str>,
190 admitted_by: &crate::core::context_admission::stores::StoreAdmission,
191) {
192 if disabled() || crate::core::memory_guard::is_under_pressure() {
193 return;
194 }
195 let len = content.len();
196 let mut c = lock();
197 if !admitted_by.is_current() {
200 return;
201 }
202 if len > c.budget_bytes {
204 return;
205 }
206 c.remove_entry(path);
207 c.map.put(path.to_path_buf(), Entry { state, content });
208 c.total_bytes += len;
209 c.inserts += 1;
210 if c.total_bytes > c.budget_bytes {
211 c.evict_to_budget();
212 }
213}
214
215pub fn get_or_read(path: &Path) -> Option<Arc<str>> {
222 let state = FileState::from_path(path)?;
223 let admission = crate::core::context_admission::stores::StoreAdmission::current();
225 if let Some(hit) = get(path, state) {
226 return Some(hit);
227 }
228 let content = admission.read(path)?;
230 let arc: Arc<str> = Arc::from(content);
231 insert(path, state, Arc::clone(&arc), &admission);
232 Some(arc)
233}
234
235pub fn clear() {
238 if CACHE.get().is_none() {
239 return;
240 }
241 let mut c = lock();
242 c.map.clear();
243 c.total_bytes = 0;
244}
245
246pub fn trim_oldest_percent(percent: u8) {
250 if CACHE.get().is_none() {
251 return;
252 }
253 let mut c = lock();
254 if c.map.is_empty() {
255 return;
256 }
257 let pct = (percent.min(100)) as usize;
258 let target_evictions = c.map.len() * pct / 100;
259 for _ in 0..target_evictions {
260 let Some((_, victim)) = c.map.pop_lru() else {
261 break;
262 };
263 c.total_bytes = c.total_bytes.saturating_sub(victim.content.len());
264 c.evictions += 1;
265 }
266}
267
268pub fn memory_usage_bytes() -> usize {
270 if CACHE.get().is_none() {
271 return 0;
272 }
273 lock().total_bytes
274}
275
276#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
278pub struct CacheStats {
279 pub hits: u64,
280 pub misses: u64,
281 pub entries: usize,
282 pub bytes: usize,
283 pub inserts: u64,
284 pub evictions: u64,
285}
286
287pub fn stats() -> CacheStats {
288 if CACHE.get().is_none() {
289 return CacheStats::default();
290 }
291 let c = lock();
292 CacheStats {
293 hits: c.hits,
294 misses: c.misses,
295 entries: c.map.len(),
296 bytes: c.total_bytes,
297 inserts: c.inserts,
298 evictions: c.evictions,
299 }
300}
301
302#[cfg(test)]
303pub mod tests {
304 use super::*;
305 use crate::core::context_admission::stores::StoreAdmission;
306 use serial_test::serial;
307
308 fn admitted() -> StoreAdmission {
309 StoreAdmission::current()
310 }
311
312 static TEST_LOCK: Mutex<()> = Mutex::new(());
315
316 type Stable = (
322 crate::core::data_dir::IsolatedDataDir,
323 crate::core::policy::runtime::TestPolicyOverride,
324 );
325
326 #[must_use]
327 fn fresh_cache(budget_bytes: usize) -> Stable {
328 let stable = (
329 crate::core::data_dir::isolated_data_dir(),
330 crate::core::policy::runtime::TestPolicyOverride::set(None),
331 );
332 crate::test_env::remove_var("LEAN_CTX_CONTENT_CACHE_MB");
333 crate::test_env::remove_var("LEAN_CTX_DISABLE_CONTENT_CACHE");
334 let mut c = lock();
335 *c = Cache::new(budget_bytes);
336 stable
337 }
338
339 fn write(dir: &Path, name: &str, body: &str) -> PathBuf {
340 let p = dir.join(name);
341 std::fs::write(&p, body).unwrap();
342 p
343 }
344
345 #[test]
346 #[serial(cache_telemetry)]
347 fn hit_after_insert_with_matching_state() {
348 let _env_lock = crate::core::data_dir::test_env_lock();
349 let _g = TEST_LOCK
350 .lock()
351 .unwrap_or_else(std::sync::PoisonError::into_inner);
352 let _stable = fresh_cache(1024 * 1024);
353 let dir = tempfile::tempdir().unwrap();
354 let p = write(dir.path(), "a.rs", "fn main() {}\n");
355 let state = FileState::from_path(&p).unwrap();
356 assert!(get(&p, state).is_none(), "cold cache must miss");
357 insert(&p, state, Arc::from("fn main() {}\n"), &admitted());
358 let got = get(&p, state).expect("warm cache must hit");
359 assert_eq!(&*got, "fn main() {}\n");
360 }
361
362 #[test]
363 #[serial(cache_telemetry)]
364 fn miss_paths_update_local_and_central_stats() {
365 let _env_lock = crate::core::data_dir::test_env_lock();
366 let _g = TEST_LOCK
367 .lock()
368 .unwrap_or_else(std::sync::PoisonError::into_inner);
369 let _stable = fresh_cache(1024 * 1024);
370 let dir = tempfile::tempdir().unwrap();
371 let p = write(dir.path(), "cold.rs", "cold\n");
372 let state = FileState::from_path(&p).unwrap();
373 let stale_state = FileState {
374 size_bytes: state.size_bytes + 1,
375 ..state
376 };
377 let local_before = stats();
378 let central = crate::core::telemetry::global_metrics();
379 let misses_before = central
380 .cache_misses
381 .load(std::sync::atomic::Ordering::Relaxed);
382
383 assert!(get(&p, state).is_none());
384 insert(&p, state, Arc::from("cold\n"), &admitted());
385 assert!(get(&p, stale_state).is_none());
386
387 let local_after = stats();
388 assert!(local_after.misses >= local_before.misses + 2);
389 assert!(
390 central
391 .cache_misses
392 .load(std::sync::atomic::Ordering::Relaxed)
393 >= misses_before + 2
394 );
395 }
396
397 #[test]
398 #[serial(cache_telemetry)]
399 fn warm_hit_updates_local_and_central_stats() {
400 let _env_lock = crate::core::data_dir::test_env_lock();
401 let _g = TEST_LOCK
402 .lock()
403 .unwrap_or_else(std::sync::PoisonError::into_inner);
404 let _stable = fresh_cache(1024 * 1024);
405 let dir = tempfile::tempdir().unwrap();
406 let p = write(dir.path(), "warm.rs", "warm\n");
407 let state = FileState::from_path(&p).unwrap();
408 insert(&p, state, Arc::from("warm\n"), &admitted());
409 let local_before = stats();
410 let central = crate::core::telemetry::global_metrics();
411 let hits_before = central
412 .cache_hits
413 .load(std::sync::atomic::Ordering::Relaxed);
414
415 assert!(get(&p, state).is_some());
416
417 let local_after = stats();
418 assert!(local_after.hits > local_before.hits);
419 assert!(
420 central
421 .cache_hits
422 .load(std::sync::atomic::Ordering::Relaxed)
423 > hits_before
424 );
425 }
426
427 #[test]
428 #[serial(cache_telemetry)]
429 fn mtime_or_size_change_invalidates() {
430 let _env_lock = crate::core::data_dir::test_env_lock();
431 let _g = TEST_LOCK
432 .lock()
433 .unwrap_or_else(std::sync::PoisonError::into_inner);
434 let _stable = fresh_cache(1024 * 1024);
435 let dir = tempfile::tempdir().unwrap();
436 let p = write(dir.path(), "a.rs", "v1\n");
437 let s1 = FileState::from_path(&p).unwrap();
438 insert(&p, s1, Arc::from("v1\n"), &admitted());
439 assert!(get(&p, s1).is_some());
440
441 let s_bigger = FileState {
443 size_bytes: s1.size_bytes + 10,
444 ..s1
445 };
446 assert!(get(&p, s_bigger).is_none(), "size change must miss");
447 assert!(
448 get(&p, s1).is_none(),
449 "stale entry must be evicted on mismatch"
450 );
451
452 insert(&p, s1, Arc::from("v1\n"), &admitted());
454 let s_newer = FileState {
455 mtime_ms: s1.mtime_ms + 1,
456 ..s1
457 };
458 assert!(get(&p, s_newer).is_none(), "mtime change must miss");
459 }
460
461 #[test]
462 #[serial(cache_telemetry)]
463 fn get_or_read_populates_then_serves_from_cache() {
464 let _env_lock = crate::core::data_dir::test_env_lock();
465 let _g = TEST_LOCK
466 .lock()
467 .unwrap_or_else(std::sync::PoisonError::into_inner);
468 let _stable = fresh_cache(1024 * 1024);
469 let dir = tempfile::tempdir().unwrap();
470 let p = write(dir.path(), "a.rs", "hello world\n");
471
472 let state = FileState::from_path(&p).unwrap();
473 assert!(get(&p, state).is_none(), "first read starts cold");
474 let first = get_or_read(&p).unwrap();
475 assert_eq!(&*first, "hello world\n");
476 assert!(
477 Arc::ptr_eq(&first, &get(&p, state).expect("first read inserts")),
478 "the populated entry retains the returned allocation"
479 );
480
481 let other = write(dir.path(), "other.rs", "unrelated\n");
484 insert(
485 &other,
486 FileState::from_path(&other).unwrap(),
487 Arc::from("unrelated\n"),
488 &admitted(),
489 );
490 let second = get_or_read(&p).unwrap();
491 assert_eq!(&*second, "hello world\n");
492 assert!(
498 Arc::ptr_eq(&first, &second),
499 "the second read must serve the cached allocation, not re-read the file"
500 );
501 }
502
503 #[test]
504 #[serial(cache_telemetry)]
505 fn eviction_keeps_cache_within_budget() {
506 let _env_lock = crate::core::data_dir::test_env_lock();
507 let _g = TEST_LOCK
508 .lock()
509 .unwrap_or_else(std::sync::PoisonError::into_inner);
510 let _stable = fresh_cache(64);
512 let dir = tempfile::tempdir().unwrap();
513 let pa = write(dir.path(), "a", "aaaaaaaaaaaaaaaaaaaaaaaaaaaa"); let pb = write(dir.path(), "b", "bbbbbbbbbbbbbbbbbbbbbbbbbbbb");
515 let pc = write(dir.path(), "c", "cccccccccccccccccccccccccccc");
516 let sa = FileState::from_path(&pa).unwrap();
517 let sb = FileState::from_path(&pb).unwrap();
518 let sc = FileState::from_path(&pc).unwrap();
519
520 insert(
521 &pa,
522 sa,
523 Arc::from("aaaaaaaaaaaaaaaaaaaaaaaaaaaa"),
524 &admitted(),
525 );
526 let _ = get(&pa, sa);
528 insert(
529 &pb,
530 sb,
531 Arc::from("bbbbbbbbbbbbbbbbbbbbbbbbbbbb"),
532 &admitted(),
533 );
534 let _ = get(&pa, sa);
535 insert(
536 &pc,
537 sc,
538 Arc::from("cccccccccccccccccccccccccccc"),
539 &admitted(),
540 );
541
542 let st = stats();
543 assert!(st.bytes <= 64, "cache must respect byte budget: {st:?}");
544 assert!(st.evictions >= 1, "an eviction must have occurred: {st:?}");
545 assert!(get(&pa, sa).is_some(), "recently-used entry must survive");
546 }
547
548 #[test]
549 #[serial(cache_telemetry)]
550 fn disabled_via_zero_budget_is_passthrough() {
551 let _env_lock = crate::core::data_dir::test_env_lock();
552 let _g = TEST_LOCK
553 .lock()
554 .unwrap_or_else(std::sync::PoisonError::into_inner);
555 let _stable = fresh_cache(1024 * 1024);
556 crate::test_env::set_var("LEAN_CTX_CONTENT_CACHE_MB", "0");
557 let dir = tempfile::tempdir().unwrap();
558 let p = write(dir.path(), "a.rs", "x\n");
559 let state = FileState::from_path(&p).unwrap();
560 insert(&p, state, Arc::from("x\n"), &admitted());
561 assert!(get(&p, state).is_none(), "zero-budget cache is a no-op");
562 crate::test_env::remove_var("LEAN_CTX_CONTENT_CACHE_MB");
563 }
564}