sentinel_proxy/static_files/
cache.rs1use bytes::Bytes;
7use dashmap::DashMap;
8use std::path::PathBuf;
9use std::time::{Duration, Instant, SystemTime};
10use tracing::{debug, trace};
11
12const DEFAULT_MAX_AGE_SECS: u64 = 3600;
14
15pub struct FileCache {
20 entries: DashMap<PathBuf, CachedFile>,
21 max_size: usize,
22 max_age: Duration,
23}
24
25pub struct CachedFile {
29 pub content: Bytes,
31 pub gzip_content: Option<Bytes>,
33 pub brotli_content: Option<Bytes>,
35 pub content_type: String,
37 pub etag: String,
39 pub last_modified: SystemTime,
41 pub cached_at: Instant,
43 pub size: u64,
45}
46
47impl FileCache {
48 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 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 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 pub fn insert(&self, path: PathBuf, file: CachedFile) {
92 let file_size = file.size;
93
94 self.evict_stale();
96
97 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 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 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 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 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 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#[derive(Debug, Clone)]
218pub struct CacheStats {
219 pub entry_count: usize,
221 pub total_size: usize,
223 pub total_compressed: usize,
225 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}