Skip to main content

ynab_mcp/ynab/
cache.rs

1use dashmap::DashMap;
2use std::sync::{Arc, RwLock};
3use std::time::{Duration, Instant};
4use tracing::{debug, warn};
5
6#[derive(Clone, Debug)]
7pub struct CacheEntry {
8    pub data: serde_json::Value,
9    pub expires_at: Instant,
10    pub last_access: Instant,
11}
12
13#[derive(Debug, Clone)]
14pub struct CacheStats {
15    pub total_requests: u64,
16    pub cache_hits: u64,
17    pub cache_misses: u64,
18    pub hit_ratio: f64,
19    pub cache_size: usize,
20}
21
22#[derive(Clone)]
23pub struct YnabCache {
24    cache: Arc<DashMap<String, CacheEntry>>,
25    stats: Arc<RwLock<CacheStats>>,
26    capacity: usize,
27}
28
29impl YnabCache {
30    pub fn new(capacity: usize) -> Self {
31        Self {
32            cache: Arc::new(DashMap::new()),
33            stats: Arc::new(RwLock::new(CacheStats {
34                total_requests: 0,
35                cache_hits: 0,
36                cache_misses: 0,
37                hit_ratio: 0.0,
38                cache_size: 0,
39            })),
40            capacity,
41        }
42    }
43
44    pub fn get(&self, key: &str) -> Option<serde_json::Value> {
45        // Update total requests
46        if let Ok(mut stats) = self.stats.write() {
47            stats.total_requests += 1;
48        }
49
50        if let Some(mut entry) = self.cache.get_mut(key) {
51            if entry.expires_at > Instant::now() {
52                entry.last_access = Instant::now();
53
54                // Update hit stats
55                if let Ok(mut stats) = self.stats.write() {
56                    stats.cache_hits += 1;
57                    stats.hit_ratio = stats.cache_hits as f64 / stats.total_requests as f64;
58                }
59
60                debug!(cache_key = key, "Cache hit");
61                Some(entry.data.clone())
62            } else {
63                // Entry expired, remove it
64                drop(entry); // Release the reference before removing
65                self.cache.remove(key);
66
67                // Update miss stats
68                if let Ok(mut stats) = self.stats.write() {
69                    stats.cache_misses += 1;
70                    stats.cache_size = self.cache.len();
71                    stats.hit_ratio = stats.cache_hits as f64 / stats.total_requests as f64;
72                }
73
74                debug!(cache_key = key, "Cache miss (expired)");
75                None
76            }
77        } else {
78            // Update miss stats
79            if let Ok(mut stats) = self.stats.write() {
80                stats.cache_misses += 1;
81                stats.hit_ratio = stats.cache_hits as f64 / stats.total_requests as f64;
82            }
83
84            debug!(cache_key = key, "Cache miss (not found)");
85            None
86        }
87    }
88
89    pub fn set(&self, key: String, data: serde_json::Value, ttl: Duration) {
90        // Check if we need to make room (simple LRU eviction)
91        if self.cache.len() >= self.capacity {
92            self.evict_expired_or_oldest();
93        }
94
95        let entry = CacheEntry {
96            data,
97            expires_at: Instant::now() + ttl,
98            last_access: Instant::now(),
99        };
100
101        self.cache.insert(key.clone(), entry);
102
103        // Update cache size
104        if let Ok(mut stats) = self.stats.write() {
105            stats.cache_size = self.cache.len();
106        }
107
108        debug!(cache_key = key, ttl_secs = ttl.as_secs(), "Data cached");
109    }
110
111    pub fn clear(&self) {
112        self.cache.clear();
113
114        if let Ok(mut stats) = self.stats.write() {
115            stats.cache_size = 0;
116        }
117
118        debug!("Cache cleared");
119    }
120
121    pub fn get_stats(&self) -> CacheStats {
122        if let Ok(stats) = self.stats.read() {
123            stats.clone()
124        } else {
125            CacheStats {
126                total_requests: 0,
127                cache_hits: 0,
128                cache_misses: 0,
129                hit_ratio: 0.0,
130                cache_size: self.cache.len(),
131            }
132        }
133    }
134
135    pub fn cleanup_expired(&self) -> usize {
136        let now = Instant::now();
137        let mut removed = 0;
138
139        // Collect expired keys
140        let expired_keys: Vec<String> = self
141            .cache
142            .iter()
143            .filter_map(|entry| {
144                if entry.value().expires_at <= now {
145                    Some(entry.key().clone())
146                } else {
147                    None
148                }
149            })
150            .collect();
151
152        // Remove expired entries
153        for key in expired_keys {
154            if self.cache.remove(&key).is_some() {
155                removed += 1;
156            }
157        }
158
159        if removed > 0 {
160            // Update cache size
161            if let Ok(mut stats) = self.stats.write() {
162                stats.cache_size = self.cache.len();
163            }
164
165            debug!(
166                expired_entries = removed,
167                "Cleaned up expired cache entries"
168            );
169        }
170
171        removed
172    }
173
174    fn evict_expired_or_oldest(&self) {
175        // First try to remove expired entries
176        if self.cleanup_expired() > 0 {
177            return;
178        }
179
180        // If no expired entries, remove the oldest accessed entry (LRU)
181        if let Some(oldest_key) = self.find_oldest_key() {
182            if self.cache.remove(&oldest_key).is_some() {
183                warn!(
184                    cache_key = oldest_key,
185                    "Evicted oldest cache entry to make room"
186                );
187
188                if let Ok(mut stats) = self.stats.write() {
189                    stats.cache_size = self.cache.len();
190                }
191            }
192        }
193    }
194
195    fn find_oldest_key(&self) -> Option<String> {
196        let mut oldest_key: Option<String> = None;
197        let mut oldest_time = Instant::now();
198
199        for entry in self.cache.iter() {
200            if entry.value().last_access < oldest_time {
201                oldest_time = entry.value().last_access;
202                oldest_key = Some(entry.key().clone());
203            }
204        }
205
206        oldest_key
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213    use serde_json::json;
214    use std::thread;
215    use std::time::Duration;
216
217    #[test]
218    fn test_cache_creation() {
219        let cache = YnabCache::new(100);
220        let stats = cache.get_stats();
221
222        assert_eq!(stats.total_requests, 0);
223        assert_eq!(stats.cache_hits, 0);
224        assert_eq!(stats.cache_misses, 0);
225        assert_eq!(stats.hit_ratio, 0.0);
226        assert_eq!(stats.cache_size, 0);
227        assert_eq!(cache.capacity, 100);
228    }
229
230    #[test]
231    fn test_cache_entry_creation() {
232        let data = json!({"key": "value"});
233        let now = Instant::now();
234        let entry = CacheEntry {
235            data: data.clone(),
236            expires_at: now + Duration::from_secs(60),
237            last_access: now,
238        };
239
240        assert_eq!(entry.data, data);
241        assert!(entry.expires_at > now);
242        assert_eq!(entry.last_access, now);
243    }
244
245    #[test]
246    fn test_cache_stats_creation() {
247        let stats = CacheStats {
248            total_requests: 10,
249            cache_hits: 8,
250            cache_misses: 2,
251            hit_ratio: 0.8,
252            cache_size: 5,
253        };
254
255        assert_eq!(stats.total_requests, 10);
256        assert_eq!(stats.cache_hits, 8);
257        assert_eq!(stats.cache_misses, 2);
258        assert_eq!(stats.hit_ratio, 0.8);
259        assert_eq!(stats.cache_size, 5);
260    }
261
262    #[test]
263    fn test_cache_set_and_get() {
264        let cache = YnabCache::new(10);
265        let key = "test_key".to_string();
266        let value = json!({"test": "data"});
267
268        cache.set(key.clone(), value.clone(), Duration::from_secs(60));
269
270        let retrieved = cache.get(&key);
271        assert!(retrieved.is_some());
272        assert_eq!(retrieved.unwrap(), value);
273
274        let stats = cache.get_stats();
275        assert_eq!(stats.total_requests, 1);
276        assert_eq!(stats.cache_hits, 1);
277        assert_eq!(stats.cache_misses, 0);
278        assert_eq!(stats.hit_ratio, 1.0);
279        assert_eq!(stats.cache_size, 1);
280    }
281
282    #[test]
283    fn test_cache_miss() {
284        let cache = YnabCache::new(10);
285
286        let result = cache.get("nonexistent_key");
287        assert!(result.is_none());
288
289        let stats = cache.get_stats();
290        assert_eq!(stats.total_requests, 1);
291        assert_eq!(stats.cache_hits, 0);
292        assert_eq!(stats.cache_misses, 1);
293        assert_eq!(stats.hit_ratio, 0.0);
294        assert_eq!(stats.cache_size, 0);
295    }
296
297    #[test]
298    fn test_cache_expiration() {
299        let cache = YnabCache::new(10);
300        let key = "test_key".to_string();
301        let value = json!({"test": "data"});
302
303        // Set with very short TTL
304        cache.set(key.clone(), value.clone(), Duration::from_millis(10));
305
306        // Immediate access should work
307        let retrieved = cache.get(&key);
308        assert!(retrieved.is_some());
309
310        // Wait for expiration
311        thread::sleep(Duration::from_millis(20));
312
313        // Access after expiration should fail
314        let retrieved = cache.get(&key);
315        assert!(retrieved.is_none());
316
317        let stats = cache.get_stats();
318        assert_eq!(stats.total_requests, 2);
319        assert_eq!(stats.cache_hits, 1);
320        assert_eq!(stats.cache_misses, 1);
321        assert_eq!(stats.hit_ratio, 0.5);
322        assert_eq!(stats.cache_size, 0); // Should be removed after expiration
323    }
324
325    #[test]
326    fn test_cache_clear() {
327        let cache = YnabCache::new(10);
328
329        // Add multiple entries
330        cache.set(
331            "key1".to_string(),
332            json!({"data": 1}),
333            Duration::from_secs(60),
334        );
335        cache.set(
336            "key2".to_string(),
337            json!({"data": 2}),
338            Duration::from_secs(60),
339        );
340        cache.set(
341            "key3".to_string(),
342            json!({"data": 3}),
343            Duration::from_secs(60),
344        );
345
346        let stats_before = cache.get_stats();
347        assert_eq!(stats_before.cache_size, 3);
348
349        cache.clear();
350
351        let stats_after = cache.get_stats();
352        assert_eq!(stats_after.cache_size, 0);
353
354        // Verify all entries are gone
355        assert!(cache.get("key1").is_none());
356        assert!(cache.get("key2").is_none());
357        assert!(cache.get("key3").is_none());
358    }
359
360    #[test]
361    fn test_cache_cleanup_expired() {
362        let cache = YnabCache::new(10);
363
364        // Add entries with different TTLs
365        cache.set(
366            "short_ttl".to_string(),
367            json!({"data": 1}),
368            Duration::from_millis(10),
369        );
370        cache.set(
371            "long_ttl".to_string(),
372            json!({"data": 2}),
373            Duration::from_secs(60),
374        );
375
376        // Wait for short TTL to expire
377        thread::sleep(Duration::from_millis(20));
378
379        // Cleanup should remove 1 expired entry
380        let removed = cache.cleanup_expired();
381        assert_eq!(removed, 1);
382
383        // Long TTL entry should still exist
384        assert!(cache.get("long_ttl").is_some());
385        assert!(cache.get("short_ttl").is_none());
386
387        let stats = cache.get_stats();
388        assert_eq!(stats.cache_size, 1);
389    }
390
391    #[test]
392    fn test_cache_capacity_eviction() {
393        let cache = YnabCache::new(2); // Small capacity
394
395        // Fill cache to capacity
396        cache.set(
397            "key1".to_string(),
398            json!({"data": 1}),
399            Duration::from_secs(60),
400        );
401        cache.set(
402            "key2".to_string(),
403            json!({"data": 2}),
404            Duration::from_secs(60),
405        );
406
407        // Access key1 to make it more recently used
408        cache.get("key1");
409
410        // Add a third entry, should evict key2 (oldest)
411        cache.set(
412            "key3".to_string(),
413            json!({"data": 3}),
414            Duration::from_secs(60),
415        );
416
417        let stats = cache.get_stats();
418        assert_eq!(stats.cache_size, 2);
419
420        // key1 and key3 should exist, key2 should be evicted
421        assert!(cache.get("key1").is_some());
422        assert!(cache.get("key3").is_some());
423        assert!(cache.get("key2").is_none());
424    }
425
426    #[test]
427    fn test_find_oldest_key() {
428        let cache = YnabCache::new(10);
429
430        // Add entries with small delays to ensure different last_access times
431        cache.set(
432            "first".to_string(),
433            json!({"data": 1}),
434            Duration::from_secs(60),
435        );
436        thread::sleep(Duration::from_millis(1));
437        cache.set(
438            "second".to_string(),
439            json!({"data": 2}),
440            Duration::from_secs(60),
441        );
442        thread::sleep(Duration::from_millis(1));
443        cache.set(
444            "third".to_string(),
445            json!({"data": 3}),
446            Duration::from_secs(60),
447        );
448
449        // Access second to update its last_access time
450        cache.get("second");
451
452        let oldest_key = cache.find_oldest_key();
453        assert!(oldest_key.is_some());
454        // Either "first" or "third" could be oldest depending on timing
455        let key = oldest_key.unwrap();
456        assert!(key == "first" || key == "third");
457    }
458
459    #[test]
460    fn test_cache_hit_ratio_calculation() {
461        let cache = YnabCache::new(10);
462
463        cache.set(
464            "key1".to_string(),
465            json!({"data": 1}),
466            Duration::from_secs(60),
467        );
468
469        // Hit
470        cache.get("key1");
471        let stats = cache.get_stats();
472        assert_eq!(stats.hit_ratio, 1.0);
473
474        // Miss
475        cache.get("nonexistent");
476        let stats = cache.get_stats();
477        assert_eq!(stats.hit_ratio, 0.5);
478
479        // Another hit
480        cache.get("key1");
481        let stats = cache.get_stats();
482        assert_eq!(stats.total_requests, 3);
483        assert_eq!(stats.cache_hits, 2);
484        assert_eq!(stats.cache_misses, 1);
485        assert!((stats.hit_ratio - 0.6666666666666666).abs() < f64::EPSILON);
486    }
487
488    #[test]
489    fn test_cache_stats_with_lock_failure() {
490        let cache = YnabCache::new(10);
491
492        // Test the fallback path when stats lock might fail
493        // This is mostly for coverage of the error handling path
494        let stats = cache.get_stats();
495        assert_eq!(stats.cache_size, 0);
496    }
497
498    #[test]
499    fn test_evict_expired_or_oldest_with_expired_entries() {
500        let cache = YnabCache::new(2);
501
502        // Add entries with short TTL
503        cache.set(
504            "expired1".to_string(),
505            json!({"data": 1}),
506            Duration::from_millis(10),
507        );
508        cache.set(
509            "expired2".to_string(),
510            json!({"data": 2}),
511            Duration::from_millis(10),
512        );
513
514        // Wait for expiration
515        thread::sleep(Duration::from_millis(20));
516
517        // This should trigger eviction of expired entries
518        cache.set(
519            "new_key".to_string(),
520            json!({"data": 3}),
521            Duration::from_secs(60),
522        );
523
524        let stats = cache.get_stats();
525        assert_eq!(stats.cache_size, 1);
526        assert!(cache.get("new_key").is_some());
527        assert!(cache.get("expired1").is_none());
528        assert!(cache.get("expired2").is_none());
529    }
530
531    #[test]
532    fn test_evict_expired_or_oldest_without_expired_entries() {
533        let cache = YnabCache::new(2);
534
535        // Fill cache with non-expired entries
536        cache.set(
537            "key1".to_string(),
538            json!({"data": 1}),
539            Duration::from_secs(60),
540        );
541        cache.set(
542            "key2".to_string(),
543            json!({"data": 2}),
544            Duration::from_secs(60),
545        );
546
547        // Access key1 to make it more recently used
548        cache.get("key1");
549
550        // This should trigger LRU eviction of key2
551        cache.set(
552            "key3".to_string(),
553            json!({"data": 3}),
554            Duration::from_secs(60),
555        );
556
557        let stats = cache.get_stats();
558        assert_eq!(stats.cache_size, 2);
559        assert!(cache.get("key1").is_some());
560        assert!(cache.get("key3").is_some());
561        assert!(cache.get("key2").is_none());
562    }
563
564    #[test]
565    fn test_multiple_cache_operations() {
566        let cache = YnabCache::new(5);
567
568        // Test complex scenario with multiple operations
569        cache.set(
570            "a".to_string(),
571            json!({"value": "a"}),
572            Duration::from_secs(60),
573        );
574        cache.set(
575            "b".to_string(),
576            json!({"value": "b"}),
577            Duration::from_millis(50),
578        );
579        cache.set(
580            "c".to_string(),
581            json!({"value": "c"}),
582            Duration::from_secs(60),
583        );
584
585        // Initial state
586        assert_eq!(cache.get_stats().cache_size, 3);
587
588        // Some hits and misses
589        assert!(cache.get("a").is_some());
590        assert!(cache.get("nonexistent").is_none());
591        assert!(cache.get("c").is_some());
592
593        // Wait for 'b' to expire
594        thread::sleep(Duration::from_millis(60));
595
596        // Access expired entry (should be removed)
597        assert!(cache.get("b").is_none());
598        assert_eq!(cache.get_stats().cache_size, 2);
599
600        // Manual cleanup
601        let removed = cache.cleanup_expired();
602        assert_eq!(removed, 0); // Already removed by get
603
604        // Clear all
605        cache.clear();
606        assert_eq!(cache.get_stats().cache_size, 0);
607    }
608
609    #[test]
610    fn test_zero_capacity_cache() {
611        let cache = YnabCache::new(0);
612
613        // Should not store anything with zero capacity
614        cache.set(
615            "key".to_string(),
616            json!({"data": "value"}),
617            Duration::from_secs(60),
618        );
619
620        // The cache might still try to store it briefly before eviction
621        let stats = cache.get_stats();
622        assert!(stats.cache_size <= 1); // Should be 0 or 1 depending on eviction timing
623    }
624
625    #[test]
626    fn test_cache_with_large_capacity() {
627        let cache = YnabCache::new(1000);
628
629        // Add many entries
630        for i in 0..100 {
631            cache.set(
632                format!("key_{}", i),
633                json!({"index": i}),
634                Duration::from_secs(60),
635            );
636        }
637
638        let stats = cache.get_stats();
639        assert_eq!(stats.cache_size, 100);
640
641        // All should be retrievable
642        for i in 0..100 {
643            assert!(cache.get(&format!("key_{}", i)).is_some());
644        }
645
646        let final_stats = cache.get_stats();
647        assert_eq!(final_stats.total_requests, 100);
648        assert_eq!(final_stats.cache_hits, 100);
649        assert_eq!(final_stats.hit_ratio, 1.0);
650    }
651
652    #[test]
653    fn test_cache_entry_debug() {
654        let entry = CacheEntry {
655            data: json!({"test": "value"}),
656            expires_at: Instant::now() + Duration::from_secs(60),
657            last_access: Instant::now(),
658        };
659
660        let debug_str = format!("{:?}", entry);
661        assert!(debug_str.contains("CacheEntry"));
662        assert!(debug_str.contains("data"));
663        assert!(debug_str.contains("expires_at"));
664        assert!(debug_str.contains("last_access"));
665    }
666
667    #[test]
668    fn test_cache_stats_debug() {
669        let stats = CacheStats {
670            total_requests: 10,
671            cache_hits: 8,
672            cache_misses: 2,
673            hit_ratio: 0.8,
674            cache_size: 5,
675        };
676
677        let debug_str = format!("{:?}", stats);
678        assert!(debug_str.contains("CacheStats"));
679        assert!(debug_str.contains("10"));
680        assert!(debug_str.contains("8"));
681        assert!(debug_str.contains("2"));
682        assert!(debug_str.contains("0.8"));
683        assert!(debug_str.contains("5"));
684    }
685
686    #[test]
687    fn test_cache_clone() {
688        let cache = YnabCache::new(10);
689        cache.set(
690            "key".to_string(),
691            json!({"data": "value"}),
692            Duration::from_secs(60),
693        );
694
695        let cloned_cache = cache.clone();
696
697        // Both should access the same underlying cache
698        assert!(cache.get("key").is_some());
699        assert!(cloned_cache.get("key").is_some());
700
701        // Operations on clone should affect original
702        cloned_cache.set(
703            "key2".to_string(),
704            json!({"data": "value2"}),
705            Duration::from_secs(60),
706        );
707        assert!(cache.get("key2").is_some());
708    }
709
710    #[test]
711    fn test_empty_cache_oldest_key() {
712        let cache = YnabCache::new(10);
713
714        let oldest_key = cache.find_oldest_key();
715        assert!(oldest_key.is_none());
716    }
717
718    #[test]
719    fn test_single_entry_oldest_key() {
720        let cache = YnabCache::new(10);
721        cache.set(
722            "only_key".to_string(),
723            json!({"data": "value"}),
724            Duration::from_secs(60),
725        );
726
727        let oldest_key = cache.find_oldest_key();
728        assert_eq!(oldest_key, Some("only_key".to_string()));
729    }
730}