Skip to main content

rusty_ssr/cache/
ssr.rs

1//! Multi-tier SSR cache
2//!
3//! Combines hot (L1/L2 CPU) and cold (RAM) caches for optimal performance.
4
5use serde::Serialize;
6use std::cell::RefCell;
7use std::sync::atomic::{AtomicU64, Ordering};
8use std::sync::Arc;
9use std::time::Instant;
10use thread_local::ThreadLocal;
11
12use super::cold::ColdCache;
13use super::hot::HotCache;
14use super::padded::CachePadded;
15use super::utils::hash_url;
16
17/// Multi-tier SSR cache
18///
19/// ## Architecture
20/// 1. **Hot cache** (L1/L2): Thread-local, 8 entries per thread
21/// 2. **Cold cache** (RAM): Shared DashMap with LRU eviction
22///
23/// Entries found in cold cache are automatically promoted to hot cache.
24pub struct SsrCache {
25    hot_cache: ThreadLocal<RefCell<HotCacheState>>,
26    cold_cache: Arc<ColdCache>,
27    ttl_secs: u64,
28    generation: AtomicU64,
29    metrics: Arc<CacheMetricsInner>,
30}
31
32#[derive(Default)]
33struct CacheMetricsInner {
34    lookups: CachePadded<AtomicU64>,
35    hot_hits: CachePadded<AtomicU64>,
36    cold_hits: CachePadded<AtomicU64>,
37    misses: CachePadded<AtomicU64>,
38    promotions: CachePadded<AtomicU64>,
39    insertions: CachePadded<AtomicU64>,
40    evictions: CachePadded<AtomicU64>,
41    last_access_ns: CachePadded<AtomicU64>,
42}
43
44/// Cache metrics snapshot
45#[derive(Clone, Debug, Serialize)]
46pub struct CacheMetrics {
47    /// Total cache lookups
48    pub lookups: u64,
49    /// Hot cache hits (L1/L2)
50    pub hot_hits: u64,
51    /// Cold cache hits (RAM)
52    pub cold_hits: u64,
53    /// Cache misses
54    pub misses: u64,
55    /// Promotions from cold to hot
56    pub promotions: u64,
57    /// Total insertions
58    pub insertions: u64,
59    /// LRU evictions
60    pub evictions: u64,
61    /// Last access time in nanoseconds
62    pub last_access_ns: u64,
63    /// Current cold cache size
64    pub cold_size: usize,
65    /// Cold cache capacity
66    pub cold_capacity: usize,
67    /// Hit rate percentage
68    pub hit_rate: f64,
69}
70
71struct HotCacheState {
72    generation: u64,
73    cache: HotCache,
74}
75
76impl SsrCache {
77    /// Create a new SSR cache
78    ///
79    /// # Arguments
80    /// * `max_cold_entries` - Maximum entries in the cold cache
81    pub fn new(max_cold_entries: usize) -> Self {
82        Self::with_ttl(max_cold_entries, 0)
83    }
84
85    /// Create a new SSR cache with TTL
86    ///
87    /// # Arguments
88    /// * `max_cold_entries` - Maximum entries in the cold cache
89    /// * `ttl_secs` - Time-to-live in seconds (0 = no expiration)
90    pub fn with_ttl(max_cold_entries: usize, ttl_secs: u64) -> Self {
91        tracing::info!(
92            "📦 Creating SSR cache (size={}, ttl={}s)",
93            max_cold_entries,
94            if ttl_secs > 0 {
95                ttl_secs.to_string()
96            } else {
97                "∞".to_string()
98            }
99        );
100
101        Self {
102            hot_cache: ThreadLocal::new(),
103            cold_cache: Arc::new(ColdCache::with_ttl(max_cold_entries, ttl_secs)),
104            ttl_secs,
105            generation: AtomicU64::new(0),
106            metrics: Arc::new(CacheMetricsInner::default()),
107        }
108    }
109
110    /// Try to get cached HTML
111    ///
112    /// Checks hot cache first, then cold cache.
113    /// Cold hits are promoted to hot cache.
114    pub fn try_get(&self, url: &str) -> Option<Arc<str>> {
115        let url_hash = hash_url(url);
116        let start = Instant::now();
117        self.metrics.lookups.fetch_add(1, Ordering::Relaxed);
118
119        // 1. Check hot cache (L1/L2) - use peek() for read-only access
120        let hot = self.get_or_init_hot_cache();
121        if let Some(html) = hot.borrow().cache.peek(url_hash) {
122            self.metrics.hot_hits.fetch_add(1, Ordering::Relaxed);
123            self.metrics
124                .last_access_ns
125                .store(start.elapsed().as_nanos() as u64, Ordering::Relaxed);
126            return Some(html);
127        }
128
129        // 2. Check cold cache (RAM)
130        if let Some(html) = self.cold_cache.get(url_hash) {
131            self.metrics.cold_hits.fetch_add(1, Ordering::Relaxed);
132
133            // Promote to hot cache
134            let mut hot_ref = hot.borrow_mut();
135            hot_ref.cache.insert(url_hash, Arc::clone(&html));
136            self.metrics.promotions.fetch_add(1, Ordering::Relaxed);
137
138            self.metrics
139                .last_access_ns
140                .store(start.elapsed().as_nanos() as u64, Ordering::Relaxed);
141            return Some(html);
142        }
143
144        self.metrics.misses.fetch_add(1, Ordering::Relaxed);
145        None
146    }
147
148    /// Insert HTML into cache
149    pub fn insert(&self, url: &str, html: Arc<str>) {
150        let url_hash = hash_url(url);
151
152        // Insert into cold cache
153        let evicted = self.cold_cache.insert(url_hash, url, Arc::clone(&html));
154        self.metrics.insertions.fetch_add(1, Ordering::Relaxed);
155        if evicted > 0 {
156            self.metrics.evictions.fetch_add(evicted as u64, Ordering::Relaxed);
157        }
158
159        // Insert into hot cache
160        let hot = self.get_or_init_hot_cache();
161        let mut hot_ref = hot.borrow_mut();
162        hot_ref.cache.insert(url_hash, html);
163    }
164
165    /// Invalidate a single cached URL
166    ///
167    /// Removes from cold cache and bumps generation to clear hot caches.
168    /// Other hot-cached entries will be re-promoted from cold on next access.
169    pub fn invalidate(&self, url: &str) {
170        let url_hash = hash_url(url);
171        if self.cold_cache.remove(url_hash) {
172            self.generation.fetch_add(1, Ordering::Relaxed);
173        }
174    }
175
176    /// Invalidate all cached URLs that start with the given prefix
177    ///
178    /// Example: `cache.invalidate_prefix("/products")` removes
179    /// `/products`, `/products/123`, `/products/foo/bar`, etc.
180    ///
181    /// Also bumps the generation counter to clear all hot caches,
182    /// ensuring stale entries don't survive in thread-local caches.
183    pub fn invalidate_prefix(&self, prefix: &str) -> usize {
184        let removed = self.cold_cache.remove_by_prefix(prefix);
185        if removed > 0 {
186            // Bump generation to invalidate hot caches that may hold stale entries
187            self.generation.fetch_add(1, Ordering::Relaxed);
188        }
189        removed
190    }
191
192    /// Clear the cache, including hot caches and metrics
193    pub fn clear(&self) {
194        self.cold_cache.clear();
195        self.generation.fetch_add(1, Ordering::Relaxed);
196        self.reset_metrics();
197    }
198
199    /// Get current cold cache size
200    pub fn size(&self) -> usize {
201        self.cold_cache.len()
202    }
203
204    /// Get cache metrics
205    pub fn metrics(&self) -> CacheMetrics {
206        let lookups = self.metrics.lookups.load(Ordering::Relaxed);
207        let hot_hits = self.metrics.hot_hits.load(Ordering::Relaxed);
208        let cold_hits = self.metrics.cold_hits.load(Ordering::Relaxed);
209        let total_hits = hot_hits + cold_hits;
210
211        CacheMetrics {
212            lookups,
213            hot_hits,
214            cold_hits,
215            misses: self.metrics.misses.load(Ordering::Relaxed),
216            promotions: self.metrics.promotions.load(Ordering::Relaxed),
217            insertions: self.metrics.insertions.load(Ordering::Relaxed),
218            evictions: self.metrics.evictions.load(Ordering::Relaxed),
219            last_access_ns: self.metrics.last_access_ns.load(Ordering::Relaxed),
220            cold_size: self.cold_cache.len(),
221            cold_capacity: self.cold_cache.capacity(),
222            hit_rate: if lookups > 0 {
223                (total_hits as f64 / lookups as f64) * 100.0
224            } else {
225                0.0
226            },
227        }
228    }
229
230    fn reset_metrics(&self) {
231        self.metrics.lookups.store(0, Ordering::Relaxed);
232        self.metrics.hot_hits.store(0, Ordering::Relaxed);
233        self.metrics.cold_hits.store(0, Ordering::Relaxed);
234        self.metrics.misses.store(0, Ordering::Relaxed);
235        self.metrics.promotions.store(0, Ordering::Relaxed);
236        self.metrics.insertions.store(0, Ordering::Relaxed);
237        self.metrics.evictions.store(0, Ordering::Relaxed);
238        self.metrics
239            .last_access_ns
240            .store(0, Ordering::Relaxed);
241    }
242
243    fn get_or_init_hot_cache(&self) -> &RefCell<HotCacheState> {
244        let generation = self.generation.load(Ordering::Relaxed);
245        let hot = self.hot_cache.get_or(|| {
246            RefCell::new(HotCacheState {
247                generation,
248                cache: HotCache::with_ttl(self.ttl_secs),
249            })
250        });
251
252        {
253            let mut state = hot.borrow_mut();
254            if state.generation != generation {
255                state.cache.clear();
256                state.generation = generation;
257            }
258        }
259
260        hot
261    }
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267
268    #[test]
269    fn test_basic_caching() {
270        let cache = SsrCache::new(100);
271
272        cache.insert("/test", Arc::from("html"));
273        assert!(cache.try_get("/test").is_some());
274        assert!(cache.try_get("/other").is_none());
275    }
276
277    #[test]
278    fn test_metrics() {
279        let cache = SsrCache::new(100);
280
281        cache.insert("/test", Arc::from("html"));
282        let _ = cache.try_get("/test");
283        let _ = cache.try_get("/missing");
284
285        let metrics = cache.metrics();
286        assert_eq!(metrics.insertions, 1);
287        assert_eq!(metrics.lookups, 2);
288        assert_eq!(metrics.misses, 1);
289    }
290
291    #[test]
292    fn test_invalidate_single() {
293        let cache = SsrCache::new(100);
294
295        cache.insert("/a", Arc::from("html_a"));
296        cache.insert("/b", Arc::from("html_b"));
297
298        cache.invalidate("/a");
299
300        assert!(cache.try_get("/a").is_none());
301        assert!(cache.try_get("/b").is_some());
302    }
303
304    #[test]
305    fn test_invalidate_prefix() {
306        let cache = SsrCache::new(100);
307
308        cache.insert("/products/1", Arc::from("p1"));
309        cache.insert("/products/2", Arc::from("p2"));
310        cache.insert("/about", Arc::from("about"));
311
312        let removed = cache.invalidate_prefix("/products");
313        assert_eq!(removed, 2);
314
315        assert!(cache.try_get("/products/1").is_none());
316        assert!(cache.try_get("/products/2").is_none());
317        assert!(cache.try_get("/about").is_some());
318    }
319
320    #[test]
321    fn test_clear_removes_hot_and_resets_metrics() {
322        let cache = SsrCache::with_ttl(16, 10);
323
324        cache.insert("/hot", Arc::from("html"));
325        assert!(cache.try_get("/hot").is_some(), "hot cache should have entry");
326
327        cache.clear();
328
329        // After clearing, both caches should miss and metrics reset
330        assert!(cache.try_get("/hot").is_none(), "hot cache should be cleared");
331        let metrics = cache.metrics();
332        assert_eq!(metrics.insertions, 0);
333        assert_eq!(metrics.lookups, 1);
334        assert_eq!(metrics.misses, 1);
335    }
336}