1use 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#[derive(Debug, Clone)]
56pub struct CacheEntry {
57 pub version: u64,
62 pub response_bytes: Bytes,
66 pub tables: Vec<String>,
69 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#[derive(Clone)]
85pub struct EdgeCache {
86 inner: Arc<EdgeCacheInner>,
87}
88
89struct MapState {
91 lru: LruCache<CacheKey, Arc<CacheEntry>>,
92 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 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 map: Mutex<MapState>,
132 next_version: AtomicU64,
133 observed_home: AtomicU64,
138 home_epoch: AtomicU64,
142 epoch: u64,
145 invalidated_hwm: AtomicU64,
149 hits: AtomicU64,
150 misses: AtomicU64,
151 inserts: AtomicU64,
152 invalidations: AtomicU64,
156 evictions: AtomicU64,
157}
158
159#[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 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
182fn 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 fn lock(&self) -> MutexGuard<'_, MapState> {
221 self.inner.map.lock().unwrap_or_else(|e| e.into_inner())
222 }
223
224 pub fn epoch(&self) -> u64 {
227 self.inner.epoch
228 }
229
230 pub fn next_version(&self) -> u64 {
233 self.inner.next_version.fetch_add(1, Ordering::Relaxed)
234 }
235
236 pub fn current_version(&self) -> u64 {
239 self.inner.next_version.load(Ordering::Relaxed)
240 }
241
242 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 pub fn observed_home_version(&self) -> u64 {
257 self.inner.observed_home.load(Ordering::Relaxed)
258 }
259
260 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 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 pub fn should_cache(&self, version: u64) -> bool {
297 version > self.inner.invalidated_hwm.load(Ordering::Relaxed)
298 }
299
300 pub fn invalidation_epoch(&self) -> u64 {
306 self.inner.invalidations.load(Ordering::Relaxed)
307 }
308
309 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 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 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 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 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 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 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 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 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 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 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 assert_eq!(c.stats().entries_evicted, 2);
560 assert_eq!(c.stats().current_entries, 3);
561 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 let _ = c.get(&CacheKey::new("fp0", "p"));
576 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 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 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 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 c.insert(
683 CacheKey::new("a", "p"),
684 entry(2, b"a2", &["orders"], Duration::from_secs(60)),
685 );
686 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 assert_eq!(c.invalidate(100, &["orders".to_string()]), 0);
697 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 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 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 let c = EdgeCache::new(10);
749 let read_version = c.next_version();
750 assert!(c.should_cache(read_version)); let w = c.next_version();
752 let _ = c.invalidate(w, &["users".to_string()]); 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 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 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 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 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 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 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 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 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 assert_eq!(c.on_home_epoch(1111), 0);
853 assert!(c.get(&CacheKey::new("fp", "p")).is_some());
854 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 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 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}