1use std::collections::HashMap;
9
10pub const DEFAULT_MAX_PROVIDERS: usize = 10_000;
14pub const DEFAULT_MAX_HINTS: usize = 5_000;
16pub const DEFAULT_MAX_NEGATIVE: usize = 2_000;
18pub const DEFAULT_PROVIDER_TTL_MS: u64 = 3_600_000;
20pub const DEFAULT_HINT_TTL_MS: u64 = 300_000;
22pub const DEFAULT_NEGATIVE_TTL_MS: u64 = 60_000;
24const MAX_PROVIDERS_PER_CID: usize = 20;
26
27#[derive(Debug, Clone, PartialEq)]
31pub struct CrcProviderRecord {
32 pub cid: String,
34 pub peer_id: String,
36 pub multiaddrs: Vec<String>,
38 pub last_provided: u64,
40 pub ttl_ms: u64,
42}
43
44impl CrcProviderRecord {
45 #[inline]
47 pub fn is_expired(&self, now: u64) -> bool {
48 now.saturating_sub(self.last_provided) > self.ttl_ms
49 }
50}
51
52#[derive(Debug, Clone, PartialEq)]
56pub struct RoutingHint {
57 pub cid: String,
59 pub nearest_peers: Vec<String>,
61 pub confidence: f64,
63 pub cached_at: u64,
65 pub ttl_ms: u64,
67}
68
69impl RoutingHint {
70 #[inline]
72 pub fn is_expired(&self, now: u64) -> bool {
73 now.saturating_sub(self.cached_at) > self.ttl_ms
74 }
75}
76
77#[derive(Debug, Clone, PartialEq)]
81pub struct NegativeCacheEntry {
82 pub cid: String,
84 pub not_found_at: u64,
86 pub ttl_ms: u64,
88}
89
90impl NegativeCacheEntry {
91 #[inline]
93 pub fn is_expired(&self, now: u64) -> bool {
94 now.saturating_sub(self.not_found_at) > self.ttl_ms
95 }
96}
97
98#[derive(Debug, Clone)]
102pub struct CacheConfig {
103 pub max_providers: usize,
105 pub max_hints: usize,
107 pub max_negative: usize,
109 pub default_provider_ttl_ms: u64,
111 pub default_hint_ttl_ms: u64,
113 pub default_negative_ttl_ms: u64,
115}
116
117impl Default for CacheConfig {
118 fn default() -> Self {
119 Self {
120 max_providers: DEFAULT_MAX_PROVIDERS,
121 max_hints: DEFAULT_MAX_HINTS,
122 max_negative: DEFAULT_MAX_NEGATIVE,
123 default_provider_ttl_ms: DEFAULT_PROVIDER_TTL_MS,
124 default_hint_ttl_ms: DEFAULT_HINT_TTL_MS,
125 default_negative_ttl_ms: DEFAULT_NEGATIVE_TTL_MS,
126 }
127 }
128}
129
130#[derive(Debug, Clone, PartialEq)]
134pub struct CacheStats {
135 pub provider_cids: usize,
137 pub total_providers: usize,
139 pub hints_cached: usize,
141 pub negative_cached: usize,
143 pub total_provider_lookups: u64,
145 pub total_hint_lookups: u64,
147 pub hit_rate: f64,
149}
150
151fn evict_oldest_by<V, F>(map: &mut HashMap<String, V>, key_fn: F)
156where
157 F: Fn(&V) -> u64,
158{
159 if map.is_empty() {
160 return;
161 }
162 let oldest = map
163 .iter()
164 .map(|(k, v)| (k.clone(), key_fn(v)))
165 .min_by_key(|(_, ts)| *ts)
166 .map(|(k, _)| k);
167 if let Some(k) = oldest {
168 map.remove(&k);
169 }
170}
171
172#[derive(Debug)]
181pub struct ContentRoutingCache {
182 pub config: CacheConfig,
184 providers: HashMap<String, Vec<CrcProviderRecord>>,
186 hints: HashMap<String, RoutingHint>,
188 negative: HashMap<String, NegativeCacheEntry>,
190 pub total_provider_lookups: u64,
192 pub total_hint_lookups: u64,
194 pub cache_hits: u64,
196 pub cache_misses: u64,
198}
199
200impl ContentRoutingCache {
201 pub fn new(config: CacheConfig) -> Self {
205 Self {
206 providers: HashMap::new(),
207 hints: HashMap::new(),
208 negative: HashMap::new(),
209 total_provider_lookups: 0,
210 total_hint_lookups: 0,
211 cache_hits: 0,
212 cache_misses: 0,
213 config,
214 }
215 }
216
217 pub fn add_provider(&mut self, record: CrcProviderRecord) {
226 if !self.providers.contains_key(&record.cid)
228 && self.providers.len() >= self.config.max_providers
229 {
230 evict_oldest_by(&mut self.providers, |records| {
232 records.iter().map(|r| r.last_provided).max().unwrap_or(0)
233 });
234 }
235
236 let list = self.providers.entry(record.cid.clone()).or_default();
237
238 if list.len() >= MAX_PROVIDERS_PER_CID {
240 if let Some(pos) = list
241 .iter()
242 .enumerate()
243 .min_by_key(|(_, r)| r.last_provided)
244 .map(|(i, _)| i)
245 {
246 list.remove(pos);
247 }
248 }
249
250 list.push(record);
251 }
252
253 pub fn get_providers(&mut self, cid: &str, now: u64) -> Vec<&CrcProviderRecord> {
258 self.total_provider_lookups += 1;
259
260 if let Some(list) = self.providers.get_mut(cid) {
262 list.retain(|r| !r.is_expired(now));
263 if list.is_empty() {
264 self.providers.remove(cid);
265 }
266 }
267
268 match self.providers.get(cid) {
269 Some(list) if !list.is_empty() => {
270 self.cache_hits += 1;
271 list.iter().collect()
272 }
273 _ => {
274 self.cache_misses += 1;
275 Vec::new()
276 }
277 }
278 }
279
280 pub fn remove_provider(&mut self, cid: &str, peer_id: &str) -> bool {
284 if let Some(list) = self.providers.get_mut(cid) {
285 let before = list.len();
286 list.retain(|r| r.peer_id != peer_id);
287 let removed = list.len() < before;
288 if list.is_empty() {
289 self.providers.remove(cid);
290 }
291 return removed;
292 }
293 false
294 }
295
296 pub fn add_hint(&mut self, hint: RoutingHint) {
303 if !self.hints.contains_key(&hint.cid) && self.hints.len() >= self.config.max_hints {
304 evict_oldest_by(&mut self.hints, |h| h.cached_at);
305 }
306 self.hints.insert(hint.cid.clone(), hint);
307 }
308
309 pub fn get_hint(&mut self, cid: &str, now: u64) -> Option<&RoutingHint> {
314 self.total_hint_lookups += 1;
315
316 if let Some(h) = self.hints.get(cid) {
318 if h.is_expired(now) {
319 self.hints.remove(cid);
320 self.cache_misses += 1;
321 return None;
322 }
323 }
324
325 match self.hints.get(cid) {
326 Some(_) => {
327 self.cache_hits += 1;
328 self.hints.get(cid)
329 }
330 None => {
331 self.cache_misses += 1;
332 None
333 }
334 }
335 }
336
337 pub fn remove_hint(&mut self, cid: &str) -> bool {
339 self.hints.remove(cid).is_some()
340 }
341
342 pub fn add_negative(&mut self, entry: NegativeCacheEntry) {
349 if !self.negative.contains_key(&entry.cid)
350 && self.negative.len() >= self.config.max_negative
351 {
352 evict_oldest_by(&mut self.negative, |e| e.not_found_at);
353 }
354 self.negative.insert(entry.cid.clone(), entry);
355 }
356
357 pub fn is_negative(&mut self, cid: &str, now: u64) -> bool {
361 match self.negative.get(cid) {
362 Some(e) if e.is_expired(now) => {
363 self.negative.remove(cid);
364 false
365 }
366 Some(_) => true,
367 None => false,
368 }
369 }
370
371 pub fn remove_negative(&mut self, cid: &str) -> bool {
373 self.negative.remove(cid).is_some()
374 }
375
376 pub fn evict_expired(&mut self, now: u64) -> usize {
382 let mut removed = 0_usize;
383
384 let mut empty_cids: Vec<String> = Vec::new();
386 for (cid, list) in self.providers.iter_mut() {
387 let before = list.len();
388 list.retain(|r| !r.is_expired(now));
389 removed += before - list.len();
390 if list.is_empty() {
391 empty_cids.push(cid.clone());
392 }
393 }
394 for cid in empty_cids {
395 self.providers.remove(&cid);
396 }
397
398 let expired_hints: Vec<String> = self
400 .hints
401 .iter()
402 .filter(|(_, h)| h.is_expired(now))
403 .map(|(k, _)| k.clone())
404 .collect();
405 removed += expired_hints.len();
406 for k in expired_hints {
407 self.hints.remove(&k);
408 }
409
410 let expired_neg: Vec<String> = self
412 .negative
413 .iter()
414 .filter(|(_, e)| e.is_expired(now))
415 .map(|(k, _)| k.clone())
416 .collect();
417 removed += expired_neg.len();
418 for k in expired_neg {
419 self.negative.remove(&k);
420 }
421
422 removed
423 }
424
425 pub fn has_content(&self, cid: &str) -> bool {
435 self.providers
436 .get(cid)
437 .map(|v| !v.is_empty())
438 .unwrap_or(false)
439 }
440
441 pub fn cache_stats(&self) -> CacheStats {
445 let provider_cids = self.providers.len();
446 let total_providers: usize = self.providers.values().map(|v| v.len()).sum();
447 let hints_cached = self.hints.len();
448 let negative_cached = self.negative.len();
449
450 let total_lookups = self.total_provider_lookups + self.total_hint_lookups;
451 let hit_rate = if total_lookups == 0 {
452 0.0_f64
453 } else {
454 self.cache_hits as f64 / total_lookups as f64
455 };
456
457 CacheStats {
458 provider_cids,
459 total_providers,
460 hints_cached,
461 negative_cached,
462 total_provider_lookups: self.total_provider_lookups,
463 total_hint_lookups: self.total_hint_lookups,
464 hit_rate,
465 }
466 }
467}
468
469#[cfg(test)]
472mod tests {
473 use super::{
474 CacheConfig, CacheStats, ContentRoutingCache, CrcProviderRecord, NegativeCacheEntry,
475 RoutingHint, DEFAULT_HINT_TTL_MS, DEFAULT_NEGATIVE_TTL_MS, DEFAULT_PROVIDER_TTL_MS,
476 MAX_PROVIDERS_PER_CID,
477 };
478
479 fn make_record(cid: &str, peer: &str, last_provided: u64, ttl_ms: u64) -> CrcProviderRecord {
482 CrcProviderRecord {
483 cid: cid.to_string(),
484 peer_id: peer.to_string(),
485 multiaddrs: vec![format!("/ip4/127.0.0.1/tcp/4001/{peer}")],
486 last_provided,
487 ttl_ms,
488 }
489 }
490
491 fn make_hint(cid: &str, peers: &[&str], cached_at: u64, ttl_ms: u64) -> RoutingHint {
492 RoutingHint {
493 cid: cid.to_string(),
494 nearest_peers: peers.iter().map(|s| s.to_string()).collect(),
495 confidence: 0.9,
496 cached_at,
497 ttl_ms,
498 }
499 }
500
501 fn make_negative(cid: &str, not_found_at: u64, ttl_ms: u64) -> NegativeCacheEntry {
502 NegativeCacheEntry {
503 cid: cid.to_string(),
504 not_found_at,
505 ttl_ms,
506 }
507 }
508
509 fn default_cache() -> ContentRoutingCache {
510 ContentRoutingCache::new(CacheConfig::default())
511 }
512
513 #[test]
516 fn test_provider_record_not_expired_when_within_ttl() {
517 let r = make_record("cid1", "peer1", 1000, 500);
518 assert!(!r.is_expired(1400));
519 }
520
521 #[test]
522 fn test_provider_record_expired_beyond_ttl() {
523 let r = make_record("cid1", "peer1", 1000, 500);
524 assert!(r.is_expired(1501));
525 }
526
527 #[test]
528 fn test_provider_record_expired_exactly_at_boundary() {
529 let r = make_record("cid1", "peer1", 0, 100);
531 assert!(!r.is_expired(100));
532 }
533
534 #[test]
535 fn test_provider_record_saturating_sub_prevents_underflow() {
536 let r = make_record("cid1", "peer1", 5000, 100);
537 assert!(!r.is_expired(100));
539 }
540
541 #[test]
544 fn test_routing_hint_not_expired() {
545 let h = make_hint("cid1", &["p1"], 1000, 500);
546 assert!(!h.is_expired(1499));
547 }
548
549 #[test]
550 fn test_routing_hint_expired() {
551 let h = make_hint("cid1", &["p1"], 1000, 500);
552 assert!(h.is_expired(1501));
553 }
554
555 #[test]
558 fn test_negative_entry_not_expired() {
559 let e = make_negative("cid1", 0, 60_000);
560 assert!(!e.is_expired(59_999));
561 }
562
563 #[test]
564 fn test_negative_entry_expired() {
565 let e = make_negative("cid1", 0, 60_000);
566 assert!(e.is_expired(60_001));
567 }
568
569 #[test]
572 fn test_add_and_get_provider_basic() {
573 let mut c = default_cache();
574 c.add_provider(make_record("cid1", "peer1", 0, DEFAULT_PROVIDER_TTL_MS));
575 let got = c.get_providers("cid1", 0);
576 assert_eq!(got.len(), 1);
577 assert_eq!(got[0].peer_id, "peer1");
578 }
579
580 #[test]
581 fn test_get_providers_empty_for_unknown_cid() {
582 let mut c = default_cache();
583 let got = c.get_providers("unknown", 0);
584 assert!(got.is_empty());
585 }
586
587 #[test]
588 fn test_get_providers_removes_expired_records() {
589 let mut c = default_cache();
590 c.add_provider(make_record("cid1", "peer1", 0, 100));
591 assert_eq!(c.get_providers("cid1", 50).len(), 1);
593 let got = c.get_providers("cid1", 200);
595 assert!(got.is_empty());
596 assert!(!c.has_content("cid1"));
598 }
599
600 #[test]
601 fn test_get_providers_updates_counters() {
602 let mut c = default_cache();
603 c.add_provider(make_record("cid1", "peer1", 0, DEFAULT_PROVIDER_TTL_MS));
604 c.get_providers("cid1", 0);
606 c.get_providers("unknown", 0);
608 assert_eq!(c.total_provider_lookups, 2);
609 assert_eq!(c.cache_hits, 1);
610 assert_eq!(c.cache_misses, 1);
611 }
612
613 #[test]
614 fn test_per_cid_provider_limit_evicts_oldest() {
615 let mut c = default_cache();
616 for i in 0..MAX_PROVIDERS_PER_CID {
618 c.add_provider(make_record(
619 "cid1",
620 &format!("peer{i}"),
621 i as u64,
622 DEFAULT_PROVIDER_TTL_MS,
623 ));
624 }
625 c.add_provider(make_record(
627 "cid1",
628 "peerNew",
629 MAX_PROVIDERS_PER_CID as u64,
630 DEFAULT_PROVIDER_TTL_MS,
631 ));
632 let providers = c.providers.get("cid1").expect("cid1 should exist");
633 assert_eq!(providers.len(), MAX_PROVIDERS_PER_CID);
634 assert!(!providers.iter().any(|r| r.peer_id == "peer0"));
636 assert!(providers.iter().any(|r| r.peer_id == "peerNew"));
638 }
639
640 #[test]
641 fn test_global_provider_cid_limit_evicts_oldest_cid() {
642 let config = CacheConfig {
643 max_providers: 3,
644 ..CacheConfig::default()
645 };
646 let mut c = ContentRoutingCache::new(config);
647 c.add_provider(make_record("cid1", "peer1", 100, DEFAULT_PROVIDER_TTL_MS));
648 c.add_provider(make_record("cid2", "peer2", 200, DEFAULT_PROVIDER_TTL_MS));
649 c.add_provider(make_record("cid3", "peer3", 300, DEFAULT_PROVIDER_TTL_MS));
650 c.add_provider(make_record("cid4", "peer4", 400, DEFAULT_PROVIDER_TTL_MS));
652 assert!(
653 !c.providers.contains_key("cid1"),
654 "cid1 should have been evicted"
655 );
656 assert!(c.providers.contains_key("cid4"));
657 }
658
659 #[test]
662 fn test_remove_provider_returns_true_when_present() {
663 let mut c = default_cache();
664 c.add_provider(make_record("cid1", "peer1", 0, DEFAULT_PROVIDER_TTL_MS));
665 assert!(c.remove_provider("cid1", "peer1"));
666 assert!(!c.has_content("cid1"));
667 }
668
669 #[test]
670 fn test_remove_provider_returns_false_when_absent() {
671 let mut c = default_cache();
672 assert!(!c.remove_provider("cid1", "peer1"));
673 }
674
675 #[test]
676 fn test_remove_provider_leaves_other_peers() {
677 let mut c = default_cache();
678 c.add_provider(make_record("cid1", "peer1", 0, DEFAULT_PROVIDER_TTL_MS));
679 c.add_provider(make_record("cid1", "peer2", 0, DEFAULT_PROVIDER_TTL_MS));
680 c.remove_provider("cid1", "peer1");
681 let got = c.get_providers("cid1", 0);
682 assert_eq!(got.len(), 1);
683 assert_eq!(got[0].peer_id, "peer2");
684 }
685
686 #[test]
689 fn test_add_and_get_hint_basic() {
690 let mut c = default_cache();
691 c.add_hint(make_hint("cid1", &["p1", "p2"], 0, DEFAULT_HINT_TTL_MS));
692 let h = c.get_hint("cid1", 0).expect("hint should be present");
693 assert_eq!(h.nearest_peers.len(), 2);
694 }
695
696 #[test]
697 fn test_get_hint_returns_none_when_expired() {
698 let mut c = default_cache();
699 c.add_hint(make_hint("cid1", &["p1"], 0, 100));
700 assert!(c.get_hint("cid1", 200).is_none());
701 assert!(!c.hints.contains_key("cid1"));
703 }
704
705 #[test]
706 fn test_get_hint_updates_counters() {
707 let mut c = default_cache();
708 c.add_hint(make_hint("cid1", &["p1"], 0, DEFAULT_HINT_TTL_MS));
709 c.get_hint("cid1", 0); c.get_hint("missing", 0); assert_eq!(c.total_hint_lookups, 2);
712 assert_eq!(c.cache_hits, 1);
713 assert_eq!(c.cache_misses, 1);
714 }
715
716 #[test]
717 fn test_hint_global_limit_evicts_oldest() {
718 let config = CacheConfig {
719 max_hints: 2,
720 ..CacheConfig::default()
721 };
722 let mut c = ContentRoutingCache::new(config);
723 c.add_hint(make_hint("cid1", &["p1"], 100, DEFAULT_HINT_TTL_MS));
724 c.add_hint(make_hint("cid2", &["p2"], 200, DEFAULT_HINT_TTL_MS));
725 c.add_hint(make_hint("cid3", &["p3"], 300, DEFAULT_HINT_TTL_MS));
727 assert!(!c.hints.contains_key("cid1"), "cid1 hint should be evicted");
728 assert!(c.hints.contains_key("cid3"));
729 }
730
731 #[test]
734 fn test_remove_hint_returns_true() {
735 let mut c = default_cache();
736 c.add_hint(make_hint("cid1", &["p1"], 0, DEFAULT_HINT_TTL_MS));
737 assert!(c.remove_hint("cid1"));
738 assert!(c.get_hint("cid1", 0).is_none());
739 }
740
741 #[test]
742 fn test_remove_hint_returns_false_when_absent() {
743 let mut c = default_cache();
744 assert!(!c.remove_hint("cid1"));
745 }
746
747 #[test]
750 fn test_is_negative_true_when_present_and_fresh() {
751 let mut c = default_cache();
752 c.add_negative(make_negative("cid1", 0, DEFAULT_NEGATIVE_TTL_MS));
753 assert!(c.is_negative("cid1", 0));
754 }
755
756 #[test]
757 fn test_is_negative_false_when_absent() {
758 let mut c = default_cache();
759 assert!(!c.is_negative("cid1", 0));
760 }
761
762 #[test]
763 fn test_is_negative_false_and_removes_when_expired() {
764 let mut c = default_cache();
765 c.add_negative(make_negative("cid1", 0, 100));
766 assert!(!c.is_negative("cid1", 200));
767 assert!(!c.negative.contains_key("cid1"));
768 }
769
770 #[test]
771 fn test_negative_global_limit_evicts_oldest() {
772 let config = CacheConfig {
773 max_negative: 2,
774 ..CacheConfig::default()
775 };
776 let mut c = ContentRoutingCache::new(config);
777 c.add_negative(make_negative("cid1", 100, DEFAULT_NEGATIVE_TTL_MS));
778 c.add_negative(make_negative("cid2", 200, DEFAULT_NEGATIVE_TTL_MS));
779 c.add_negative(make_negative("cid3", 300, DEFAULT_NEGATIVE_TTL_MS));
781 assert!(
782 !c.negative.contains_key("cid1"),
783 "cid1 negative should be evicted"
784 );
785 assert!(c.negative.contains_key("cid3"));
786 }
787
788 #[test]
791 fn test_remove_negative_returns_true() {
792 let mut c = default_cache();
793 c.add_negative(make_negative("cid1", 0, DEFAULT_NEGATIVE_TTL_MS));
794 assert!(c.remove_negative("cid1"));
795 assert!(!c.is_negative("cid1", 0));
796 }
797
798 #[test]
799 fn test_remove_negative_returns_false_when_absent() {
800 let mut c = default_cache();
801 assert!(!c.remove_negative("cid1"));
802 }
803
804 #[test]
807 fn test_evict_expired_removes_all_tiers() {
808 let mut c = default_cache();
809 c.add_provider(make_record("cid1", "peer1", 0, 100));
811 c.add_provider(make_record("cid2", "peer2", 0, DEFAULT_PROVIDER_TTL_MS));
812 c.add_hint(make_hint("cid3", &["p3"], 0, 100));
814 c.add_hint(make_hint("cid4", &["p4"], 0, DEFAULT_HINT_TTL_MS));
815 c.add_negative(make_negative("cid5", 0, 100));
817 c.add_negative(make_negative("cid6", 0, DEFAULT_NEGATIVE_TTL_MS));
818
819 let removed = c.evict_expired(200);
820 assert_eq!(removed, 3);
822 assert!(!c.providers.contains_key("cid1"));
823 assert!(c.providers.contains_key("cid2"));
824 assert!(!c.hints.contains_key("cid3"));
825 assert!(c.hints.contains_key("cid4"));
826 assert!(!c.negative.contains_key("cid5"));
827 assert!(c.negative.contains_key("cid6"));
828 }
829
830 #[test]
831 fn test_evict_expired_returns_zero_when_nothing_expired() {
832 let mut c = default_cache();
833 c.add_provider(make_record("cid1", "peer1", 0, DEFAULT_PROVIDER_TTL_MS));
834 assert_eq!(c.evict_expired(0), 0);
835 }
836
837 #[test]
838 fn test_evict_expired_multiple_providers_per_cid() {
839 let mut c = default_cache();
840 c.add_provider(make_record("cid1", "peer1", 0, 50));
842 c.add_provider(make_record("cid1", "peer2", 0, DEFAULT_PROVIDER_TTL_MS));
843 let removed = c.evict_expired(100);
844 assert_eq!(removed, 1);
845 assert!(c.has_content("cid1"));
846 let providers = c.providers.get("cid1").expect("cid1 must remain");
847 assert_eq!(providers.len(), 1);
848 assert_eq!(providers[0].peer_id, "peer2");
849 }
850
851 #[test]
854 fn test_has_content_true_when_provider_present() {
855 let mut c = default_cache();
856 c.add_provider(make_record("cid1", "peer1", 0, DEFAULT_PROVIDER_TTL_MS));
857 assert!(c.has_content("cid1"));
858 }
859
860 #[test]
861 fn test_has_content_false_for_unknown_cid() {
862 let c = default_cache();
863 assert!(!c.has_content("unknown"));
864 }
865
866 #[test]
869 fn test_cache_stats_initial_state() {
870 let c = default_cache();
871 let s = c.cache_stats();
872 assert_eq!(s.provider_cids, 0);
873 assert_eq!(s.total_providers, 0);
874 assert_eq!(s.hints_cached, 0);
875 assert_eq!(s.negative_cached, 0);
876 assert_eq!(s.hit_rate, 0.0);
877 }
878
879 #[test]
880 fn test_cache_stats_counts_correctly() {
881 let mut c = default_cache();
882 c.add_provider(make_record("cid1", "peer1", 0, DEFAULT_PROVIDER_TTL_MS));
883 c.add_provider(make_record("cid1", "peer2", 0, DEFAULT_PROVIDER_TTL_MS));
884 c.add_provider(make_record("cid2", "peer3", 0, DEFAULT_PROVIDER_TTL_MS));
885 c.add_hint(make_hint("cid3", &["p1"], 0, DEFAULT_HINT_TTL_MS));
886 c.add_negative(make_negative("cid4", 0, DEFAULT_NEGATIVE_TTL_MS));
887
888 let s = c.cache_stats();
889 assert_eq!(s.provider_cids, 2);
890 assert_eq!(s.total_providers, 3);
891 assert_eq!(s.hints_cached, 1);
892 assert_eq!(s.negative_cached, 1);
893 }
894
895 #[test]
896 fn test_cache_stats_hit_rate_all_hits() {
897 let mut c = default_cache();
898 c.add_provider(make_record("cid1", "peer1", 0, DEFAULT_PROVIDER_TTL_MS));
899 c.get_providers("cid1", 0);
900 let s = c.cache_stats();
901 assert!((s.hit_rate - 1.0).abs() < f64::EPSILON);
902 }
903
904 #[test]
905 fn test_cache_stats_hit_rate_mixed() {
906 let mut c = default_cache();
907 c.add_provider(make_record("cid1", "peer1", 0, DEFAULT_PROVIDER_TTL_MS));
908 c.get_providers("cid1", 0); c.get_providers("miss1", 0); c.get_providers("miss2", 0); let s = c.cache_stats();
912 let expected = 1.0 / 3.0;
914 assert!((s.hit_rate - expected).abs() < 1e-10);
915 }
916
917 #[test]
920 fn test_cache_config_defaults() {
921 let cfg = CacheConfig::default();
922 assert_eq!(cfg.max_providers, 10_000);
923 assert_eq!(cfg.max_hints, 5_000);
924 assert_eq!(cfg.max_negative, 2_000);
925 assert_eq!(cfg.default_provider_ttl_ms, DEFAULT_PROVIDER_TTL_MS);
926 assert_eq!(cfg.default_hint_ttl_ms, DEFAULT_HINT_TTL_MS);
927 assert_eq!(cfg.default_negative_ttl_ms, DEFAULT_NEGATIVE_TTL_MS);
928 }
929
930 #[test]
933 fn test_cache_stats_equality() {
934 let s1 = CacheStats {
935 provider_cids: 1,
936 total_providers: 2,
937 hints_cached: 3,
938 negative_cached: 4,
939 total_provider_lookups: 5,
940 total_hint_lookups: 6,
941 hit_rate: 0.5,
942 };
943 let s2 = s1.clone();
944 assert_eq!(s1, s2);
945 }
946
947 #[test]
950 fn test_add_hint_overwrites_existing() {
951 let mut c = default_cache();
952 c.add_hint(make_hint("cid1", &["p1"], 0, DEFAULT_HINT_TTL_MS));
953 c.add_hint(make_hint("cid1", &["p2", "p3"], 1000, DEFAULT_HINT_TTL_MS));
954 let h = c.get_hint("cid1", 1000).expect("hint present");
955 assert_eq!(h.nearest_peers.len(), 2);
956 assert_eq!(c.hints.len(), 1, "overwrite should not increase count");
957 }
958
959 #[test]
962 fn test_add_negative_overwrites_existing() {
963 let mut c = default_cache();
964 c.add_negative(make_negative("cid1", 0, DEFAULT_NEGATIVE_TTL_MS));
965 c.add_negative(make_negative("cid1", 500, DEFAULT_NEGATIVE_TTL_MS));
966 assert_eq!(c.negative.len(), 1);
967 let entry = c.negative.get("cid1").expect("entry exists");
968 assert_eq!(entry.not_found_at, 500);
969 }
970
971 #[test]
974 fn test_multiple_providers_same_cid_returned() {
975 let mut c = default_cache();
976 for i in 0_u64..5 {
977 c.add_provider(make_record(
978 "cid1",
979 &format!("peer{i}"),
980 i,
981 DEFAULT_PROVIDER_TTL_MS,
982 ));
983 }
984 let got = c.get_providers("cid1", 0);
985 assert_eq!(got.len(), 5);
986 }
987
988 #[test]
991 fn test_partial_expiry_within_cid_bucket() {
992 let mut c = default_cache();
993 c.add_provider(make_record("cid1", "peer1", 0, 50));
994 c.add_provider(make_record("cid1", "peer2", 0, 200));
995 c.add_provider(make_record("cid1", "peer3", 0, 300));
996 let got = c.get_providers("cid1", 100);
998 assert_eq!(got.len(), 2);
999 let ids: Vec<&str> = got.iter().map(|r| r.peer_id.as_str()).collect();
1000 assert!(ids.contains(&"peer2"));
1001 assert!(ids.contains(&"peer3"));
1002 }
1003}