use bytes::Bytes;
use lru::LruCache;
use std::collections::{HashMap, HashSet};
use std::num::NonZeroUsize;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex, MutexGuard};
use std::time::Instant;
use serde::Serialize;
#[derive(Debug, Clone)]
pub struct CacheEntry {
pub version: u64,
pub response_bytes: Bytes,
pub tables: Vec<String>,
pub expires_at: Instant,
}
#[derive(Debug, Clone, Copy, Default, Serialize)]
pub struct EdgeCacheStats {
pub hits: u64,
pub misses: u64,
pub inserts: u64,
pub invalidations_received: u64,
pub entries_evicted: u64,
pub current_entries: usize,
}
#[derive(Clone)]
pub struct EdgeCache {
inner: Arc<EdgeCacheInner>,
}
struct MapState {
lru: LruCache<CacheKey, Arc<CacheEntry>>,
by_table: HashMap<String, HashSet<CacheKey>>,
}
impl MapState {
fn index_add(&mut self, key: &CacheKey, tables: &[String]) {
for t in tables {
self.by_table
.entry(t.clone())
.or_default()
.insert(key.clone());
}
}
fn index_remove(&mut self, key: &CacheKey, tables: &[String]) {
for t in tables {
if let Some(set) = self.by_table.get_mut(t) {
set.remove(key);
if set.is_empty() {
self.by_table.remove(t);
}
}
}
}
fn pop(&mut self, key: &CacheKey) -> Option<Arc<CacheEntry>> {
let entry = self.lru.pop(key)?;
let tables = entry.tables.clone();
self.index_remove(key, &tables);
Some(entry)
}
}
struct EdgeCacheInner {
map: Mutex<MapState>,
next_version: AtomicU64,
observed_home: AtomicU64,
home_epoch: AtomicU64,
epoch: u64,
invalidated_hwm: AtomicU64,
hits: AtomicU64,
misses: AtomicU64,
inserts: AtomicU64,
invalidations: AtomicU64,
evictions: AtomicU64,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct CacheKey {
pub fingerprint: String,
pub params_hash: String,
pub database: String,
pub user: String,
}
impl CacheKey {
pub fn new(fingerprint: impl Into<String>, params_hash: impl Into<String>) -> Self {
Self {
fingerprint: fingerprint.into(),
params_hash: params_hash.into(),
database: String::new(),
user: String::new(),
}
}
}
fn mint_process_epoch() -> u64 {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0);
let pid = (std::process::id() as u64).rotate_left(48);
(nanos ^ pid).max(1)
}
impl EdgeCache {
pub fn new(max_entries: usize) -> Self {
let cap = NonZeroUsize::new(max_entries).expect("max_entries must be > 0");
Self {
inner: Arc::new(EdgeCacheInner {
map: Mutex::new(MapState {
lru: LruCache::new(cap),
by_table: HashMap::new(),
}),
next_version: AtomicU64::new(1),
observed_home: AtomicU64::new(0),
home_epoch: AtomicU64::new(0),
epoch: mint_process_epoch(),
invalidated_hwm: AtomicU64::new(0),
hits: AtomicU64::new(0),
misses: AtomicU64::new(0),
inserts: AtomicU64::new(0),
invalidations: AtomicU64::new(0),
evictions: AtomicU64::new(0),
}),
}
}
fn lock(&self) -> MutexGuard<'_, MapState> {
self.inner.map.lock().unwrap_or_else(|e| e.into_inner())
}
pub fn epoch(&self) -> u64 {
self.inner.epoch
}
pub fn next_version(&self) -> u64 {
self.inner.next_version.fetch_add(1, Ordering::Relaxed)
}
pub fn current_version(&self) -> u64 {
self.inner.next_version.load(Ordering::Relaxed)
}
pub fn observe_home_version(&self, v: u64) {
self.inner.observed_home.fetch_max(v, Ordering::Relaxed);
self.inner
.next_version
.fetch_max(v.saturating_add(1), Ordering::Relaxed);
}
pub fn observed_home_version(&self) -> u64 {
self.inner.observed_home.load(Ordering::Relaxed)
}
pub fn on_home_epoch(&self, epoch: u64) -> usize {
if epoch == 0 {
return 0;
}
let prev = self.inner.home_epoch.swap(epoch, Ordering::Relaxed);
if prev == 0 || prev == epoch {
return 0;
}
self.inner.observed_home.store(0, Ordering::Relaxed);
self.flush_all()
}
pub fn flush_all(&self) -> usize {
self.inner.invalidations.fetch_add(1, Ordering::Relaxed);
let mut map = self.lock();
let n = map.lru.len();
map.lru.clear();
map.by_table.clear();
n
}
pub fn should_cache(&self, version: u64) -> bool {
version > self.inner.invalidated_hwm.load(Ordering::Relaxed)
}
pub fn invalidation_epoch(&self) -> u64 {
self.inner.invalidations.load(Ordering::Relaxed)
}
pub fn get(&self, key: &CacheKey) -> Option<Arc<CacheEntry>> {
let now = Instant::now();
let mut map = self.lock();
match map.lru.get(key) {
Some(e) if e.expires_at > now => {
let out = Arc::clone(e);
drop(map);
self.inner.hits.fetch_add(1, Ordering::Relaxed);
Some(out)
}
Some(_) => {
map.pop(key);
drop(map);
self.inner.misses.fetch_add(1, Ordering::Relaxed);
None
}
None => {
drop(map);
self.inner.misses.fetch_add(1, Ordering::Relaxed);
None
}
}
}
pub fn insert(&self, key: CacheKey, entry: CacheEntry) {
let mut map = self.lock();
self.insert_locked(&mut map, key, entry);
}
pub fn insert_if_fresh(&self, key: CacheKey, entry: CacheEntry) -> bool {
let mut map = self.lock();
if entry.version <= self.inner.invalidated_hwm.load(Ordering::Relaxed) {
return false;
}
self.insert_locked(&mut map, key, entry);
true
}
pub fn insert_if_epoch(&self, key: CacheKey, entry: CacheEntry, epoch: u64) -> bool {
let mut map = self.lock();
if self.inner.invalidations.load(Ordering::Relaxed) != epoch {
return false;
}
self.insert_locked(&mut map, key, entry);
true
}
fn insert_locked(&self, map: &mut MapState, key: CacheKey, entry: CacheEntry) {
let tables = entry.tables.clone();
let updating = map.lru.contains(&key);
map.index_add(&key, &tables);
let displaced = map.lru.push(key, Arc::new(entry));
if let Some((old_key, old_entry)) = &displaced {
if updating {
let stale: Vec<String> = old_entry
.tables
.iter()
.filter(|t| !tables.contains(t))
.cloned()
.collect();
map.index_remove(old_key, &stale);
} else {
let victim_tables = old_entry.tables.clone();
map.index_remove(old_key, &victim_tables);
}
}
self.inner.inserts.fetch_add(1, Ordering::Relaxed);
if displaced.is_some() && !updating {
self.inner.evictions.fetch_add(1, Ordering::Relaxed);
}
}
pub fn invalidate(&self, up_to_version: u64, tables: &[String]) -> usize {
self.inner.invalidations.fetch_add(1, Ordering::Relaxed);
self.inner
.invalidated_hwm
.fetch_max(up_to_version, Ordering::Relaxed);
let mut map = self.lock();
let drop_keys: Vec<CacheKey> = if tables.is_empty() {
map.lru
.iter()
.filter(|(_, e)| e.version <= up_to_version)
.map(|(k, _)| k.clone())
.collect()
} else {
let mut keys: HashSet<CacheKey> = HashSet::new();
for t in tables {
if let Some(set) = map.by_table.get(t) {
for k in set {
keys.insert(k.clone());
}
}
}
keys.into_iter()
.filter(|k| {
map.lru
.peek(k)
.map(|e| e.version <= up_to_version)
.unwrap_or(false)
})
.collect()
};
for k in &drop_keys {
map.pop(k);
}
drop_keys.len()
}
pub fn stats(&self) -> EdgeCacheStats {
EdgeCacheStats {
hits: self.inner.hits.load(Ordering::Relaxed),
misses: self.inner.misses.load(Ordering::Relaxed),
inserts: self.inner.inserts.load(Ordering::Relaxed),
invalidations_received: self.inner.invalidations.load(Ordering::Relaxed),
entries_evicted: self.inner.evictions.load(Ordering::Relaxed),
current_entries: self.lock().lru.len(),
}
}
pub fn insert_with(&self, key: CacheKey, entry: CacheEntry) {
self.insert(key, entry);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
fn entry(version: u64, body: &[u8], tables: &[&str], ttl: Duration) -> CacheEntry {
CacheEntry {
version,
response_bytes: Bytes::copy_from_slice(body),
tables: tables.iter().map(|s| s.to_string()).collect(),
expires_at: Instant::now() + ttl,
}
}
#[test]
fn insert_then_get_returns_value() {
let c = EdgeCache::new(10);
let k = CacheKey::new("fp1", "p1");
c.insert(
k.clone(),
entry(1, b"row", &["users"], Duration::from_secs(60)),
);
let got = c.get(&k).expect("hit");
assert_eq!(&got.response_bytes[..], b"row");
}
#[test]
fn miss_returns_none() {
let c = EdgeCache::new(10);
assert!(c.get(&CacheKey::new("fp1", "p1")).is_none());
assert_eq!(c.stats().misses, 1);
}
#[test]
fn key_isolates_database_and_user_verbatim() {
let c = EdgeCache::new(10);
let a = CacheKey {
fingerprint: "FP".into(),
params_hash: "SAMEHASH".into(),
database: "tenant_a".into(),
user: "alice".into(),
};
let b = CacheKey {
database: "tenant_b".into(),
..a.clone()
};
let u = CacheKey {
user: "bob".into(),
..a.clone()
};
assert_ne!(a, b);
assert_ne!(a, u);
c.insert(
a.clone(),
entry(1, b"a-rows", &["t"], Duration::from_secs(60)),
);
assert!(c.get(&b).is_none(), "other database must not alias");
assert!(c.get(&u).is_none(), "other user must not alias");
assert!(c.get(&a).is_some());
}
#[test]
fn expired_entry_is_dropped_on_read() {
let c = EdgeCache::new(10);
let k = CacheKey::new("fp1", "p1");
let mut e = entry(1, b"x", &[], Duration::from_secs(0));
e.expires_at = Instant::now() - Duration::from_millis(1);
c.insert(k.clone(), e);
assert!(c.get(&k).is_none());
let s = c.stats();
assert_eq!(s.current_entries, 0);
assert_eq!(s.misses, 1, "expired read counts as a miss");
assert_eq!(s.entries_evicted, 0, "TTL expiry is not an LRU eviction");
}
#[test]
fn lru_evicts_oldest_when_over_capacity() {
let c = EdgeCache::new(3);
for i in 0..5 {
let k = CacheKey::new(format!("fp{}", i), "p");
c.insert(k, entry(i, b"x", &[], Duration::from_secs(60)));
}
assert_eq!(c.stats().entries_evicted, 2);
assert_eq!(c.stats().current_entries, 3);
assert!(c.get(&CacheKey::new("fp0", "p")).is_none());
assert!(c.get(&CacheKey::new("fp1", "p")).is_none());
assert!(c.get(&CacheKey::new("fp4", "p")).is_some());
}
#[test]
fn lru_promotes_recently_read_entries() {
let c = EdgeCache::new(3);
for i in 0..3 {
let k = CacheKey::new(format!("fp{}", i), "p");
c.insert(k, entry(i, b"x", &[], Duration::from_secs(60)));
}
let _ = c.get(&CacheKey::new("fp0", "p"));
c.insert(
CacheKey::new("fp3", "p"),
entry(3, b"x", &[], Duration::from_secs(60)),
);
assert!(c.get(&CacheKey::new("fp0", "p")).is_some());
assert!(c.get(&CacheKey::new("fp1", "p")).is_none());
assert!(c.get(&CacheKey::new("fp2", "p")).is_some());
assert!(c.get(&CacheKey::new("fp3", "p")).is_some());
}
#[test]
fn eviction_counter_respects_lru_order_at_capacity() {
let c = EdgeCache::new(2);
c.insert(
CacheKey::new("a", "p"),
entry(1, b"a", &[], Duration::from_secs(60)),
);
c.insert(
CacheKey::new("b", "p"),
entry(2, b"b", &[], Duration::from_secs(60)),
);
assert_eq!(c.stats().entries_evicted, 0);
assert!(c.get(&CacheKey::new("a", "p")).is_some());
c.insert(
CacheKey::new("c", "p"),
entry(3, b"c", &[], Duration::from_secs(60)),
);
let s = c.stats();
assert_eq!(s.entries_evicted, 1);
assert_eq!(s.current_entries, 2);
assert!(c.get(&CacheKey::new("b", "p")).is_none(), "b was the LRU");
assert!(c.get(&CacheKey::new("a", "p")).is_some());
assert!(c.get(&CacheKey::new("c", "p")).is_some());
}
#[test]
fn overwrite_same_key_is_not_an_eviction() {
let c = EdgeCache::new(2);
let k = CacheKey::new("a", "p");
c.insert(k.clone(), entry(1, b"v1", &[], Duration::from_secs(60)));
c.insert(k.clone(), entry(2, b"v2", &[], Duration::from_secs(60)));
let s = c.stats();
assert_eq!(s.entries_evicted, 0);
assert_eq!(s.inserts, 2);
assert_eq!(&c.get(&k).expect("hit").response_bytes[..], b"v2");
}
#[test]
fn invalidate_drops_old_versions_only() {
let c = EdgeCache::new(10);
c.insert(
CacheKey::new("fp1", "p"),
entry(5, b"v5", &["users"], Duration::from_secs(60)),
);
c.insert(
CacheKey::new("fp2", "p"),
entry(10, b"v10", &["users"], Duration::from_secs(60)),
);
let dropped = c.invalidate(7, &["users".to_string()]);
assert_eq!(dropped, 1);
assert!(c.get(&CacheKey::new("fp1", "p")).is_none());
assert!(c.get(&CacheKey::new("fp2", "p")).is_some());
}
#[test]
fn invalidate_filters_by_tables() {
let c = EdgeCache::new(10);
c.insert(
CacheKey::new("fp1", "p"),
entry(5, b"x", &["users"], Duration::from_secs(60)),
);
c.insert(
CacheKey::new("fp2", "p"),
entry(5, b"y", &["orders"], Duration::from_secs(60)),
);
let dropped = c.invalidate(100, &["users".to_string()]);
assert_eq!(dropped, 1);
assert!(c.get(&CacheKey::new("fp1", "p")).is_none());
assert!(c.get(&CacheKey::new("fp2", "p")).is_some());
}
#[test]
fn invalidate_matches_any_of_the_entry_tables() {
let c = EdgeCache::new(10);
c.insert(
CacheKey::new("fp1", "p"),
entry(5, b"x", &["users", "orders"], Duration::from_secs(60)),
);
let dropped = c.invalidate(100, &["orders".to_string()]);
assert_eq!(dropped, 1);
assert!(c.get(&CacheKey::new("fp1", "p")).is_none());
}
#[test]
fn table_index_survives_eviction_and_overwrite() {
let c = EdgeCache::new(2);
c.insert(
CacheKey::new("a", "p"),
entry(1, b"a", &["users"], Duration::from_secs(60)),
);
c.insert(
CacheKey::new("a", "p"),
entry(2, b"a2", &["orders"], Duration::from_secs(60)),
);
c.insert(
CacheKey::new("b", "p"),
entry(3, b"b", &["users"], Duration::from_secs(60)),
);
c.insert(
CacheKey::new("c", "p"),
entry(4, b"c", &["users"], Duration::from_secs(60)),
);
assert_eq!(c.invalidate(100, &["orders".to_string()]), 0);
assert_eq!(c.invalidate(100, &["users".to_string()]), 2);
assert_eq!(c.stats().current_entries, 0);
}
#[test]
fn invalidate_with_no_tables_drops_everything_within_version() {
let c = EdgeCache::new(10);
c.insert(
CacheKey::new("fp1", "p"),
entry(5, b"x", &["users"], Duration::from_secs(60)),
);
c.insert(
CacheKey::new("fp2", "p"),
entry(10, b"y", &["orders"], Duration::from_secs(60)),
);
let dropped = c.invalidate(7, &[]);
assert_eq!(dropped, 1, "fp1 (v5) should be dropped, fp2 (v10) kept");
assert!(c.get(&CacheKey::new("fp2", "p")).is_some());
}
#[test]
fn next_version_is_monotonic() {
let c = EdgeCache::new(10);
let v1 = c.next_version();
let v2 = c.next_version();
let v3 = c.next_version();
assert!(v1 < v2 && v2 < v3);
assert_eq!(v1, 1, "version counter starts at 1");
}
#[test]
fn should_cache_gated_by_invalidation_hwm() {
let c = EdgeCache::new(10);
assert!(c.should_cache(1));
let _ = c.invalidate(7, &["users".to_string()]);
assert!(!c.should_cache(7), "version == hwm must not be cached");
assert!(!c.should_cache(3), "version < hwm must not be cached");
assert!(c.should_cache(8), "version > hwm is cacheable");
let _ = c.invalidate(2, &[]);
assert!(!c.should_cache(7));
assert!(c.should_cache(8));
}
#[test]
fn insert_if_fresh_rejects_read_invalidated_in_flight() {
let c = EdgeCache::new(10);
let read_version = c.next_version();
assert!(c.should_cache(read_version)); let w = c.next_version();
let _ = c.invalidate(w, &["users".to_string()]); let stored = c.insert_if_fresh(
CacheKey::new("fp", "p"),
entry(read_version, b"stale", &["users"], Duration::from_secs(60)),
);
assert!(!stored, "stale store must be rejected under the lock");
assert!(c.get(&CacheKey::new("fp", "p")).is_none());
let fresh = c.next_version();
assert!(c.insert_if_fresh(
CacheKey::new("fp", "p"),
entry(fresh, b"fresh", &["users"], Duration::from_secs(60)),
));
}
#[test]
fn insert_if_epoch_rejects_after_any_invalidation() {
let c = EdgeCache::new(10);
let epoch = c.invalidation_epoch();
let _ = c.invalidate(5, &["users".to_string()]);
let stored = c.insert_if_epoch(
CacheKey::new("fp", "p"),
entry(0, b"stale", &["users"], Duration::from_secs(60)),
epoch,
);
assert!(!stored);
let epoch2 = c.invalidation_epoch();
assert!(c.insert_if_epoch(
CacheKey::new("fp", "p"),
entry(
c.observed_home_version(),
b"fresh",
&["users"],
Duration::from_secs(60)
),
epoch2,
));
assert!(c.get(&CacheKey::new("fp", "p")).is_some());
}
#[test]
fn edge_stamps_in_home_domain_are_swept_by_next_home_write() {
let c = EdgeCache::new(10);
c.observe_home_version(5);
let stamp = c.observed_home_version();
assert_eq!(stamp, 5);
c.insert(
CacheKey::new("fp", "p"),
entry(stamp, b"rows", &["users"], Duration::from_secs(60)),
);
assert_eq!(c.invalidate(6, &["users".to_string()]), 1);
assert!(c.get(&CacheKey::new("fp", "p")).is_none());
}
#[test]
fn fresh_boot_edge_entries_swept_by_first_invalidation() {
let c = EdgeCache::new(10);
assert_eq!(c.observed_home_version(), 0);
c.insert(
CacheKey::new("fp", "p"),
entry(0, b"rows", &["users"], Duration::from_secs(60)),
);
assert_eq!(c.invalidate(1, &["users".to_string()]), 1);
}
#[test]
fn observe_home_version_advances_counter() {
let c = EdgeCache::new(10);
c.observe_home_version(100);
assert_eq!(c.observed_home_version(), 100);
assert!(c.next_version() > 100);
c.observe_home_version(5);
assert_eq!(c.observed_home_version(), 100);
assert!(c.next_version() > 100);
}
#[test]
fn home_epoch_change_flushes_and_resets_observed_home() {
let c = EdgeCache::new(10);
assert_eq!(c.on_home_epoch(1111), 0, "first sighting records only");
c.observe_home_version(1_000_000);
c.insert(
CacheKey::new("fp", "p"),
entry(1_000_000, b"old", &["users"], Duration::from_secs(60)),
);
assert_eq!(c.on_home_epoch(1111), 0);
assert!(c.get(&CacheKey::new("fp", "p")).is_some());
assert_eq!(c.on_home_epoch(2222), 1);
assert!(c.get(&CacheKey::new("fp", "p")).is_none());
assert_eq!(c.observed_home_version(), 0);
c.insert(
CacheKey::new("fp2", "p"),
entry(1, b"x", &[], Duration::from_secs(60)),
);
assert_eq!(c.on_home_epoch(0), 0);
assert!(c.get(&CacheKey::new("fp2", "p")).is_some());
}
#[test]
fn process_epoch_is_stable_and_nonzero() {
let c = EdgeCache::new(2);
assert_ne!(c.epoch(), 0);
assert_eq!(c.epoch(), c.epoch());
}
#[test]
fn stats_track_hits_and_misses() {
let c = EdgeCache::new(10);
let k = CacheKey::new("fp1", "p");
c.insert(k.clone(), entry(1, b"x", &[], Duration::from_secs(60)));
let _ = c.get(&k);
let _ = c.get(&k);
let _ = c.get(&CacheKey::new("missing", "p"));
let s = c.stats();
assert_eq!(s.hits, 2);
assert_eq!(s.misses, 1);
assert_eq!(s.inserts, 1);
}
#[test]
fn invalidate_bumps_received_counter() {
let c = EdgeCache::new(10);
let _ = c.invalidate(1, &[]);
let _ = c.invalidate(2, &["users".to_string()]);
assert_eq!(c.stats().invalidations_received, 2);
}
#[test]
fn concurrent_get_insert_invalidate() {
let c = EdgeCache::new(64);
let mut handles = Vec::new();
for t in 0..4u64 {
let c = c.clone();
handles.push(std::thread::spawn(move || {
for i in 0..500u64 {
let k = CacheKey::new(format!("fp{}", (t * 500 + i) % 100), "p");
if i % 7 == 0 {
let v = c.next_version();
let _ = c.invalidate(v, &["users".to_string()]);
} else if i % 3 == 0 {
let v = c.next_version();
c.insert(k, entry(v, b"x", &["users"], Duration::from_secs(60)));
} else {
let _ = c.get(&k);
}
}
}));
}
for h in handles {
h.join().expect("worker panicked");
}
assert!(c.stats().current_entries <= 64);
}
#[test]
fn panics_on_zero_capacity() {
let res = std::panic::catch_unwind(|| EdgeCache::new(0));
assert!(res.is_err());
}
}