Skip to main content

arcbox_fs/
cache.rs

1//! Filesystem caching layer.
2//!
3//! This module provides caching mechanisms for filesystem operations,
4//! including metadata caching and negative (non-existence) caching.
5
6use std::collections::HashMap;
7use std::path::{Path, PathBuf};
8use std::sync::RwLock;
9use std::sync::atomic::{AtomicU64, Ordering};
10use std::time::{Duration, Instant};
11
12use dashmap::DashMap;
13
14/// Cached entry with expiration.
15#[derive(Debug)]
16struct CacheEntry<T> {
17    value: T,
18    expires_at: Instant,
19}
20
21impl<T> CacheEntry<T> {
22    fn new(value: T, ttl: Duration) -> Self {
23        Self {
24            value,
25            expires_at: Instant::now() + ttl,
26        }
27    }
28
29    fn is_expired(&self) -> bool {
30        Instant::now() >= self.expires_at
31    }
32}
33
34/// Simple LRU cache for filesystem metadata.
35pub struct MetadataCache<K, V> {
36    entries: RwLock<HashMap<K, CacheEntry<V>>>,
37    ttl: Duration,
38    max_entries: usize,
39}
40
41impl<K: std::hash::Hash + Eq + Clone, V: Clone> MetadataCache<K, V> {
42    /// Creates a new cache.
43    #[must_use]
44    pub fn new(ttl: Duration, max_entries: usize) -> Self {
45        Self {
46            entries: RwLock::new(HashMap::new()),
47            ttl,
48            max_entries,
49        }
50    }
51
52    /// Gets a value from the cache.
53    #[must_use]
54    #[allow(clippy::significant_drop_tightening)]
55    pub fn get(&self, key: &K) -> Option<V> {
56        let entries = self.entries.read().ok()?;
57        let entry = entries.get(key)?;
58        if entry.is_expired() {
59            None
60        } else {
61            Some(entry.value.clone())
62        }
63    }
64
65    /// Inserts a value into the cache.
66    pub fn insert(&self, key: K, value: V) {
67        if let Ok(mut entries) = self.entries.write() {
68            // Simple eviction: remove expired entries
69            if entries.len() >= self.max_entries {
70                entries.retain(|_, v| !v.is_expired());
71            }
72
73            entries.insert(key, CacheEntry::new(value, self.ttl));
74        }
75    }
76
77    /// Removes a value from the cache.
78    pub fn remove(&self, key: &K) {
79        if let Ok(mut entries) = self.entries.write() {
80            entries.remove(key);
81        }
82    }
83
84    /// Clears the cache.
85    pub fn clear(&self) {
86        if let Ok(mut entries) = self.entries.write() {
87            entries.clear();
88        }
89    }
90}
91
92/// Configuration for adaptive negative cache TTLs.
93///
94/// Different path patterns have different rates of change. Build artifacts
95/// and dependency directories are relatively stable during a work session,
96/// while source files change frequently. This allows the negative cache to
97/// use longer TTLs for stable paths and shorter TTLs for volatile ones.
98#[derive(Debug, Clone)]
99pub struct AdaptiveTtlConfig {
100    /// Rules evaluated in order; first match wins.
101    pub rules: Vec<TtlRule>,
102    /// Default TTL for paths matching no rule.
103    pub default_ttl: Duration,
104}
105
106/// A single path-pattern-to-TTL mapping for the adaptive negative cache.
107#[derive(Debug, Clone)]
108pub struct TtlRule {
109    /// Path substring to match (e.g., "/node_modules/").
110    pub prefix: String,
111    /// TTL for ENOENT results matching this pattern.
112    pub ttl: Duration,
113}
114
115impl Default for AdaptiveTtlConfig {
116    fn default() -> Self {
117        Self {
118            rules: vec![
119                TtlRule {
120                    prefix: "/node_modules/".into(),
121                    ttl: Duration::from_secs(30),
122                },
123                TtlRule {
124                    prefix: "/.git/".into(),
125                    ttl: Duration::from_secs(60),
126                },
127                TtlRule {
128                    prefix: "/.pnpm/".into(),
129                    ttl: Duration::from_secs(30),
130                },
131                TtlRule {
132                    prefix: "/target/".into(),
133                    ttl: Duration::from_secs(30),
134                },
135                TtlRule {
136                    prefix: "/__pycache__/".into(),
137                    ttl: Duration::from_secs(30),
138                },
139            ],
140            default_ttl: Duration::from_secs(5),
141        }
142    }
143}
144
145impl AdaptiveTtlConfig {
146    /// Returns the TTL for a given path by matching against the rule list.
147    ///
148    /// Rules are evaluated in order; the first matching rule's TTL is returned.
149    /// If no rule matches, `default_ttl` is used.
150    #[must_use]
151    pub fn ttl_for(&self, path: &str) -> Duration {
152        for rule in &self.rules {
153            if path.contains(&rule.prefix) {
154                return rule.ttl;
155            }
156        }
157        self.default_ttl
158    }
159}
160
161/// Configuration for the negative cache.
162///
163/// Negative caching stores "file not found" results to avoid repeated
164/// filesystem lookups for non-existent files. This is particularly effective
165/// for directories like `node_modules` and `.git` where many lookups fail.
166#[derive(Debug, Clone)]
167pub struct NegativeCacheConfig {
168    /// Maximum number of entries in the cache.
169    /// When exceeded, expired entries are evicted.
170    /// Default: 10000
171    pub max_entries: usize,
172
173    /// Time-to-live for cache entries.
174    /// Entries older than this are considered stale.
175    /// Default: 5 seconds
176    pub timeout: Duration,
177
178    /// Adaptive TTL configuration for path-pattern-based timeouts.
179    /// When set, `insert_with_path` uses per-path TTLs instead of the
180    /// global `timeout` value.
181    pub adaptive_ttl: Option<AdaptiveTtlConfig>,
182}
183
184impl Default for NegativeCacheConfig {
185    fn default() -> Self {
186        Self::new()
187    }
188}
189
190impl NegativeCacheConfig {
191    /// Creates a new configuration with default values.
192    #[must_use]
193    pub fn new() -> Self {
194        Self {
195            max_entries: 10_000,
196            timeout: Duration::from_secs(5),
197            adaptive_ttl: Some(AdaptiveTtlConfig::default()),
198        }
199    }
200
201    /// Creates a configuration with no adaptive TTL (fixed timeout only).
202    #[must_use]
203    pub const fn fixed(timeout: Duration, max_entries: usize) -> Self {
204        Self {
205            max_entries,
206            timeout,
207            adaptive_ttl: None,
208        }
209    }
210}
211
212/// Statistics for the negative cache.
213#[derive(Debug, Clone, Default)]
214pub struct NegativeCacheStats {
215    /// Current number of entries in the cache.
216    pub entries: usize,
217    /// Total number of cache hits (path found in negative cache).
218    pub hits: u64,
219    /// Total number of cache misses (path not in cache or expired).
220    pub misses: u64,
221}
222
223impl NegativeCacheStats {
224    /// Returns the hit ratio as a percentage.
225    /// Returns 0.0 if no lookups have been performed.
226    #[must_use]
227    #[allow(clippy::cast_precision_loss)]
228    pub fn hit_ratio(&self) -> f64 {
229        let total = self.hits + self.misses;
230        if total == 0 {
231            0.0
232        } else {
233            (self.hits as f64 / total as f64) * 100.0
234        }
235    }
236}
237
238/// Per-entry data stored in the negative cache.
239///
240/// Each entry records when it was inserted and when it expires, allowing
241/// different entries to have different TTLs based on their path pattern.
242#[derive(Debug, Clone, Copy)]
243struct NegativeCacheEntry {
244    /// When this entry expires.
245    expires_at: Instant,
246}
247
248impl NegativeCacheEntry {
249    fn new(ttl: Duration) -> Self {
250        Self {
251            expires_at: Instant::now() + ttl,
252        }
253    }
254
255    fn is_expired(&self) -> bool {
256        Instant::now() >= self.expires_at
257    }
258}
259
260/// Thread-safe negative cache for filesystem lookups.
261///
262/// Caches paths that are known to not exist, avoiding repeated system calls
263/// for non-existent files. Uses lock-free concurrent access via `DashMap`.
264///
265/// When adaptive TTL is configured, different paths receive different cache
266/// durations based on pattern matching. For example, lookups inside
267/// `/node_modules/` get a 30s TTL while source files default to 5s.
268///
269/// # Example
270///
271/// ```
272/// use std::time::Duration;
273/// use std::path::PathBuf;
274/// use arcbox_fs::cache::{NegativeCache, NegativeCacheConfig};
275///
276/// let config = NegativeCacheConfig {
277///     max_entries: 1000,
278///     timeout: Duration::from_millis(500),
279///     adaptive_ttl: None,
280/// };
281/// let cache = NegativeCache::new(config);
282///
283/// // File lookup failed, add to negative cache
284/// cache.insert(PathBuf::from("/app/node_modules/missing-package"));
285///
286/// // Later lookup - returns true without syscall
287/// assert!(cache.contains(&PathBuf::from("/app/node_modules/missing-package")));
288///
289/// // File created - invalidate cache
290/// cache.invalidate(&PathBuf::from("/app/node_modules/missing-package"));
291/// assert!(!cache.contains(&PathBuf::from("/app/node_modules/missing-package")));
292/// ```
293pub struct NegativeCache {
294    /// Map from path to cache entry with per-entry expiration.
295    entries: DashMap<PathBuf, NegativeCacheEntry>,
296    /// Cache configuration.
297    config: NegativeCacheConfig,
298    /// Number of cache hits.
299    hits: AtomicU64,
300    /// Number of cache misses.
301    misses: AtomicU64,
302}
303
304impl NegativeCache {
305    /// Creates a new negative cache with the given configuration.
306    #[must_use]
307    pub fn new(config: NegativeCacheConfig) -> Self {
308        Self {
309            entries: DashMap::with_capacity(config.max_entries),
310            config,
311            hits: AtomicU64::new(0),
312            misses: AtomicU64::new(0),
313        }
314    }
315
316    /// Creates a new negative cache with default configuration (adaptive TTL enabled).
317    #[must_use]
318    pub fn with_defaults() -> Self {
319        Self::new(NegativeCacheConfig::default())
320    }
321
322    /// Returns the effective TTL for a given path.
323    ///
324    /// If adaptive TTL is configured, the path is matched against the rule
325    /// list. Otherwise, the global `timeout` is returned.
326    fn ttl_for_path(&self, path: &Path) -> Duration {
327        if let Some(ref adaptive) = self.config.adaptive_ttl {
328            // Use lossy conversion for pattern matching; the path prefixes
329            // we match against are pure ASCII.
330            adaptive.ttl_for(&path.to_string_lossy())
331        } else {
332            self.config.timeout
333        }
334    }
335
336    /// Checks if the path is in the negative cache and not expired.
337    ///
338    /// Returns `true` if the path was previously marked as non-existent
339    /// and the cache entry has not expired.
340    pub fn contains(&self, path: &Path) -> bool {
341        if let Some(entry) = self.entries.get(path) {
342            if !entry.is_expired() {
343                self.hits.fetch_add(1, Ordering::Relaxed);
344                return true;
345            }
346            // Entry expired, remove it
347            drop(entry); // Release the lock before removing
348            self.entries.remove(path);
349        }
350        self.misses.fetch_add(1, Ordering::Relaxed);
351        false
352    }
353
354    /// Adds a path to the negative cache.
355    ///
356    /// The TTL is determined by adaptive TTL rules if configured, or by the
357    /// global `timeout` value otherwise.
358    ///
359    /// If the cache is at capacity, expired entries are evicted first.
360    pub fn insert(&self, path: PathBuf) {
361        // Check capacity and evict if necessary
362        if self.entries.len() >= self.config.max_entries {
363            self.evict_expired();
364        }
365
366        let ttl = self.ttl_for_path(&path);
367        self.entries.insert(path, NegativeCacheEntry::new(ttl));
368    }
369
370    /// Invalidates a path in the negative cache.
371    ///
372    /// This should be called when a file is created to ensure subsequent
373    /// lookups don't incorrectly return "not found" from the cache.
374    ///
375    /// Also invalidates the parent directory path to handle cases where
376    /// the parent's directory listing was cached.
377    pub fn invalidate(&self, path: &Path) {
378        self.entries.remove(path);
379
380        // Also invalidate parent directory to handle directory listing caches
381        if let Some(parent) = path.parent() {
382            self.entries.remove(parent);
383        }
384    }
385
386    /// Removes all expired entries from the cache.
387    ///
388    /// This is called automatically when the cache reaches capacity,
389    /// but can also be called manually for maintenance.
390    pub fn evict_expired(&self) {
391        self.entries.retain(|_, entry| !entry.is_expired());
392    }
393
394    /// Returns current cache statistics.
395    #[must_use]
396    pub fn stats(&self) -> NegativeCacheStats {
397        NegativeCacheStats {
398            entries: self.entries.len(),
399            hits: self.hits.load(Ordering::Relaxed),
400            misses: self.misses.load(Ordering::Relaxed),
401        }
402    }
403
404    /// Clears all entries from the cache.
405    pub fn clear(&self) {
406        self.entries.clear();
407    }
408
409    /// Returns the current number of entries in the cache.
410    #[must_use]
411    pub fn len(&self) -> usize {
412        self.entries.len()
413    }
414
415    /// Returns `true` if the cache is empty.
416    #[must_use]
417    pub fn is_empty(&self) -> bool {
418        self.entries.is_empty()
419    }
420}
421
422impl std::fmt::Debug for NegativeCache {
423    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
424        f.debug_struct("NegativeCache")
425            .field("entries", &self.entries.len())
426            .field("config", &self.config)
427            .field("hits", &self.hits.load(Ordering::Relaxed))
428            .field("misses", &self.misses.load(Ordering::Relaxed))
429            .finish()
430    }
431}
432
433#[cfg(test)]
434mod tests {
435    use super::*;
436    use std::thread;
437
438    #[test]
439    fn test_insert_and_contains() {
440        let cache = NegativeCache::with_defaults();
441        let path = PathBuf::from("/test/path");
442
443        assert!(!cache.contains(&path));
444        cache.insert(path.clone());
445        assert!(cache.contains(&path));
446    }
447
448    #[test]
449    fn test_expiration() {
450        let config = NegativeCacheConfig {
451            max_entries: 100,
452            timeout: Duration::from_millis(50),
453            adaptive_ttl: None,
454        };
455        let cache = NegativeCache::new(config);
456        let path = PathBuf::from("/test/expiring");
457
458        cache.insert(path.clone());
459        assert!(cache.contains(&path));
460
461        // Wait for expiration
462        thread::sleep(Duration::from_millis(100));
463        assert!(!cache.contains(&path));
464    }
465
466    #[test]
467    fn test_invalidate() {
468        let cache = NegativeCache::with_defaults();
469        let path = PathBuf::from("/test/dir/file.txt");
470
471        cache.insert(path.clone());
472        assert!(cache.contains(&path));
473
474        cache.invalidate(&path);
475        assert!(!cache.contains(&path));
476    }
477
478    #[test]
479    fn test_invalidate_removes_parent() {
480        let cache = NegativeCache::with_defaults();
481        let parent = PathBuf::from("/test/dir");
482        let child = PathBuf::from("/test/dir/file.txt");
483
484        cache.insert(parent.clone());
485        cache.insert(child.clone());
486
487        // Invalidating child should also invalidate parent
488        cache.invalidate(&child);
489
490        assert!(!cache.contains(&child));
491        assert!(!cache.contains(&parent));
492    }
493
494    #[test]
495    fn test_concurrent_access() {
496        use std::sync::Arc;
497
498        let cache = Arc::new(NegativeCache::with_defaults());
499        let mut handles = vec![];
500
501        // Spawn multiple threads that insert and check entries
502        for i in 0..10 {
503            let cache = Arc::clone(&cache);
504            handles.push(thread::spawn(move || {
505                for j in 0..100 {
506                    let path = PathBuf::from(format!("/thread_{i}/file_{j}"));
507                    cache.insert(path.clone());
508                    assert!(cache.contains(&path));
509                }
510            }));
511        }
512
513        for handle in handles {
514            handle.join().expect("Thread panicked");
515        }
516
517        // All entries should be accessible
518        assert!(cache.len() <= 1000);
519    }
520
521    #[test]
522    fn test_max_entries() {
523        let config = NegativeCacheConfig {
524            max_entries: 10,
525            timeout: Duration::from_millis(10), // Short timeout for eviction
526            adaptive_ttl: None,
527        };
528        let cache = NegativeCache::new(config);
529
530        // Insert more than max_entries
531        for i in 0..20 {
532            let path = PathBuf::from(format!("/file_{i}"));
533            cache.insert(path);
534            // Small delay to ensure some entries expire
535            if i == 10 {
536                thread::sleep(Duration::from_millis(15));
537            }
538        }
539
540        // Cache should have evicted expired entries
541        // The exact count depends on timing, but should be <= max
542        assert!(cache.len() <= 20);
543    }
544
545    #[test]
546    fn test_stats() {
547        let cache = NegativeCache::with_defaults();
548        let path1 = PathBuf::from("/path1");
549        let path2 = PathBuf::from("/path2");
550
551        // Initial stats
552        let stats = cache.stats();
553        assert_eq!(stats.entries, 0);
554        assert_eq!(stats.hits, 0);
555        assert_eq!(stats.misses, 0);
556
557        // Miss
558        cache.contains(&path1);
559        let stats = cache.stats();
560        assert_eq!(stats.misses, 1);
561
562        // Insert and hit
563        cache.insert(path1.clone());
564        cache.contains(&path1);
565        let stats = cache.stats();
566        assert_eq!(stats.entries, 1);
567        assert_eq!(stats.hits, 1);
568        assert_eq!(stats.misses, 1);
569
570        // Another miss
571        cache.contains(&path2);
572        let stats = cache.stats();
573        assert_eq!(stats.misses, 2);
574    }
575
576    #[test]
577    fn test_hit_ratio() {
578        let stats = NegativeCacheStats {
579            entries: 10,
580            hits: 75,
581            misses: 25,
582        };
583        assert!((stats.hit_ratio() - 75.0).abs() < f64::EPSILON);
584
585        let empty_stats = NegativeCacheStats::default();
586        assert!((empty_stats.hit_ratio() - 0.0).abs() < f64::EPSILON);
587    }
588
589    #[test]
590    fn test_clear() {
591        let cache = NegativeCache::with_defaults();
592
593        for i in 0..10 {
594            cache.insert(PathBuf::from(format!("/file_{i}")));
595        }
596        assert_eq!(cache.len(), 10);
597
598        cache.clear();
599        assert!(cache.is_empty());
600    }
601
602    #[test]
603    fn test_evict_expired() {
604        let config = NegativeCacheConfig {
605            max_entries: 100,
606            timeout: Duration::from_millis(30),
607            adaptive_ttl: None,
608        };
609        let cache = NegativeCache::new(config);
610
611        // Insert entries
612        for i in 0..10 {
613            cache.insert(PathBuf::from(format!("/old_{i}")));
614        }
615
616        // Wait for them to expire
617        thread::sleep(Duration::from_millis(50));
618
619        // Insert new entries
620        for i in 0..5 {
621            cache.insert(PathBuf::from(format!("/new_{i}")));
622        }
623
624        // Evict expired
625        cache.evict_expired();
626
627        // Only new entries should remain
628        assert_eq!(cache.len(), 5);
629    }
630
631    // ========================================================================
632    // Adaptive TTL Tests
633    // ========================================================================
634
635    #[test]
636    fn test_adaptive_ttl_node_modules() {
637        let config = AdaptiveTtlConfig::default();
638        let ttl = config.ttl_for("/app/node_modules/lodash/index.js");
639        assert_eq!(ttl, Duration::from_secs(30));
640    }
641
642    #[test]
643    fn test_adaptive_ttl_git() {
644        let config = AdaptiveTtlConfig::default();
645        let ttl = config.ttl_for("/repo/.git/objects/ab/cd1234");
646        assert_eq!(ttl, Duration::from_secs(60));
647    }
648
649    #[test]
650    fn test_adaptive_ttl_pnpm() {
651        let config = AdaptiveTtlConfig::default();
652        let ttl = config.ttl_for("/app/.pnpm/some-package@1.0.0/node_modules/dep");
653        // .pnpm rule matches first (earlier in the list)
654        assert_eq!(ttl, Duration::from_secs(30));
655    }
656
657    #[test]
658    fn test_adaptive_ttl_target() {
659        let config = AdaptiveTtlConfig::default();
660        let ttl = config.ttl_for("/project/target/debug/build/something");
661        assert_eq!(ttl, Duration::from_secs(30));
662    }
663
664    #[test]
665    fn test_adaptive_ttl_pycache() {
666        let config = AdaptiveTtlConfig::default();
667        let ttl = config.ttl_for("/app/__pycache__/module.cpython-311.pyc");
668        assert_eq!(ttl, Duration::from_secs(30));
669    }
670
671    #[test]
672    fn test_adaptive_ttl_source_file_uses_default() {
673        let config = AdaptiveTtlConfig::default();
674        let ttl = config.ttl_for("/app/src/main.rs");
675        assert_eq!(ttl, Duration::from_secs(5));
676    }
677
678    #[test]
679    fn test_adaptive_ttl_custom_rules() {
680        let config = AdaptiveTtlConfig {
681            rules: vec![TtlRule {
682                prefix: "/vendor/".into(),
683                ttl: Duration::from_secs(120),
684            }],
685            default_ttl: Duration::from_secs(2),
686        };
687        assert_eq!(
688            config.ttl_for("/project/vendor/github.com/foo"),
689            Duration::from_secs(120)
690        );
691        assert_eq!(
692            config.ttl_for("/project/src/main.go"),
693            Duration::from_secs(2)
694        );
695    }
696
697    #[test]
698    fn test_adaptive_ttl_first_match_wins() {
699        let config = AdaptiveTtlConfig {
700            rules: vec![
701                TtlRule {
702                    prefix: "/a/".into(),
703                    ttl: Duration::from_secs(10),
704                },
705                TtlRule {
706                    prefix: "/a/b/".into(),
707                    ttl: Duration::from_secs(20),
708                },
709            ],
710            default_ttl: Duration::from_secs(1),
711        };
712        // "/a/" matches first even though "/a/b/" is also a match
713        assert_eq!(config.ttl_for("/a/b/c"), Duration::from_secs(10));
714    }
715
716    #[test]
717    fn test_negative_cache_adaptive_ttl_integration() {
718        // Use a short adaptive TTL for a specific pattern and verify that
719        // the entry expires according to its path-specific TTL.
720        let config = NegativeCacheConfig {
721            max_entries: 100,
722            timeout: Duration::from_secs(60), // Long default (shouldn't be used)
723            adaptive_ttl: Some(AdaptiveTtlConfig {
724                rules: vec![TtlRule {
725                    prefix: "/fast/".into(),
726                    ttl: Duration::from_millis(50),
727                }],
728                default_ttl: Duration::from_secs(60),
729            }),
730        };
731        let cache = NegativeCache::new(config);
732
733        // Path matching /fast/ rule gets 50ms TTL
734        let fast_path = PathBuf::from("/fast/file.txt");
735        cache.insert(fast_path.clone());
736        assert!(cache.contains(&fast_path));
737
738        // Path not matching any rule gets 60s TTL
739        let slow_path = PathBuf::from("/slow/file.txt");
740        cache.insert(slow_path.clone());
741        assert!(cache.contains(&slow_path));
742
743        // Wait for the fast path to expire
744        thread::sleep(Duration::from_millis(80));
745
746        // Fast path should have expired, slow path should still be valid
747        assert!(!cache.contains(&fast_path));
748        assert!(cache.contains(&slow_path));
749    }
750}