Skip to main content

ipfrs_network/
content_routing_cache.rs

1//! Content Routing Cache — multi-tier DHT routing cache.
2//!
3//! Stores provider records, routing hints, and negative cache entries to reduce
4//! DHT lookup latency. Organises data into three independent tiers with
5//! configurable per-tier TTLs, per-CID provider limits, and global capacity
6//! caps with oldest-CID eviction.
7
8use std::collections::HashMap;
9
10// ── Constants ──────────────────────────────────────────────────────────────
11
12/// Default provider cache capacity (number of distinct CIDs).
13pub const DEFAULT_MAX_PROVIDERS: usize = 10_000;
14/// Default routing-hint cache capacity (number of distinct CIDs).
15pub const DEFAULT_MAX_HINTS: usize = 5_000;
16/// Default negative cache capacity (number of distinct CIDs).
17pub const DEFAULT_MAX_NEGATIVE: usize = 2_000;
18/// Default provider record TTL: 1 hour in milliseconds.
19pub const DEFAULT_PROVIDER_TTL_MS: u64 = 3_600_000;
20/// Default routing-hint TTL: 5 minutes in milliseconds.
21pub const DEFAULT_HINT_TTL_MS: u64 = 300_000;
22/// Default negative-entry TTL: 1 minute in milliseconds.
23pub const DEFAULT_NEGATIVE_TTL_MS: u64 = 60_000;
24/// Maximum provider records stored per CID.
25const MAX_PROVIDERS_PER_CID: usize = 20;
26
27// ── ProviderRecord ─────────────────────────────────────────────────────────
28
29/// A single provider record: which peer provides a given CID.
30#[derive(Debug, Clone, PartialEq)]
31pub struct CrcProviderRecord {
32    /// Content identifier (CID string).
33    pub cid: String,
34    /// Peer identifier (peer-id string).
35    pub peer_id: String,
36    /// Known multiaddresses for this peer.
37    pub multiaddrs: Vec<String>,
38    /// Millisecond timestamp when this record was last seen.
39    pub last_provided: u64,
40    /// Time-to-live in milliseconds.
41    pub ttl_ms: u64,
42}
43
44impl CrcProviderRecord {
45    /// Returns `true` when `now - last_provided > ttl_ms`.
46    #[inline]
47    pub fn is_expired(&self, now: u64) -> bool {
48        now.saturating_sub(self.last_provided) > self.ttl_ms
49    }
50}
51
52// ── RoutingHint ────────────────────────────────────────────────────────────
53
54/// Cached routing hint: the nearest peers known for a CID.
55#[derive(Debug, Clone, PartialEq)]
56pub struct RoutingHint {
57    /// Content identifier (CID string).
58    pub cid: String,
59    /// Peer IDs of the nearest known peers.
60    pub nearest_peers: Vec<String>,
61    /// Confidence score in [0.0, 1.0].
62    pub confidence: f64,
63    /// Millisecond timestamp when this hint was cached.
64    pub cached_at: u64,
65    /// Time-to-live in milliseconds.
66    pub ttl_ms: u64,
67}
68
69impl RoutingHint {
70    /// Returns `true` when `now - cached_at > ttl_ms`.
71    #[inline]
72    pub fn is_expired(&self, now: u64) -> bool {
73        now.saturating_sub(self.cached_at) > self.ttl_ms
74    }
75}
76
77// ── NegativeCacheEntry ─────────────────────────────────────────────────────
78
79/// Negative-cache entry: CID was looked up and not found.
80#[derive(Debug, Clone, PartialEq)]
81pub struct NegativeCacheEntry {
82    /// Content identifier (CID string).
83    pub cid: String,
84    /// Millisecond timestamp when the not-found result was recorded.
85    pub not_found_at: u64,
86    /// Time-to-live in milliseconds.
87    pub ttl_ms: u64,
88}
89
90impl NegativeCacheEntry {
91    /// Returns `true` when `now - not_found_at > ttl_ms`.
92    #[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// ── CacheConfig ────────────────────────────────────────────────────────────
99
100/// Configuration for [`ContentRoutingCache`].
101#[derive(Debug, Clone)]
102pub struct CacheConfig {
103    /// Maximum number of CIDs in the provider tier.
104    pub max_providers: usize,
105    /// Maximum number of CIDs in the routing-hint tier.
106    pub max_hints: usize,
107    /// Maximum number of CIDs in the negative-cache tier.
108    pub max_negative: usize,
109    /// Default provider record TTL in milliseconds.
110    pub default_provider_ttl_ms: u64,
111    /// Default routing-hint TTL in milliseconds.
112    pub default_hint_ttl_ms: u64,
113    /// Default negative-cache TTL in milliseconds.
114    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// ── CacheStats ─────────────────────────────────────────────────────────────
131
132/// Snapshot of cache statistics.
133#[derive(Debug, Clone, PartialEq)]
134pub struct CacheStats {
135    /// Number of distinct CIDs in the provider tier.
136    pub provider_cids: usize,
137    /// Total individual provider records stored.
138    pub total_providers: usize,
139    /// Number of routing hints cached.
140    pub hints_cached: usize,
141    /// Number of negative-cache entries.
142    pub negative_cached: usize,
143    /// Total provider lookups since creation.
144    pub total_provider_lookups: u64,
145    /// Total routing-hint lookups since creation.
146    pub total_hint_lookups: u64,
147    /// Fraction of lookups that were cache hits, in [0.0, 1.0].
148    pub hit_rate: f64,
149}
150
151// ── Internal eviction helper ───────────────────────────────────────────────
152
153/// Remove the entry with the smallest `key_fn` value from `map`.
154/// Tie-breaks deterministically by `HashMap` iteration order.
155fn 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// ── ContentRoutingCache ────────────────────────────────────────────────────
173
174/// Multi-tier cache for DHT content routing.
175///
176/// Three independent tiers:
177/// 1. **Provider tier** – stores [`CrcProviderRecord`]s (multiple per CID).
178/// 2. **Hint tier** – stores one [`RoutingHint`] per CID.
179/// 3. **Negative tier** – stores one [`NegativeCacheEntry`] per CID.
180#[derive(Debug)]
181pub struct ContentRoutingCache {
182    /// Cache configuration.
183    pub config: CacheConfig,
184    /// Provider tier: CID → list of provider records.
185    providers: HashMap<String, Vec<CrcProviderRecord>>,
186    /// Hint tier: CID → routing hint.
187    hints: HashMap<String, RoutingHint>,
188    /// Negative tier: CID → negative cache entry.
189    negative: HashMap<String, NegativeCacheEntry>,
190    /// Cumulative provider-tier lookup count.
191    pub total_provider_lookups: u64,
192    /// Cumulative hint-tier lookup count.
193    pub total_hint_lookups: u64,
194    /// Cumulative cache hits (provider + hint tiers combined).
195    pub cache_hits: u64,
196    /// Cumulative cache misses (provider + hint tiers combined).
197    pub cache_misses: u64,
198}
199
200impl ContentRoutingCache {
201    // ── Construction ───────────────────────────────────────────────────────
202
203    /// Create a new cache with the given configuration.
204    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    // ── Provider tier ──────────────────────────────────────────────────────
218
219    /// Insert a provider record.
220    ///
221    /// If the per-CID limit (`MAX_PROVIDERS_PER_CID`) would be exceeded the
222    /// oldest record (smallest `last_provided`) is evicted first.  If the
223    /// global provider-CID limit would be exceeded the CID whose most-recent
224    /// provider record is oldest is removed entirely.
225    pub fn add_provider(&mut self, record: CrcProviderRecord) {
226        // Global CID-level eviction: if map is at capacity AND this CID is new.
227        if !self.providers.contains_key(&record.cid)
228            && self.providers.len() >= self.config.max_providers
229        {
230            // Evict the CID with the oldest most-recent provider.
231            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        // Per-CID limit: evict the oldest record.
239        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    /// Return all non-expired provider records for `cid`.
254    ///
255    /// Expired records are removed in place.  Updates lookup and hit/miss
256    /// counters.
257    pub fn get_providers(&mut self, cid: &str, now: u64) -> Vec<&CrcProviderRecord> {
258        self.total_provider_lookups += 1;
259
260        // Sweep expired records for this CID only.
261        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    /// Remove a specific provider record identified by `(cid, peer_id)`.
281    ///
282    /// Returns `true` if a record was removed.
283    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    // ── Hint tier ──────────────────────────────────────────────────────────
297
298    /// Insert a routing hint.
299    ///
300    /// If the hint-tier capacity would be exceeded the oldest hint (smallest
301    /// `cached_at`) is evicted first.
302    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    /// Return the routing hint for `cid` if it exists and has not expired.
310    ///
311    /// If the hint exists but is expired it is removed and `None` is returned.
312    /// Updates lookup and hit/miss counters.
313    pub fn get_hint(&mut self, cid: &str, now: u64) -> Option<&RoutingHint> {
314        self.total_hint_lookups += 1;
315
316        // Check expiry first.
317        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    /// Remove the hint for `cid`.  Returns `true` if a hint was present.
338    pub fn remove_hint(&mut self, cid: &str) -> bool {
339        self.hints.remove(cid).is_some()
340    }
341
342    // ── Negative tier ─────────────────────────────────────────────────────
343
344    /// Insert a negative cache entry.
345    ///
346    /// If the negative-tier capacity would be exceeded the oldest entry
347    /// (smallest `not_found_at`) is evicted first.
348    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    /// Returns `true` if there is a live (non-expired) negative entry for `cid`.
358    ///
359    /// If the entry is expired it is removed.
360    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    /// Remove the negative entry for `cid`.  Returns `true` if one existed.
372    pub fn remove_negative(&mut self, cid: &str) -> bool {
373        self.negative.remove(cid).is_some()
374    }
375
376    // ── Cross-tier sweeps ─────────────────────────────────────────────────
377
378    /// Sweep all three tiers and remove expired entries.
379    ///
380    /// Returns the total number of items removed.
381    pub fn evict_expired(&mut self, now: u64) -> usize {
382        let mut removed = 0_usize;
383
384        // Provider tier: remove individual records, then empty CID buckets.
385        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        // Hint tier.
399        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        // Negative tier.
411        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    // ── Query helpers ─────────────────────────────────────────────────────
426
427    /// Returns `true` if the provider tier holds at least one record for `cid`.
428    ///
429    /// Note: this does **not** check expiry; call [`evict_expired`] or
430    /// [`get_providers`] to prune first if freshness matters.
431    ///
432    /// [`evict_expired`]: ContentRoutingCache::evict_expired
433    /// [`get_providers`]: ContentRoutingCache::get_providers
434    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    // ── Statistics ────────────────────────────────────────────────────────
442
443    /// Return a statistics snapshot.
444    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// ── Tests ──────────────────────────────────────────────────────────────────
470
471#[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    // ── Helpers ───────────────────────────────────────────────────────────
480
481    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    // ── CrcProviderRecord ─────────────────────────────────────────────────
514
515    #[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        // is_expired uses >, so at exactly ttl_ms it is NOT expired.
530        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        // now < last_provided → saturating_sub returns 0, not expired.
538        assert!(!r.is_expired(100));
539    }
540
541    // ── RoutingHint ────────────────────────────────────────────────────────
542
543    #[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    // ── NegativeCacheEntry ─────────────────────────────────────────────────
556
557    #[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    // ── add_provider / get_providers ───────────────────────────────────────
570
571    #[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        // Not expired at t=50.
592        assert_eq!(c.get_providers("cid1", 50).len(), 1);
593        // Expired at t=200.
594        let got = c.get_providers("cid1", 200);
595        assert!(got.is_empty());
596        // The CID should have been removed from the map entirely.
597        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        // hit
605        c.get_providers("cid1", 0);
606        // miss
607        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        // Fill up to the limit with timestamps 0..MAX-1.
617        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        // The 21st record triggers eviction of the oldest (peer0, ts=0).
626        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        // peer0 must be gone.
635        assert!(!providers.iter().any(|r| r.peer_id == "peer0"));
636        // peerNew must be present.
637        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        // Fourth CID: cid1 (ts=100) is oldest and should be evicted.
651        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    // ── remove_provider ────────────────────────────────────────────────────
660
661    #[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    // ── add_hint / get_hint ────────────────────────────────────────────────
687
688    #[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        // Expired hint must have been removed.
702        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); // hit
710        c.get_hint("missing", 0); // miss
711        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        // Third hint: cid1 (ts=100) should be evicted.
726        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    // ── remove_hint ───────────────────────────────────────────────────────
732
733    #[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    // ── add_negative / is_negative ─────────────────────────────────────────
748
749    #[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        // Third entry: cid1 (ts=100) should be evicted.
780        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    // ── remove_negative ────────────────────────────────────────────────────
789
790    #[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    // ── evict_expired ─────────────────────────────────────────────────────
805
806    #[test]
807    fn test_evict_expired_removes_all_tiers() {
808        let mut c = default_cache();
809        // Provider: cid1 expires, cid2 fresh.
810        c.add_provider(make_record("cid1", "peer1", 0, 100));
811        c.add_provider(make_record("cid2", "peer2", 0, DEFAULT_PROVIDER_TTL_MS));
812        // Hint: cid3 expires, cid4 fresh.
813        c.add_hint(make_hint("cid3", &["p3"], 0, 100));
814        c.add_hint(make_hint("cid4", &["p4"], 0, DEFAULT_HINT_TTL_MS));
815        // Negative: cid5 expires, cid6 fresh.
816        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        // 1 provider record + 1 hint + 1 negative = 3.
821        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        // Two providers for cid1: one expired, one fresh.
841        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    // ── has_content ────────────────────────────────────────────────────────
852
853    #[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    // ── cache_stats ────────────────────────────────────────────────────────
867
868    #[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); // hit
909        c.get_providers("miss1", 0); // miss
910        c.get_providers("miss2", 0); // miss
911        let s = c.cache_stats();
912        // 1 hit out of 3 lookups.
913        let expected = 1.0 / 3.0;
914        assert!((s.hit_rate - expected).abs() < 1e-10);
915    }
916
917    // ── CacheConfig default ────────────────────────────────────────────────
918
919    #[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    // ── CacheStats PartialEq ────────────────────────────────────────────────
931
932    #[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    // ── Hint overwrite ─────────────────────────────────────────────────────
948
949    #[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    // ── Negative overwrite ─────────────────────────────────────────────────
960
961    #[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    // ── Multiple providers same CID ────────────────────────────────────────
972
973    #[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    // ── Mixed expiry in single CID bucket ─────────────────────────────────
989
990    #[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        // At t=100: peer1 expired, peer2 and peer3 still live.
997        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}