Skip to main content

rust_serv/memory_cache/
cache.rs

1//! Memory cache with LRU eviction strategy
2
3use std::collections::HashMap;
4use std::path::PathBuf;
5use std::sync::Arc;
6use std::sync::RwLock;
7use std::time::Duration;
8
9use bytes::Bytes;
10
11use super::cached_file::CachedFile;
12use super::stats::CacheStats;
13
14/// Configuration for memory cache
15#[derive(Debug, Clone)]
16pub struct CacheConfig {
17    /// Maximum number of entries in cache
18    pub max_entries: usize,
19    /// Maximum total size in bytes
20    pub max_size: usize,
21    /// Default TTL for cache entries
22    pub default_ttl: Duration,
23}
24
25impl Default for CacheConfig {
26    fn default() -> Self {
27        Self {
28            max_entries: 1000,
29            max_size: 100 * 1024 * 1024, // 100 MB
30            default_ttl: Duration::from_secs(300), // 5 minutes
31        }
32    }
33}
34
35/// LRU cache entry metadata
36#[derive(Debug, Clone)]
37struct LruEntry {
38    /// Cached file data
39    file: CachedFile,
40}
41
42/// Thread-safe memory cache with LRU eviction
43pub struct MemoryCache {
44    /// Cache storage
45    cache: RwLock<HashMap<PathBuf, LruEntry>>,
46    /// Access order for LRU (most recently used at the end)
47    access_order: RwLock<Vec<PathBuf>>,
48    /// Cache configuration
49    config: CacheConfig,
50    /// Cache statistics
51    stats: Arc<CacheStats>,
52}
53
54impl MemoryCache {
55    /// Create a new memory cache with default configuration
56    pub fn new() -> Self {
57        Self::with_config(CacheConfig::default())
58    }
59
60    /// Create a new memory cache with custom configuration
61    pub fn with_config(config: CacheConfig) -> Self {
62        Self {
63            cache: RwLock::new(HashMap::new()),
64            access_order: RwLock::new(Vec::new()),
65            config,
66            stats: Arc::new(CacheStats::new()),
67        }
68    }
69
70    /// Get a cached file by path
71    pub fn get(&self, path: &PathBuf) -> Option<CachedFile> {
72        let cache = self.cache.read().unwrap();
73        
74        if let Some(entry) = cache.get(path) {
75            // Check if expired
76            if entry.file.is_expired() {
77                drop(cache);
78                self.stats.record_miss();
79                self.remove(path);
80                return None;
81            }
82            
83            let file = entry.file.clone();
84            drop(cache);
85            
86            // Update access order
87            self.touch(path);
88            self.stats.record_hit();
89            
90            return Some(file);
91        }
92        
93        self.stats.record_miss();
94        None
95    }
96
97    /// Insert a file into the cache
98    pub fn insert(&self, path: PathBuf, content: Bytes, mime_type: String, etag: String, last_modified: u64) {
99        self.insert_with_ttl(path, content, mime_type, etag, last_modified, self.config.default_ttl)
100    }
101
102    /// Insert a file into the cache with custom TTL
103    pub fn insert_with_ttl(
104        &self,
105        path: PathBuf,
106        content: Bytes,
107        mime_type: String,
108        etag: String,
109        last_modified: u64,
110        ttl: Duration,
111    ) {
112        let size = content.len();
113        
114        // Check if we need to evict entries
115        self.evict_if_needed(size);
116        
117        let file = CachedFile::new(content, mime_type, etag, last_modified, ttl);
118        let entry = LruEntry { file };
119        
120        {
121            let mut cache = self.cache.write().unwrap();
122            
123            // If path already exists, remove old entry first
124            if cache.contains_key(&path) {
125                if let Some(old) = cache.get(&path) {
126                    self.stats.remove_entry(old.file.size);
127                }
128                cache.remove(&path);
129                self.remove_from_access_order(&path);
130            }
131            
132            cache.insert(path.clone(), entry);
133        }
134        
135        // Add to access order
136        {
137            let mut order = self.access_order.write().unwrap();
138            order.push(path);
139        }
140        
141        self.stats.add_entry(size);
142    }
143
144    /// Remove a file from the cache
145    pub fn remove(&self, path: &PathBuf) -> bool {
146        let mut cache = self.cache.write().unwrap();
147        
148        if let Some(entry) = cache.remove(path) {
149            self.stats.remove_entry(entry.file.size);
150            drop(cache);
151            self.remove_from_access_order(path);
152            return true;
153        }
154        
155        false
156    }
157
158    /// Check if a path is cached
159    pub fn contains(&self, path: &PathBuf) -> bool {
160        let cache = self.cache.read().unwrap();
161        if let Some(entry) = cache.get(path) {
162            !entry.file.is_expired()
163        } else {
164            false
165        }
166    }
167
168    /// Clear the cache
169    pub fn clear(&self) {
170        let mut cache = self.cache.write().unwrap();
171        let mut order = self.access_order.write().unwrap();
172        
173        cache.clear();
174        order.clear();
175        
176        self.stats.reset();
177    }
178
179    /// Get cache statistics
180    pub fn stats(&self) -> Arc<CacheStats> {
181        Arc::clone(&self.stats)
182    }
183
184    /// Get current number of entries
185    pub fn len(&self) -> usize {
186        self.cache.read().unwrap().len()
187    }
188
189    /// Check if cache is empty
190    pub fn is_empty(&self) -> bool {
191        self.len() == 0
192    }
193
194    /// Remove expired entries
195    pub fn remove_expired(&self) -> usize {
196        let cache = self.cache.read().unwrap();
197        let expired: Vec<PathBuf> = cache
198            .iter()
199            .filter(|(_, entry)| entry.file.is_expired())
200            .map(|(path, _)| path.clone())
201            .collect();
202        drop(cache);
203        
204        let count = expired.len();
205        for path in expired {
206            self.remove(&path);
207            self.stats.record_expired();
208        }
209        
210        count
211    }
212
213    /// Touch a path (move to end of access order)
214    fn touch(&self, path: &PathBuf) {
215        self.remove_from_access_order(path);
216        let mut order = self.access_order.write().unwrap();
217        order.push(path.clone());
218    }
219
220    /// Remove from access order
221    fn remove_from_access_order(&self, path: &PathBuf) {
222        let mut order = self.access_order.write().unwrap();
223        order.retain(|p| p != path);
224    }
225
226    /// Evict entries if needed
227    fn evict_if_needed(&self, new_size: usize) {
228        // Evict by entry count
229        while self.len() >= self.config.max_entries {
230            self.evict_one();
231        }
232        
233        // Evict by size
234        let current_size = self.stats.total_size() as usize;
235        while current_size + new_size > self.config.max_size && self.len() > 0 {
236            self.evict_one();
237        }
238    }
239
240    /// Evict one entry (LRU)
241    fn evict_one(&self) {
242        let path = {
243            let order = self.access_order.read().unwrap();
244            if order.is_empty() {
245                return;
246            }
247            order.first().unwrap().clone()
248        };
249        
250        self.remove(&path);
251        self.stats.record_eviction();
252    }
253
254    /// Get configuration
255    pub fn config(&self) -> &CacheConfig {
256        &self.config
257    }
258}
259
260impl Default for MemoryCache {
261    fn default() -> Self {
262        Self::new()
263    }
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269
270    fn create_test_cache() -> MemoryCache {
271        MemoryCache::with_config(CacheConfig {
272            max_entries: 10,
273            max_size: 1024, // 1 KB for testing
274            default_ttl: Duration::from_secs(60),
275        })
276    }
277
278    fn make_path(s: &str) -> PathBuf {
279        PathBuf::from(s)
280    }
281
282    #[test]
283    fn test_cache_creation() {
284        let cache = MemoryCache::new();
285        assert!(cache.is_empty());
286        assert_eq!(cache.len(), 0);
287    }
288
289    #[test]
290    fn test_cache_with_config() {
291        let config = CacheConfig {
292            max_entries: 100,
293            max_size: 1024 * 1024,
294            default_ttl: Duration::from_secs(120),
295        };
296        let cache = MemoryCache::with_config(config);
297        assert_eq!(cache.config().max_entries, 100);
298    }
299
300    #[test]
301    fn test_insert_and_get() {
302        let cache = create_test_cache();
303        let path = make_path("/test.txt");
304        
305        cache.insert(
306            path.clone(),
307            Bytes::from("Hello, World!"),
308            "text/plain".to_string(),
309            "\"etag1\"".to_string(),
310            1234567890,
311        );
312        
313        assert_eq!(cache.len(), 1);
314        
315        let cached = cache.get(&path).unwrap();
316        assert_eq!(cached.content, Bytes::from("Hello, World!"));
317        assert_eq!(cached.mime_type, "text/plain");
318    }
319
320    #[test]
321    fn test_get_nonexistent() {
322        let cache = create_test_cache();
323        let path = make_path("/nonexistent.txt");
324        
325        let result = cache.get(&path);
326        assert!(result.is_none());
327    }
328
329    #[test]
330    fn test_remove() {
331        let cache = create_test_cache();
332        let path = make_path("/test.txt");
333        
334        cache.insert(
335            path.clone(),
336            Bytes::from("test"),
337            "text/plain".to_string(),
338            "\"etag\"".to_string(),
339            0,
340        );
341        
342        assert_eq!(cache.len(), 1);
343        
344        let removed = cache.remove(&path);
345        assert!(removed);
346        assert!(cache.is_empty());
347    }
348
349    #[test]
350    fn test_remove_nonexistent() {
351        let cache = create_test_cache();
352        let path = make_path("/nonexistent.txt");
353        
354        let removed = cache.remove(&path);
355        assert!(!removed);
356    }
357
358    #[test]
359    fn test_contains() {
360        let cache = create_test_cache();
361        let path = make_path("/test.txt");
362        
363        assert!(!cache.contains(&path));
364        
365        cache.insert(
366            path.clone(),
367            Bytes::from("test"),
368            "text/plain".to_string(),
369            "\"etag\"".to_string(),
370            0,
371        );
372        
373        assert!(cache.contains(&path));
374    }
375
376    #[test]
377    fn test_clear() {
378        let cache = create_test_cache();
379        
380        for i in 0..5 {
381            let path = make_path(&format!("/file{}.txt", i));
382            cache.insert(
383                path,
384                Bytes::from("test"),
385                "text/plain".to_string(),
386                "\"etag\"".to_string(),
387                0,
388            );
389        }
390        
391        assert_eq!(cache.len(), 5);
392        
393        cache.clear();
394        
395        assert!(cache.is_empty());
396    }
397
398    #[test]
399    fn test_stats_hits_and_misses() {
400        let cache = create_test_cache();
401        let path = make_path("/test.txt");
402        
403        // Miss
404        cache.get(&path);
405        assert_eq!(cache.stats().misses(), 1);
406        
407        // Insert
408        cache.insert(
409            path.clone(),
410            Bytes::from("test"),
411            "text/plain".to_string(),
412            "\"etag\"".to_string(),
413            0,
414        );
415        
416        // Hit
417        cache.get(&path);
418        assert_eq!(cache.stats().hits(), 1);
419        assert_eq!(cache.stats().misses(), 1);
420    }
421
422    #[test]
423    fn test_lru_eviction_by_count() {
424        let cache = create_test_cache();
425        
426        // Insert 10 entries (max_entries = 10)
427        for i in 0..10 {
428            let path = make_path(&format!("/file{}.txt", i));
429            cache.insert(
430                path,
431                Bytes::from("x"),
432                "text/plain".to_string(),
433                format!("\"etag{}\"", i),
434                0,
435            );
436        }
437        
438        assert_eq!(cache.len(), 10);
439        
440        // Insert one more - should evict the oldest
441        let new_path = make_path("/new.txt");
442        cache.insert(
443            new_path.clone(),
444            Bytes::from("y"),
445            "text/plain".to_string(),
446            "\"newetag\"".to_string(),
447            0,
448        );
449        
450        assert_eq!(cache.len(), 10);
451        
452        // First entry should be evicted
453        let first_path = make_path("/file0.txt");
454        assert!(!cache.contains(&first_path));
455        
456        // New entry should exist
457        assert!(cache.contains(&new_path));
458        
459        // Should have recorded an eviction
460        assert!(cache.stats().evictions() > 0);
461    }
462
463    #[test]
464    fn test_lru_access_order() {
465        let cache = create_test_cache();
466        
467        // Insert 3 entries
468        let path1 = make_path("/file1.txt");
469        let path2 = make_path("/file2.txt");
470        let path3 = make_path("/file3.txt");
471        
472        cache.insert(path1.clone(), Bytes::from("a"), "text/plain".to_string(), "\"e1\"".to_string(), 0);
473        cache.insert(path2.clone(), Bytes::from("b"), "text/plain".to_string(), "\"e2\"".to_string(), 0);
474        cache.insert(path3.clone(), Bytes::from("c"), "text/plain".to_string(), "\"e3\"".to_string(), 0);
475        
476        // Access file1 to move it to the end
477        cache.get(&path1);
478        
479        // Insert entries until eviction
480        for i in 4..=12 {
481            let path = make_path(&format!("/file{}.txt", i));
482            cache.insert(path, Bytes::from("x"), "text/plain".to_string(), format!("\"e{}\"", i), 0);
483        }
484        
485        // file2 should be evicted before file1 (file1 was accessed recently)
486        assert!(cache.contains(&path1));
487    }
488
489    #[test]
490    fn test_size_eviction() {
491        let config = CacheConfig {
492            max_entries: 100,
493            max_size: 10, // Very small for testing
494            default_ttl: Duration::from_secs(60),
495        };
496        let cache = MemoryCache::with_config(config);
497        
498        // Insert a 5-byte entry
499        let path1 = make_path("/file1.txt");
500        cache.insert(path1.clone(), Bytes::from("12345"), "text/plain".to_string(), "\"e1\"".to_string(), 0);
501        
502        assert_eq!(cache.len(), 1);
503        
504        // Insert another 5-byte entry
505        let path2 = make_path("/file2.txt");
506        cache.insert(path2.clone(), Bytes::from("67890"), "text/plain".to_string(), "\"e2\"".to_string(), 0);
507        
508        // Total size is now 10 bytes (max)
509        assert_eq!(cache.len(), 2);
510        
511        // Insert a 6-byte entry - should evict file1
512        let path3 = make_path("/file3.txt");
513        cache.insert(path3.clone(), Bytes::from("123456"), "text/plain".to_string(), "\"e3\"".to_string(), 0);
514        
515        // file1 should be evicted due to size
516        assert!(!cache.contains(&path1));
517    }
518
519    #[test]
520    fn test_expired_entry() {
521        let cache = create_test_cache();
522        let path = make_path("/test.txt");
523        
524        // Insert with very short TTL
525        cache.insert_with_ttl(
526            path.clone(),
527            Bytes::from("test"),
528            "text/plain".to_string(),
529            "\"etag\"".to_string(),
530            0,
531            Duration::from_millis(1),
532        );
533        
534        // Wait for expiration
535        std::thread::sleep(Duration::from_millis(10));
536        
537        // Get should return None
538        let result = cache.get(&path);
539        assert!(result.is_none());
540    }
541
542    #[test]
543    fn test_remove_expired() {
544        let cache = create_test_cache();
545        
546        // Insert entries with different TTLs
547        let path1 = make_path("/long.txt");
548        let path2 = make_path("/short.txt");
549        
550        cache.insert_with_ttl(
551            path1.clone(),
552            Bytes::from("test1"),
553            "text/plain".to_string(),
554            "\"etag1\"".to_string(),
555            0,
556            Duration::from_secs(60),
557        );
558        
559        cache.insert_with_ttl(
560            path2.clone(),
561            Bytes::from("test2"),
562            "text/plain".to_string(),
563            "\"etag2\"".to_string(),
564            0,
565            Duration::from_millis(1),
566        );
567        
568        // Wait for short to expire
569        std::thread::sleep(Duration::from_millis(10));
570        
571        // Remove expired
572        let removed = cache.remove_expired();
573        
574        assert_eq!(removed, 1);
575        assert!(cache.contains(&path1));
576        assert!(!cache.contains(&path2));
577    }
578
579    #[test]
580    fn test_update_existing_entry() {
581        let cache = create_test_cache();
582        let path = make_path("/test.txt");
583        
584        cache.insert(
585            path.clone(),
586            Bytes::from("original"),
587            "text/plain".to_string(),
588            "\"etag1\"".to_string(),
589            0,
590        );
591        
592        assert_eq!(cache.len(), 1);
593        
594        cache.insert(
595            path.clone(),
596            Bytes::from("updated"),
597            "text/plain".to_string(),
598            "\"etag2\"".to_string(),
599            0,
600        );
601        
602        assert_eq!(cache.len(), 1);
603        
604        let cached = cache.get(&path).unwrap();
605        assert_eq!(cached.content, Bytes::from("updated"));
606        assert_eq!(cached.etag, "\"etag2\"");
607    }
608
609    #[test]
610    fn test_stats_evictions() {
611        let cache = create_test_cache();
612        
613        // Fill cache
614        for i in 0..10 {
615            let path = make_path(&format!("/file{}.txt", i));
616            cache.insert(
617                path,
618                Bytes::from("x"),
619                "text/plain".to_string(),
620                format!("\"etag{}\"", i),
621                0,
622            );
623        }
624        
625        // Insert one more to trigger eviction
626        let path = make_path("/overflow.txt");
627        cache.insert(path, Bytes::from("y"), "text/plain".to_string(), "\"overflow\"".to_string(), 0);
628        
629        assert!(cache.stats().evictions() > 0);
630    }
631
632    #[test]
633    fn test_concurrent_access() {
634        use std::thread;
635
636        let cache = Arc::new(create_test_cache());
637        let mut handles = vec![];
638
639        // Spawn multiple threads for concurrent writes
640        for i in 0..5 {
641            let cache_clone = Arc::clone(&cache);
642            handles.push(thread::spawn(move || {
643                let path = make_path(&format!("/file{}.txt", i));
644                cache_clone.insert(
645                    path,
646                    Bytes::from(format!("content{}", i)),
647                    "text/plain".to_string(),
648                    format!("\"etag{}\"", i),
649                    0,
650                );
651            }));
652        }
653
654        for handle in handles {
655            handle.join().unwrap();
656        }
657
658        assert_eq!(cache.len(), 5);
659    }
660
661    #[test]
662    fn test_concurrent_reads() {
663        use std::thread;
664
665        let cache = Arc::new(create_test_cache());
666        
667        // Insert a file
668        let path = make_path("/test.txt");
669        cache.insert(
670            path.clone(),
671            Bytes::from("test content"),
672            "text/plain".to_string(),
673            "\"etag\"".to_string(),
674            0,
675        );
676
677        let mut handles = vec![];
678
679        // Spawn multiple threads for concurrent reads
680        for _ in 0..10 {
681            let cache_clone = Arc::clone(&cache);
682            let path_clone = path.clone();
683            handles.push(thread::spawn(move || {
684                cache_clone.get(&path_clone)
685            }));
686        }
687
688        let results: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
689        
690        // All reads should succeed
691        assert!(results.iter().all(|r| r.is_some()));
692        assert_eq!(cache.stats().hits(), 10);
693    }
694
695    #[test]
696    fn test_stats_total_size() {
697        let cache = create_test_cache();
698        
699        cache.insert(
700            make_path("/file1.txt"),
701            Bytes::from("12345"), // 5 bytes
702            "text/plain".to_string(),
703            "\"e1\"".to_string(),
704            0,
705        );
706        
707        assert_eq!(cache.stats().total_size(), 5);
708        
709        cache.insert(
710            make_path("/file2.txt"),
711            Bytes::from("67890"), // 5 bytes
712            "text/plain".to_string(),
713            "\"e2\"".to_string(),
714            0,
715        );
716        
717        assert_eq!(cache.stats().total_size(), 10);
718    }
719
720    #[test]
721    fn test_hit_rate_calculation() {
722        let cache = create_test_cache();
723        let path = make_path("/test.txt");
724        
725        // 2 misses
726        cache.get(&path);
727        cache.get(&path);
728        
729        // Insert
730        cache.insert(
731            path.clone(),
732            Bytes::from("test"),
733            "text/plain".to_string(),
734            "\"etag\"".to_string(),
735            0,
736        );
737        
738        // 3 hits
739        cache.get(&path);
740        cache.get(&path);
741        cache.get(&path);
742        
743        // Hit rate should be 60% (3 hits / 5 total requests)
744        let expected = 3.0 / 5.0;
745        assert!((cache.stats().hit_rate() - expected).abs() < 0.001);
746    }
747}