Skip to main content

fallow_engine/
warm_parse.rs

1//! Parsed modules that a long-lived process keeps across analysis sessions.
2//!
3//! A typed MCP tool call builds a new [`AnalysisSession`] for each call. Each
4//! session loads the persisted parse cache, checks each file against it, and
5//! writes the cache back. A process that installs a [`WarmParseStore`] keeps
6//! the parsed modules of recent sessions in memory. A later session with the
7//! same file list and the same file fingerprints takes its modules from the
8//! store and does no parse work.
9//!
10//! The store is safe to share between sessions with different configs,
11//! because a parse depends only on the file path, the file content, and the
12//! config hash of the persisted parse cache. The key holds all three: the
13//! project root and the cache config hash, the ordered file list (the file ids
14//! follow from it), and one fingerprint per file. The store keeps a module
15//! only when each fingerprint can stand in for the file content without a
16//! content check, which needs a known ctime. On a platform with no ctime, such
17//! as Windows, each session parses through the persisted cache as before.
18//!
19//! The limit of the store is on the memory of the kept modules. The store
20//! cannot measure that memory, so it makes an estimate from the source size
21//! and the file count. Each kept file list holds its own modules: two lists
22//! that share files, such as a full list and a production list, each count in
23//! full.
24//!
25//! [`AnalysisSession`]: crate::session::AnalysisSession
26
27use std::path::{Path, PathBuf};
28use std::sync::atomic::{AtomicUsize, Ordering};
29use std::sync::{Arc, Mutex, PoisonError, RwLock};
30
31use fallow_types::discover::{DiscoveredFile, FileId};
32use fallow_types::extract::{ModuleInfo, SourceParseDegradation, SourceReadFailure};
33use fallow_types::source_fingerprint::SourceFingerprint;
34
35/// The default number of parsed file lists that a store keeps.
36pub const DEFAULT_MAX_ENTRIES: usize = 4;
37
38/// The default limit on the estimated memory of the kept modules.
39pub const DEFAULT_MAX_RETAINED_BYTES: u64 = 512 * 1024 * 1024;
40
41/// The estimated heap memory of the parsed modules for one byte of source.
42///
43/// The heap of the parsed modules of ten public projects was 4.4 to 10.5
44/// times the source size. The estimate uses a value above the largest ratio,
45/// so the real memory stays below the limit.
46const RETAINED_BYTES_PER_SOURCE_BYTE: u64 = 12;
47
48/// Memory limits of a [`WarmParseStore`].
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub struct WarmParseLimits {
51    /// The most parsed file lists that the store keeps. The store removes the
52    /// least recently used list first.
53    pub max_entries: usize,
54    /// The limit on the estimated memory, in bytes, of the kept modules of
55    /// all file lists. The estimate is 12 bytes for each source byte, plus
56    /// the size of one module struct for each file. A file list with an
57    /// estimate over this limit is not kept.
58    pub max_retained_bytes: u64,
59}
60
61impl Default for WarmParseLimits {
62    fn default() -> Self {
63        Self {
64            max_entries: DEFAULT_MAX_ENTRIES,
65            max_retained_bytes: DEFAULT_MAX_RETAINED_BYTES,
66        }
67    }
68}
69
70/// The parse work of the sessions that used a store.
71#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
72pub struct WarmParseCounts {
73    /// Parse passes over a full file list.
74    pub parse_runs: usize,
75    /// Files parsed from source.
76    pub modules_parsed: usize,
77    /// Files served from the persisted parse cache.
78    pub disk_cache_hits: usize,
79    /// Files served from the modules in the store.
80    pub modules_reused: usize,
81}
82
83/// Parsed modules kept across analysis sessions in one process.
84#[derive(Debug)]
85pub struct WarmParseStore {
86    limits: WarmParseLimits,
87    entries: Mutex<Vec<WarmEntry>>,
88    parse_runs: AtomicUsize,
89    modules_parsed: AtomicUsize,
90    disk_cache_hits: AtomicUsize,
91    modules_reused: AtomicUsize,
92}
93
94#[derive(Debug)]
95struct WarmEntry {
96    root: PathBuf,
97    cache_config_hash: u64,
98    paths: Vec<PathBuf>,
99    file_ids: Vec<FileId>,
100    fingerprints: Vec<SourceFingerprint>,
101    has_complexity: bool,
102    retained_bytes: u64,
103    parse: WarmParse,
104}
105
106/// The identity of one parse: the project, the file list, and the file
107/// fingerprints.
108#[derive(Debug, Clone, Copy)]
109pub(crate) struct WarmParseKey<'a> {
110    pub(crate) root: &'a Path,
111    pub(crate) cache_config_hash: u64,
112    pub(crate) files: &'a [DiscoveredFile],
113    pub(crate) fingerprints: &'a [SourceFingerprint],
114}
115
116impl WarmParseKey<'_> {
117    /// Whether the store may keep the modules of this parse. Each fingerprint
118    /// must stand in for the file content without a content check.
119    pub(crate) fn is_reusable(&self) -> bool {
120        self.files.len() == self.fingerprints.len()
121            && self
122                .fingerprints
123                .iter()
124                .all(|fingerprint| fingerprint.is_trustworthy_without_content())
125    }
126
127    /// The estimated heap memory of the parsed modules of this file list.
128    fn retained_bytes(&self) -> u64 {
129        estimated_retained_bytes(self.fingerprints)
130    }
131}
132
133/// The estimated heap memory of the parsed modules of the files with these
134/// fingerprints: 12 bytes for each source byte, plus the size of one module
135/// struct for each file. [`WarmParseLimits::max_retained_bytes`] applies to
136/// this estimate.
137#[must_use]
138pub fn estimated_retained_bytes(fingerprints: &[SourceFingerprint]) -> u64 {
139    let source_bytes: u64 = fingerprints
140        .iter()
141        .map(|fingerprint| fingerprint.file_size)
142        .sum();
143    let module_bytes = u64::try_from(size_of::<ModuleInfo>()).unwrap_or(u64::MAX);
144    let file_count = u64::try_from(fingerprints.len()).unwrap_or(u64::MAX);
145    source_bytes
146        .saturating_mul(RETAINED_BYTES_PER_SOURCE_BYTE)
147        .saturating_add(file_count.saturating_mul(module_bytes))
148}
149
150/// The output of one parse that a later session can use again.
151#[derive(Debug, Clone)]
152pub(crate) struct WarmParse {
153    pub(crate) modules: Arc<[ModuleInfo]>,
154    pub(crate) read_failures: Arc<[SourceReadFailure]>,
155    pub(crate) parse_degradations: Arc<[SourceParseDegradation]>,
156}
157
158impl WarmEntry {
159    fn matches(&self, key: &WarmParseKey<'_>) -> bool {
160        self.matches_files(key) && self.fingerprints == key.fingerprints
161    }
162
163    /// Whether the entry is a parse of the same file list. The kept modules
164    /// carry their file ids, so the ids must also be the same.
165    fn matches_files(&self, key: &WarmParseKey<'_>) -> bool {
166        self.cache_config_hash == key.cache_config_hash
167            && self.root == key.root
168            && self
169                .paths
170                .iter()
171                .eq(key.files.iter().map(|file| &file.path))
172            && self
173                .file_ids
174                .iter()
175                .copied()
176                .eq(key.files.iter().map(|file| file.id))
177    }
178}
179
180impl WarmParseStore {
181    /// Create an empty store with the given memory limits.
182    #[must_use]
183    pub fn new(limits: WarmParseLimits) -> Self {
184        Self {
185            limits,
186            entries: Mutex::new(Vec::new()),
187            parse_runs: AtomicUsize::new(0),
188            modules_parsed: AtomicUsize::new(0),
189            disk_cache_hits: AtomicUsize::new(0),
190            modules_reused: AtomicUsize::new(0),
191        }
192    }
193
194    /// The parse work of the sessions that used this store.
195    #[must_use]
196    pub fn counts(&self) -> WarmParseCounts {
197        WarmParseCounts {
198            parse_runs: self.parse_runs.load(Ordering::Relaxed),
199            modules_parsed: self.modules_parsed.load(Ordering::Relaxed),
200            disk_cache_hits: self.disk_cache_hits.load(Ordering::Relaxed),
201            modules_reused: self.modules_reused.load(Ordering::Relaxed),
202        }
203    }
204
205    /// The number of parsed file lists in the store.
206    #[must_use]
207    pub fn len(&self) -> usize {
208        self.lock().len()
209    }
210
211    /// Whether the store keeps no parsed file list.
212    #[must_use]
213    pub fn is_empty(&self) -> bool {
214        self.lock().is_empty()
215    }
216
217    /// The kept parse for `key`, when it has complexity or the caller needs
218    /// none. A hit becomes the most recently used entry.
219    pub(crate) fn get(&self, key: &WarmParseKey<'_>, need_complexity: bool) -> Option<WarmParse> {
220        if !key.is_reusable() {
221            return None;
222        }
223        let mut entries = self.lock();
224        let position = entries
225            .iter()
226            .position(|entry| entry.matches(key) && (entry.has_complexity || !need_complexity))?;
227        let entry = entries.remove(position);
228        let parse = entry.parse.clone();
229        entries.push(entry);
230        drop(entries);
231        self.modules_reused
232            .fetch_add(parse.modules.len(), Ordering::Relaxed);
233        Some(parse)
234    }
235
236    /// Keep the parse for `key`. It replaces an older parse of the same file
237    /// list, and the least recently used entries leave the store until it is
238    /// within its limits.
239    pub(crate) fn put(&self, key: &WarmParseKey<'_>, has_complexity: bool, parse: WarmParse) {
240        let retained_bytes = key.retained_bytes();
241        let keep = key.is_reusable()
242            && self.limits.max_entries > 0
243            && retained_bytes <= self.limits.max_retained_bytes;
244        let entry = keep.then(|| WarmEntry {
245            root: key.root.to_path_buf(),
246            cache_config_hash: key.cache_config_hash,
247            paths: key.files.iter().map(|file| file.path.clone()).collect(),
248            file_ids: key.files.iter().map(|file| file.id).collect(),
249            fingerprints: key.fingerprints.to_vec(),
250            has_complexity,
251            retained_bytes,
252            parse,
253        });
254
255        let mut entries = self.lock();
256        entries.retain(|entry| !entry.matches_files(key));
257        entries.extend(entry);
258        let mut total_bytes: u64 = entries.iter().map(|entry| entry.retained_bytes).sum();
259        while entries.len() > self.limits.max_entries
260            || total_bytes > self.limits.max_retained_bytes
261        {
262            let removed = entries.remove(0);
263            total_bytes -= removed.retained_bytes;
264        }
265        drop(entries);
266    }
267
268    /// Count one parse pass over a full file list.
269    pub(crate) fn record_parse(&self, cache_misses: usize, cache_hits: usize) {
270        self.parse_runs.fetch_add(1, Ordering::Relaxed);
271        self.modules_parsed
272            .fetch_add(cache_misses, Ordering::Relaxed);
273        self.disk_cache_hits
274            .fetch_add(cache_hits, Ordering::Relaxed);
275    }
276
277    fn lock(&self) -> std::sync::MutexGuard<'_, Vec<WarmEntry>> {
278        self.entries.lock().unwrap_or_else(PoisonError::into_inner)
279    }
280}
281
282static INSTALLED: RwLock<Option<Arc<WarmParseStore>>> = RwLock::new(None);
283
284/// Make `store` the store of each session that this process creates from now
285/// on. `None` removes the store, so new sessions parse as before.
286///
287/// Only a long-lived process that runs many analyses of the same project
288/// installs a store, such as the MCP server. A session keeps the store that
289/// was installed when the session was created.
290pub fn install(store: Option<Arc<WarmParseStore>>) {
291    *INSTALLED.write().unwrap_or_else(PoisonError::into_inner) = store;
292}
293
294/// The store that new sessions use, if a process installed one.
295#[must_use]
296pub fn installed() -> Option<Arc<WarmParseStore>> {
297    INSTALLED
298        .read()
299        .unwrap_or_else(PoisonError::into_inner)
300        .clone()
301}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306
307    fn files(paths: &[&str]) -> Vec<DiscoveredFile> {
308        paths
309            .iter()
310            .enumerate()
311            .map(|(index, path)| DiscoveredFile {
312                id: FileId(u32::try_from(index).expect("small index")),
313                path: PathBuf::from(path),
314                size_bytes: 1,
315            })
316            .collect()
317    }
318
319    fn fingerprints(count: usize, size: u64) -> Vec<SourceFingerprint> {
320        (0..count)
321            .map(|index| SourceFingerprint::with_ctime(10 + index as u64, 20, size))
322            .collect()
323    }
324
325    fn parse() -> WarmParse {
326        WarmParse {
327            modules: Arc::from(Vec::new()),
328            read_failures: Arc::from(Vec::new()),
329            parse_degradations: Arc::from(Vec::new()),
330        }
331    }
332
333    fn key<'a>(
334        root: &'a Path,
335        files: &'a [DiscoveredFile],
336        fingerprints: &'a [SourceFingerprint],
337    ) -> WarmParseKey<'a> {
338        WarmParseKey {
339            root,
340            cache_config_hash: 7,
341            files,
342            fingerprints,
343        }
344    }
345
346    #[test]
347    fn a_hit_needs_the_same_files_and_fingerprints() {
348        let store = WarmParseStore::new(WarmParseLimits::default());
349        let root = Path::new("/project");
350        let listed = files(&["/project/a.ts", "/project/b.ts"]);
351        let marks = fingerprints(2, 5);
352        store.put(&key(root, &listed, &marks), true, parse());
353
354        assert!(store.get(&key(root, &listed, &marks), true).is_some());
355
356        let mut edited = marks.clone();
357        edited[1].mtime_ns += 1;
358        assert!(store.get(&key(root, &listed, &edited), false).is_none());
359
360        let added = files(&["/project/a.ts", "/project/b.ts", "/project/c.ts"]);
361        assert!(
362            store
363                .get(&key(root, &added, &fingerprints(3, 5)), false)
364                .is_none()
365        );
366
367        let mut other_config = key(root, &listed, &marks);
368        other_config.cache_config_hash = 8;
369        assert!(store.get(&other_config, false).is_none());
370    }
371
372    #[test]
373    fn a_parse_without_complexity_does_not_serve_a_request_for_it() {
374        let store = WarmParseStore::new(WarmParseLimits::default());
375        let root = Path::new("/project");
376        let listed = files(&["/project/a.ts"]);
377        let marks = fingerprints(1, 5);
378        store.put(&key(root, &listed, &marks), false, parse());
379
380        assert!(store.get(&key(root, &listed, &marks), true).is_none());
381        assert!(store.get(&key(root, &listed, &marks), false).is_some());
382    }
383
384    #[test]
385    fn fingerprints_without_ctime_are_not_kept() {
386        let store = WarmParseStore::new(WarmParseLimits::default());
387        let root = Path::new("/project");
388        let listed = files(&["/project/a.ts"]);
389        let marks = [SourceFingerprint::new(10, 5)];
390        store.put(&key(root, &listed, &marks), true, parse());
391
392        assert!(store.is_empty());
393        assert!(store.get(&key(root, &listed, &marks), false).is_none());
394    }
395
396    #[test]
397    fn a_new_parse_of_the_same_files_replaces_the_old_one() {
398        let store = WarmParseStore::new(WarmParseLimits::default());
399        let root = Path::new("/project");
400        let listed = files(&["/project/a.ts"]);
401        let before = fingerprints(1, 5);
402        let after = fingerprints(1, 6);
403        store.put(&key(root, &listed, &before), true, parse());
404        store.put(&key(root, &listed, &after), true, parse());
405
406        assert_eq!(store.len(), 1);
407        assert!(store.get(&key(root, &listed, &after), true).is_some());
408    }
409
410    #[test]
411    fn the_least_recently_used_entry_leaves_first() {
412        let store = WarmParseStore::new(WarmParseLimits {
413            max_entries: 2,
414            max_retained_bytes: u64::MAX,
415        });
416        let marks = fingerprints(1, 5);
417        let first = files(&["/first/a.ts"]);
418        let second = files(&["/second/a.ts"]);
419        let third = files(&["/third/a.ts"]);
420        store.put(&key(Path::new("/first"), &first, &marks), true, parse());
421        store.put(&key(Path::new("/second"), &second, &marks), true, parse());
422        assert!(
423            store
424                .get(&key(Path::new("/first"), &first, &marks), true)
425                .is_some()
426        );
427        store.put(&key(Path::new("/third"), &third, &marks), true, parse());
428
429        assert_eq!(store.len(), 2);
430        assert!(
431            store
432                .get(&key(Path::new("/second"), &second, &marks), true)
433                .is_none()
434        );
435        assert!(
436            store
437                .get(&key(Path::new("/first"), &first, &marks), true)
438                .is_some()
439        );
440    }
441
442    #[test]
443    fn the_default_limit_counts_the_memory_of_the_kept_modules() {
444        let store = WarmParseStore::new(WarmParseLimits::default());
445        let large = files(&["/large/a.ts"]);
446        store.put(
447            &key(
448                Path::new("/large"),
449                &large,
450                &fingerprints(1, 64 * 1024 * 1024),
451            ),
452            true,
453            parse(),
454        );
455        assert!(
456            store.is_empty(),
457            "the modules of 64 MiB of source take more memory than the default limit"
458        );
459
460        let medium = files(&["/medium/a.ts"]);
461        store.put(
462            &key(
463                Path::new("/medium"),
464                &medium,
465                &fingerprints(1, 16 * 1024 * 1024),
466            ),
467            true,
468            parse(),
469        );
470        assert_eq!(store.len(), 1);
471    }
472
473    #[test]
474    fn a_list_with_other_file_ids_is_not_served() {
475        let store = WarmParseStore::new(WarmParseLimits::default());
476        let root = Path::new("/project");
477        let listed = files(&["/project/a.ts", "/project/b.ts"]);
478        let marks = fingerprints(2, 5);
479        store.put(&key(root, &listed, &marks), true, parse());
480
481        let mut renumbered = listed.clone();
482        renumbered[0].id = FileId(7);
483        assert!(store.get(&key(root, &renumbered, &marks), false).is_none());
484        assert!(store.get(&key(root, &listed, &marks), false).is_some());
485    }
486
487    #[test]
488    fn the_memory_limit_bounds_the_store() {
489        let small = files(&["/small/a.ts"]);
490        let small_marks = fingerprints(1, 6);
491        let small_key = key(Path::new("/small"), &small, &small_marks);
492        let store = WarmParseStore::new(WarmParseLimits {
493            max_entries: 8,
494            max_retained_bytes: small_key.retained_bytes(),
495        });
496        let large = files(&["/large/a.ts", "/large/b.ts"]);
497        store.put(&small_key, true, parse());
498        store.put(
499            &key(Path::new("/large"), &large, &fingerprints(2, 6)),
500            true,
501            parse(),
502        );
503        assert_eq!(store.len(), 1, "a list over the limit is not kept");
504
505        let other = files(&["/other/a.ts"]);
506        store.put(
507            &key(Path::new("/other"), &other, &fingerprints(1, 6)),
508            true,
509            parse(),
510        );
511        assert_eq!(
512            store.len(),
513            1,
514            "the older list leaves to keep the sum within the limit"
515        );
516        assert!(
517            store
518                .get(&key(Path::new("/other"), &other, &fingerprints(1, 6)), true)
519                .is_some()
520        );
521    }
522}