#![allow(dead_code)]
use std::collections::HashMap;
use uuid::Uuid;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct CacheEntry {
pub score: f64,
pub created_at: i64,
pub expires_at: i64,
pub access_count: u64,
pub last_accessed: i64,
}
impl CacheEntry {
#[must_use]
pub fn new(score: f64, now: i64, ttl_secs: i64) -> Self {
Self {
score,
created_at: now,
expires_at: now + ttl_secs,
access_count: 0,
last_accessed: now,
}
}
#[must_use]
pub fn is_expired(&self, now: i64) -> bool {
now >= self.expires_at
}
#[must_use]
pub fn remaining_ttl(&self, now: i64) -> i64 {
(self.expires_at - now).max(0)
}
#[must_use]
pub fn age(&self, now: i64) -> i64 {
now - self.created_at
}
pub fn record_access(&mut self, now: i64) {
self.access_count += 1;
self.last_accessed = now;
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct CacheKey {
pub user_id: Uuid,
pub content_id: Uuid,
}
impl CacheKey {
#[must_use]
pub fn new(user_id: Uuid, content_id: Uuid) -> Self {
Self {
user_id,
content_id,
}
}
}
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct CacheStats {
pub lookups: u64,
pub hits: u64,
pub misses: u64,
pub evictions: u64,
pub expirations: u64,
}
impl CacheStats {
#[must_use]
#[allow(clippy::cast_precision_loss)]
pub fn hit_rate(&self) -> f64 {
if self.lookups == 0 {
return 0.0;
}
self.hits as f64 / self.lookups as f64
}
}
#[derive(Debug)]
pub struct ScoreCache {
entries: HashMap<CacheKey, CacheEntry>,
default_ttl_secs: i64,
max_capacity: usize,
stats: CacheStats,
}
impl ScoreCache {
#[must_use]
pub fn new(default_ttl_secs: i64, max_capacity: usize) -> Self {
Self {
entries: HashMap::new(),
default_ttl_secs,
max_capacity,
stats: CacheStats::default(),
}
}
pub fn get(&mut self, key: &CacheKey, now: i64) -> Option<f64> {
self.stats.lookups += 1;
let entry = if let Some(e) = self.entries.get_mut(key) {
e
} else {
self.stats.misses += 1;
return None;
};
if entry.is_expired(now) {
self.stats.misses += 1;
self.entries.remove(key);
self.stats.expirations += 1;
return None;
}
entry.record_access(now);
self.stats.hits += 1;
Some(entry.score)
}
pub fn put(&mut self, key: CacheKey, score: f64, now: i64) {
self.put_with_ttl(key, score, now, self.default_ttl_secs);
}
pub fn put_with_ttl(&mut self, key: CacheKey, score: f64, now: i64, ttl_secs: i64) {
if self.entries.len() >= self.max_capacity && !self.entries.contains_key(&key) {
self.evict_lru();
}
self.entries
.insert(key, CacheEntry::new(score, now, ttl_secs));
}
pub fn get_or_compute<F>(&mut self, key: CacheKey, now: i64, compute: F) -> f64
where
F: FnOnce() -> f64,
{
if let Some(score) = self.get(&key, now) {
return score;
}
let score = compute();
self.put(key, score, now);
score
}
pub fn evict_expired(&mut self, now: i64) {
let before = self.entries.len();
self.entries.retain(|_, entry| !entry.is_expired(now));
let removed = before - self.entries.len();
self.stats.expirations += removed as u64;
}
fn evict_lru(&mut self) {
if self.entries.is_empty() {
return;
}
let lru_key = self
.entries
.iter()
.min_by_key(|(_, entry)| entry.last_accessed)
.map(|(k, _)| *k);
if let Some(key) = lru_key {
self.entries.remove(&key);
self.stats.evictions += 1;
}
}
pub fn invalidate(&mut self, key: &CacheKey) -> bool {
self.entries.remove(key).is_some()
}
pub fn invalidate_user(&mut self, user_id: Uuid) {
self.entries.retain(|k, _| k.user_id != user_id);
}
pub fn invalidate_content(&mut self, content_id: Uuid) {
self.entries.retain(|k, _| k.content_id != content_id);
}
pub fn clear(&mut self) {
self.entries.clear();
}
#[must_use]
pub fn len(&self) -> usize {
self.entries.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
#[must_use]
pub fn stats(&self) -> &CacheStats {
&self.stats
}
pub fn reset_stats(&mut self) {
self.stats = CacheStats::default();
}
}
#[cfg(test)]
mod tests {
use super::*;
fn uid() -> Uuid {
Uuid::new_v4()
}
#[test]
fn test_cache_entry_creation() {
let entry = CacheEntry::new(0.85, 1000, 60);
assert!((entry.score - 0.85).abs() < f64::EPSILON);
assert_eq!(entry.expires_at, 1060);
assert_eq!(entry.access_count, 0);
}
#[test]
fn test_cache_entry_expiration() {
let entry = CacheEntry::new(1.0, 1000, 60);
assert!(!entry.is_expired(1050));
assert!(entry.is_expired(1060));
assert!(entry.is_expired(1100));
}
#[test]
fn test_cache_entry_remaining_ttl() {
let entry = CacheEntry::new(1.0, 1000, 60);
assert_eq!(entry.remaining_ttl(1000), 60);
assert_eq!(entry.remaining_ttl(1030), 30);
assert_eq!(entry.remaining_ttl(1070), 0);
}
#[test]
fn test_cache_entry_age() {
let entry = CacheEntry::new(1.0, 1000, 60);
assert_eq!(entry.age(1000), 0);
assert_eq!(entry.age(1025), 25);
}
#[test]
fn test_cache_entry_record_access() {
let mut entry = CacheEntry::new(1.0, 1000, 60);
entry.record_access(1010);
assert_eq!(entry.access_count, 1);
assert_eq!(entry.last_accessed, 1010);
entry.record_access(1020);
assert_eq!(entry.access_count, 2);
}
#[test]
fn test_cache_put_and_get() {
let mut cache = ScoreCache::new(300, 100);
let key = CacheKey::new(uid(), uid());
cache.put(key, 0.92, 1000);
let val = cache.get(&key, 1001).expect("should succeed in test");
assert!((val - 0.92).abs() < f64::EPSILON);
}
#[test]
fn test_cache_miss() {
let mut cache = ScoreCache::new(300, 100);
let key = CacheKey::new(uid(), uid());
assert!(cache.get(&key, 1000).is_none());
assert_eq!(cache.stats().misses, 1);
}
#[test]
fn test_cache_expired_entry_returns_none() {
let mut cache = ScoreCache::new(60, 100);
let key = CacheKey::new(uid(), uid());
cache.put(key, 0.5, 1000);
assert!(cache.get(&key, 1061).is_none());
}
#[test]
fn test_get_or_compute_miss() {
let mut cache = ScoreCache::new(300, 100);
let key = CacheKey::new(uid(), uid());
let val = cache.get_or_compute(key, 1000, || 0.77);
assert!((val - 0.77).abs() < f64::EPSILON);
assert_eq!(cache.len(), 1);
}
#[test]
fn test_get_or_compute_hit() {
let mut cache = ScoreCache::new(300, 100);
let key = CacheKey::new(uid(), uid());
cache.put(key, 0.5, 1000);
let val = cache.get_or_compute(key, 1001, || 0.99);
assert!((val - 0.5).abs() < f64::EPSILON);
}
#[test]
fn test_evict_expired() {
let mut cache = ScoreCache::new(60, 100);
let k1 = CacheKey::new(uid(), uid());
let k2 = CacheKey::new(uid(), uid());
cache.put(k1, 0.1, 1000);
cache.put(k2, 0.2, 1050);
cache.evict_expired(1061);
assert_eq!(cache.len(), 1);
}
#[test]
fn test_capacity_eviction() {
let mut cache = ScoreCache::new(300, 2);
let k1 = CacheKey::new(uid(), uid());
let k2 = CacheKey::new(uid(), uid());
let k3 = CacheKey::new(uid(), uid());
cache.put(k1, 0.1, 1000);
cache.put(k2, 0.2, 1001);
let _ = cache.get(&k1, 1002);
cache.put(k3, 0.3, 1003);
assert_eq!(cache.len(), 2);
assert_eq!(cache.stats().evictions, 1);
}
#[test]
fn test_invalidate_specific() {
let mut cache = ScoreCache::new(300, 100);
let key = CacheKey::new(uid(), uid());
cache.put(key, 0.5, 1000);
assert!(cache.invalidate(&key));
assert!(cache.is_empty());
}
#[test]
fn test_invalidate_user() {
let mut cache = ScoreCache::new(300, 100);
let u = uid();
let k1 = CacheKey::new(u, uid());
let k2 = CacheKey::new(u, uid());
let k3 = CacheKey::new(uid(), uid());
cache.put(k1, 0.1, 1000);
cache.put(k2, 0.2, 1000);
cache.put(k3, 0.3, 1000);
cache.invalidate_user(u);
assert_eq!(cache.len(), 1);
}
#[test]
fn test_hit_rate() {
let mut cache = ScoreCache::new(300, 100);
let key = CacheKey::new(uid(), uid());
cache.put(key, 0.5, 1000);
let _ = cache.get(&key, 1001); let _ = cache.get(&CacheKey::new(uid(), uid()), 1001); assert!((cache.stats().hit_rate() - 0.5).abs() < f64::EPSILON);
}
#[test]
fn test_clear_cache() {
let mut cache = ScoreCache::new(300, 100);
cache.put(CacheKey::new(uid(), uid()), 0.1, 1000);
cache.put(CacheKey::new(uid(), uid()), 0.2, 1000);
cache.clear();
assert!(cache.is_empty());
assert_eq!(cache.len(), 0);
}
}