sentinel_proxy/static_files/
cache.rs1use bytes::Bytes;
7use dashmap::DashMap;
8use std::path::PathBuf;
9use std::time::{Duration, Instant, SystemTime};
10
11const DEFAULT_MAX_AGE_SECS: u64 = 3600;
13
14pub struct FileCache {
19 entries: DashMap<PathBuf, CachedFile>,
20 max_size: usize,
21 max_age: Duration,
22}
23
24pub struct CachedFile {
28 pub content: Bytes,
30 pub gzip_content: Option<Bytes>,
32 pub brotli_content: Option<Bytes>,
34 pub content_type: String,
36 pub etag: String,
38 pub last_modified: SystemTime,
40 pub cached_at: Instant,
42 pub size: u64,
44}
45
46impl FileCache {
47 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 pub fn with_defaults() -> Self {
63 Self::new(100 * 1024 * 1024, DEFAULT_MAX_AGE_SECS)
64 }
65
66 pub fn get(&self, path: &std::path::Path) -> Option<CachedFile> {
68 self.entries.get(path).map(|entry| entry.clone())
69 }
70
71 pub fn insert(&self, path: PathBuf, file: CachedFile) {
73 self.evict_stale();
75
76 if self.entries.len() > 1000 {
78 self.evict_oldest(100);
79 }
80
81 self.entries.insert(path, file);
82 }
83
84 fn evict_stale(&self) {
86 self.entries.retain(|_, v| v.is_fresh());
87 }
88
89 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 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 pub fn clear(&self) {
126 self.entries.clear();
127 }
128}
129
130impl CachedFile {
131 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#[derive(Debug, Clone)]
154pub struct CacheStats {
155 pub entry_count: usize,
157 pub total_size: usize,
159 pub total_compressed: usize,
161 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}