Skip to main content

ipfrs_network/
discovery_cache.rs

1//! Peer Discovery Cache
2//!
3//! Caches peer discovery results from multiple sources (DHT, mDNS, bootstrap, relay)
4//! to reduce redundant lookups and enable offline-resilient reconnection.
5
6use std::collections::HashMap;
7
8/// Source from which a peer was discovered.
9#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
10pub enum DiscoverySource {
11    /// Kademlia DHT discovery
12    Dht,
13    /// mDNS local discovery
14    Mdns,
15    /// Bootstrap node discovery
16    Bootstrap,
17    /// Relay-based discovery
18    Relay,
19    /// Manually added peer
20    Manual,
21}
22
23/// A record representing a discovered peer and its connection history.
24#[derive(Clone, Debug)]
25pub struct PeerRecord {
26    /// Peer identifier (libp2p PeerId as string)
27    pub peer_id: String,
28    /// Known multiaddrs for this peer
29    pub addresses: Vec<String>,
30    /// Source from which this peer was discovered
31    pub source: DiscoverySource,
32    /// Unix timestamp (seconds) when this peer was first discovered
33    pub discovered_at_secs: u64,
34    /// Unix timestamp (seconds) when this peer was last seen
35    pub last_seen_secs: u64,
36    /// Number of successful connection attempts
37    pub successful_connections: u32,
38    /// Number of failed connection attempts
39    pub failed_connections: u32,
40}
41
42impl PeerRecord {
43    /// Compute a reliability score in [0.0, 1.0].
44    ///
45    /// Returns 0.5 when no connection attempts have been made.
46    /// Otherwise returns `successful / (successful + failed)`.
47    pub fn reliability_score(&self) -> f64 {
48        let total = self.successful_connections + self.failed_connections;
49        if total == 0 {
50            0.5
51        } else {
52            self.successful_connections as f64 / total as f64
53        }
54    }
55}
56
57/// Aggregate statistics for the discovery cache.
58#[derive(Clone, Debug)]
59pub struct DiscoveryCacheStats {
60    /// Total number of peers currently cached
61    pub total_peers: usize,
62    /// Count of peers per discovery source
63    pub by_source: HashMap<DiscoverySource, usize>,
64    /// Average reliability score across all cached peers (0.0 if no peers)
65    pub avg_reliability: f64,
66}
67
68/// Cache for peer discovery results with TTL and capacity management.
69pub struct PeerDiscoveryCache {
70    /// Peer records keyed by peer_id
71    pub records: HashMap<String, PeerRecord>,
72    /// Maximum number of records to retain
73    pub max_size: usize,
74    /// Time-to-live in seconds; records older than this are stale
75    pub ttl_secs: u64,
76}
77
78impl PeerDiscoveryCache {
79    /// Create a new empty cache.
80    pub fn new(max_size: usize, ttl_secs: u64) -> Self {
81        Self {
82            records: HashMap::new(),
83            max_size,
84            ttl_secs,
85        }
86    }
87
88    /// Insert or update a peer record.
89    ///
90    /// If the peer already exists, addresses are merged (deduped), `last_seen_secs` is
91    /// updated if the incoming record is newer, and `source` is updated.
92    ///
93    /// If the cache is at capacity and the peer is new, the peer with the lowest
94    /// `reliability_score` is evicted (ties broken by oldest `last_seen_secs`).
95    pub fn upsert(&mut self, record: PeerRecord) {
96        if let Some(existing) = self.records.get_mut(&record.peer_id) {
97            // Merge addresses (dedup)
98            for addr in &record.addresses {
99                if !existing.addresses.contains(addr) {
100                    existing.addresses.push(addr.clone());
101                }
102            }
103            // Update last_seen_secs if incoming is newer
104            if record.last_seen_secs > existing.last_seen_secs {
105                existing.last_seen_secs = record.last_seen_secs;
106            }
107            // Update source
108            existing.source = record.source;
109            return;
110        }
111
112        // New peer — evict if at capacity
113        if self.records.len() >= self.max_size {
114            self.evict_lowest_reliability();
115        }
116
117        self.records.insert(record.peer_id.clone(), record);
118    }
119
120    /// Evict the peer with the lowest reliability score.
121    /// Ties are broken by choosing the peer with the oldest `last_seen_secs`.
122    fn evict_lowest_reliability(&mut self) {
123        let victim = self
124            .records
125            .iter()
126            .min_by(|a, b| {
127                let score_a = a.1.reliability_score();
128                let score_b = b.1.reliability_score();
129                score_a
130                    .partial_cmp(&score_b)
131                    .unwrap_or(std::cmp::Ordering::Equal)
132                    .then_with(|| a.1.last_seen_secs.cmp(&b.1.last_seen_secs))
133            })
134            .map(|(k, _)| k.clone());
135
136        if let Some(key) = victim {
137            self.records.remove(&key);
138        }
139    }
140
141    /// Record a successful connection to a peer.
142    ///
143    /// Increments `successful_connections` and updates `last_seen_secs`.
144    /// Returns `false` if the peer is not in the cache.
145    pub fn record_success(&mut self, peer_id: &str, now_secs: u64) -> bool {
146        match self.records.get_mut(peer_id) {
147            Some(record) => {
148                record.successful_connections += 1;
149                record.last_seen_secs = now_secs;
150                true
151            }
152            None => false,
153        }
154    }
155
156    /// Record a failed connection attempt to a peer.
157    ///
158    /// Increments `failed_connections`.
159    /// Returns `false` if the peer is not in the cache.
160    pub fn record_failure(&mut self, peer_id: &str) -> bool {
161        match self.records.get_mut(peer_id) {
162            Some(record) => {
163                record.failed_connections += 1;
164                true
165            }
166            None => false,
167        }
168    }
169
170    /// Remove all records where `last_seen_secs + ttl_secs < now_secs`.
171    pub fn evict_stale(&mut self, now_secs: u64) {
172        let ttl = self.ttl_secs;
173        self.records
174            .retain(|_, record| record.last_seen_secs + ttl >= now_secs);
175    }
176
177    /// Look up a peer record by peer_id.
178    pub fn get(&self, peer_id: &str) -> Option<&PeerRecord> {
179        self.records.get(peer_id)
180    }
181
182    /// Return all peer records sorted by reliability score descending.
183    pub fn peers_by_reliability(&self) -> Vec<&PeerRecord> {
184        let mut peers: Vec<&PeerRecord> = self.records.values().collect();
185        peers.sort_by(|a, b| {
186            b.reliability_score()
187                .partial_cmp(&a.reliability_score())
188                .unwrap_or(std::cmp::Ordering::Equal)
189        });
190        peers
191    }
192
193    /// Return all peers discovered from a specific source, sorted by `last_seen_secs` descending.
194    pub fn peers_from_source(&self, source: DiscoverySource) -> Vec<&PeerRecord> {
195        let mut peers: Vec<&PeerRecord> = self
196            .records
197            .values()
198            .filter(|r| r.source == source)
199            .collect();
200        peers.sort_by_key(|b| std::cmp::Reverse(b.last_seen_secs));
201        peers
202    }
203
204    /// Remove a peer from the cache.
205    ///
206    /// Returns `true` if the peer was present and removed, `false` otherwise.
207    pub fn remove(&mut self, peer_id: &str) -> bool {
208        self.records.remove(peer_id).is_some()
209    }
210
211    /// Compute and return aggregate cache statistics.
212    pub fn stats(&self) -> DiscoveryCacheStats {
213        let total_peers = self.records.len();
214
215        let mut by_source: HashMap<DiscoverySource, usize> = HashMap::new();
216        let mut reliability_sum = 0.0_f64;
217
218        for record in self.records.values() {
219            *by_source.entry(record.source).or_insert(0) += 1;
220            reliability_sum += record.reliability_score();
221        }
222
223        let avg_reliability = if total_peers == 0 {
224            0.0
225        } else {
226            reliability_sum / total_peers as f64
227        };
228
229        DiscoveryCacheStats {
230            total_peers,
231            by_source,
232            avg_reliability,
233        }
234    }
235}
236
237// ──────────────────────────────────────────────────────────────────────────────
238// Tests
239// ──────────────────────────────────────────────────────────────────────────────
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244
245    fn make_record(
246        peer_id: &str,
247        source: DiscoverySource,
248        last_seen_secs: u64,
249        successful: u32,
250        failed: u32,
251    ) -> PeerRecord {
252        PeerRecord {
253            peer_id: peer_id.to_string(),
254            addresses: vec![format!("/ip4/127.0.0.1/tcp/{}", peer_id.len())],
255            source,
256            discovered_at_secs: 1000,
257            last_seen_secs,
258            successful_connections: successful,
259            failed_connections: failed,
260        }
261    }
262
263    // 1. new() starts empty
264    #[test]
265    fn test_new_starts_empty() {
266        let cache = PeerDiscoveryCache::new(10, 300);
267        assert_eq!(cache.records.len(), 0);
268        assert_eq!(cache.max_size, 10);
269        assert_eq!(cache.ttl_secs, 300);
270    }
271
272    // 2. upsert adds new record
273    #[test]
274    fn test_upsert_adds_new_record() {
275        let mut cache = PeerDiscoveryCache::new(10, 300);
276        let rec = make_record("peer1", DiscoverySource::Dht, 2000, 0, 0);
277        cache.upsert(rec);
278        assert!(cache.records.contains_key("peer1"));
279    }
280
281    // 3. upsert updates existing (merge addresses, update last_seen)
282    #[test]
283    fn test_upsert_updates_existing_merge_addresses() {
284        let mut cache = PeerDiscoveryCache::new(10, 300);
285        let rec1 = PeerRecord {
286            peer_id: "peer1".to_string(),
287            addresses: vec!["/ip4/1.2.3.4/tcp/4001".to_string()],
288            source: DiscoverySource::Dht,
289            discovered_at_secs: 1000,
290            last_seen_secs: 2000,
291            successful_connections: 0,
292            failed_connections: 0,
293        };
294        cache.upsert(rec1);
295
296        let rec2 = PeerRecord {
297            peer_id: "peer1".to_string(),
298            addresses: vec![
299                "/ip4/1.2.3.4/tcp/4001".to_string(),
300                "/ip4/5.6.7.8/tcp/4002".to_string(),
301            ],
302            source: DiscoverySource::Mdns,
303            discovered_at_secs: 1500,
304            last_seen_secs: 3000,
305            successful_connections: 0,
306            failed_connections: 0,
307        };
308        cache.upsert(rec2);
309
310        let record = cache
311            .records
312            .get("peer1")
313            .expect("test: peer1 should exist in cache after upsert");
314        assert_eq!(record.addresses.len(), 2);
315        assert_eq!(record.last_seen_secs, 3000);
316        assert_eq!(record.source, DiscoverySource::Mdns);
317    }
318
319    // 4. upsert at capacity evicts lowest reliability
320    #[test]
321    fn test_upsert_at_capacity_evicts_lowest_reliability() {
322        let mut cache = PeerDiscoveryCache::new(2, 300);
323        // peer_a: reliability 1.0 (1 success, 0 fail)
324        cache.upsert(make_record("peer_a", DiscoverySource::Dht, 2000, 1, 0));
325        // peer_b: reliability 0.0 (0 success, 1 fail)
326        cache.upsert(make_record("peer_b", DiscoverySource::Dht, 2000, 0, 1));
327        // peer_c should evict peer_b (lowest reliability)
328        cache.upsert(make_record("peer_c", DiscoverySource::Dht, 2000, 1, 1));
329
330        assert!(cache.records.contains_key("peer_a"));
331        assert!(!cache.records.contains_key("peer_b"));
332        assert!(cache.records.contains_key("peer_c"));
333    }
334
335    // 5. upsert at capacity: tie broken by oldest last_seen_secs
336    #[test]
337    fn test_upsert_at_capacity_tie_broken_by_oldest_last_seen() {
338        let mut cache = PeerDiscoveryCache::new(2, 300);
339        // Both peers have default reliability (0.5 — no connections)
340        // peer_a last seen at 1000 (older)
341        cache.upsert(make_record("peer_a", DiscoverySource::Dht, 1000, 0, 0));
342        // peer_b last seen at 2000 (newer)
343        cache.upsert(make_record("peer_b", DiscoverySource::Dht, 2000, 0, 0));
344        // peer_c should evict peer_a (tie in reliability, older last_seen)
345        cache.upsert(make_record("peer_c", DiscoverySource::Dht, 3000, 0, 0));
346
347        assert!(
348            !cache.records.contains_key("peer_a"),
349            "peer_a should have been evicted"
350        );
351        assert!(cache.records.contains_key("peer_b"));
352        assert!(cache.records.contains_key("peer_c"));
353    }
354
355    // 6. record_success increments counter
356    #[test]
357    fn test_record_success_increments_counter() {
358        let mut cache = PeerDiscoveryCache::new(10, 300);
359        cache.upsert(make_record("peer1", DiscoverySource::Dht, 2000, 0, 0));
360        assert!(cache.record_success("peer1", 3000));
361        assert_eq!(cache.records["peer1"].successful_connections, 1);
362    }
363
364    // 7. record_success updates last_seen_secs
365    #[test]
366    fn test_record_success_updates_last_seen() {
367        let mut cache = PeerDiscoveryCache::new(10, 300);
368        cache.upsert(make_record("peer1", DiscoverySource::Dht, 2000, 0, 0));
369        cache.record_success("peer1", 5000);
370        assert_eq!(cache.records["peer1"].last_seen_secs, 5000);
371    }
372
373    // 8. record_success returns false for unknown peer
374    #[test]
375    fn test_record_success_false_if_unknown() {
376        let mut cache = PeerDiscoveryCache::new(10, 300);
377        assert!(!cache.record_success("unknown_peer", 5000));
378    }
379
380    // 9. record_failure increments counter
381    #[test]
382    fn test_record_failure_increments_counter() {
383        let mut cache = PeerDiscoveryCache::new(10, 300);
384        cache.upsert(make_record("peer1", DiscoverySource::Dht, 2000, 0, 0));
385        assert!(cache.record_failure("peer1"));
386        assert_eq!(cache.records["peer1"].failed_connections, 1);
387    }
388
389    // 10. record_failure returns false for unknown peer
390    #[test]
391    fn test_record_failure_false_if_unknown() {
392        let mut cache = PeerDiscoveryCache::new(10, 300);
393        assert!(!cache.record_failure("unknown_peer"));
394    }
395
396    // 11. reliability_score = 0.5 when no connections
397    #[test]
398    fn test_reliability_score_no_connections() {
399        let rec = make_record("peer1", DiscoverySource::Dht, 2000, 0, 0);
400        assert!((rec.reliability_score() - 0.5).abs() < f64::EPSILON);
401    }
402
403    // 12. reliability_score computed correctly
404    #[test]
405    fn test_reliability_score_computed_correctly() {
406        let rec = make_record("peer1", DiscoverySource::Dht, 2000, 3, 1);
407        assert!((rec.reliability_score() - 0.75).abs() < f64::EPSILON);
408
409        let rec2 = make_record("peer2", DiscoverySource::Dht, 2000, 0, 4);
410        assert!(rec2.reliability_score().abs() < f64::EPSILON);
411
412        let rec3 = make_record("peer3", DiscoverySource::Dht, 2000, 5, 0);
413        assert!((rec3.reliability_score() - 1.0).abs() < f64::EPSILON);
414    }
415
416    // 13. evict_stale removes old records
417    #[test]
418    fn test_evict_stale_removes_old() {
419        let mut cache = PeerDiscoveryCache::new(10, 100);
420        // last_seen = 500, ttl = 100; at now=700: 500+100=600 < 700 => stale
421        cache.upsert(make_record("stale_peer", DiscoverySource::Dht, 500, 0, 0));
422        cache.evict_stale(700);
423        assert!(!cache.records.contains_key("stale_peer"));
424    }
425
426    // 14. evict_stale keeps fresh records
427    #[test]
428    fn test_evict_stale_keeps_fresh() {
429        let mut cache = PeerDiscoveryCache::new(10, 100);
430        // last_seen = 650, ttl = 100; at now=700: 650+100=750 >= 700 => fresh
431        cache.upsert(make_record("fresh_peer", DiscoverySource::Dht, 650, 0, 0));
432        cache.evict_stale(700);
433        assert!(cache.records.contains_key("fresh_peer"));
434    }
435
436    // 15. peers_by_reliability sorted descending
437    #[test]
438    fn test_peers_by_reliability_sorted_desc() {
439        let mut cache = PeerDiscoveryCache::new(10, 300);
440        cache.upsert(make_record("low", DiscoverySource::Dht, 2000, 1, 9)); // 0.1
441        cache.upsert(make_record("mid", DiscoverySource::Dht, 2000, 5, 5)); // 0.5
442        cache.upsert(make_record("high", DiscoverySource::Dht, 2000, 9, 1)); // 0.9
443
444        let sorted = cache.peers_by_reliability();
445        assert_eq!(sorted.len(), 3);
446        assert!(sorted[0].reliability_score() >= sorted[1].reliability_score());
447        assert!(sorted[1].reliability_score() >= sorted[2].reliability_score());
448        assert_eq!(sorted[0].peer_id, "high");
449        assert_eq!(sorted[2].peer_id, "low");
450    }
451
452    // 16. peers_from_source filters correctly
453    #[test]
454    fn test_peers_from_source_filters_correctly() {
455        let mut cache = PeerDiscoveryCache::new(10, 300);
456        cache.upsert(make_record("dht1", DiscoverySource::Dht, 2000, 0, 0));
457        cache.upsert(make_record("dht2", DiscoverySource::Dht, 2001, 0, 0));
458        cache.upsert(make_record("mdns1", DiscoverySource::Mdns, 2002, 0, 0));
459
460        let dht_peers = cache.peers_from_source(DiscoverySource::Dht);
461        assert_eq!(dht_peers.len(), 2);
462        for p in &dht_peers {
463            assert_eq!(p.source, DiscoverySource::Dht);
464        }
465
466        let mdns_peers = cache.peers_from_source(DiscoverySource::Mdns);
467        assert_eq!(mdns_peers.len(), 1);
468        assert_eq!(mdns_peers[0].peer_id, "mdns1");
469    }
470
471    // 17. peers_from_source sorted by last_seen_secs descending
472    #[test]
473    fn test_peers_from_source_sorted_by_last_seen_desc() {
474        let mut cache = PeerDiscoveryCache::new(10, 300);
475        cache.upsert(make_record("a", DiscoverySource::Bootstrap, 1000, 0, 0));
476        cache.upsert(make_record("b", DiscoverySource::Bootstrap, 3000, 0, 0));
477        cache.upsert(make_record("c", DiscoverySource::Bootstrap, 2000, 0, 0));
478
479        let peers = cache.peers_from_source(DiscoverySource::Bootstrap);
480        assert_eq!(peers.len(), 3);
481        assert_eq!(peers[0].last_seen_secs, 3000);
482        assert_eq!(peers[1].last_seen_secs, 2000);
483        assert_eq!(peers[2].last_seen_secs, 1000);
484    }
485
486    // 18. remove returns true when peer found and removed
487    #[test]
488    fn test_remove_returns_true_when_present() {
489        let mut cache = PeerDiscoveryCache::new(10, 300);
490        cache.upsert(make_record("peer1", DiscoverySource::Dht, 2000, 0, 0));
491        assert!(cache.remove("peer1"));
492        assert!(!cache.records.contains_key("peer1"));
493    }
494
495    // 19. remove returns false when peer not found
496    #[test]
497    fn test_remove_returns_false_when_absent() {
498        let mut cache = PeerDiscoveryCache::new(10, 300);
499        assert!(!cache.remove("nonexistent"));
500    }
501
502    // 20. get returns Some for known peer
503    #[test]
504    fn test_get_returns_some() {
505        let mut cache = PeerDiscoveryCache::new(10, 300);
506        cache.upsert(make_record("peer1", DiscoverySource::Relay, 2000, 2, 1));
507        let rec = cache.get("peer1");
508        assert!(rec.is_some());
509        assert_eq!(
510            rec.expect("test: peer1 record should be found by get()")
511                .peer_id,
512            "peer1"
513        );
514    }
515
516    // 21. get returns None for unknown peer
517    #[test]
518    fn test_get_returns_none() {
519        let cache = PeerDiscoveryCache::new(10, 300);
520        assert!(cache.get("nobody").is_none());
521    }
522
523    // 22. stats total_peers
524    #[test]
525    fn test_stats_total_peers() {
526        let mut cache = PeerDiscoveryCache::new(10, 300);
527        assert_eq!(cache.stats().total_peers, 0);
528        cache.upsert(make_record("p1", DiscoverySource::Dht, 2000, 0, 0));
529        cache.upsert(make_record("p2", DiscoverySource::Dht, 2000, 0, 0));
530        assert_eq!(cache.stats().total_peers, 2);
531    }
532
533    // 23. stats by_source counts
534    #[test]
535    fn test_stats_by_source_counts() {
536        let mut cache = PeerDiscoveryCache::new(10, 300);
537        cache.upsert(make_record("d1", DiscoverySource::Dht, 2000, 0, 0));
538        cache.upsert(make_record("d2", DiscoverySource::Dht, 2000, 0, 0));
539        cache.upsert(make_record("m1", DiscoverySource::Mdns, 2000, 0, 0));
540        cache.upsert(make_record("b1", DiscoverySource::Bootstrap, 2000, 0, 0));
541
542        let stats = cache.stats();
543        assert_eq!(*stats.by_source.get(&DiscoverySource::Dht).unwrap_or(&0), 2);
544        assert_eq!(
545            *stats.by_source.get(&DiscoverySource::Mdns).unwrap_or(&0),
546            1
547        );
548        assert_eq!(
549            *stats
550                .by_source
551                .get(&DiscoverySource::Bootstrap)
552                .unwrap_or(&0),
553            1
554        );
555        assert_eq!(
556            *stats.by_source.get(&DiscoverySource::Relay).unwrap_or(&0),
557            0
558        );
559    }
560
561    // 24. stats avg_reliability
562    #[test]
563    fn test_stats_avg_reliability() {
564        let mut cache = PeerDiscoveryCache::new(10, 300);
565        // Empty cache => 0.0
566        assert!((cache.stats().avg_reliability - 0.0).abs() < f64::EPSILON);
567
568        // Two peers: reliability 1.0 and 0.0 => avg 0.5
569        cache.upsert(make_record("good", DiscoverySource::Dht, 2000, 4, 0));
570        cache.upsert(make_record("bad", DiscoverySource::Dht, 2000, 0, 4));
571        let avg = cache.stats().avg_reliability;
572        assert!((avg - 0.5).abs() < f64::EPSILON);
573    }
574
575    // 25. upsert does not change last_seen_secs when incoming is older
576    #[test]
577    fn test_upsert_does_not_update_last_seen_if_older() {
578        let mut cache = PeerDiscoveryCache::new(10, 300);
579        cache.upsert(make_record("peer1", DiscoverySource::Dht, 5000, 0, 0));
580        let older = PeerRecord {
581            peer_id: "peer1".to_string(),
582            addresses: vec![],
583            source: DiscoverySource::Mdns,
584            discovered_at_secs: 1000,
585            last_seen_secs: 3000,
586            successful_connections: 0,
587            failed_connections: 0,
588        };
589        cache.upsert(older);
590        assert_eq!(cache.records["peer1"].last_seen_secs, 5000);
591    }
592
593    // 26. Manual source entries work correctly
594    #[test]
595    fn test_manual_source() {
596        let mut cache = PeerDiscoveryCache::new(10, 300);
597        cache.upsert(make_record("manual1", DiscoverySource::Manual, 9000, 10, 0));
598        let peers = cache.peers_from_source(DiscoverySource::Manual);
599        assert_eq!(peers.len(), 1);
600        assert!((peers[0].reliability_score() - 1.0).abs() < f64::EPSILON);
601    }
602}