1use 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
17pub 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#[derive(Clone, Debug, Serialize)]
46pub struct CacheMetrics {
47 pub lookups: u64,
49 pub hot_hits: u64,
51 pub cold_hits: u64,
53 pub misses: u64,
55 pub promotions: u64,
57 pub insertions: u64,
59 pub evictions: u64,
61 pub last_access_ns: u64,
63 pub cold_size: usize,
65 pub cold_capacity: usize,
67 pub hit_rate: f64,
69}
70
71struct HotCacheState {
72 generation: u64,
73 cache: HotCache,
74}
75
76impl SsrCache {
77 pub fn new(max_cold_entries: usize) -> Self {
82 Self::with_ttl(max_cold_entries, 0)
83 }
84
85 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 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 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 if let Some(html) = self.cold_cache.get(url_hash) {
131 self.metrics.cold_hits.fetch_add(1, Ordering::Relaxed);
132
133 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 pub fn insert(&self, url: &str, html: Arc<str>) {
150 let url_hash = hash_url(url);
151
152 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 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 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 pub fn invalidate_prefix(&self, prefix: &str) -> usize {
184 let removed = self.cold_cache.remove_by_prefix(prefix);
185 if removed > 0 {
186 self.generation.fetch_add(1, Ordering::Relaxed);
188 }
189 removed
190 }
191
192 pub fn clear(&self) {
194 self.cold_cache.clear();
195 self.generation.fetch_add(1, Ordering::Relaxed);
196 self.reset_metrics();
197 }
198
199 pub fn size(&self) -> usize {
201 self.cold_cache.len()
202 }
203
204 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 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}