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        return None;
160    };
161    if entry.state != current {
162        // Stale version cached — drop it so we don't keep paying for it.
163        c.remove_entry(path);
164        c.misses += 1;
165        return None;
166    }
167    c.hits += 1;
168    // `get` promotes to MRU; present under the lock we still hold, but degrade
169    // gracefully instead of panicking on the read hot path if that invariant
170    // ever changes.
171    let entry = c.map.get(path)?;
172    Some(Arc::clone(&entry.content))
173}
174
175/// Insert (or replace) the content for `path` at version `state`. Skipped while
176/// the process is under memory pressure or when the cache is disabled, so the
177/// cache never *adds* to a memory problem.
178pub fn insert(path: &Path, state: FileState, content: Arc<str>) {
179    if disabled() || crate::core::memory_guard::is_under_pressure() {
180        return;
181    }
182    let len = content.len();
183    let mut c = lock();
184    // A single file larger than the whole budget would thrash eviction — skip it.
185    if len > c.budget_bytes {
186        return;
187    }
188    c.remove_entry(path);
189    c.map.put(path.to_path_buf(), Entry { state, content });
190    c.total_bytes += len;
191    c.inserts += 1;
192    if c.total_bytes > c.budget_bytes {
193        c.evict_to_budget();
194    }
195}
196
197/// Read a file through the cache: returns cached content on a fresh hit, else
198/// reads from disk (UTF-8), populates the cache, and returns it. `None` on a
199/// non-UTF-8/unreadable/unstatable file. Convenience for callers without their
200/// own size/special-file gating (the search-index build and `ctx_search` use
201/// the explicit [`get`]/[`insert`] pair so they keep their own skip rules).
202pub fn get_or_read(path: &Path) -> Option<Arc<str>> {
203    let state = FileState::from_path(path)?;
204    if let Some(hit) = get(path, state) {
205        return Some(hit);
206    }
207    let content = std::fs::read_to_string(path).ok()?;
208    let arc: Arc<str> = Arc::from(content);
209    insert(path, state, Arc::clone(&arc));
210    Some(arc)
211}
212
213/// Drop all entries, freeing the heap. Called by the eviction orchestrator under
214/// memory pressure; the cache simply re-warms on subsequent reads.
215pub fn clear() {
216    if CACHE.get().is_none() {
217        return;
218    }
219    let mut c = lock();
220    c.map.clear();
221    c.total_bytes = 0;
222}
223
224/// Evict the oldest `percent` of entries by LRU clock. Used after index builds
225/// to release file contents that were only needed for chunking/tokenization.
226/// `percent` is clamped to `[0, 100]`.
227pub fn trim_oldest_percent(percent: u8) {
228    if CACHE.get().is_none() {
229        return;
230    }
231    let mut c = lock();
232    if c.map.is_empty() {
233        return;
234    }
235    let pct = (percent.min(100)) as usize;
236    let target_evictions = c.map.len() * pct / 100;
237    for _ in 0..target_evictions {
238        let Some((_, victim)) = c.map.pop_lru() else {
239            break;
240        };
241        c.total_bytes = c.total_bytes.saturating_sub(victim.content.len());
242        c.evictions += 1;
243    }
244}
245
246/// Approximate resident heap used by cached contents, in bytes.
247pub fn memory_usage_bytes() -> usize {
248    if CACHE.get().is_none() {
249        return 0;
250    }
251    lock().total_bytes
252}
253
254/// Observability snapshot: `(hits, misses, entries, bytes, evictions)`.
255#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
256pub struct CacheStats {
257    pub hits: u64,
258    pub misses: u64,
259    pub entries: usize,
260    pub bytes: usize,
261    pub inserts: u64,
262    pub evictions: u64,
263}
264
265pub fn stats() -> CacheStats {
266    if CACHE.get().is_none() {
267        return CacheStats::default();
268    }
269    let c = lock();
270    CacheStats {
271        hits: c.hits,
272        misses: c.misses,
273        entries: c.map.len(),
274        bytes: c.total_bytes,
275        inserts: c.inserts,
276        evictions: c.evictions,
277    }
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283
284    /// The cache is a process-wide global and tests mutate it (and the budget
285    /// env var). Serialize them so they cannot observe each other's state.
286    static TEST_LOCK: Mutex<()> = Mutex::new(());
287
288    fn fresh_cache(budget_bytes: usize) {
289        crate::test_env::remove_var("LEAN_CTX_CONTENT_CACHE_MB");
290        crate::test_env::remove_var("LEAN_CTX_DISABLE_CONTENT_CACHE");
291        let mut c = lock();
292        *c = Cache::new(budget_bytes);
293    }
294
295    fn write(dir: &Path, name: &str, body: &str) -> PathBuf {
296        let p = dir.join(name);
297        std::fs::write(&p, body).unwrap();
298        p
299    }
300
301    #[test]
302    fn hit_after_insert_with_matching_state() {
303        let _g = TEST_LOCK
304            .lock()
305            .unwrap_or_else(std::sync::PoisonError::into_inner);
306        fresh_cache(1024 * 1024);
307        let dir = tempfile::tempdir().unwrap();
308        let p = write(dir.path(), "a.rs", "fn main() {}\n");
309        let state = FileState::from_path(&p).unwrap();
310        assert!(get(&p, state).is_none(), "cold cache must miss");
311        insert(&p, state, Arc::from("fn main() {}\n"));
312        let got = get(&p, state).expect("warm cache must hit");
313        assert_eq!(&*got, "fn main() {}\n");
314    }
315
316    #[test]
317    fn mtime_or_size_change_invalidates() {
318        let _g = TEST_LOCK
319            .lock()
320            .unwrap_or_else(std::sync::PoisonError::into_inner);
321        fresh_cache(1024 * 1024);
322        let dir = tempfile::tempdir().unwrap();
323        let p = write(dir.path(), "a.rs", "v1\n");
324        let s1 = FileState::from_path(&p).unwrap();
325        insert(&p, s1, Arc::from("v1\n"));
326        assert!(get(&p, s1).is_some());
327
328        // Different size ⇒ different state ⇒ miss, and the stale entry is dropped.
329        let s_bigger = FileState {
330            size_bytes: s1.size_bytes + 10,
331            ..s1
332        };
333        assert!(get(&p, s_bigger).is_none(), "size change must miss");
334        assert!(
335            get(&p, s1).is_none(),
336            "stale entry must be evicted on mismatch"
337        );
338
339        // Different mtime ⇒ miss as well.
340        insert(&p, s1, Arc::from("v1\n"));
341        let s_newer = FileState {
342            mtime_ms: s1.mtime_ms + 1,
343            ..s1
344        };
345        assert!(get(&p, s_newer).is_none(), "mtime change must miss");
346    }
347
348    #[test]
349    fn get_or_read_populates_then_serves_from_cache() {
350        let _g = TEST_LOCK
351            .lock()
352            .unwrap_or_else(std::sync::PoisonError::into_inner);
353        fresh_cache(1024 * 1024);
354        let dir = tempfile::tempdir().unwrap();
355        let p = write(dir.path(), "a.rs", "hello world\n");
356
357        let before = stats();
358        let first = get_or_read(&p).unwrap();
359        assert_eq!(&*first, "hello world\n");
360        let after_first = stats();
361        assert_eq!(
362            after_first.inserts,
363            before.inserts + 1,
364            "first read inserts"
365        );
366
367        let second = get_or_read(&p).unwrap();
368        assert_eq!(&*second, "hello world\n");
369        let after_second = stats();
370        assert_eq!(
371            after_second.inserts, after_first.inserts,
372            "second read must NOT re-insert (served from cache)"
373        );
374        assert!(after_second.hits > after_first.hits, "second read is a hit");
375    }
376
377    #[test]
378    fn eviction_keeps_cache_within_budget() {
379        let _g = TEST_LOCK
380            .lock()
381            .unwrap_or_else(std::sync::PoisonError::into_inner);
382        // Budget fits ~2 small files; a third insert must evict the LRU one.
383        fresh_cache(64);
384        let dir = tempfile::tempdir().unwrap();
385        let pa = write(dir.path(), "a", "aaaaaaaaaaaaaaaaaaaaaaaaaaaa"); // 28 bytes
386        let pb = write(dir.path(), "b", "bbbbbbbbbbbbbbbbbbbbbbbbbbbb");
387        let pc = write(dir.path(), "c", "cccccccccccccccccccccccccccc");
388        let sa = FileState::from_path(&pa).unwrap();
389        let sb = FileState::from_path(&pb).unwrap();
390        let sc = FileState::from_path(&pc).unwrap();
391
392        insert(&pa, sa, Arc::from("aaaaaaaaaaaaaaaaaaaaaaaaaaaa"));
393        // Touch a so b becomes the LRU victim.
394        let _ = get(&pa, sa);
395        insert(&pb, sb, Arc::from("bbbbbbbbbbbbbbbbbbbbbbbbbbbb"));
396        let _ = get(&pa, sa);
397        insert(&pc, sc, Arc::from("cccccccccccccccccccccccccccc"));
398
399        let st = stats();
400        assert!(st.bytes <= 64, "cache must respect byte budget: {st:?}");
401        assert!(st.evictions >= 1, "an eviction must have occurred: {st:?}");
402        assert!(get(&pa, sa).is_some(), "recently-used entry must survive");
403    }
404
405    #[test]
406    fn disabled_via_zero_budget_is_passthrough() {
407        let _g = TEST_LOCK
408            .lock()
409            .unwrap_or_else(std::sync::PoisonError::into_inner);
410        fresh_cache(1024 * 1024);
411        crate::test_env::set_var("LEAN_CTX_CONTENT_CACHE_MB", "0");
412        let dir = tempfile::tempdir().unwrap();
413        let p = write(dir.path(), "a.rs", "x\n");
414        let state = FileState::from_path(&p).unwrap();
415        insert(&p, state, Arc::from("x\n"));
416        assert!(get(&p, state).is_none(), "zero-budget cache is a no-op");
417        crate::test_env::remove_var("LEAN_CTX_CONTENT_CACHE_MB");
418    }
419}