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