use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Duration;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct L1Stats {
pub l1_hits: u64,
pub l2_hits: u64,
pub misses: u64,
pub l1_enabled: bool,
}
impl L1Stats {
pub fn l1_hit_rate(&self) -> f64 {
let total = self.l1_hits + self.l2_hits + self.misses;
if total == 0 {
0.0
} else {
self.l1_hits as f64 / total as f64
}
}
}
pub type MetricsProvider = Arc<dyn Fn() -> Option<L1Stats> + Send + Sync>;
pub fn metrics_headers(provider: Option<&MetricsProvider>) -> Vec<(&'static str, String)> {
let disabled = vec![("X-CacheKit-L1-Status", "disabled".to_string())];
let provider = match provider {
Some(p) => p,
None => return disabled,
};
let stats = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| (provider)())) {
Ok(Some(s)) => s,
_ => return disabled,
};
if !stats.l1_enabled {
return disabled;
}
vec![
("X-CacheKit-L1-Status", "miss".to_string()),
("X-CacheKit-L1-Hits", stats.l1_hits.to_string()),
("X-CacheKit-L2-Hits", stats.l2_hits.to_string()),
("X-CacheKit-Misses", stats.misses.to_string()),
(
"X-CacheKit-L1-Hit-Rate",
format!("{:.3}", stats.l1_hit_rate()),
),
]
}
pub fn key_hash(key: &str) -> String {
use blake2::{digest::consts::U16, Blake2b, Digest};
let mut hasher = Blake2b::<U16>::new();
hasher.update(key.as_bytes());
hex::encode(hasher.finalize())
}
#[cfg_attr(
not(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32"))),
allow(dead_code)
)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ReadOutcome {
L1Hit,
L1Stale,
L2Hit,
Miss,
}
#[cfg(feature = "tracing")]
impl ReadOutcome {
fn as_str(self) -> &'static str {
match self {
Self::L1Hit => "l1_hit",
Self::L1Stale => "l1_stale",
Self::L2Hit => "l2_hit",
Self::Miss => "miss",
}
}
}
#[derive(Debug)]
pub(crate) struct CacheCounters {
l1_hits: AtomicU64,
l2_hits: AtomicU64,
misses: AtomicU64,
l1_enabled: bool,
}
impl CacheCounters {
pub(crate) fn new(l1_enabled: bool) -> Self {
Self {
l1_hits: AtomicU64::new(0),
l2_hits: AtomicU64::new(0),
misses: AtomicU64::new(0),
l1_enabled,
}
}
pub(crate) fn record(&self, outcome: ReadOutcome, full_key: &str) {
let counter = match outcome {
ReadOutcome::L1Hit | ReadOutcome::L1Stale => &self.l1_hits,
ReadOutcome::L2Hit => &self.l2_hits,
ReadOutcome::Miss => &self.misses,
};
counter.fetch_add(1, Ordering::Relaxed);
trace_read(full_key, outcome);
}
pub(crate) fn snapshot(&self) -> L1Stats {
L1Stats {
l1_hits: self.l1_hits.load(Ordering::Relaxed),
l2_hits: self.l2_hits.load(Ordering::Relaxed),
misses: self.misses.load(Ordering::Relaxed),
l1_enabled: self.l1_enabled,
}
}
pub(crate) fn provider(self: &Arc<Self>) -> MetricsProvider {
let counters = Arc::downgrade(self);
Arc::new(move || counters.upgrade().map(|c| c.snapshot()))
}
}
#[cfg(feature = "tracing")]
const TARGET: &str = "cachekit";
fn trace_read(full_key: &str, outcome: ReadOutcome) {
#[cfg(feature = "tracing")]
tracing::debug!(
target: TARGET,
op = "get",
key_hash = %key_hash(full_key),
outcome = outcome.as_str(),
);
#[cfg(not(feature = "tracing"))]
let _ = (full_key, outcome);
}
pub(crate) fn trace_write(full_key: &str, ttl: Duration) {
#[cfg(feature = "tracing")]
tracing::debug!(
target: TARGET,
op = "set",
key_hash = %key_hash(full_key),
ttl_secs = ttl.as_secs(),
);
#[cfg(not(feature = "tracing"))]
let _ = (full_key, ttl);
}
pub(crate) fn trace_delete(full_key: &str, existed: bool) {
#[cfg(feature = "tracing")]
tracing::debug!(
target: TARGET,
op = "delete",
key_hash = %key_hash(full_key),
existed,
);
#[cfg(not(feature = "tracing"))]
let _ = (full_key, existed);
}
#[cfg(test)]
#[allow(clippy::panic)] mod tests {
use super::*;
fn header<'a>(headers: &'a [(&str, String)], name: &str) -> &'a str {
headers
.iter()
.find(|h| h.0 == name)
.map(|h| h.1.as_str())
.unwrap_or_else(|| panic!("header {name} missing from {headers:?}"))
}
fn provider(l1_hits: u64, l2_hits: u64, misses: u64, l1_enabled: bool) -> MetricsProvider {
Arc::new(move || {
Some(L1Stats {
l1_hits,
l2_hits,
misses,
l1_enabled,
})
})
}
#[test]
fn disabled_when_no_provider() {
let headers = metrics_headers(None);
assert_eq!(headers.len(), 1);
assert_eq!(headers[0], ("X-CacheKit-L1-Status", "disabled".to_string()));
}
#[test]
fn disabled_when_l1_not_enabled() {
let headers = metrics_headers(Some(&provider(0, 0, 0, false)));
assert_eq!(headers[0].1, "disabled");
}
#[test]
fn status_is_a_server_accepted_value_when_l1_enabled() {
let headers = metrics_headers(Some(&provider(3, 2, 5, true)));
let status = header(&headers, "X-CacheKit-L1-Status");
assert_eq!(status, "miss");
assert!(["hit", "miss", "disabled"].contains(&status));
}
#[test]
fn correct_hit_rate_calculation() {
let headers = metrics_headers(Some(&provider(3, 2, 5, true)));
assert_eq!(header(&headers, "X-CacheKit-L1-Hit-Rate"), "0.300"); assert_eq!(header(&headers, "X-CacheKit-L1-Hits"), "3");
assert_eq!(header(&headers, "X-CacheKit-L2-Hits"), "2");
assert_eq!(header(&headers, "X-CacheKit-Misses"), "5");
}
#[test]
fn zero_division_guard() {
let headers = metrics_headers(Some(&provider(0, 0, 0, true)));
assert_eq!(header(&headers, "X-CacheKit-L1-Hit-Rate"), "0.000");
assert_eq!(L1Stats::default().l1_hit_rate(), 0.0);
}
#[test]
fn disabled_when_provider_panics() {
#[allow(clippy::panic)]
let provider: MetricsProvider = Arc::new(|| panic!("boom"));
let headers = metrics_headers(Some(&provider));
assert_eq!(headers[0].1, "disabled");
}
#[test]
fn counters_record_and_snapshot() {
let counters = Arc::new(CacheCounters::new(true));
counters.record(ReadOutcome::L1Hit, "k");
counters.record(ReadOutcome::L1Stale, "k");
counters.record(ReadOutcome::L2Hit, "k");
counters.record(ReadOutcome::Miss, "k");
assert_eq!(
counters.snapshot(),
L1Stats {
l1_hits: 2,
l2_hits: 1,
misses: 1,
l1_enabled: true,
}
);
let provider = counters.provider();
counters.record(ReadOutcome::Miss, "k");
assert_eq!(provider().map(|s| s.misses), Some(2));
drop(counters);
assert_eq!(provider(), None);
assert_eq!(metrics_headers(Some(&provider))[0].1, "disabled");
}
#[test]
fn key_hash_is_py_blake2b16_hex() {
assert_eq!(
key_hash("ns:app:func:m.f:args:abc:v1"),
"bcb35ae6f64fa65b2770ab3af631b1ce" );
assert_eq!(key_hash("").len(), 32);
}
}