use alloy_chains::NamedChain;
use async_trait::async_trait;
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
use std::fmt;
use crate::blocks::window::DailyBlockWindow;
use crate::errors::BlockWindowError;
mod disk;
mod memory;
mod noop;
pub use disk::DiskCache;
pub use memory::MemoryCache;
pub use noop::NoOpCache;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct CacheKey {
pub(crate) chain: NamedChain,
pub(crate) date: NaiveDate,
}
impl CacheKey {
pub fn new(chain: NamedChain, date: NaiveDate) -> Self {
Self { chain, date }
}
}
impl fmt::Display for CacheKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}:{}", self.chain as u64, self.date)
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CacheStats {
pub hits: u64,
pub misses: u64,
pub evictions: u64,
pub expirations: u64,
#[serde(default)]
pub skip_inserts: u64,
pub entries: usize,
}
impl CacheStats {
pub fn hit_rate(&self) -> f64 {
let denominator = self
.hits
.saturating_add(self.misses)
.saturating_sub(self.skip_inserts);
if denominator == 0 {
0.0
} else {
(self.hits as f64 / denominator as f64) * 100.0
}
}
}
impl fmt::Display for CacheStats {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"hits={}, misses={}, skip_inserts={}, evictions={}, expirations={}, entries={}, hit_rate={:.1}%",
self.hits,
self.misses,
self.skip_inserts,
self.evictions,
self.expirations,
self.entries,
self.hit_rate()
)
}
}
#[async_trait]
pub trait BlockWindowCache: Send + Sync {
async fn get(&self, key: &CacheKey) -> Option<DailyBlockWindow>;
async fn insert(&self, key: CacheKey, window: DailyBlockWindow)
-> Result<(), BlockWindowError>;
async fn clear(&self) -> Result<(), BlockWindowError>;
async fn stats(&self) -> CacheStats;
async fn record_skip_insert(&self);
fn name(&self) -> &'static str;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hit_rate_excludes_skip_inserts_from_denominator() {
let stats = CacheStats {
hits: 99,
misses: 30,
skip_inserts: 30,
..Default::default()
};
assert_eq!(stats.hit_rate(), 100.0);
}
#[test]
fn hit_rate_falls_back_to_zero_when_all_misses_are_skips() {
let stats = CacheStats {
hits: 0,
misses: 5,
skip_inserts: 5,
..Default::default()
};
assert_eq!(stats.hit_rate(), 0.0);
}
#[test]
fn hit_rate_unchanged_when_skip_inserts_zero() {
let stats = CacheStats {
hits: 3,
misses: 1,
skip_inserts: 0,
..Default::default()
};
assert_eq!(stats.hit_rate(), 75.0);
}
#[test]
fn hit_rate_saturates_when_skip_inserts_exceeds_misses() {
let stats = CacheStats {
hits: 0,
misses: 1,
skip_inserts: 5,
..Default::default()
};
assert_eq!(stats.hit_rate(), 0.0);
}
#[test]
fn display_includes_skip_inserts() {
let stats = CacheStats {
hits: 10,
misses: 4,
skip_inserts: 2,
evictions: 1,
expirations: 0,
entries: 7,
};
let rendered = stats.to_string();
assert!(
rendered.contains("skip_inserts=2"),
"Display must surface skip_inserts so operators see it in logs: {rendered}"
);
}
}