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(crate) const METRICS_HEADER_NAMES: [&str; 5] = [
"X-CacheKit-L1-Status",
"X-CacheKit-L1-Hits",
"X-CacheKit-L2-Hits",
"X-CacheKit-Misses",
"X-CacheKit-L1-Hit-Rate",
];
pub fn metrics_headers(provider: Option<&MetricsProvider>) -> Vec<(&'static str, String)> {
metrics_headers_named(provider, &METRICS_HEADER_NAMES)
}
pub(crate) fn metrics_headers_named<N: Clone>(
provider: Option<&MetricsProvider>,
names: &[N; 5],
) -> Vec<(N, String)> {
let [status, l1_hits, l2_hits, misses, hit_rate] = names.clone();
let disabled = vec![(status.clone(), "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![
(status, "miss".to_string()),
(l1_hits, stats.l1_hits.to_string()),
(l2_hits, stats.l2_hits.to_string()),
(misses, stats.misses.to_string()),
(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())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[doc(hidden)]
#[non_exhaustive]
pub 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 = crate::backend::ttl_wire_secs(ttl),
);
#[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(all(feature = "reliability", not(target_arch = "wasm32")))]
pub(crate) fn trace_lock_error(full_key: &str, op: &'static str, err: &crate::BackendError) {
#[cfg(feature = "tracing")]
tracing::debug!(
target: TARGET,
op,
key_hash = %key_hash(full_key),
error_kind = %err.kind,
);
#[cfg(not(feature = "tracing"))]
let _ = (full_key, op, err);
}
#[doc(hidden)]
pub fn __trace_store_error(key: &str, err: &crate::CachekitError) {
#[cfg(feature = "tracing")]
{
use crate::CachekitError as E;
let error_kind: &dyn std::fmt::Display = match err {
E::Backend(be) => &be.kind,
E::Serialization(_) => &"serialization",
E::Encryption(_) => &"encryption",
E::Config(_) => &"config",
E::PayloadTooLarge { .. } => &"payload-too-large",
E::InvalidKey(_) => &"invalid-key",
};
tracing::debug!(
target: TARGET,
op = "set",
key_hash = %key_hash(key),
error_kind = %error_kind,
);
}
#[cfg(not(feature = "tracing"))]
let _ = (key, err);
}
#[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);
}
}