Skip to main content

fallow_extract/cache/
store.rs

1//! Cache store: load, save, and query cached module data.
2
3use std::path::Path;
4
5#[cfg(test)]
6use std::cell::Cell;
7
8use fallow_types::cache_rejection::CacheRejection;
9use rustc_hash::FxHashMap;
10
11use bitcode::{Decode, Encode};
12
13use super::types::{
14    CACHE_VERSION, CachedModule, DEFAULT_CACHE_MAX_SIZE, EVICTION_SIGNIFICANT_BPS,
15    EVICTION_TARGET_BPS, EVICTION_TRIGGER_BPS,
16};
17
18#[cfg(test)]
19thread_local! {
20    static FULL_STORE_ENCODE_COUNT: Cell<usize> = const { Cell::new(0) };
21}
22
23/// Cached module information stored on disk.
24///
25/// Entries are keyed on the ROOT-RELATIVE, forward-slash-normalised path, and
26/// the root is recorded once in the header. Absolute keys made the blob
27/// unusable anywhere but the directory that wrote it: a container job, a
28/// matrix over roots, a GitLab shell executor, or a plain `cp -Rp` to a
29/// sibling path paid the full decode of a multi-megabyte file and then missed
30/// every single lookup, with nothing on stderr to say so.
31#[derive(Debug, Encode, Decode)]
32pub struct CacheStore {
33    version: u32,
34    /// Stable hash of extraction-affecting config fields.
35    config_hash: u64,
36    /// Project root the entries are relative to, forward-slash normalised and
37    /// without a trailing separator. Informational after load: the loader
38    /// re-anchors to the CURRENT root, because a blob restored under another
39    /// path is exactly the case root-relative keys exist to serve.
40    root: String,
41    /// Map from root-relative file path to cached module data.
42    entries: FxHashMap<String, CachedModule>,
43}
44
45impl CacheStore {
46    /// Create a new empty cache anchored at `root`.
47    #[must_use]
48    pub fn new(root: &Path) -> Self {
49        Self {
50            version: CACHE_VERSION,
51            config_hash: 0,
52            root: normalise_root(root),
53            entries: FxHashMap::default(),
54        }
55    }
56
57    /// Load cache from disk.
58    ///
59    /// # Errors
60    ///
61    /// Returns the [`CacheRejection`] that decided against reuse. Every branch
62    /// names itself instead of collapsing into a bare miss: a run that read a
63    /// multi-megabyte blob and then refused it costs the same as a cold run
64    /// but used to be indistinguishable from having no cache at all, and the
65    /// config-hash branch in particular said nothing whatsoever. Callers carry
66    /// the reason into the perf table and `fallow doctor`.
67    ///
68    /// The version is read from the file header BEFORE the payload is
69    /// decoded, because the two are decided by different things. A format bump
70    /// changes the encoded shape, so decoding a blob from the previous release
71    /// fails outright and never reaches a version comparison made afterwards:
72    /// the most ordinary event there is (upgrading fallow) then reported
73    /// "cache file could not be decoded", which reads as corruption and sent
74    /// people looking for a damaged disk. With the version in front, an upgrade
75    /// says the format changed. The framing is checked separately from the
76    /// version it carries, so an unframed or unreadable payload reports `Undecodable`
77    /// rather than borrowing the upgrade message.
78    ///
79    /// Every branch that refuses a file that DID exist logs at warn, because
80    /// the user paid the read and got nothing back. Only the missing-file case
81    /// stays quiet.
82    pub fn load(
83        cache_dir: &Path,
84        root: &Path,
85        expected_config_hash: u64,
86        max_size_bytes: usize,
87    ) -> Result<Self, CacheRejection> {
88        let cache_file = cache_dir.join("cache.bin");
89        let data = std::fs::read(&cache_file).map_err(|error| {
90            if error.kind() == std::io::ErrorKind::NotFound {
91                return CacheRejection::Absent;
92            }
93            tracing::warn!("Cache file could not be read; check the path and permissions");
94            CacheRejection::Unreadable
95        })?;
96        let safety_ceiling = max_size_bytes.max(DEFAULT_CACHE_MAX_SIZE);
97        if data.len() > safety_ceiling {
98            tracing::warn!(
99                size_mb = data.len() / (1024 * 1024),
100                ceiling_mb = safety_ceiling / (1024 * 1024),
101                "Cache file exceeds safety ceiling, ignoring"
102            );
103            return Err(CacheRejection::Oversize {
104                size_bytes: data.len() as u64,
105                ceiling_bytes: safety_ceiling as u64,
106            });
107        }
108        let payload = read_header(&data)?;
109        let mut store: Self = match bitcode::decode(payload) {
110            Ok(s) => s,
111            Err(_) => {
112                tracing::warn!(
113                    "Cache file carries the current format version but its payload could not be \
114                     decoded, rebuilding"
115                );
116                return Err(CacheRejection::Undecodable);
117            }
118        };
119        // The header already agreed with `CACHE_VERSION`, so this catches only a
120        // file whose header and payload disagree: a spliced or hand-edited blob.
121        if store.version != CACHE_VERSION {
122            tracing::warn!(
123                cached_version = store.version,
124                expected_version = CACHE_VERSION,
125                "Cache header and payload declare different format versions, rebuilding"
126            );
127            return Err(CacheRejection::VersionMismatch);
128        }
129        if store.config_hash != expected_config_hash {
130            tracing::warn!(
131                "Cache was built under different extraction config, rebuilding from cold"
132            );
133            return Err(CacheRejection::ConfigHashMismatch);
134        }
135        let current_root = normalise_root(root);
136        if store.root != current_root {
137            tracing::debug!(
138                cached_root = %store.root,
139                "Reusing a cache written under a different project root"
140            );
141            store.root = current_root;
142        }
143        Ok(store)
144    }
145
146    /// Save cache to disk with write-time size enforcement and atomic rename.
147    pub fn save(
148        &mut self,
149        cache_dir: &Path,
150        config_hash: u64,
151        max_size_bytes: usize,
152    ) -> Result<(), String> {
153        std::fs::create_dir_all(cache_dir)
154            .map_err(|e| format!("Failed to create cache dir: {e}"))?;
155        write_cache_gitignore(cache_dir)?;
156
157        self.config_hash = config_hash;
158        let initial_entries = self.entries.len();
159        let mut encoded = self.encode();
160
161        let trigger = (max_size_bytes / 10_000).saturating_mul(EVICTION_TRIGGER_BPS);
162        if encoded.len().saturating_add(CACHE_HEADER_LEN) > trigger {
163            // The cap is a promise about the file, and the file carries the
164            // header as well as the payload, so eviction aims below both.
165            let target = (max_size_bytes / 10_000)
166                .saturating_mul(EVICTION_TARGET_BPS)
167                .saturating_sub(CACHE_HEADER_LEN);
168            encoded = self.evict_lru_to_target(target, encoded);
169            let evicted = initial_entries.saturating_sub(self.entries.len());
170            let final_size = encoded.len();
171            let significant_evicted =
172                initial_entries.saturating_mul(EVICTION_SIGNIFICANT_BPS) / 10_000;
173            if evicted >= significant_evicted && initial_entries > 0 {
174                tracing::info!(
175                    evicted_entries = evicted,
176                    remaining_entries = self.entries.len(),
177                    final_size_kb = final_size / 1024,
178                    max_size_kb = max_size_bytes / 1024,
179                    "Cache eviction: removed oldest entries to stay under cap"
180                );
181            } else {
182                tracing::debug!(
183                    evicted_entries = evicted,
184                    remaining_entries = self.entries.len(),
185                    final_size_kb = final_size / 1024,
186                    max_size_kb = max_size_bytes / 1024,
187                    "Cache eviction"
188                );
189            }
190        }
191
192        let cache_file = cache_dir.join("cache.bin");
193        atomic_write(&cache_file, &framed(self.version, &encoded))?;
194        Ok(())
195    }
196
197    /// Evict LRU entries until the re-encoded size is under `target_bytes`
198    /// or only one entry remains.
199    fn evict_lru_to_target(&mut self, target_bytes: usize, mut encoded: Vec<u8>) -> Vec<u8> {
200        let mut order: Vec<(u64, String, usize)> = self
201            .entries
202            .iter()
203            .map(|(key, entry)| {
204                (
205                    entry.last_access_secs,
206                    key.clone(),
207                    bitcode::encode(entry)
208                        .len()
209                        .saturating_add(key.len())
210                        .max(1),
211                )
212            })
213            .collect();
214        order.sort();
215
216        const MAX_REFINEMENT_PASSES: usize = 2;
217        const ESTIMATE_SAFETY_BPS: usize = 9_800;
218        let mut idx = 0;
219        let mut estimated_remaining: usize = order
220            .iter()
221            .map(|(_, _, estimated_bytes)| estimated_bytes)
222            .sum();
223        for _ in 0..MAX_REFINEMENT_PASSES {
224            if encoded.len() <= target_bytes || self.entries.len() <= 1 {
225                break;
226            }
227
228            let estimated_budget = estimated_eviction_budget(
229                estimated_remaining,
230                target_bytes,
231                encoded.len(),
232                ESTIMATE_SAFETY_BPS,
233            );
234            let start_idx = idx;
235            while idx + 1 < order.len() && estimated_remaining > estimated_budget {
236                let (_, key, estimated_bytes) = &order[idx];
237                self.entries.remove(key);
238                estimated_remaining = estimated_remaining.saturating_sub(*estimated_bytes);
239                idx += 1;
240            }
241            if idx == start_idx && idx + 1 < order.len() {
242                let (_, key, estimated_bytes) = &order[idx];
243                self.entries.remove(key);
244                estimated_remaining = estimated_remaining.saturating_sub(*estimated_bytes);
245                idx += 1;
246            }
247            encoded = self.encode();
248        }
249
250        if encoded.len() > target_bytes && self.entries.len() > 1 {
251            let conservative_budget = target_bytes / 2;
252            while idx + 1 < order.len() && estimated_remaining > conservative_budget {
253                let (_, key, estimated_bytes) = &order[idx];
254                self.entries.remove(key);
255                estimated_remaining = estimated_remaining.saturating_sub(*estimated_bytes);
256                idx += 1;
257            }
258            encoded = self.encode();
259        }
260
261        // Per-entry encodings are deliberately conservative, but keep the
262        // configured cap exact if a future bitcode layout violates that
263        // estimate. This safety path runs only after byte-aware retention had
264        // three opportunities to preserve a recent suffix.
265        if encoded.len() > target_bytes && self.entries.len() > 1 {
266            let keep_newest_from = order.len().saturating_sub(1);
267            for (_, key, _) in &order[idx..keep_newest_from] {
268                self.entries.remove(key);
269            }
270            encoded = self.encode();
271        }
272
273        if encoded.len() > target_bytes && self.entries.len() == 1 {
274            tracing::warn!(
275                encoded_kb = encoded.len() / 1024,
276                target_kb = target_bytes / 1024,
277                "Single cache entry exceeds configured max; cache will overshoot the cap"
278            );
279        }
280        encoded
281    }
282
283    fn encode(&self) -> Vec<u8> {
284        #[cfg(test)]
285        FULL_STORE_ENCODE_COUNT.with(|count| count.set(count.get() + 1));
286        bitcode::encode(self)
287    }
288
289    #[cfg(test)]
290    pub(super) fn reset_full_store_encode_count() {
291        FULL_STORE_ENCODE_COUNT.with(|count| count.set(0));
292    }
293
294    #[cfg(test)]
295    pub(super) fn full_store_encode_count() -> usize {
296        FULL_STORE_ENCODE_COUNT.with(Cell::get)
297    }
298
299    /// Key `path` the way entries are stored: root-relative where possible,
300    /// forward-slash normalised.
301    ///
302    /// A path outside the root keeps its own normalised spelling. It is still
303    /// stable within one root, which is all a lookup needs, and no root-
304    /// relative spelling of it exists to prefer.
305    fn key_for(&self, path: &Path) -> String {
306        let text = path.to_string_lossy().replace('\\', "/");
307        if self.root.is_empty() {
308            return text;
309        }
310        match text
311            .strip_prefix(&self.root)
312            .and_then(|rest| rest.strip_prefix('/'))
313        {
314            Some(relative) => relative.to_owned(),
315            None => text,
316        }
317    }
318
319    /// Rebuild the absolute path an entry key refers to under the current root.
320    fn path_for_key(&self, key: &str) -> std::path::PathBuf {
321        if self.root.is_empty() {
322            return std::path::PathBuf::from(key);
323        }
324        let candidate = Path::new(key);
325        if candidate.is_absolute() {
326            return candidate.to_path_buf();
327        }
328        Path::new(&self.root).join(key)
329    }
330
331    /// Look up a cached module by path and content hash.
332    /// Returns None if not cached or hash mismatch.
333    #[must_use]
334    pub fn get(&self, path: &Path, content_hash: u64) -> Option<&CachedModule> {
335        let entry = self.entries.get(&self.key_for(path))?;
336        if entry.content_hash == content_hash {
337            Some(entry)
338        } else {
339            None
340        }
341    }
342
343    /// Insert or update a cached module.
344    pub fn insert(&mut self, path: &Path, module: CachedModule) {
345        let key = self.key_for(path);
346        self.entries.insert(key, module);
347    }
348
349    /// Look up a cached module by path only (ignoring hash).
350    #[must_use]
351    pub fn get_by_path_only(&self, path: &Path) -> Option<&CachedModule> {
352        self.entries.get(&self.key_for(path))
353    }
354
355    /// Remove cache entries for files that no longer exist on disk.
356    ///
357    /// Returns `true` when any entry was removed.
358    ///
359    /// The predicate is deliberately "still exists", not "was discovered by
360    /// this run". Discovery is scoped: `--production` drops test and story
361    /// files, `--root` narrows to a subtree, and `ignorePatterns` differs per
362    /// command. Evicting whatever the current scope did not walk meant one
363    /// `--production` run threw away the entries for every test file, and the
364    /// next full run reparsed them from cold. Entries are keyed by absolute
365    /// path, so the check is one `symlink_metadata` per undiscovered entry
366    /// (`symlink_metadata`, not `metadata`, so a broken symlink still counts as
367    /// present rather than being evicted as missing). Size is not this
368    /// method's concern: `evict_lru_to_target` remains the only guard on how
369    /// large the blob may grow.
370    pub fn retain_paths(&mut self, files: &[fallow_types::discover::DiscoveredFile]) -> bool {
371        use rustc_hash::FxHashSet;
372        let current_keys: FxHashSet<String> = files.iter().map(|f| self.key_for(&f.path)).collect();
373        let before = self.entries.len();
374        let retained: FxHashSet<String> = self
375            .entries
376            .keys()
377            .filter(|key| {
378                current_keys.contains(*key)
379                    || std::fs::symlink_metadata(self.path_for_key(key)).is_ok()
380            })
381            .cloned()
382            .collect();
383        self.entries.retain(|key, _| retained.contains(key));
384        self.entries.len() != before
385    }
386
387    /// Number of cached entries.
388    #[must_use]
389    pub fn len(&self) -> usize {
390        self.entries.len()
391    }
392
393    /// Whether the cache is empty.
394    #[must_use]
395    pub fn is_empty(&self) -> bool {
396        self.entries.is_empty()
397    }
398}
399
400/// Marker written ahead of every cache payload so the format version can be
401/// read without decoding the payload it describes.
402///
403/// Constant across format bumps: only the version field beside it moves. That
404/// lets future upgrades report an explicit version mismatch; older unframed
405/// caches still report an ambiguous decode failure.
406pub(super) const CACHE_MAGIC: [u8; 4] = *b"FLWX";
407
408/// Bytes the framing adds ahead of the payload: the magic plus a little-endian
409/// `u32` format version.
410pub(super) const CACHE_HEADER_LEN: usize = CACHE_MAGIC.len() + 4;
411
412/// Prepend the format header to an encoded payload.
413///
414/// The version comes from the store being written rather than from the
415/// constant, so the header always describes the payload behind it.
416pub(super) fn framed(version: u32, payload: &[u8]) -> Vec<u8> {
417    let mut framed = Vec::with_capacity(CACHE_HEADER_LEN + payload.len());
418    framed.extend_from_slice(&CACHE_MAGIC);
419    framed.extend_from_slice(&version.to_le_bytes());
420    framed.extend_from_slice(payload);
421    framed
422}
423
424/// Split a cache file into its declared version and its payload, refusing
425/// anything this binary cannot read WITHOUT decoding it first.
426///
427/// The version check has to come first: a format bump changes the encoded
428/// shape, so a blob from the previous release fails to decode and a version
429/// comparison made after the decode is unreachable on the one event that
430/// triggers it most, an upgrade.
431///
432/// A recognized header exposes a version mismatch without decoding. Releases
433/// before framing wrote raw payloads, so a missing header cannot distinguish
434/// an older cache from foreign or damaged data. `Undecodable` keeps that
435/// uncertainty explicit and the next successful run replaces the blob.
436fn read_header(data: &[u8]) -> Result<&[u8], CacheRejection> {
437    let Some((header, payload)) = data.split_at_checked(CACHE_HEADER_LEN) else {
438        tracing::warn!("Cache file is too short to carry a format header, rebuilding");
439        return Err(CacheRejection::Undecodable);
440    };
441    let (declared_magic, declared_version) = header.split_at(CACHE_MAGIC.len());
442    if declared_magic != CACHE_MAGIC {
443        tracing::warn!("Cache file does not carry fallow's cache framing, rebuilding");
444        return Err(CacheRejection::Undecodable);
445    }
446    // The slice is exactly four bytes; the fallback only has to be a version
447    // this binary never writes, so an impossible header is refused rather than
448    // trusted.
449    let declared = declared_version.try_into().map_or(0, u32::from_le_bytes);
450    if declared != CACHE_VERSION {
451        tracing::warn!(
452            cached_version = declared,
453            expected_version = CACHE_VERSION,
454            "Cache format upgraded, rebuilding (one-time cost after version bump)"
455        );
456        return Err(CacheRejection::VersionMismatch);
457    }
458    Ok(payload)
459}
460
461pub(super) fn estimated_eviction_budget(
462    estimated_remaining: usize,
463    target_bytes: usize,
464    encoded_bytes: usize,
465    safety_bps: usize,
466) -> usize {
467    if encoded_bytes == 0 {
468        return 0;
469    }
470
471    const BASIS_POINTS: u128 = 10_000;
472    let scaled = estimated_remaining as u128 * target_bytes as u128 / encoded_bytes as u128;
473    let safety = (safety_bps as u128).min(BASIS_POINTS);
474    let budget = scaled / BASIS_POINTS * safety + scaled % BASIS_POINTS * safety / BASIS_POINTS;
475    budget.min(usize::MAX as u128) as usize
476}
477
478/// Normalise a project root for storage and prefix stripping: forward slashes,
479/// no trailing separator. An empty root disables stripping, which is what a
480/// default-constructed store gets.
481fn normalise_root(root: &Path) -> String {
482    let text = root.to_string_lossy().replace('\\', "/");
483    match text.strip_suffix('/') {
484        Some(trimmed) => trimmed.to_owned(),
485        None => text,
486    }
487}
488
489fn write_cache_gitignore(cache_dir: &Path) -> Result<(), String> {
490    std::fs::write(cache_dir.join(".gitignore"), "*\n")
491        .map_err(|e| format!("Failed to write cache .gitignore: {e}"))
492}
493
494/// Write `data` atomically via a sibling `.tmp` file, best-effort fsync, then rename.
495fn atomic_write(cache_file: &Path, data: &[u8]) -> Result<(), String> {
496    let tmp_file = match cache_file.file_name() {
497        Some(name) => cache_file.with_file_name({
498            let mut s = name.to_os_string();
499            s.push(".tmp");
500            s
501        }),
502        None => return Err("Cache file path has no filename component".to_owned()),
503    };
504
505    {
506        use std::io::Write as _;
507        let mut f = std::fs::File::create(&tmp_file)
508            .map_err(|e| format!("Failed to create cache tmp: {e}"))?;
509        f.write_all(data)
510            .map_err(|e| format!("Failed to write cache tmp: {e}"))?;
511        let _ = f.sync_all();
512    }
513
514    std::fs::rename(&tmp_file, cache_file)
515        .map_err(|e| format!("Failed to rename cache tmp into place: {e}"))?;
516    Ok(())
517}
518
519impl Default for CacheStore {
520    fn default() -> Self {
521        Self::new(Path::new(""))
522    }
523}