Skip to main content

driven/fusion/
hot_cache.rs

1//! Hot Template Cache
2//!
3//! In-memory cache for frequently used templates with LRU eviction.
4
5use std::collections::HashMap;
6use std::sync::atomic::{AtomicU64, Ordering};
7use std::time::{Duration, Instant};
8
9/// Cached template entry
10#[derive(Debug)]
11pub struct TemplateCacheEntry {
12    /// Compiled template bytes
13    pub data: Vec<u8>,
14    /// Last access time
15    last_access: Instant,
16    /// Access count
17    access_count: u64,
18    /// Template hash
19    template_hash: u64,
20    /// Source hash for staleness check
21    source_hash: u64,
22}
23
24impl TemplateCacheEntry {
25    /// Create a new cache entry
26    pub fn new(data: Vec<u8>, template_hash: u64, source_hash: u64) -> Self {
27        Self {
28            data,
29            last_access: Instant::now(),
30            access_count: 1,
31            template_hash,
32            source_hash,
33        }
34    }
35
36    /// Mark as accessed
37    fn touch(&mut self) {
38        self.last_access = Instant::now();
39        self.access_count += 1;
40    }
41
42    /// Check if stale
43    pub fn is_stale(&self, current_source_hash: u64) -> bool {
44        self.source_hash != current_source_hash
45    }
46
47    /// Get age
48    pub fn age(&self) -> Duration {
49        self.last_access.elapsed()
50    }
51}
52
53/// Hot template cache with LRU eviction
54#[derive(Debug)]
55pub struct HotCache {
56    /// Cached templates by hash
57    entries: HashMap<u64, TemplateCacheEntry>,
58    /// Maximum cache size in bytes
59    max_size: usize,
60    /// Current cache size
61    current_size: usize,
62    /// Hit counter
63    hits: AtomicU64,
64    /// Miss counter
65    misses: AtomicU64,
66    /// TTL for cache entries
67    ttl: Duration,
68}
69
70impl HotCache {
71    /// Create a new cache with size limit
72    pub fn new(max_size: usize) -> Self {
73        Self {
74            entries: HashMap::new(),
75            max_size,
76            current_size: 0,
77            hits: AtomicU64::new(0),
78            misses: AtomicU64::new(0),
79            ttl: Duration::from_secs(3600), // 1 hour default
80        }
81    }
82
83    /// Set TTL for cache entries
84    pub fn with_ttl(mut self, ttl: Duration) -> Self {
85        self.ttl = ttl;
86        self
87    }
88
89    /// Get a cached template
90    pub fn get(&mut self, template_hash: u64) -> Option<&[u8]> {
91        // Check if entry exists and is not expired
92        if let Some(entry) = self.entries.get_mut(&template_hash) {
93            if entry.age() < self.ttl {
94                entry.touch();
95                self.hits.fetch_add(1, Ordering::Relaxed);
96                return Some(&entry.data);
97            }
98            // Entry expired, will be removed
99        }
100
101        self.misses.fetch_add(1, Ordering::Relaxed);
102        None
103    }
104
105    /// Insert a template into cache
106    pub fn insert(&mut self, template_hash: u64, source_hash: u64, data: Vec<u8>) {
107        let entry_size = data.len();
108
109        // Evict if necessary
110        while self.current_size + entry_size > self.max_size && !self.entries.is_empty() {
111            self.evict_one();
112        }
113
114        // Don't cache if entry is too large
115        if entry_size > self.max_size {
116            return;
117        }
118
119        // Remove old entry if exists
120        if let Some(old) = self.entries.remove(&template_hash) {
121            self.current_size -= old.data.len();
122        }
123
124        // Insert new entry
125        let entry = TemplateCacheEntry::new(data, template_hash, source_hash);
126        self.current_size += entry_size;
127        self.entries.insert(template_hash, entry);
128    }
129
130    /// Remove stale entries
131    pub fn invalidate_stale(&mut self, template_hash: u64, current_source_hash: u64) {
132        if let Some(entry) = self.entries.get(&template_hash) {
133            if entry.is_stale(current_source_hash) {
134                if let Some(removed) = self.entries.remove(&template_hash) {
135                    self.current_size -= removed.data.len();
136                }
137            }
138        }
139    }
140
141    /// Clear all entries
142    pub fn clear(&mut self) {
143        self.entries.clear();
144        self.current_size = 0;
145    }
146
147    /// Get cache statistics
148    pub fn stats(&self) -> CacheStats {
149        let hits = self.hits.load(Ordering::Relaxed);
150        let misses = self.misses.load(Ordering::Relaxed);
151
152        CacheStats {
153            entries: self.entries.len(),
154            size_bytes: self.current_size,
155            max_size_bytes: self.max_size,
156            hits,
157            misses,
158            hit_rate: if hits + misses > 0 {
159                hits as f64 / (hits + misses) as f64
160            } else {
161                0.0
162            },
163        }
164    }
165
166    // Private helpers
167
168    fn evict_one(&mut self) {
169        // LRU eviction: find oldest entry
170        let oldest = self
171            .entries
172            .iter()
173            .min_by_key(|(_, e)| e.last_access)
174            .map(|(k, _)| *k);
175
176        if let Some(key) = oldest {
177            if let Some(entry) = self.entries.remove(&key) {
178                self.current_size -= entry.data.len();
179            }
180        }
181    }
182}
183
184impl Default for HotCache {
185    fn default() -> Self {
186        // 64MB default cache
187        Self::new(64 * 1024 * 1024)
188    }
189}
190
191/// Cache statistics
192#[derive(Debug, Clone)]
193pub struct CacheStats {
194    /// Number of cached entries
195    pub entries: usize,
196    /// Current cache size in bytes
197    pub size_bytes: usize,
198    /// Maximum cache size in bytes
199    pub max_size_bytes: usize,
200    /// Cache hits
201    pub hits: u64,
202    /// Cache misses
203    pub misses: u64,
204    /// Hit rate (0.0 - 1.0)
205    pub hit_rate: f64,
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211
212    #[test]
213    fn test_cache_hit_miss() {
214        let mut cache = HotCache::new(1024);
215
216        cache.insert(1, 100, vec![1, 2, 3, 4]);
217
218        assert!(cache.get(1).is_some());
219        assert!(cache.get(2).is_none());
220
221        let stats = cache.stats();
222        assert_eq!(stats.hits, 1);
223        assert_eq!(stats.misses, 1);
224    }
225
226    #[test]
227    fn test_cache_eviction() {
228        let mut cache = HotCache::new(100);
229
230        // Insert 10 entries of 20 bytes each = 200 bytes
231        for i in 0..10 {
232            cache.insert(i, i * 10, vec![0u8; 20]);
233        }
234
235        // Only 5 should fit (100 bytes / 20 bytes)
236        assert!(cache.entries.len() <= 5);
237    }
238
239    #[test]
240    fn test_cache_invalidation() {
241        let mut cache = HotCache::new(1024);
242
243        cache.insert(1, 100, vec![1, 2, 3]);
244
245        // Same source hash - should stay
246        cache.invalidate_stale(1, 100);
247        assert!(cache.entries.contains_key(&1));
248
249        // Different source hash - should be removed
250        cache.invalidate_stale(1, 200);
251        assert!(!cache.entries.contains_key(&1));
252    }
253}