Skip to main content

lean_ctx/core/
content_cache.rs

1//! Resident, bounded file-content cache shared across the search-index build and
2//! `ctx_search` (issue #148).
3//!
4//! Before this module the trigram [`search_index`](crate::core::search_index)
5//! build read *every* file in the corpus to extract trigrams and then threw the
6//! content away, after which `ctx_search` read the narrowed candidate files
7//! **again** to run the regex line-by-line — the corpus was read from disk
8//! twice. This cache lets the first reader (whichever it is) populate file
9//! contents once, keyed by absolute path and validated by `(mtime, size)`, and
10//! every subsequent reader reuse them as an in-memory hit.
11//!
12//! Correctness: an entry is only ever served when the file's *current*
13//! `(mtime, size)` exactly matches the stored identity, so any edit (which
14//! changes mtime, and usually size) is a guaranteed miss — results can never go
15//! stale. A miss simply falls back to a disk read.
16//!
17//! Bounds & safety:
18//! - Total resident bytes are capped (`LEAN_CTX_CONTENT_CACHE_MB`, default
19//!   128 MB) with approximate-LRU eviction, so a large corpus cannot grow the
20//!   cache without limit.
21//! - Inserts are skipped while the process is under memory pressure, and the
22//!   eviction orchestrator can [`clear`] the cache on `UnloadIndices` /
23//!   `EmergencyDrop`.
24
25use std::fs::Metadata;
26use std::path::{Path, PathBuf};
27use std::sync::{Arc, Mutex, OnceLock};
28use std::time::UNIX_EPOCH;
29
30use lru::LruCache;
31
32/// Default resident byte budget when `LEAN_CTX_CONTENT_CACHE_MB` is unset.
33const DEFAULT_BUDGET_MB: usize = 128;
34
35/// Identity of one file *version*. A changed mtime or size ⇒ stale ⇒ cache miss.
36/// Mirrors the `(mtime, size)` pair the BM25 index already trusts for staleness.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub struct FileState {
39    pub mtime_ms: u64,
40    pub size_bytes: u64,
41}
42
43impl FileState {
44    /// Build from an already-`stat`ed [`Metadata`] (no extra syscall) — callers
45    /// in the hot path typically have this in hand from their size/regular-file
46    /// checks. Returns `None` only when the platform cannot report mtime.
47    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    /// Convenience: `stat` the path then build the state. Costs one syscall.
60    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    /// Unbounded by entry count — eviction is driven by `total_bytes` vs
72    /// `budget_bytes` below, not by a capacity `lru::LruCache` would enforce on
73    /// its own. `LruCache` gives O(1) recency tracking and O(1) LRU-victim
74    /// lookup (`pop_lru`), replacing a hand-rolled `last_used` clock plus an
75    /// O(n) `min_by_key` scan per eviction — the scan was rare per insert as
76    /// originally noted, but `trim_oldest_percent` called it in a loop, making
77    /// a big trim O(n²) over the resident set.
78    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    /// Evict LRU entries until the budget is satisfied. O(1) per eviction.
107    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    // A zero byte budget (or the explicit disable flag) turns the cache into a
130    // no-op pass-through — every `get` misses and `insert` is dropped.
131    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
146/// Look up `path`; returns the cached content only when the supplied current
147/// `(mtime, size)` matches the stored identity. A mismatch evicts the stale
148/// entry and reports a miss. `state` is passed in (not re-`stat`ed) because hot
149/// callers already hold the metadata.
150pub fn get(path: &Path, current: FileState) -> Option<Arc<str>> {
151    if disabled() {
152        return None;
153    }
154    let mut c = lock();
155    // `peek` (not `get`) first: a stale hit must not promote to MRU on its way
156    // to being evicted.
157    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        // Stale version cached — drop it so we don't keep paying for it.
164        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    // `get` promotes to MRU; present under the lock we still hold, but degrade
172    // gracefully instead of panicking on the read hot path if that invariant
173    // ever changes.
174    let entry = c.map.get(path)?;
175    Some(Arc::clone(&entry.content))
176}
177
178/// Insert (or replace) the content for `path` at version `state`. Skipped while
179/// the process is under memory pressure or when the cache is disabled, so the
180/// cache never *adds* to a memory problem.
181///
182/// `content` must be what `admitted_by` admitted: the cache only ever holds
183/// admitted text (Context Gateway, G5). Text admitted under a policy that is
184/// no longer the observed one is refused, so a build that outlived a policy
185/// change cannot repopulate the cache with it.
186pub 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    // Checked under the lock: a policy change advances the epoch before it
198    // clears the cache, so this entry is either refused here or cleared.
199    if !admitted_by.is_current() {
200        return;
201    }
202    // A single file larger than the whole budget would thrash eviction — skip it.
203    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
215/// Read a file through the cache: returns cached content on a fresh hit, else
216/// reads from disk (any text encoding, see `text_decode`), populates the cache,
217/// and returns it. `None` on a
218/// binary/unreadable/unstatable file. Convenience for callers without their
219/// own size/special-file gating (the search-index build and `ctx_search` use
220/// the explicit [`get`]/[`insert`] pair so they keep their own skip rules).
221pub fn get_or_read(path: &Path) -> Option<Arc<str>> {
222    let state = FileState::from_path(path)?;
223    // Observed first: a changed policy drops the cache before it can hit.
224    let admission = crate::core::context_admission::stores::StoreAdmission::current();
225    if let Some(hit) = get(path, state) {
226        return Some(hit);
227    }
228    // The cache only ever holds admitted text (Context Gateway, G5).
229    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
235/// Drop all entries, freeing the heap. Called by the eviction orchestrator under
236/// memory pressure; the cache simply re-warms on subsequent reads.
237pub 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
246/// Evict the oldest `percent` of entries by LRU clock. Used after index builds
247/// to release file contents that were only needed for chunking/tokenization.
248/// `percent` is clamped to `[0, 100]`.
249pub 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
268/// Approximate resident heap used by cached contents, in bytes.
269pub fn memory_usage_bytes() -> usize {
270    if CACHE.get().is_none() {
271        return 0;
272    }
273    lock().total_bytes
274}
275
276/// Observability snapshot: `(hits, misses, entries, bytes, evictions)`.
277#[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    /// The cache is a process-wide global and tests mutate it (and the budget
313    /// env var). Serialize them so they cannot observe each other's state.
314    static TEST_LOCK: Mutex<()> = Mutex::new(());
315
316    /// Held for the whole test: configuration and policy are process-wide,
317    /// and a change of either clears this cache (see `get_or_read` and
318    /// `insert`). Without these guards a parallel test that switches its data
319    /// directory or policy empties the cache between two reads of this test —
320    /// seen in gate `h15-engine` as a re-read where a hit was asserted.
321    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        // Different size ⇒ different state ⇒ miss, and the stale entry is dropped.
442        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        // Different mtime ⇒ miss as well.
453        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        // Other readers can populate unrelated paths between these calls.
482        // Global insertion counters cannot prove a hit for this specific file.
483        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        // Allocation identity, not counter arithmetic: a cache hit clones the
493        // stored `Arc`, a re-read allocates a new one. `stats()` is
494        // process-wide, so bracketing a call with `stats()` deltas races every
495        // parallel test that touches the content cache — that is what made this
496        // test flake in CI (seen on #1736 / #1738).
497        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        // Budget fits ~2 small files; a third insert must evict the LRU one.
511        let _stable = fresh_cache(64);
512        let dir = tempfile::tempdir().unwrap();
513        let pa = write(dir.path(), "a", "aaaaaaaaaaaaaaaaaaaaaaaaaaaa"); // 28 bytes
514        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        // Touch a so b becomes the LRU victim.
527        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}