sentinel_proxy/static_files/
cache.rs

1//! File caching for static file serving
2//!
3//! This module provides in-memory caching for small static files
4//! with pre-computed compressed variants.
5
6use bytes::Bytes;
7use dashmap::DashMap;
8use std::path::PathBuf;
9use std::time::{Duration, Instant, SystemTime};
10use tracing::{debug, trace};
11
12/// Maximum age for cached files (1 hour)
13const DEFAULT_MAX_AGE_SECS: u64 = 3600;
14
15/// File cache for improved performance
16///
17/// Caches small files in memory with their compressed variants
18/// to avoid repeated disk I/O and compression overhead.
19pub struct FileCache {
20    entries: DashMap<PathBuf, CachedFile>,
21    max_size: usize,
22    max_age: Duration,
23}
24
25/// Cached file entry
26///
27/// Contains the file content, pre-compressed variants, and metadata.
28pub struct CachedFile {
29    /// Original file content
30    pub content: Bytes,
31    /// Pre-compressed gzip content (if compressible)
32    pub gzip_content: Option<Bytes>,
33    /// Pre-compressed brotli content (if compressible)
34    pub brotli_content: Option<Bytes>,
35    /// MIME content type
36    pub content_type: String,
37    /// ETag for conditional requests
38    pub etag: String,
39    /// Last modification time
40    pub last_modified: SystemTime,
41    /// When this entry was cached
42    pub cached_at: Instant,
43    /// Original file size
44    pub size: u64,
45}
46
47impl FileCache {
48    /// Create a new file cache
49    ///
50    /// # Arguments
51    ///
52    /// * `max_size` - Maximum total cache size in bytes
53    /// * `max_age_secs` - Maximum age of cached entries in seconds
54    pub fn new(max_size: usize, max_age_secs: u64) -> Self {
55        trace!(
56            max_size_mb = max_size / (1024 * 1024),
57            max_age_secs = max_age_secs,
58            "Creating file cache"
59        );
60
61        debug!(
62            max_size_mb = max_size / (1024 * 1024),
63            "File cache initialized"
64        );
65
66        Self {
67            entries: DashMap::new(),
68            max_size,
69            max_age: Duration::from_secs(max_age_secs),
70        }
71    }
72
73    /// Create a cache with default settings (100MB, 1 hour)
74    pub fn with_defaults() -> Self {
75        trace!("Creating file cache with default settings");
76        Self::new(100 * 1024 * 1024, DEFAULT_MAX_AGE_SECS)
77    }
78
79    /// Get a cached file by path
80    pub fn get(&self, path: &std::path::Path) -> Option<CachedFile> {
81        let result = self.entries.get(path).map(|entry| entry.clone());
82        trace!(
83            path = %path.display(),
84            hit = result.is_some(),
85            "Cache lookup"
86        );
87        result
88    }
89
90    /// Insert a file into the cache
91    pub fn insert(&self, path: PathBuf, file: CachedFile) {
92        let file_size = file.size;
93
94        // Simple cache eviction - remove old entries
95        self.evict_stale();
96
97        // Check cache size limit (simplified entry count limit)
98        if self.entries.len() > 1000 {
99            trace!(
100                current_entries = self.entries.len(),
101                "Cache entry limit reached, evicting oldest"
102            );
103            self.evict_oldest(100);
104        }
105
106        self.entries.insert(path.clone(), file);
107
108        trace!(
109            path = %path.display(),
110            size = file_size,
111            entry_count = self.entries.len(),
112            "Inserted file into cache"
113        );
114    }
115
116    /// Remove stale entries from the cache
117    fn evict_stale(&self) {
118        let before = self.entries.len();
119        self.entries.retain(|_, v| v.is_fresh());
120        let evicted = before - self.entries.len();
121        if evicted > 0 {
122            trace!(
123                evicted = evicted,
124                remaining = self.entries.len(),
125                "Evicted stale cache entries"
126            );
127        }
128    }
129
130    /// Remove the N oldest entries from the cache
131    fn evict_oldest(&self, count: usize) {
132        let mut oldest: Vec<_> = self
133            .entries
134            .iter()
135            .map(|e| (e.key().clone(), e.cached_at))
136            .collect();
137
138        oldest.sort_by_key(|e| e.1);
139
140        let mut evicted = 0;
141        for (path, _) in oldest.iter().take(count) {
142            self.entries.remove(path);
143            evicted += 1;
144        }
145
146        trace!(
147            requested = count,
148            evicted = evicted,
149            remaining = self.entries.len(),
150            "Evicted oldest cache entries"
151        );
152    }
153
154    /// Get current cache statistics
155    pub fn stats(&self) -> CacheStats {
156        let total_size: usize = self.entries.iter().map(|e| e.size as usize).sum();
157        let total_compressed: usize = self
158            .entries
159            .iter()
160            .map(|e| {
161                e.gzip_content.as_ref().map_or(0, |b| b.len())
162                    + e.brotli_content.as_ref().map_or(0, |b| b.len())
163            })
164            .sum();
165
166        let stats = CacheStats {
167            entry_count: self.entries.len(),
168            total_size,
169            total_compressed,
170            max_size: self.max_size,
171        };
172
173        trace!(
174            entry_count = stats.entry_count,
175            total_size_kb = stats.total_size / 1024,
176            compressed_size_kb = stats.total_compressed / 1024,
177            "Retrieved cache stats"
178        );
179
180        stats
181    }
182
183    /// Clear all cached entries
184    pub fn clear(&self) {
185        let count = self.entries.len();
186        self.entries.clear();
187        debug!(
188            cleared_entries = count,
189            "File cache cleared"
190        );
191    }
192}
193
194impl CachedFile {
195    /// Check if the cached entry is still fresh
196    pub fn is_fresh(&self) -> bool {
197        self.cached_at.elapsed() < Duration::from_secs(DEFAULT_MAX_AGE_SECS)
198    }
199}
200
201impl Clone for CachedFile {
202    fn clone(&self) -> Self {
203        Self {
204            content: self.content.clone(),
205            gzip_content: self.gzip_content.clone(),
206            brotli_content: self.brotli_content.clone(),
207            content_type: self.content_type.clone(),
208            etag: self.etag.clone(),
209            last_modified: self.last_modified,
210            cached_at: self.cached_at,
211            size: self.size,
212        }
213    }
214}
215
216/// Cache statistics
217#[derive(Debug, Clone)]
218pub struct CacheStats {
219    /// Number of cached entries
220    pub entry_count: usize,
221    /// Total size of original content
222    pub total_size: usize,
223    /// Total size of compressed content
224    pub total_compressed: usize,
225    /// Maximum cache size
226    pub max_size: usize,
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232
233    #[test]
234    fn test_cache_insert_get() {
235        let cache = FileCache::with_defaults();
236        let path = PathBuf::from("/test/file.txt");
237
238        let cached = CachedFile {
239            content: Bytes::from_static(b"Hello, World!"),
240            gzip_content: None,
241            brotli_content: None,
242            content_type: "text/plain".to_string(),
243            etag: "abc123".to_string(),
244            last_modified: SystemTime::now(),
245            cached_at: Instant::now(),
246            size: 13,
247        };
248
249        cache.insert(path.clone(), cached);
250
251        let retrieved = cache.get(&path);
252        assert!(retrieved.is_some());
253        assert_eq!(retrieved.unwrap().content, Bytes::from_static(b"Hello, World!"));
254    }
255
256    #[test]
257    fn test_cache_stats() {
258        let cache = FileCache::with_defaults();
259
260        let cached = CachedFile {
261            content: Bytes::from_static(b"Test content"),
262            gzip_content: Some(Bytes::from_static(b"compressed")),
263            brotli_content: None,
264            content_type: "text/plain".to_string(),
265            etag: "test".to_string(),
266            last_modified: SystemTime::now(),
267            cached_at: Instant::now(),
268            size: 12,
269        };
270
271        cache.insert(PathBuf::from("/test.txt"), cached);
272
273        let stats = cache.stats();
274        assert_eq!(stats.entry_count, 1);
275        assert_eq!(stats.total_size, 12);
276    }
277
278    #[test]
279    fn test_cache_clear() {
280        let cache = FileCache::with_defaults();
281
282        for i in 0..10 {
283            cache.insert(
284                PathBuf::from(format!("/file{}.txt", i)),
285                CachedFile {
286                    content: Bytes::from_static(b"test"),
287                    gzip_content: None,
288                    brotli_content: None,
289                    content_type: "text/plain".to_string(),
290                    etag: format!("etag{}", i),
291                    last_modified: SystemTime::now(),
292                    cached_at: Instant::now(),
293                    size: 4,
294                },
295            );
296        }
297
298        assert_eq!(cache.stats().entry_count, 10);
299        cache.clear();
300        assert_eq!(cache.stats().entry_count, 0);
301    }
302}