use std::collections::HashMap;
use std::time::{Duration, Instant};
const FNV_OFFSET_BASIS: u64 = 14695981039346656037;
const FNV_PRIME: u64 = 1099511628211;
#[inline]
fn fnv1a_hash(bytes: &[u8]) -> u64 {
let mut hash = FNV_OFFSET_BASIS;
for &byte in bytes {
hash ^= u64::from(byte);
hash = hash.wrapping_mul(FNV_PRIME);
}
hash
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct RequestKey(pub u64);
impl RequestKey {
#[allow(clippy::should_implement_trait)]
pub fn from_str(s: &str) -> Self {
Self(fnv1a_hash(s.as_bytes()))
}
pub fn from_messages(messages: &[(&str, &str)]) -> Self {
let mut hash = FNV_OFFSET_BASIS;
for (role, content) in messages {
for &byte in role.as_bytes() {
hash ^= u64::from(byte);
hash = hash.wrapping_mul(FNV_PRIME);
}
hash ^= 0x00;
hash = hash.wrapping_mul(FNV_PRIME);
for &byte in content.as_bytes() {
hash ^= u64::from(byte);
hash = hash.wrapping_mul(FNV_PRIME);
}
hash ^= 0x01;
hash = hash.wrapping_mul(FNV_PRIME);
}
Self(hash)
}
pub fn value(&self) -> u64 {
self.0
}
}
#[derive(Debug, Clone)]
pub struct CachedResponse {
pub content: String,
pub created_at: Instant,
pub hit_count: u64,
pub ttl: Duration,
}
impl CachedResponse {
pub fn new(content: String, ttl: Duration) -> Self {
Self {
content,
created_at: Instant::now(),
hit_count: 0,
ttl,
}
}
pub fn is_expired(&self) -> bool {
self.created_at.elapsed() > self.ttl
}
pub fn record_hit(&mut self) {
self.hit_count += 1;
}
}
#[derive(Debug, Clone, Default)]
pub struct DedupStats {
pub total_requests: u64,
pub cache_hits: u64,
pub cache_misses: u64,
pub evictions: u64,
}
impl DedupStats {
pub fn hit_rate(&self) -> f64 {
if self.total_requests == 0 {
0.0
} else {
self.cache_hits as f64 / self.total_requests as f64
}
}
pub fn summary(&self) -> String {
format!(
"requests={} hits={} misses={} evictions={} hit_rate={:.1}%",
self.total_requests,
self.cache_hits,
self.cache_misses,
self.evictions,
self.hit_rate() * 100.0,
)
}
}
pub struct DedupCache {
cache: HashMap<RequestKey, (CachedResponse, u64)>,
capacity: usize,
default_ttl: Duration,
stats: DedupStats,
next_seq: u64,
}
impl DedupCache {
pub fn new(capacity: usize, default_ttl: Duration) -> Self {
Self {
cache: HashMap::new(),
capacity,
default_ttl,
stats: DedupStats::default(),
next_seq: 0,
}
}
pub fn with_capacity(n: usize) -> Self {
Self::new(n, Duration::from_secs(60))
}
pub fn get(&mut self, key: &RequestKey) -> Option<&str> {
self.stats.total_requests += 1;
let expired = self
.cache
.get(key)
.map(|(entry, _)| entry.is_expired())
.unwrap_or(false);
if expired {
self.cache.remove(key);
self.stats.cache_misses += 1;
self.stats.evictions += 1;
return None;
}
match self.cache.get_mut(key) {
Some((entry, _seq)) => {
entry.record_hit();
self.stats.cache_hits += 1;
Some(self.cache[key].0.content.as_str())
}
None => {
self.stats.cache_misses += 1;
None
}
}
}
pub fn insert(&mut self, key: RequestKey, response: String) {
let ttl = self.default_ttl;
self.insert_with_ttl(key, response, ttl);
}
pub fn insert_with_ttl(&mut self, key: RequestKey, response: String, ttl: Duration) {
if self.cache.contains_key(&key) {
let seq = self.next_seq;
self.next_seq += 1;
let entry = CachedResponse::new(response, ttl);
self.cache.insert(key, (entry, seq));
return;
}
if self.cache.len() >= self.capacity {
self.evict_oldest();
}
let seq = self.next_seq;
self.next_seq += 1;
let entry = CachedResponse::new(response, ttl);
self.cache.insert(key, (entry, seq));
}
pub fn evict_expired(&mut self) -> usize {
let before = self.cache.len();
self.cache.retain(|_, (entry, _)| !entry.is_expired());
let removed = before - self.cache.len();
self.stats.evictions += removed as u64;
removed
}
pub fn len(&self) -> usize {
self.cache.len()
}
pub fn is_empty(&self) -> bool {
self.cache.is_empty()
}
pub fn stats(&self) -> &DedupStats {
&self.stats
}
pub fn clear(&mut self) {
self.cache.clear();
self.stats = DedupStats::default();
self.next_seq = 0;
}
fn evict_oldest(&mut self) {
let oldest_key = self
.cache
.iter()
.min_by_key(|(_, (_, seq))| *seq)
.map(|(k, _)| k.clone());
if let Some(key) = oldest_key {
self.cache.remove(&key);
self.stats.evictions += 1;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn request_key_deterministic() {
let k1 = RequestKey::from_str("hello");
let k2 = RequestKey::from_str("hello");
assert_eq!(k1, k2);
}
#[test]
fn request_key_different_inputs() {
let k1 = RequestKey::from_str("foo");
let k2 = RequestKey::from_str("bar");
assert_ne!(k1, k2);
}
#[test]
fn cached_response_not_expired_immediately() {
let r = CachedResponse::new("hi".to_string(), Duration::from_secs(60));
assert!(!r.is_expired());
}
#[test]
fn dedup_cache_basic_insert_get() {
let mut cache = DedupCache::with_capacity(10);
let key = RequestKey::from_str("test");
cache.insert(key.clone(), "response".to_string());
assert_eq!(cache.get(&key), Some("response"));
}
#[test]
fn dedup_stats_hit_rate_zero_on_empty() {
let stats = DedupStats::default();
assert!((stats.hit_rate() - 0.0).abs() < f64::EPSILON);
}
}