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// ============================================================================
93// Negative Cache
94// ============================================================================
95
96/// Configuration for the negative cache.
97///
98/// Negative caching stores "file not found" results to avoid repeated
99/// filesystem lookups for non-existent files. This is particularly effective
100/// for directories like `node_modules` and `.git` where many lookups fail.
101#[derive(Debug, Clone)]
102pub struct NegativeCacheConfig {
103    /// Maximum number of entries in the cache.
104    /// When exceeded, expired entries are evicted.
105    /// Default: 10000
106    pub max_entries: usize,
107
108    /// Time-to-live for cache entries.
109    /// Entries older than this are considered stale.
110    /// Default: 1 second
111    pub timeout: Duration,
112}
113
114impl Default for NegativeCacheConfig {
115    fn default() -> Self {
116        Self::new()
117    }
118}
119
120impl NegativeCacheConfig {
121    /// Creates a new configuration with default values.
122    #[must_use]
123    pub const fn new() -> Self {
124        Self {
125            max_entries: 10_000,
126            timeout: Duration::from_secs(1),
127        }
128    }
129}
130
131/// Statistics for the negative cache.
132#[derive(Debug, Clone, Default)]
133pub struct NegativeCacheStats {
134    /// Current number of entries in the cache.
135    pub entries: usize,
136    /// Total number of cache hits (path found in negative cache).
137    pub hits: u64,
138    /// Total number of cache misses (path not in cache or expired).
139    pub misses: u64,
140}
141
142impl NegativeCacheStats {
143    /// Returns the hit ratio as a percentage.
144    /// Returns 0.0 if no lookups have been performed.
145    #[must_use]
146    #[allow(clippy::cast_precision_loss)]
147    pub fn hit_ratio(&self) -> f64 {
148        let total = self.hits + self.misses;
149        if total == 0 {
150            0.0
151        } else {
152            (self.hits as f64 / total as f64) * 100.0
153        }
154    }
155}
156
157/// Thread-safe negative cache for filesystem lookups.
158///
159/// Caches paths that are known to not exist, avoiding repeated system calls
160/// for non-existent files. Uses lock-free concurrent access via `DashMap`.
161///
162/// # Example
163///
164/// ```
165/// use std::time::Duration;
166/// use std::path::PathBuf;
167/// use arcbox_fs::cache::{NegativeCache, NegativeCacheConfig};
168///
169/// let config = NegativeCacheConfig {
170///     max_entries: 1000,
171///     timeout: Duration::from_millis(500),
172/// };
173/// let cache = NegativeCache::new(config);
174///
175/// // File lookup failed, add to negative cache
176/// cache.insert(PathBuf::from("/app/node_modules/missing-package"));
177///
178/// // Later lookup - returns true without syscall
179/// assert!(cache.contains(&PathBuf::from("/app/node_modules/missing-package")));
180///
181/// // File created - invalidate cache
182/// cache.invalidate(&PathBuf::from("/app/node_modules/missing-package"));
183/// assert!(!cache.contains(&PathBuf::from("/app/node_modules/missing-package")));
184/// ```
185pub struct NegativeCache {
186    /// Map from path to insertion timestamp.
187    entries: DashMap<PathBuf, Instant>,
188    /// Cache configuration.
189    config: NegativeCacheConfig,
190    /// Number of cache hits.
191    hits: AtomicU64,
192    /// Number of cache misses.
193    misses: AtomicU64,
194}
195
196impl NegativeCache {
197    /// Creates a new negative cache with the given configuration.
198    #[must_use]
199    pub fn new(config: NegativeCacheConfig) -> Self {
200        Self {
201            entries: DashMap::with_capacity(config.max_entries),
202            config,
203            hits: AtomicU64::new(0),
204            misses: AtomicU64::new(0),
205        }
206    }
207
208    /// Creates a new negative cache with default configuration.
209    #[must_use]
210    pub fn with_defaults() -> Self {
211        Self::new(NegativeCacheConfig::default())
212    }
213
214    /// Checks if the path is in the negative cache and not expired.
215    ///
216    /// Returns `true` if the path was previously marked as non-existent
217    /// and the cache entry has not expired.
218    pub fn contains(&self, path: &Path) -> bool {
219        if let Some(entry) = self.entries.get(path) {
220            let inserted_at = *entry;
221            if inserted_at.elapsed() < self.config.timeout {
222                self.hits.fetch_add(1, Ordering::Relaxed);
223                return true;
224            }
225            // Entry expired, remove it
226            drop(entry); // Release the lock before removing
227            self.entries.remove(path);
228        }
229        self.misses.fetch_add(1, Ordering::Relaxed);
230        false
231    }
232
233    /// Adds a path to the negative cache.
234    ///
235    /// If the cache is at capacity, expired entries are evicted first.
236    pub fn insert(&self, path: PathBuf) {
237        // Check capacity and evict if necessary
238        if self.entries.len() >= self.config.max_entries {
239            self.evict_expired();
240        }
241
242        self.entries.insert(path, Instant::now());
243    }
244
245    /// Invalidates a path in the negative cache.
246    ///
247    /// This should be called when a file is created to ensure subsequent
248    /// lookups don't incorrectly return "not found" from the cache.
249    ///
250    /// Also invalidates the parent directory path to handle cases where
251    /// the parent's directory listing was cached.
252    pub fn invalidate(&self, path: &Path) {
253        self.entries.remove(path);
254
255        // Also invalidate parent directory to handle directory listing caches
256        if let Some(parent) = path.parent() {
257            self.entries.remove(parent);
258        }
259    }
260
261    /// Removes all expired entries from the cache.
262    ///
263    /// This is called automatically when the cache reaches capacity,
264    /// but can also be called manually for maintenance.
265    pub fn evict_expired(&self) {
266        let timeout = self.config.timeout;
267        self.entries
268            .retain(|_, inserted_at| inserted_at.elapsed() < timeout);
269    }
270
271    /// Returns current cache statistics.
272    #[must_use]
273    pub fn stats(&self) -> NegativeCacheStats {
274        NegativeCacheStats {
275            entries: self.entries.len(),
276            hits: self.hits.load(Ordering::Relaxed),
277            misses: self.misses.load(Ordering::Relaxed),
278        }
279    }
280
281    /// Clears all entries from the cache.
282    pub fn clear(&self) {
283        self.entries.clear();
284    }
285
286    /// Returns the current number of entries in the cache.
287    #[must_use]
288    pub fn len(&self) -> usize {
289        self.entries.len()
290    }
291
292    /// Returns `true` if the cache is empty.
293    #[must_use]
294    pub fn is_empty(&self) -> bool {
295        self.entries.is_empty()
296    }
297}
298
299impl std::fmt::Debug for NegativeCache {
300    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
301        f.debug_struct("NegativeCache")
302            .field("entries", &self.entries.len())
303            .field("config", &self.config)
304            .field("hits", &self.hits.load(Ordering::Relaxed))
305            .field("misses", &self.misses.load(Ordering::Relaxed))
306            .finish()
307    }
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313    use std::thread;
314
315    #[test]
316    fn test_insert_and_contains() {
317        let cache = NegativeCache::with_defaults();
318        let path = PathBuf::from("/test/path");
319
320        assert!(!cache.contains(&path));
321        cache.insert(path.clone());
322        assert!(cache.contains(&path));
323    }
324
325    #[test]
326    fn test_expiration() {
327        let config = NegativeCacheConfig {
328            max_entries: 100,
329            timeout: Duration::from_millis(50),
330        };
331        let cache = NegativeCache::new(config);
332        let path = PathBuf::from("/test/expiring");
333
334        cache.insert(path.clone());
335        assert!(cache.contains(&path));
336
337        // Wait for expiration
338        thread::sleep(Duration::from_millis(100));
339        assert!(!cache.contains(&path));
340    }
341
342    #[test]
343    fn test_invalidate() {
344        let cache = NegativeCache::with_defaults();
345        let path = PathBuf::from("/test/dir/file.txt");
346
347        cache.insert(path.clone());
348        assert!(cache.contains(&path));
349
350        cache.invalidate(&path);
351        assert!(!cache.contains(&path));
352    }
353
354    #[test]
355    fn test_invalidate_removes_parent() {
356        let cache = NegativeCache::with_defaults();
357        let parent = PathBuf::from("/test/dir");
358        let child = PathBuf::from("/test/dir/file.txt");
359
360        cache.insert(parent.clone());
361        cache.insert(child.clone());
362
363        // Invalidating child should also invalidate parent
364        cache.invalidate(&child);
365
366        assert!(!cache.contains(&child));
367        assert!(!cache.contains(&parent));
368    }
369
370    #[test]
371    fn test_concurrent_access() {
372        use std::sync::Arc;
373
374        let cache = Arc::new(NegativeCache::with_defaults());
375        let mut handles = vec![];
376
377        // Spawn multiple threads that insert and check entries
378        for i in 0..10 {
379            let cache = Arc::clone(&cache);
380            handles.push(thread::spawn(move || {
381                for j in 0..100 {
382                    let path = PathBuf::from(format!("/thread_{i}/file_{j}"));
383                    cache.insert(path.clone());
384                    assert!(cache.contains(&path));
385                }
386            }));
387        }
388
389        for handle in handles {
390            handle.join().expect("Thread panicked");
391        }
392
393        // All entries should be accessible
394        assert!(cache.len() <= 1000);
395    }
396
397    #[test]
398    fn test_max_entries() {
399        let config = NegativeCacheConfig {
400            max_entries: 10,
401            timeout: Duration::from_millis(10), // Short timeout for eviction
402        };
403        let cache = NegativeCache::new(config);
404
405        // Insert more than max_entries
406        for i in 0..20 {
407            let path = PathBuf::from(format!("/file_{i}"));
408            cache.insert(path);
409            // Small delay to ensure some entries expire
410            if i == 10 {
411                thread::sleep(Duration::from_millis(15));
412            }
413        }
414
415        // Cache should have evicted expired entries
416        // The exact count depends on timing, but should be <= max
417        assert!(cache.len() <= 20);
418    }
419
420    #[test]
421    fn test_stats() {
422        let cache = NegativeCache::with_defaults();
423        let path1 = PathBuf::from("/path1");
424        let path2 = PathBuf::from("/path2");
425
426        // Initial stats
427        let stats = cache.stats();
428        assert_eq!(stats.entries, 0);
429        assert_eq!(stats.hits, 0);
430        assert_eq!(stats.misses, 0);
431
432        // Miss
433        cache.contains(&path1);
434        let stats = cache.stats();
435        assert_eq!(stats.misses, 1);
436
437        // Insert and hit
438        cache.insert(path1.clone());
439        cache.contains(&path1);
440        let stats = cache.stats();
441        assert_eq!(stats.entries, 1);
442        assert_eq!(stats.hits, 1);
443        assert_eq!(stats.misses, 1);
444
445        // Another miss
446        cache.contains(&path2);
447        let stats = cache.stats();
448        assert_eq!(stats.misses, 2);
449    }
450
451    #[test]
452    fn test_hit_ratio() {
453        let stats = NegativeCacheStats {
454            entries: 10,
455            hits: 75,
456            misses: 25,
457        };
458        assert!((stats.hit_ratio() - 75.0).abs() < f64::EPSILON);
459
460        let empty_stats = NegativeCacheStats::default();
461        assert!((empty_stats.hit_ratio() - 0.0).abs() < f64::EPSILON);
462    }
463
464    #[test]
465    fn test_clear() {
466        let cache = NegativeCache::with_defaults();
467
468        for i in 0..10 {
469            cache.insert(PathBuf::from(format!("/file_{i}")));
470        }
471        assert_eq!(cache.len(), 10);
472
473        cache.clear();
474        assert!(cache.is_empty());
475    }
476
477    #[test]
478    fn test_evict_expired() {
479        let config = NegativeCacheConfig {
480            max_entries: 100,
481            timeout: Duration::from_millis(30),
482        };
483        let cache = NegativeCache::new(config);
484
485        // Insert entries
486        for i in 0..10 {
487            cache.insert(PathBuf::from(format!("/old_{i}")));
488        }
489
490        // Wait for them to expire
491        thread::sleep(Duration::from_millis(50));
492
493        // Insert new entries
494        for i in 0..5 {
495            cache.insert(PathBuf::from(format!("/new_{i}")));
496        }
497
498        // Evict expired
499        cache.evict_expired();
500
501        // Only new entries should remain
502        assert_eq!(cache.len(), 5);
503    }
504}