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::source_fingerprint::SourceFingerprint;
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#[derive(Debug, Encode, Decode)]
25pub struct CacheStore {
26    version: u32,
27    /// Stable hash of extraction-affecting config fields.
28    config_hash: u64,
29    /// Map from file path to cached module data.
30    entries: FxHashMap<String, CachedModule>,
31}
32
33impl CacheStore {
34    /// Create a new empty cache.
35    #[must_use]
36    pub fn new() -> Self {
37        Self {
38            version: CACHE_VERSION,
39            config_hash: 0,
40            entries: FxHashMap::default(),
41        }
42    }
43
44    /// Load cache from disk.
45    ///
46    /// Returns `None` when the file is missing, too large, undecodable, or
47    /// built for a different `config_hash`.
48    #[must_use]
49    pub fn load(
50        cache_dir: &Path,
51        expected_config_hash: u64,
52        max_size_bytes: usize,
53    ) -> Option<Self> {
54        let cache_file = cache_dir.join("cache.bin");
55        let data = std::fs::read(&cache_file).ok()?;
56        let safety_ceiling = max_size_bytes.max(DEFAULT_CACHE_MAX_SIZE);
57        if data.len() > safety_ceiling {
58            tracing::warn!(
59                size_mb = data.len() / (1024 * 1024),
60                ceiling_mb = safety_ceiling / (1024 * 1024),
61                "Cache file exceeds safety ceiling, ignoring"
62            );
63            return None;
64        }
65        let store: Self = match bitcode::decode(&data) {
66            Ok(s) => s,
67            Err(_) => {
68                tracing::info!(
69                    "Cache format upgraded, rebuilding (one-time cost after version bump)"
70                );
71                return None;
72            }
73        };
74        if store.version != CACHE_VERSION {
75            tracing::info!("Cache format upgraded, rebuilding (one-time cost after version bump)");
76            return None;
77        }
78        if store.config_hash != expected_config_hash {
79            return None;
80        }
81        Some(store)
82    }
83
84    /// Save cache to disk with write-time size enforcement and atomic rename.
85    pub fn save(
86        &mut self,
87        cache_dir: &Path,
88        config_hash: u64,
89        max_size_bytes: usize,
90    ) -> Result<(), String> {
91        std::fs::create_dir_all(cache_dir)
92            .map_err(|e| format!("Failed to create cache dir: {e}"))?;
93        write_cache_gitignore(cache_dir)?;
94
95        self.config_hash = config_hash;
96        let initial_entries = self.entries.len();
97        let mut encoded = self.encode();
98
99        let trigger = (max_size_bytes / 10_000).saturating_mul(EVICTION_TRIGGER_BPS);
100        if encoded.len() > trigger {
101            let target = (max_size_bytes / 10_000).saturating_mul(EVICTION_TARGET_BPS);
102            encoded = self.evict_lru_to_target(target, encoded);
103            let evicted = initial_entries.saturating_sub(self.entries.len());
104            let final_size = encoded.len();
105            let significant_evicted =
106                initial_entries.saturating_mul(EVICTION_SIGNIFICANT_BPS) / 10_000;
107            if evicted >= significant_evicted && initial_entries > 0 {
108                tracing::info!(
109                    evicted_entries = evicted,
110                    remaining_entries = self.entries.len(),
111                    final_size_kb = final_size / 1024,
112                    max_size_kb = max_size_bytes / 1024,
113                    "Cache eviction: removed oldest entries to stay under cap"
114                );
115            } else {
116                tracing::debug!(
117                    evicted_entries = evicted,
118                    remaining_entries = self.entries.len(),
119                    final_size_kb = final_size / 1024,
120                    max_size_kb = max_size_bytes / 1024,
121                    "Cache eviction"
122                );
123            }
124        }
125
126        let cache_file = cache_dir.join("cache.bin");
127        atomic_write(&cache_file, &encoded)?;
128        Ok(())
129    }
130
131    /// Evict LRU entries until the re-encoded size is under `target_bytes`
132    /// or only one entry remains.
133    fn evict_lru_to_target(&mut self, target_bytes: usize, mut encoded: Vec<u8>) -> Vec<u8> {
134        let mut order: Vec<(u64, String, usize)> = self
135            .entries
136            .iter()
137            .map(|(key, entry)| {
138                (
139                    entry.last_access_secs,
140                    key.clone(),
141                    bitcode::encode(entry)
142                        .len()
143                        .saturating_add(key.len())
144                        .max(1),
145                )
146            })
147            .collect();
148        order.sort();
149
150        const MAX_REFINEMENT_PASSES: usize = 2;
151        const ESTIMATE_SAFETY_BPS: usize = 9_800;
152        let mut idx = 0;
153        let mut estimated_remaining: usize = order
154            .iter()
155            .map(|(_, _, estimated_bytes)| estimated_bytes)
156            .sum();
157        for _ in 0..MAX_REFINEMENT_PASSES {
158            if encoded.len() <= target_bytes || self.entries.len() <= 1 {
159                break;
160            }
161
162            let estimated_budget = estimated_eviction_budget(
163                estimated_remaining,
164                target_bytes,
165                encoded.len(),
166                ESTIMATE_SAFETY_BPS,
167            );
168            let start_idx = idx;
169            while idx + 1 < order.len() && estimated_remaining > estimated_budget {
170                let (_, key, estimated_bytes) = &order[idx];
171                self.entries.remove(key);
172                estimated_remaining = estimated_remaining.saturating_sub(*estimated_bytes);
173                idx += 1;
174            }
175            if idx == start_idx && idx + 1 < order.len() {
176                let (_, key, estimated_bytes) = &order[idx];
177                self.entries.remove(key);
178                estimated_remaining = estimated_remaining.saturating_sub(*estimated_bytes);
179                idx += 1;
180            }
181            encoded = self.encode();
182        }
183
184        if encoded.len() > target_bytes && self.entries.len() > 1 {
185            let conservative_budget = target_bytes / 2;
186            while idx + 1 < order.len() && estimated_remaining > conservative_budget {
187                let (_, key, estimated_bytes) = &order[idx];
188                self.entries.remove(key);
189                estimated_remaining = estimated_remaining.saturating_sub(*estimated_bytes);
190                idx += 1;
191            }
192            encoded = self.encode();
193        }
194
195        // Per-entry encodings are deliberately conservative, but keep the
196        // configured cap exact if a future bitcode layout violates that
197        // estimate. This safety path runs only after byte-aware retention had
198        // three opportunities to preserve a recent suffix.
199        if encoded.len() > target_bytes && self.entries.len() > 1 {
200            let keep_newest_from = order.len().saturating_sub(1);
201            for (_, key, _) in &order[idx..keep_newest_from] {
202                self.entries.remove(key);
203            }
204            encoded = self.encode();
205        }
206
207        if encoded.len() > target_bytes && self.entries.len() == 1 {
208            tracing::warn!(
209                encoded_kb = encoded.len() / 1024,
210                target_kb = target_bytes / 1024,
211                "Single cache entry exceeds configured max; cache will overshoot the cap"
212            );
213        }
214        encoded
215    }
216
217    fn encode(&self) -> Vec<u8> {
218        #[cfg(test)]
219        FULL_STORE_ENCODE_COUNT.with(|count| count.set(count.get() + 1));
220        bitcode::encode(self)
221    }
222
223    #[cfg(test)]
224    pub(super) fn reset_full_store_encode_count() {
225        FULL_STORE_ENCODE_COUNT.with(|count| count.set(0));
226    }
227
228    #[cfg(test)]
229    pub(super) fn full_store_encode_count() -> usize {
230        FULL_STORE_ENCODE_COUNT.with(Cell::get)
231    }
232
233    /// Look up a cached module by path and content hash.
234    /// Returns None if not cached or hash mismatch.
235    #[must_use]
236    pub fn get(&self, path: &Path, content_hash: u64) -> Option<&CachedModule> {
237        let key = path.to_string_lossy();
238        let entry = self.entries.get(key.as_ref())?;
239        if entry.content_hash == content_hash {
240            Some(entry)
241        } else {
242            None
243        }
244    }
245
246    /// Insert or update a cached module.
247    pub fn insert(&mut self, path: &Path, module: CachedModule) {
248        let key = path.to_string_lossy().into_owned();
249        self.entries.insert(key, module);
250    }
251
252    /// Fast cache lookup using only file metadata (mtime + size).
253    #[must_use]
254    pub fn get_by_metadata(
255        &self,
256        path: &Path,
257        fingerprint: SourceFingerprint,
258    ) -> Option<&CachedModule> {
259        let key = path.to_string_lossy();
260        let entry = self.entries.get(key.as_ref())?;
261        if entry.source_fingerprint() == fingerprint && fingerprint.has_known_mtime() {
262            Some(entry)
263        } else {
264            None
265        }
266    }
267
268    /// Look up a cached module by path only (ignoring hash).
269    #[must_use]
270    pub fn get_by_path_only(&self, path: &Path) -> Option<&CachedModule> {
271        let key = path.to_string_lossy();
272        self.entries.get(key.as_ref())
273    }
274
275    /// Remove cache entries for files that are no longer in the project.
276    ///
277    /// Returns `true` when any entry was removed.
278    pub fn retain_paths(&mut self, files: &[fallow_types::discover::DiscoveredFile]) -> bool {
279        use rustc_hash::FxHashSet;
280        let current_paths: FxHashSet<String> = files
281            .iter()
282            .map(|f| f.path.to_string_lossy().to_string())
283            .collect();
284        let before = self.entries.len();
285        self.entries.retain(|key, _| current_paths.contains(key));
286        self.entries.len() != before
287    }
288
289    /// Number of cached entries.
290    #[must_use]
291    pub fn len(&self) -> usize {
292        self.entries.len()
293    }
294
295    /// Whether the cache is empty.
296    #[must_use]
297    pub fn is_empty(&self) -> bool {
298        self.entries.is_empty()
299    }
300}
301
302pub(super) fn estimated_eviction_budget(
303    estimated_remaining: usize,
304    target_bytes: usize,
305    encoded_bytes: usize,
306    safety_bps: usize,
307) -> usize {
308    if encoded_bytes == 0 {
309        return 0;
310    }
311
312    const BASIS_POINTS: u128 = 10_000;
313    let scaled = estimated_remaining as u128 * target_bytes as u128 / encoded_bytes as u128;
314    let safety = (safety_bps as u128).min(BASIS_POINTS);
315    let budget = scaled / BASIS_POINTS * safety + scaled % BASIS_POINTS * safety / BASIS_POINTS;
316    budget.min(usize::MAX as u128) as usize
317}
318
319fn write_cache_gitignore(cache_dir: &Path) -> Result<(), String> {
320    std::fs::write(cache_dir.join(".gitignore"), "*\n")
321        .map_err(|e| format!("Failed to write cache .gitignore: {e}"))
322}
323
324/// Write `data` atomically via a sibling `.tmp` file, best-effort fsync, then rename.
325fn atomic_write(cache_file: &Path, data: &[u8]) -> Result<(), String> {
326    let tmp_file = match cache_file.file_name() {
327        Some(name) => cache_file.with_file_name({
328            let mut s = name.to_os_string();
329            s.push(".tmp");
330            s
331        }),
332        None => return Err("Cache file path has no filename component".to_owned()),
333    };
334
335    {
336        use std::io::Write as _;
337        let mut f = std::fs::File::create(&tmp_file)
338            .map_err(|e| format!("Failed to create cache tmp: {e}"))?;
339        f.write_all(data)
340            .map_err(|e| format!("Failed to write cache tmp: {e}"))?;
341        let _ = f.sync_all();
342    }
343
344    std::fs::rename(&tmp_file, cache_file)
345        .map_err(|e| format!("Failed to rename cache tmp into place: {e}"))?;
346    Ok(())
347}
348
349impl Default for CacheStore {
350    fn default() -> Self {
351        Self::new()
352    }
353}