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