use serde::Serialize;
use std::cell::RefCell;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Instant;
use thread_local::ThreadLocal;
use super::cold::ColdCache;
use super::hot::HotCache;
use super::padded::CachePadded;
use super::utils::hash_url;
pub struct SsrCache {
hot_cache: ThreadLocal<RefCell<HotCacheState>>,
cold_cache: Arc<ColdCache>,
ttl_secs: u64,
generation: AtomicU64,
metrics: Arc<CacheMetricsInner>,
}
#[derive(Default)]
struct CacheMetricsInner {
lookups: CachePadded<AtomicU64>,
hot_hits: CachePadded<AtomicU64>,
cold_hits: CachePadded<AtomicU64>,
misses: CachePadded<AtomicU64>,
promotions: CachePadded<AtomicU64>,
insertions: CachePadded<AtomicU64>,
evictions: CachePadded<AtomicU64>,
last_access_ns: CachePadded<AtomicU64>,
}
#[derive(Clone, Debug, Serialize)]
pub struct CacheMetrics {
pub lookups: u64,
pub hot_hits: u64,
pub cold_hits: u64,
pub misses: u64,
pub promotions: u64,
pub insertions: u64,
pub evictions: u64,
pub last_access_ns: u64,
pub cold_size: usize,
pub cold_capacity: usize,
pub hit_rate: f64,
}
struct HotCacheState {
generation: u64,
cache: HotCache,
}
impl SsrCache {
pub fn new(max_cold_entries: usize) -> Self {
Self::with_ttl(max_cold_entries, 0)
}
pub fn with_ttl(max_cold_entries: usize, ttl_secs: u64) -> Self {
tracing::info!(
"📦 Creating SSR cache (size={}, ttl={}s)",
max_cold_entries,
if ttl_secs > 0 {
ttl_secs.to_string()
} else {
"∞".to_string()
}
);
Self {
hot_cache: ThreadLocal::new(),
cold_cache: Arc::new(ColdCache::with_ttl(max_cold_entries, ttl_secs)),
ttl_secs,
generation: AtomicU64::new(0),
metrics: Arc::new(CacheMetricsInner::default()),
}
}
pub fn try_get(&self, url: &str) -> Option<Arc<str>> {
let url_hash = hash_url(url);
let start = Instant::now();
self.metrics.lookups.fetch_add(1, Ordering::Relaxed);
let hot = self.get_or_init_hot_cache();
if let Some(html) = hot.borrow().cache.peek(url_hash) {
self.metrics.hot_hits.fetch_add(1, Ordering::Relaxed);
self.metrics
.last_access_ns
.store(start.elapsed().as_nanos() as u64, Ordering::Relaxed);
return Some(html);
}
if let Some(html) = self.cold_cache.get(url_hash) {
self.metrics.cold_hits.fetch_add(1, Ordering::Relaxed);
let mut hot_ref = hot.borrow_mut();
hot_ref.cache.insert(url_hash, Arc::clone(&html));
self.metrics.promotions.fetch_add(1, Ordering::Relaxed);
self.metrics
.last_access_ns
.store(start.elapsed().as_nanos() as u64, Ordering::Relaxed);
return Some(html);
}
self.metrics.misses.fetch_add(1, Ordering::Relaxed);
None
}
pub fn insert(&self, url: &str, html: Arc<str>) {
let url_hash = hash_url(url);
let evicted = self.cold_cache.insert(url_hash, url, Arc::clone(&html));
self.metrics.insertions.fetch_add(1, Ordering::Relaxed);
if evicted > 0 {
self.metrics.evictions.fetch_add(evicted as u64, Ordering::Relaxed);
}
let hot = self.get_or_init_hot_cache();
let mut hot_ref = hot.borrow_mut();
hot_ref.cache.insert(url_hash, html);
}
pub fn invalidate(&self, url: &str) {
let url_hash = hash_url(url);
if self.cold_cache.remove(url_hash) {
self.generation.fetch_add(1, Ordering::Relaxed);
}
}
pub fn invalidate_prefix(&self, prefix: &str) -> usize {
let removed = self.cold_cache.remove_by_prefix(prefix);
if removed > 0 {
self.generation.fetch_add(1, Ordering::Relaxed);
}
removed
}
pub fn clear(&self) {
self.cold_cache.clear();
self.generation.fetch_add(1, Ordering::Relaxed);
self.reset_metrics();
}
pub fn size(&self) -> usize {
self.cold_cache.len()
}
pub fn metrics(&self) -> CacheMetrics {
let lookups = self.metrics.lookups.load(Ordering::Relaxed);
let hot_hits = self.metrics.hot_hits.load(Ordering::Relaxed);
let cold_hits = self.metrics.cold_hits.load(Ordering::Relaxed);
let total_hits = hot_hits + cold_hits;
CacheMetrics {
lookups,
hot_hits,
cold_hits,
misses: self.metrics.misses.load(Ordering::Relaxed),
promotions: self.metrics.promotions.load(Ordering::Relaxed),
insertions: self.metrics.insertions.load(Ordering::Relaxed),
evictions: self.metrics.evictions.load(Ordering::Relaxed),
last_access_ns: self.metrics.last_access_ns.load(Ordering::Relaxed),
cold_size: self.cold_cache.len(),
cold_capacity: self.cold_cache.capacity(),
hit_rate: if lookups > 0 {
(total_hits as f64 / lookups as f64) * 100.0
} else {
0.0
},
}
}
fn reset_metrics(&self) {
self.metrics.lookups.store(0, Ordering::Relaxed);
self.metrics.hot_hits.store(0, Ordering::Relaxed);
self.metrics.cold_hits.store(0, Ordering::Relaxed);
self.metrics.misses.store(0, Ordering::Relaxed);
self.metrics.promotions.store(0, Ordering::Relaxed);
self.metrics.insertions.store(0, Ordering::Relaxed);
self.metrics.evictions.store(0, Ordering::Relaxed);
self.metrics
.last_access_ns
.store(0, Ordering::Relaxed);
}
fn get_or_init_hot_cache(&self) -> &RefCell<HotCacheState> {
let generation = self.generation.load(Ordering::Relaxed);
let hot = self.hot_cache.get_or(|| {
RefCell::new(HotCacheState {
generation,
cache: HotCache::with_ttl(self.ttl_secs),
})
});
{
let mut state = hot.borrow_mut();
if state.generation != generation {
state.cache.clear();
state.generation = generation;
}
}
hot
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_basic_caching() {
let cache = SsrCache::new(100);
cache.insert("/test", Arc::from("html"));
assert!(cache.try_get("/test").is_some());
assert!(cache.try_get("/other").is_none());
}
#[test]
fn test_metrics() {
let cache = SsrCache::new(100);
cache.insert("/test", Arc::from("html"));
let _ = cache.try_get("/test");
let _ = cache.try_get("/missing");
let metrics = cache.metrics();
assert_eq!(metrics.insertions, 1);
assert_eq!(metrics.lookups, 2);
assert_eq!(metrics.misses, 1);
}
#[test]
fn test_invalidate_single() {
let cache = SsrCache::new(100);
cache.insert("/a", Arc::from("html_a"));
cache.insert("/b", Arc::from("html_b"));
cache.invalidate("/a");
assert!(cache.try_get("/a").is_none());
assert!(cache.try_get("/b").is_some());
}
#[test]
fn test_invalidate_prefix() {
let cache = SsrCache::new(100);
cache.insert("/products/1", Arc::from("p1"));
cache.insert("/products/2", Arc::from("p2"));
cache.insert("/about", Arc::from("about"));
let removed = cache.invalidate_prefix("/products");
assert_eq!(removed, 2);
assert!(cache.try_get("/products/1").is_none());
assert!(cache.try_get("/products/2").is_none());
assert!(cache.try_get("/about").is_some());
}
#[test]
fn test_clear_removes_hot_and_resets_metrics() {
let cache = SsrCache::with_ttl(16, 10);
cache.insert("/hot", Arc::from("html"));
assert!(cache.try_get("/hot").is_some(), "hot cache should have entry");
cache.clear();
assert!(cache.try_get("/hot").is_none(), "hot cache should be cleared");
let metrics = cache.metrics();
assert_eq!(metrics.insertions, 0);
assert_eq!(metrics.lookups, 1);
assert_eq!(metrics.misses, 1);
}
}