Skip to main content

heliosdb_proxy/edge/
cache.rs

1//! Per-edge query result cache.
2//!
3//! Keyed by `(fingerprint, params_hash, database, user)`. The tenant
4//! identity (database/user) is carried **verbatim** in the key — a
5//! 64-bit `params_hash` collision alone can never alias two tenants'
6//! entries (mirrors the query-cache `CacheKey`).
7//!
8//! Internals: one `std::sync::Mutex` over the LRU map plus a
9//! `table -> keys` reverse index — `get`/`insert` are O(1), and a
10//! table-targeted `invalidate` touches only the keys registered under
11//! the written tables (O(matching entries), not O(all entries)). Only
12//! an *empty* table set (an unparseable write) falls back to the full
13//! scan. The lock is never held across an await (every method is sync).
14//!
15//! ## Version domains
16//!
17//! Entry `version` stamps and invalidation `up_to_version` bounds must
18//! come from the SAME clock or sweeps are meaningless:
19//!
20//! - **Home role**: one local counter (`next_version`) stamps read
21//!   misses and mints write versions — a single domain.
22//! - **Edge role**: writes are versioned by the HOME, so edge entries
23//!   are stamped with the **last observed home version**
24//!   (`observed_home_version`), never with local mints. Any read that
25//!   began before a home write therefore carries a stamp `<` that
26//!   write's version and is always swept by its invalidation.
27//!
28//! Store races are closed per role: the home re-checks the
29//! `invalidated_hwm` under the map lock (`insert_if_fresh`), the edge
30//! re-checks the invalidation epoch under the map lock
31//! (`insert_if_epoch`) — both piggyback on `invalidate()` bumping its
32//! atomics *before* taking the map lock, so either the store observes
33//! the bump and skips, or the sweep runs after the insert and drops it.
34//!
35//! ## Home epochs
36//!
37//! The home's version clock is per-process; a home restart resets it.
38//! Every `InvalidationEvent` (and the subscribe-time hello frame)
39//! carries the home's per-boot `epoch`; when the edge observes an
40//! epoch change (`on_home_epoch`) it flushes everything and resets its
41//! observed-home clock, so post-restart invalidations sweep correctly
42//! instead of silently no-op'ing against pre-restart stamps.
43
44use bytes::Bytes;
45use lru::LruCache;
46use std::collections::{HashMap, HashSet};
47use std::num::NonZeroUsize;
48use std::sync::atomic::{AtomicU64, Ordering};
49use std::sync::{Arc, Mutex, MutexGuard};
50use std::time::Instant;
51
52use serde::Serialize;
53
54/// One cached query result.
55#[derive(Debug, Clone)]
56pub struct CacheEntry {
57    /// Logical version in the invalidation clock's domain (home role:
58    /// local mint; edge role: last observed home version). An
59    /// invalidation drops every entry whose version <=
60    /// invalidation.version.
61    pub version: u64,
62    /// Pre-encoded PostgreSQL wire-protocol response bytes ready
63    /// to write back to the client. `Bytes` so the store path shares
64    /// one refcounted buffer with the query-cache instead of copying.
65    pub response_bytes: Bytes,
66    /// Tables the query touched, used by the home for invalidation
67    /// fan-out — empty when the home didn't supply them.
68    pub tables: Vec<String>,
69    /// Wall-clock entry expiry.
70    pub expires_at: Instant,
71}
72
73#[derive(Debug, Clone, Copy, Default, Serialize)]
74pub struct EdgeCacheStats {
75    pub hits: u64,
76    pub misses: u64,
77    pub inserts: u64,
78    pub invalidations_received: u64,
79    pub entries_evicted: u64,
80    pub current_entries: usize,
81}
82
83/// LRU + version + TTL cache. Cheap to clone via Arc.
84#[derive(Clone)]
85pub struct EdgeCache {
86    inner: Arc<EdgeCacheInner>,
87}
88
89/// Map + reverse index, guarded by ONE mutex so they can never drift.
90struct MapState {
91    lru: LruCache<CacheKey, Arc<CacheEntry>>,
92    /// table -> keys of live entries referencing it. Maintained on
93    /// every insert/pop/eviction, so `invalidate(tables)` visits only
94    /// candidate keys instead of scanning the whole LRU.
95    by_table: HashMap<String, HashSet<CacheKey>>,
96}
97
98impl MapState {
99    fn index_add(&mut self, key: &CacheKey, tables: &[String]) {
100        for t in tables {
101            self.by_table
102                .entry(t.clone())
103                .or_default()
104                .insert(key.clone());
105        }
106    }
107
108    fn index_remove(&mut self, key: &CacheKey, tables: &[String]) {
109        for t in tables {
110            if let Some(set) = self.by_table.get_mut(t) {
111                set.remove(key);
112                if set.is_empty() {
113                    self.by_table.remove(t);
114                }
115            }
116        }
117    }
118
119    /// Pop `key` and unindex it. Returns the removed entry.
120    fn pop(&mut self, key: &CacheKey) -> Option<Arc<CacheEntry>> {
121        let entry = self.lru.pop(key)?;
122        let tables = entry.tables.clone();
123        self.index_remove(key, &tables);
124        Some(entry)
125    }
126}
127
128struct EdgeCacheInner {
129    /// Single lock over the LRU map + table index. Entries are
130    /// `Arc`-wrapped so a hit clones a pointer, not the response bytes.
131    map: Mutex<MapState>,
132    next_version: AtomicU64,
133    /// Highest home version observed (edge role) — SSE invalidations
134    /// and the hello frame feed it. Edge-role reads are stamped with
135    /// this value so home invalidations (which are strictly greater)
136    /// always sweep them.
137    observed_home: AtomicU64,
138    /// The home's per-boot epoch as last seen by this edge (0 =
139    /// unknown / never seen). A change means the home restarted and
140    /// its version clock reset — flush everything.
141    home_epoch: AtomicU64,
142    /// This process's own per-boot epoch (home role: carried in every
143    /// broadcast so edges can detect a restart). Never 0.
144    epoch: u64,
145    /// Highest `up_to_version` seen by `invalidate()`. Home-role reads
146    /// stamped at or below this must not be cached — a write
147    /// invalidated their tables while they were in flight.
148    invalidated_hwm: AtomicU64,
149    hits: AtomicU64,
150    misses: AtomicU64,
151    inserts: AtomicU64,
152    /// Also serves as the store-race "epoch": bumped BEFORE the map
153    /// lock is taken by `invalidate`/`flush_all`, and re-checked under
154    /// the map lock by `insert_if_epoch`.
155    invalidations: AtomicU64,
156    evictions: AtomicU64,
157}
158
159/// Cache key. `database`/`user` are verbatim tenant identity — two
160/// tenants can never alias through a `params_hash` collision.
161#[derive(Debug, Clone, PartialEq, Eq, Hash)]
162pub struct CacheKey {
163    pub fingerprint: String,
164    pub params_hash: String,
165    pub database: String,
166    pub user: String,
167}
168
169impl CacheKey {
170    /// Tenant-less constructor kept for tests/tools; production call
171    /// sites populate `database`/`user` explicitly.
172    pub fn new(fingerprint: impl Into<String>, params_hash: impl Into<String>) -> Self {
173        Self {
174            fingerprint: fingerprint.into(),
175            params_hash: params_hash.into(),
176            database: String::new(),
177            user: String::new(),
178        }
179    }
180}
181
182/// Per-boot epoch: unique across restarts of the same host with
183/// overwhelming probability (nanos since UNIX epoch XOR rotated pid),
184/// and never the 0 legacy sentinel.
185fn mint_process_epoch() -> u64 {
186    let nanos = std::time::SystemTime::now()
187        .duration_since(std::time::UNIX_EPOCH)
188        .map(|d| d.as_nanos() as u64)
189        .unwrap_or(0);
190    let pid = (std::process::id() as u64).rotate_left(48);
191    (nanos ^ pid).max(1)
192}
193
194impl EdgeCache {
195    pub fn new(max_entries: usize) -> Self {
196        let cap = NonZeroUsize::new(max_entries).expect("max_entries must be > 0");
197        Self {
198            inner: Arc::new(EdgeCacheInner {
199                map: Mutex::new(MapState {
200                    lru: LruCache::new(cap),
201                    by_table: HashMap::new(),
202                }),
203                next_version: AtomicU64::new(1),
204                observed_home: AtomicU64::new(0),
205                home_epoch: AtomicU64::new(0),
206                epoch: mint_process_epoch(),
207                invalidated_hwm: AtomicU64::new(0),
208                hits: AtomicU64::new(0),
209                misses: AtomicU64::new(0),
210                inserts: AtomicU64::new(0),
211                invalidations: AtomicU64::new(0),
212                evictions: AtomicU64::new(0),
213            }),
214        }
215    }
216
217    /// Lock the map, recovering from poisoning — the cache holds no
218    /// cross-entry invariants worth propagating a panic for (the
219    /// index is rebuilt consistently by every mutation path).
220    fn lock(&self) -> MutexGuard<'_, MapState> {
221        self.inner.map.lock().unwrap_or_else(|e| e.into_inner())
222    }
223
224    /// This process's per-boot epoch (home role: stamped into every
225    /// broadcast invalidation + the subscribe hello frame).
226    pub fn epoch(&self) -> u64 {
227        self.inner.epoch
228    }
229
230    /// Mint a fresh logical version (home role: stamps read misses and
231    /// write invalidations from one local clock).
232    pub fn next_version(&self) -> u64 {
233        self.inner.next_version.fetch_add(1, Ordering::Relaxed)
234    }
235
236    /// Current value of the local version counter, without minting.
237    /// Strictly greater than every version this process has stamped.
238    pub fn current_version(&self) -> u64 {
239        self.inner.next_version.load(Ordering::Relaxed)
240    }
241
242    /// Record a version observed from the home (edge role: SSE
243    /// invalidations + the hello frame carry the home's clock). Edge
244    /// reads are stamped with `observed_home_version()`, i.e. *at* the
245    /// last home version — any later home write mints a strictly
246    /// greater version, so its `<=` sweep always catches them.
247    pub fn observe_home_version(&self, v: u64) {
248        self.inner.observed_home.fetch_max(v, Ordering::Relaxed);
249        self.inner
250            .next_version
251            .fetch_max(v.saturating_add(1), Ordering::Relaxed);
252    }
253
254    /// Last home version observed (0 until the first event/hello).
255    /// The edge-role read stamp.
256    pub fn observed_home_version(&self) -> u64 {
257        self.inner.observed_home.load(Ordering::Relaxed)
258    }
259
260    /// Handle the home's per-boot epoch carried by an event. Returns
261    /// the number of entries flushed. `0` (legacy/absent) is ignored.
262    /// First sighting just records the epoch; a CHANGE means the home
263    /// restarted with a reset version clock — every cached stamp is
264    /// from an incomparable clock, so flush everything and reset the
265    /// observed-home clock (the same event's `observe_home_version`
266    /// re-syncs it).
267    pub fn on_home_epoch(&self, epoch: u64) -> usize {
268        if epoch == 0 {
269            return 0;
270        }
271        let prev = self.inner.home_epoch.swap(epoch, Ordering::Relaxed);
272        if prev == 0 || prev == epoch {
273            return 0;
274        }
275        self.inner.observed_home.store(0, Ordering::Relaxed);
276        self.flush_all()
277    }
278
279    /// Drop every entry. Bumps the invalidation counter BEFORE taking
280    /// the map lock so in-flight epoch-gated stores are rejected (same
281    /// ordering contract as `invalidate`). Returns the count dropped.
282    pub fn flush_all(&self) -> usize {
283        self.inner.invalidations.fetch_add(1, Ordering::Relaxed);
284        let mut map = self.lock();
285        let n = map.lru.len();
286        map.lru.clear();
287        map.by_table.clear();
288        n
289    }
290
291    /// Whether a home-role read stamped with `version` is still safe
292    /// to cache. False once an invalidation with `up_to_version >=
293    /// version` has been applied — the read may predate the write that
294    /// triggered it. (Cheap unlocked fast-path; the authoritative
295    /// re-check is `insert_if_fresh`.)
296    pub fn should_cache(&self, version: u64) -> bool {
297        version > self.inner.invalidated_hwm.load(Ordering::Relaxed)
298    }
299
300    /// Monotonic count of invalidation events applied. Edge-role reads
301    /// snapshot it before forwarding; `insert_if_epoch` re-checks it
302    /// under the map lock, so a store whose flight overlapped ANY
303    /// invalidation is skipped (table-agnostic and conservative — the
304    /// next identical read simply re-stores).
305    pub fn invalidation_epoch(&self) -> u64 {
306        self.inner.invalidations.load(Ordering::Relaxed)
307    }
308
309    /// Look up a cache entry. Returns None on miss or expired TTL.
310    /// Bumps the LRU on hit (O(1)); increments hit/miss counters
311    /// either way. Expired entries are removed lazily here.
312    pub fn get(&self, key: &CacheKey) -> Option<Arc<CacheEntry>> {
313        let now = Instant::now();
314        let mut map = self.lock();
315        match map.lru.get(key) {
316            Some(e) if e.expires_at > now => {
317                let out = Arc::clone(e);
318                drop(map);
319                self.inner.hits.fetch_add(1, Ordering::Relaxed);
320                Some(out)
321            }
322            Some(_) => {
323                // Lazy expiry: evict on read so we don't bloat memory
324                // with stale entries even when nothing else touches
325                // them. Counted as a miss, not an eviction.
326                map.pop(key);
327                drop(map);
328                self.inner.misses.fetch_add(1, Ordering::Relaxed);
329                None
330            }
331            None => {
332                drop(map);
333                self.inner.misses.fetch_add(1, Ordering::Relaxed);
334                None
335            }
336        }
337    }
338
339    /// Insert / overwrite an entry. The LRU victim is auto-evicted
340    /// when at capacity (O(1)). Prefer the race-checked variants on
341    /// production store paths.
342    pub fn insert(&self, key: CacheKey, entry: CacheEntry) {
343        let mut map = self.lock();
344        self.insert_locked(&mut map, key, entry);
345    }
346
347    /// Home-role store: re-checks the invalidation high-water mark
348    /// UNDER the map lock, closing the TOCTOU between an unlocked
349    /// `should_cache` and the push (a concurrent `invalidate` bumps
350    /// the hwm before taking this same lock, so either we see the bump
351    /// here and skip, or our insert lands first and its sweep drops
352    /// the entry). Returns whether the entry was stored.
353    pub fn insert_if_fresh(&self, key: CacheKey, entry: CacheEntry) -> bool {
354        let mut map = self.lock();
355        if entry.version <= self.inner.invalidated_hwm.load(Ordering::Relaxed) {
356            return false;
357        }
358        self.insert_locked(&mut map, key, entry);
359        true
360    }
361
362    /// Edge-role store: entry stamps live in the home's clock, so the
363    /// hwm gate is inert (stamps == hwm right after every event).
364    /// Instead the store is valid only if NO invalidation was applied
365    /// since `epoch` was snapshotted (before the read was forwarded).
366    /// Checked under the map lock — same ordering proof as
367    /// `insert_if_fresh` (invalidate bumps the counter before locking).
368    /// Returns whether the entry was stored.
369    pub fn insert_if_epoch(&self, key: CacheKey, entry: CacheEntry, epoch: u64) -> bool {
370        let mut map = self.lock();
371        if self.inner.invalidations.load(Ordering::Relaxed) != epoch {
372            return false;
373        }
374        self.insert_locked(&mut map, key, entry);
375        true
376    }
377
378    fn insert_locked(&self, map: &mut MapState, key: CacheKey, entry: CacheEntry) {
379        let tables = entry.tables.clone();
380        // `push` returns the displaced pair: the old value when the
381        // key was already present (an update, not an eviction), or
382        // the LRU victim when capacity forced one out.
383        let updating = map.lru.contains(&key);
384        map.index_add(&key, &tables);
385        let displaced = map.lru.push(key, Arc::new(entry));
386        if let Some((old_key, old_entry)) = &displaced {
387            if updating {
388                // Same-key overwrite: drop index links the new entry
389                // no longer has (index_add above re-added the shared
390                // ones).
391                let stale: Vec<String> = old_entry
392                    .tables
393                    .iter()
394                    .filter(|t| !tables.contains(t))
395                    .cloned()
396                    .collect();
397                map.index_remove(old_key, &stale);
398            } else {
399                let victim_tables = old_entry.tables.clone();
400                map.index_remove(old_key, &victim_tables);
401            }
402        }
403        self.inner.inserts.fetch_add(1, Ordering::Relaxed);
404        if displaced.is_some() && !updating {
405            self.inner.evictions.fetch_add(1, Ordering::Relaxed);
406        }
407    }
408
409    /// Drop every entry whose version <= `up_to_version` AND whose
410    /// `tables` overlaps with `tables` (empty `tables` invalidates
411    /// every entry meeting the version bound). Also raises the
412    /// high-water mark consulted by `should_cache` and bumps the
413    /// invalidation epoch — both BEFORE the map lock is taken, which
414    /// the `insert_if_*` race checks rely on. Returns the count
415    /// dropped.
416    ///
417    /// Table-targeted sweeps walk only the keys registered under the
418    /// written tables (reverse index) — O(matching entries). Only the
419    /// empty-table wildcard falls back to a full scan.
420    pub fn invalidate(&self, up_to_version: u64, tables: &[String]) -> usize {
421        self.inner.invalidations.fetch_add(1, Ordering::Relaxed);
422        self.inner
423            .invalidated_hwm
424            .fetch_max(up_to_version, Ordering::Relaxed);
425        let mut map = self.lock();
426        let drop_keys: Vec<CacheKey> = if tables.is_empty() {
427            map.lru
428                .iter()
429                .filter(|(_, e)| e.version <= up_to_version)
430                .map(|(k, _)| k.clone())
431                .collect()
432        } else {
433            let mut keys: HashSet<CacheKey> = HashSet::new();
434            for t in tables {
435                if let Some(set) = map.by_table.get(t) {
436                    for k in set {
437                        keys.insert(k.clone());
438                    }
439                }
440            }
441            keys.into_iter()
442                .filter(|k| {
443                    map.lru
444                        .peek(k)
445                        .map(|e| e.version <= up_to_version)
446                        .unwrap_or(false)
447                })
448                .collect()
449        };
450        for k in &drop_keys {
451            map.pop(k);
452        }
453        drop_keys.len()
454    }
455
456    pub fn stats(&self) -> EdgeCacheStats {
457        EdgeCacheStats {
458            hits: self.inner.hits.load(Ordering::Relaxed),
459            misses: self.inner.misses.load(Ordering::Relaxed),
460            inserts: self.inner.inserts.load(Ordering::Relaxed),
461            invalidations_received: self.inner.invalidations.load(Ordering::Relaxed),
462            entries_evicted: self.inner.evictions.load(Ordering::Relaxed),
463            current_entries: self.lock().lru.len(),
464        }
465    }
466
467    /// Test-only: deterministic insert with explicit version + TTL.
468    pub fn insert_with(&self, key: CacheKey, entry: CacheEntry) {
469        self.insert(key, entry);
470    }
471}
472
473#[cfg(test)]
474mod tests {
475    use super::*;
476    use std::time::Duration;
477
478    fn entry(version: u64, body: &[u8], tables: &[&str], ttl: Duration) -> CacheEntry {
479        CacheEntry {
480            version,
481            response_bytes: Bytes::copy_from_slice(body),
482            tables: tables.iter().map(|s| s.to_string()).collect(),
483            expires_at: Instant::now() + ttl,
484        }
485    }
486
487    #[test]
488    fn insert_then_get_returns_value() {
489        let c = EdgeCache::new(10);
490        let k = CacheKey::new("fp1", "p1");
491        c.insert(
492            k.clone(),
493            entry(1, b"row", &["users"], Duration::from_secs(60)),
494        );
495        let got = c.get(&k).expect("hit");
496        assert_eq!(&got.response_bytes[..], b"row");
497    }
498
499    #[test]
500    fn miss_returns_none() {
501        let c = EdgeCache::new(10);
502        assert!(c.get(&CacheKey::new("fp1", "p1")).is_none());
503        assert_eq!(c.stats().misses, 1);
504    }
505
506    #[test]
507    fn key_isolates_database_and_user_verbatim() {
508        // F13: tenant identity is part of key EQUALITY, not just the
509        // hash — a params_hash collision alone can never alias tenants.
510        let c = EdgeCache::new(10);
511        let a = CacheKey {
512            fingerprint: "FP".into(),
513            params_hash: "SAMEHASH".into(),
514            database: "tenant_a".into(),
515            user: "alice".into(),
516        };
517        let b = CacheKey {
518            database: "tenant_b".into(),
519            ..a.clone()
520        };
521        let u = CacheKey {
522            user: "bob".into(),
523            ..a.clone()
524        };
525        assert_ne!(a, b);
526        assert_ne!(a, u);
527        c.insert(
528            a.clone(),
529            entry(1, b"a-rows", &["t"], Duration::from_secs(60)),
530        );
531        assert!(c.get(&b).is_none(), "other database must not alias");
532        assert!(c.get(&u).is_none(), "other user must not alias");
533        assert!(c.get(&a).is_some());
534    }
535
536    #[test]
537    fn expired_entry_is_dropped_on_read() {
538        let c = EdgeCache::new(10);
539        let k = CacheKey::new("fp1", "p1");
540        // Insert with a 0-duration TTL — already expired.
541        let mut e = entry(1, b"x", &[], Duration::from_secs(0));
542        e.expires_at = Instant::now() - Duration::from_millis(1);
543        c.insert(k.clone(), e);
544        assert!(c.get(&k).is_none());
545        let s = c.stats();
546        assert_eq!(s.current_entries, 0);
547        assert_eq!(s.misses, 1, "expired read counts as a miss");
548        assert_eq!(s.entries_evicted, 0, "TTL expiry is not an LRU eviction");
549    }
550
551    #[test]
552    fn lru_evicts_oldest_when_over_capacity() {
553        let c = EdgeCache::new(3);
554        for i in 0..5 {
555            let k = CacheKey::new(format!("fp{}", i), "p");
556            c.insert(k, entry(i, b"x", &[], Duration::from_secs(60)));
557        }
558        // Capacity 3, inserted 5 → 2 evictions.
559        assert_eq!(c.stats().entries_evicted, 2);
560        assert_eq!(c.stats().current_entries, 3);
561        // The two oldest (fp0, fp1) should be gone.
562        assert!(c.get(&CacheKey::new("fp0", "p")).is_none());
563        assert!(c.get(&CacheKey::new("fp1", "p")).is_none());
564        assert!(c.get(&CacheKey::new("fp4", "p")).is_some());
565    }
566
567    #[test]
568    fn lru_promotes_recently_read_entries() {
569        let c = EdgeCache::new(3);
570        for i in 0..3 {
571            let k = CacheKey::new(format!("fp{}", i), "p");
572            c.insert(k, entry(i, b"x", &[], Duration::from_secs(60)));
573        }
574        // Read fp0 — promotes it to most-recently-used.
575        let _ = c.get(&CacheKey::new("fp0", "p"));
576        // Insert one more, should evict fp1 (now the oldest).
577        c.insert(
578            CacheKey::new("fp3", "p"),
579            entry(3, b"x", &[], Duration::from_secs(60)),
580        );
581        assert!(c.get(&CacheKey::new("fp0", "p")).is_some());
582        assert!(c.get(&CacheKey::new("fp1", "p")).is_none());
583        assert!(c.get(&CacheKey::new("fp2", "p")).is_some());
584        assert!(c.get(&CacheKey::new("fp3", "p")).is_some());
585    }
586
587    #[test]
588    fn eviction_counter_respects_lru_order_at_capacity() {
589        // Insert A, B at cap 2; touch A; insert C → B (LRU) evicted.
590        let c = EdgeCache::new(2);
591        c.insert(
592            CacheKey::new("a", "p"),
593            entry(1, b"a", &[], Duration::from_secs(60)),
594        );
595        c.insert(
596            CacheKey::new("b", "p"),
597            entry(2, b"b", &[], Duration::from_secs(60)),
598        );
599        assert_eq!(c.stats().entries_evicted, 0);
600        assert!(c.get(&CacheKey::new("a", "p")).is_some());
601        c.insert(
602            CacheKey::new("c", "p"),
603            entry(3, b"c", &[], Duration::from_secs(60)),
604        );
605        let s = c.stats();
606        assert_eq!(s.entries_evicted, 1);
607        assert_eq!(s.current_entries, 2);
608        assert!(c.get(&CacheKey::new("b", "p")).is_none(), "b was the LRU");
609        assert!(c.get(&CacheKey::new("a", "p")).is_some());
610        assert!(c.get(&CacheKey::new("c", "p")).is_some());
611    }
612
613    #[test]
614    fn overwrite_same_key_is_not_an_eviction() {
615        let c = EdgeCache::new(2);
616        let k = CacheKey::new("a", "p");
617        c.insert(k.clone(), entry(1, b"v1", &[], Duration::from_secs(60)));
618        c.insert(k.clone(), entry(2, b"v2", &[], Duration::from_secs(60)));
619        let s = c.stats();
620        assert_eq!(s.entries_evicted, 0);
621        assert_eq!(s.inserts, 2);
622        assert_eq!(&c.get(&k).expect("hit").response_bytes[..], b"v2");
623    }
624
625    #[test]
626    fn invalidate_drops_old_versions_only() {
627        let c = EdgeCache::new(10);
628        c.insert(
629            CacheKey::new("fp1", "p"),
630            entry(5, b"v5", &["users"], Duration::from_secs(60)),
631        );
632        c.insert(
633            CacheKey::new("fp2", "p"),
634            entry(10, b"v10", &["users"], Duration::from_secs(60)),
635        );
636        let dropped = c.invalidate(7, &["users".to_string()]);
637        assert_eq!(dropped, 1);
638        assert!(c.get(&CacheKey::new("fp1", "p")).is_none());
639        assert!(c.get(&CacheKey::new("fp2", "p")).is_some());
640    }
641
642    #[test]
643    fn invalidate_filters_by_tables() {
644        let c = EdgeCache::new(10);
645        c.insert(
646            CacheKey::new("fp1", "p"),
647            entry(5, b"x", &["users"], Duration::from_secs(60)),
648        );
649        c.insert(
650            CacheKey::new("fp2", "p"),
651            entry(5, b"y", &["orders"], Duration::from_secs(60)),
652        );
653        let dropped = c.invalidate(100, &["users".to_string()]);
654        assert_eq!(dropped, 1);
655        assert!(c.get(&CacheKey::new("fp1", "p")).is_none());
656        assert!(c.get(&CacheKey::new("fp2", "p")).is_some());
657    }
658
659    #[test]
660    fn invalidate_matches_any_of_the_entry_tables() {
661        // Reverse-index sweep must catch an entry via its SECOND table.
662        let c = EdgeCache::new(10);
663        c.insert(
664            CacheKey::new("fp1", "p"),
665            entry(5, b"x", &["users", "orders"], Duration::from_secs(60)),
666        );
667        let dropped = c.invalidate(100, &["orders".to_string()]);
668        assert_eq!(dropped, 1);
669        assert!(c.get(&CacheKey::new("fp1", "p")).is_none());
670    }
671
672    #[test]
673    fn table_index_survives_eviction_and_overwrite() {
674        // Evicted / overwritten entries must leave no stale index links
675        // (a stale link would inflate later sweeps or leak memory).
676        let c = EdgeCache::new(2);
677        c.insert(
678            CacheKey::new("a", "p"),
679            entry(1, b"a", &["users"], Duration::from_secs(60)),
680        );
681        // Overwrite a with a different table set.
682        c.insert(
683            CacheKey::new("a", "p"),
684            entry(2, b"a2", &["orders"], Duration::from_secs(60)),
685        );
686        // Fill + force eviction of `a` (LRU).
687        c.insert(
688            CacheKey::new("b", "p"),
689            entry(3, b"b", &["users"], Duration::from_secs(60)),
690        );
691        c.insert(
692            CacheKey::new("c", "p"),
693            entry(4, b"c", &["users"], Duration::from_secs(60)),
694        );
695        // `a` (orders) was evicted; sweeping orders must drop nothing.
696        assert_eq!(c.invalidate(100, &["orders".to_string()]), 0);
697        // Sweeping the stale pre-overwrite table of `a` (users) drops
698        // only the live users entries (b, c).
699        assert_eq!(c.invalidate(100, &["users".to_string()]), 2);
700        assert_eq!(c.stats().current_entries, 0);
701    }
702
703    #[test]
704    fn invalidate_with_no_tables_drops_everything_within_version() {
705        let c = EdgeCache::new(10);
706        c.insert(
707            CacheKey::new("fp1", "p"),
708            entry(5, b"x", &["users"], Duration::from_secs(60)),
709        );
710        c.insert(
711            CacheKey::new("fp2", "p"),
712            entry(10, b"y", &["orders"], Duration::from_secs(60)),
713        );
714        let dropped = c.invalidate(7, &[]);
715        assert_eq!(dropped, 1, "fp1 (v5) should be dropped, fp2 (v10) kept");
716        assert!(c.get(&CacheKey::new("fp2", "p")).is_some());
717    }
718
719    #[test]
720    fn next_version_is_monotonic() {
721        let c = EdgeCache::new(10);
722        let v1 = c.next_version();
723        let v2 = c.next_version();
724        let v3 = c.next_version();
725        assert!(v1 < v2 && v2 < v3);
726        assert_eq!(v1, 1, "version counter starts at 1");
727    }
728
729    #[test]
730    fn should_cache_gated_by_invalidation_hwm() {
731        let c = EdgeCache::new(10);
732        // Nothing invalidated yet — every positive version is cacheable.
733        assert!(c.should_cache(1));
734        let _ = c.invalidate(7, &["users".to_string()]);
735        assert!(!c.should_cache(7), "version == hwm must not be cached");
736        assert!(!c.should_cache(3), "version < hwm must not be cached");
737        assert!(c.should_cache(8), "version > hwm is cacheable");
738        // The hwm only ratchets up: a lower invalidation doesn't lower it.
739        let _ = c.invalidate(2, &[]);
740        assert!(!c.should_cache(7));
741        assert!(c.should_cache(8));
742    }
743
744    #[test]
745    fn insert_if_fresh_rejects_read_invalidated_in_flight() {
746        // F18 repro: gate check passes, a full invalidation completes,
747        // THEN the insert runs — the locked re-check must reject it.
748        let c = EdgeCache::new(10);
749        let read_version = c.next_version();
750        assert!(c.should_cache(read_version)); // unlocked fast-path passes
751        let w = c.next_version();
752        let _ = c.invalidate(w, &["users".to_string()]); // completes fully
753        let stored = c.insert_if_fresh(
754            CacheKey::new("fp", "p"),
755            entry(read_version, b"stale", &["users"], Duration::from_secs(60)),
756        );
757        assert!(!stored, "stale store must be rejected under the lock");
758        assert!(c.get(&CacheKey::new("fp", "p")).is_none());
759        // A read stamped after the invalidation stores fine.
760        let fresh = c.next_version();
761        assert!(c.insert_if_fresh(
762            CacheKey::new("fp", "p"),
763            entry(fresh, b"fresh", &["users"], Duration::from_secs(60)),
764        ));
765    }
766
767    #[test]
768    fn insert_if_epoch_rejects_after_any_invalidation() {
769        // F1 edge-role store gate: snapshot before forwarding, reject
770        // when any invalidation (or flush) landed in between.
771        let c = EdgeCache::new(10);
772        let epoch = c.invalidation_epoch();
773        let _ = c.invalidate(5, &["users".to_string()]);
774        let stored = c.insert_if_epoch(
775            CacheKey::new("fp", "p"),
776            entry(0, b"stale", &["users"], Duration::from_secs(60)),
777            epoch,
778        );
779        assert!(!stored);
780        // Caching resumes with a fresh snapshot.
781        let epoch2 = c.invalidation_epoch();
782        assert!(c.insert_if_epoch(
783            CacheKey::new("fp", "p"),
784            entry(
785                c.observed_home_version(),
786                b"fresh",
787                &["users"],
788                Duration::from_secs(60)
789            ),
790            epoch2,
791        ));
792        assert!(c.get(&CacheKey::new("fp", "p")).is_some());
793    }
794
795    #[test]
796    fn edge_stamps_in_home_domain_are_swept_by_next_home_write() {
797        // F1 repro (old scheme cached entries ABOVE the home clock, so
798        // the next home invalidation swept nothing): entries stamped
799        // with the observed home version must be dropped by the next
800        // home write's `<=` sweep.
801        let c = EdgeCache::new(10);
802        c.observe_home_version(5);
803        let stamp = c.observed_home_version();
804        assert_eq!(stamp, 5);
805        c.insert(
806            CacheKey::new("fp", "p"),
807            entry(stamp, b"rows", &["users"], Duration::from_secs(60)),
808        );
809        // Home's next write mints 6 and sweeps <= 6.
810        assert_eq!(c.invalidate(6, &["users".to_string()]), 1);
811        assert!(c.get(&CacheKey::new("fp", "p")).is_none());
812    }
813
814    #[test]
815    fn fresh_boot_edge_entries_swept_by_first_invalidation() {
816        // Before any home contact observed_home is 0; the first real
817        // invalidation must still sweep those entries.
818        let c = EdgeCache::new(10);
819        assert_eq!(c.observed_home_version(), 0);
820        c.insert(
821            CacheKey::new("fp", "p"),
822            entry(0, b"rows", &["users"], Duration::from_secs(60)),
823        );
824        assert_eq!(c.invalidate(1, &["users".to_string()]), 1);
825    }
826
827    #[test]
828    fn observe_home_version_advances_counter() {
829        let c = EdgeCache::new(10);
830        c.observe_home_version(100);
831        assert_eq!(c.observed_home_version(), 100);
832        assert!(c.next_version() > 100);
833        // Observing an older version never moves the clocks backwards.
834        c.observe_home_version(5);
835        assert_eq!(c.observed_home_version(), 100);
836        assert!(c.next_version() > 100);
837    }
838
839    #[test]
840    fn home_epoch_change_flushes_and_resets_observed_home() {
841        // F9: a home restart resets its version clock. The epoch change
842        // must flush everything (old stamps are incomparable) and reset
843        // the observed-home clock so re-sync starts clean.
844        let c = EdgeCache::new(10);
845        assert_eq!(c.on_home_epoch(1111), 0, "first sighting records only");
846        c.observe_home_version(1_000_000);
847        c.insert(
848            CacheKey::new("fp", "p"),
849            entry(1_000_000, b"old", &["users"], Duration::from_secs(60)),
850        );
851        // Same epoch again: nothing happens.
852        assert_eq!(c.on_home_epoch(1111), 0);
853        assert!(c.get(&CacheKey::new("fp", "p")).is_some());
854        // Restarted home (new epoch): flush + reset.
855        assert_eq!(c.on_home_epoch(2222), 1);
856        assert!(c.get(&CacheKey::new("fp", "p")).is_none());
857        assert_eq!(c.observed_home_version(), 0);
858        // Legacy events (epoch 0) never trigger a flush.
859        c.insert(
860            CacheKey::new("fp2", "p"),
861            entry(1, b"x", &[], Duration::from_secs(60)),
862        );
863        assert_eq!(c.on_home_epoch(0), 0);
864        assert!(c.get(&CacheKey::new("fp2", "p")).is_some());
865    }
866
867    #[test]
868    fn process_epoch_is_stable_and_nonzero() {
869        let c = EdgeCache::new(2);
870        assert_ne!(c.epoch(), 0);
871        assert_eq!(c.epoch(), c.epoch());
872    }
873
874    #[test]
875    fn stats_track_hits_and_misses() {
876        let c = EdgeCache::new(10);
877        let k = CacheKey::new("fp1", "p");
878        c.insert(k.clone(), entry(1, b"x", &[], Duration::from_secs(60)));
879        let _ = c.get(&k);
880        let _ = c.get(&k);
881        let _ = c.get(&CacheKey::new("missing", "p"));
882        let s = c.stats();
883        assert_eq!(s.hits, 2);
884        assert_eq!(s.misses, 1);
885        assert_eq!(s.inserts, 1);
886    }
887
888    #[test]
889    fn invalidate_bumps_received_counter() {
890        let c = EdgeCache::new(10);
891        let _ = c.invalidate(1, &[]);
892        let _ = c.invalidate(2, &["users".to_string()]);
893        assert_eq!(c.stats().invalidations_received, 2);
894    }
895
896    #[test]
897    fn concurrent_get_insert_invalidate() {
898        // Smoke-test the single lock under contention: no deadlock,
899        // no panic, and the map stays within capacity.
900        let c = EdgeCache::new(64);
901        let mut handles = Vec::new();
902        for t in 0..4u64 {
903            let c = c.clone();
904            handles.push(std::thread::spawn(move || {
905                for i in 0..500u64 {
906                    let k = CacheKey::new(format!("fp{}", (t * 500 + i) % 100), "p");
907                    if i % 7 == 0 {
908                        let v = c.next_version();
909                        let _ = c.invalidate(v, &["users".to_string()]);
910                    } else if i % 3 == 0 {
911                        let v = c.next_version();
912                        c.insert(k, entry(v, b"x", &["users"], Duration::from_secs(60)));
913                    } else {
914                        let _ = c.get(&k);
915                    }
916                }
917            }));
918        }
919        for h in handles {
920            h.join().expect("worker panicked");
921        }
922        assert!(c.stats().current_entries <= 64);
923    }
924
925    #[test]
926    fn panics_on_zero_capacity() {
927        let res = std::panic::catch_unwind(|| EdgeCache::new(0));
928        assert!(res.is_err());
929    }
930}