Skip to main content

ipfrs_network/
peer_discovery.rs

1//! Peer Discovery Manager
2//!
3//! Manages multiple peer discovery mechanisms (mDNS, Bootstrap, DHT random walk, etc.)
4//! and deduplicates discovered peers across all sources.
5
6use std::collections::HashMap;
7
8/// Source from which a peer was discovered.
9#[derive(Clone, Debug, PartialEq)]
10pub enum DiscoverySource {
11    /// Local network multicast discovery via mDNS.
12    Mdns,
13    /// Discovered from a static bootstrap list.
14    Bootstrap,
15    /// Discovered via DHT iterative peer lookup (random walk).
16    DhtRandomWalk,
17    /// A connected peer advertised this peer (peer exchange).
18    PeerExchange,
19    /// Manually added by an operator.
20    Manual,
21}
22
23/// A peer that has been discovered through one of the discovery mechanisms.
24#[derive(Clone, Debug)]
25pub struct DiscoveredPeer {
26    /// Unique peer identifier (e.g., libp2p PeerId as string).
27    pub peer_id: String,
28    /// Known multiaddresses for this peer.
29    pub addresses: Vec<String>,
30    /// Which mechanism surfaced this peer.
31    pub source: DiscoverySource,
32    /// Unix timestamp (seconds) when this peer was first discovered.
33    pub discovered_at_secs: u64,
34    /// How many dial attempts have been made so far.
35    pub dial_attempts: u32,
36    /// Result of the most recent dial attempt.
37    /// `Some(true)` = success, `Some(false)` = failure, `None` = not yet dialed.
38    pub last_dial_result: Option<bool>,
39}
40
41impl DiscoveredPeer {
42    /// Returns `true` when another dial should be attempted.
43    ///
44    /// A retry is warranted when we have not yet reached `max_attempts` *and*
45    /// the peer has not already been reached successfully.
46    pub fn should_retry(&self, max_attempts: u32) -> bool {
47        self.dial_attempts < max_attempts && self.last_dial_result != Some(true)
48    }
49}
50
51/// Aggregate statistics for the discovery manager.
52#[derive(Clone, Debug, Default)]
53pub struct DiscoveryStats {
54    /// Total number of unique peers ever added (duplicates excluded).
55    pub total_discovered: u64,
56    /// Peers sourced from mDNS.
57    pub from_mdns: u64,
58    /// Peers sourced from a bootstrap list.
59    pub from_bootstrap: u64,
60    /// Peers sourced from DHT random walk.
61    pub from_dht: u64,
62    /// Peers sourced from peer exchange.
63    pub from_peer_exchange: u64,
64    /// Peers sourced from manual operator input.
65    pub from_manual: u64,
66    /// How many `add_peer` calls were rejected because the peer was already known.
67    pub duplicates_skipped: u64,
68    /// Cumulative successful dial results recorded.
69    pub dial_successes: u64,
70    /// Cumulative failed dial results recorded.
71    pub dial_failures: u64,
72}
73
74/// Manages multiple peer discovery mechanisms and deduplicates discovered peers.
75pub struct PeerDiscoveryManager {
76    /// Map from peer_id to its discovery record.
77    pub peers: HashMap<String, DiscoveredPeer>,
78    /// Maximum number of dial attempts before a peer is considered permanently unreachable.
79    pub max_dial_attempts: u32,
80    /// Running statistics.
81    pub stats: DiscoveryStats,
82}
83
84impl PeerDiscoveryManager {
85    /// Create a new manager with the given dial-attempt limit.
86    pub fn new(max_dial_attempts: u32) -> Self {
87        Self {
88            peers: HashMap::new(),
89            max_dial_attempts,
90            stats: DiscoveryStats::default(),
91        }
92    }
93
94    /// Attempt to register a newly discovered peer.
95    ///
96    /// Returns `true` when the peer was inserted (first time seen).
97    /// Returns `false` when the peer was already known; `stats.duplicates_skipped` is
98    /// incremented in that case.
99    pub fn add_peer(&mut self, peer: DiscoveredPeer) -> bool {
100        if self.peers.contains_key(&peer.peer_id) {
101            self.stats.duplicates_skipped += 1;
102            return false;
103        }
104
105        // Update per-source counters.
106        match &peer.source {
107            DiscoverySource::Mdns => self.stats.from_mdns += 1,
108            DiscoverySource::Bootstrap => self.stats.from_bootstrap += 1,
109            DiscoverySource::DhtRandomWalk => self.stats.from_dht += 1,
110            DiscoverySource::PeerExchange => self.stats.from_peer_exchange += 1,
111            DiscoverySource::Manual => self.stats.from_manual += 1,
112        }
113
114        self.stats.total_discovered += 1;
115        self.peers.insert(peer.peer_id.clone(), peer);
116        true
117    }
118
119    /// Record the outcome of a dial attempt for the given peer.
120    ///
121    /// Increments `dial_attempts`, updates `last_dial_result`, and bumps the
122    /// appropriate aggregate counter.  If `peer_id` is unknown this is a no-op.
123    pub fn record_dial_result(&mut self, peer_id: &str, success: bool) {
124        if let Some(peer) = self.peers.get_mut(peer_id) {
125            peer.dial_attempts += 1;
126            peer.last_dial_result = Some(success);
127            if success {
128                self.stats.dial_successes += 1;
129            } else {
130                self.stats.dial_failures += 1;
131            }
132        }
133    }
134
135    /// Return all peers that should still be dialed, sorted by ascending `dial_attempts`.
136    ///
137    /// A peer is a candidate when `should_retry(max_dial_attempts)` is true.
138    pub fn candidates_to_dial(&self) -> Vec<&DiscoveredPeer> {
139        let mut candidates: Vec<&DiscoveredPeer> = self
140            .peers
141            .values()
142            .filter(|p| p.should_retry(self.max_dial_attempts))
143            .collect();
144        candidates.sort_by_key(|p| p.dial_attempts);
145        candidates
146    }
147
148    /// Return all peers whose last dial was successful.
149    pub fn connected_peers(&self) -> Vec<&DiscoveredPeer> {
150        self.peers
151            .values()
152            .filter(|p| p.last_dial_result == Some(true))
153            .collect()
154    }
155
156    /// Return all peers that have exhausted their dial budget without success.
157    pub fn failed_peers(&self) -> Vec<&DiscoveredPeer> {
158        self.peers
159            .values()
160            .filter(|p| {
161                p.dial_attempts >= self.max_dial_attempts && p.last_dial_result != Some(true)
162            })
163            .collect()
164    }
165
166    /// Return all peers discovered via a specific source.
167    pub fn peers_by_source(&self, source: DiscoverySource) -> Vec<&DiscoveredPeer> {
168        self.peers.values().filter(|p| p.source == source).collect()
169    }
170
171    /// Remove a peer from the manager.
172    ///
173    /// Returns `true` if the peer was present and has been removed, `false` otherwise.
174    pub fn remove_peer(&mut self, peer_id: &str) -> bool {
175        self.peers.remove(peer_id).is_some()
176    }
177
178    /// Borrow the current discovery statistics.
179    pub fn stats(&self) -> &DiscoveryStats {
180        &self.stats
181    }
182
183    /// Merge additional addresses into a known peer's address list.
184    ///
185    /// Any address already recorded for that peer is silently skipped.
186    /// If `peer_id` is unknown this is a no-op.
187    pub fn merge_addresses(&mut self, peer_id: &str, new_addrs: &[String]) {
188        if let Some(peer) = self.peers.get_mut(peer_id) {
189            for addr in new_addrs {
190                if !peer.addresses.contains(addr) {
191                    peer.addresses.push(addr.clone());
192                }
193            }
194        }
195    }
196}
197
198// ─── Tests ────────────────────────────────────────────────────────────────────
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203
204    /// Helper: build a minimal `DiscoveredPeer` with sensible defaults.
205    fn make_peer(id: &str, source: DiscoverySource) -> DiscoveredPeer {
206        DiscoveredPeer {
207            peer_id: id.to_string(),
208            addresses: vec![format!("/ip4/127.0.0.1/tcp/{}", id.len())],
209            source,
210            discovered_at_secs: 1_000_000,
211            dial_attempts: 0,
212            last_dial_result: None,
213        }
214    }
215
216    // ── 1. new() produces empty state ─────────────────────────────────────────
217
218    #[test]
219    fn test_new_empty_state() {
220        let mgr = PeerDiscoveryManager::new(3);
221        assert_eq!(mgr.max_dial_attempts, 3);
222        assert!(mgr.peers.is_empty());
223        let s = mgr.stats();
224        assert_eq!(s.total_discovered, 0);
225        assert_eq!(s.duplicates_skipped, 0);
226        assert_eq!(s.dial_successes, 0);
227        assert_eq!(s.dial_failures, 0);
228    }
229
230    // ── 2. add_peer: new peer returns true, stats updated ────────────────────
231
232    #[test]
233    fn test_add_new_peer_returns_true() {
234        let mut mgr = PeerDiscoveryManager::new(3);
235        let result = mgr.add_peer(make_peer("peer1", DiscoverySource::Mdns));
236        assert!(result);
237        assert_eq!(mgr.stats().total_discovered, 1);
238        assert_eq!(mgr.peers.len(), 1);
239    }
240
241    // ── 3. add_peer: duplicate returns false, duplicates_skipped++ ───────────
242
243    #[test]
244    fn test_add_duplicate_peer_returns_false() {
245        let mut mgr = PeerDiscoveryManager::new(3);
246        mgr.add_peer(make_peer("peer1", DiscoverySource::Mdns));
247        let result = mgr.add_peer(make_peer("peer1", DiscoverySource::Bootstrap));
248        assert!(!result);
249        assert_eq!(mgr.stats().duplicates_skipped, 1);
250        assert_eq!(mgr.stats().total_discovered, 1);
251        assert_eq!(mgr.peers.len(), 1);
252    }
253
254    // ── 4-a. per-source stats: Mdns ──────────────────────────────────────────
255
256    #[test]
257    fn test_per_source_stats_mdns() {
258        let mut mgr = PeerDiscoveryManager::new(3);
259        mgr.add_peer(make_peer("p1", DiscoverySource::Mdns));
260        assert_eq!(mgr.stats().from_mdns, 1);
261    }
262
263    // ── 4-b. per-source stats: Bootstrap ─────────────────────────────────────
264
265    #[test]
266    fn test_per_source_stats_bootstrap() {
267        let mut mgr = PeerDiscoveryManager::new(3);
268        mgr.add_peer(make_peer("p2", DiscoverySource::Bootstrap));
269        assert_eq!(mgr.stats().from_bootstrap, 1);
270    }
271
272    // ── 4-c. per-source stats: DhtRandomWalk ─────────────────────────────────
273
274    #[test]
275    fn test_per_source_stats_dht() {
276        let mut mgr = PeerDiscoveryManager::new(3);
277        mgr.add_peer(make_peer("p3", DiscoverySource::DhtRandomWalk));
278        assert_eq!(mgr.stats().from_dht, 1);
279    }
280
281    // ── 4-d. per-source stats: PeerExchange ──────────────────────────────────
282
283    #[test]
284    fn test_per_source_stats_peer_exchange() {
285        let mut mgr = PeerDiscoveryManager::new(3);
286        mgr.add_peer(make_peer("p4", DiscoverySource::PeerExchange));
287        assert_eq!(mgr.stats().from_peer_exchange, 1);
288    }
289
290    // ── 4-e. per-source stats: Manual ────────────────────────────────────────
291
292    #[test]
293    fn test_per_source_stats_manual() {
294        let mut mgr = PeerDiscoveryManager::new(3);
295        mgr.add_peer(make_peer("p5", DiscoverySource::Manual));
296        assert_eq!(mgr.stats().from_manual, 1);
297    }
298
299    // ── 5. record_dial_result: success ────────────────────────────────────────
300
301    #[test]
302    fn test_record_dial_result_success() {
303        let mut mgr = PeerDiscoveryManager::new(3);
304        mgr.add_peer(make_peer("peer1", DiscoverySource::Bootstrap));
305        mgr.record_dial_result("peer1", true);
306        let peer = mgr.peers.get("peer1").expect("peer must exist");
307        assert_eq!(peer.dial_attempts, 1);
308        assert_eq!(peer.last_dial_result, Some(true));
309        assert_eq!(mgr.stats().dial_successes, 1);
310        assert_eq!(mgr.stats().dial_failures, 0);
311    }
312
313    // ── 6. record_dial_result: failure ────────────────────────────────────────
314
315    #[test]
316    fn test_record_dial_result_failure() {
317        let mut mgr = PeerDiscoveryManager::new(3);
318        mgr.add_peer(make_peer("peer1", DiscoverySource::Bootstrap));
319        mgr.record_dial_result("peer1", false);
320        let peer = mgr.peers.get("peer1").expect("peer must exist");
321        assert_eq!(peer.dial_attempts, 1);
322        assert_eq!(peer.last_dial_result, Some(false));
323        assert_eq!(mgr.stats().dial_successes, 0);
324        assert_eq!(mgr.stats().dial_failures, 1);
325    }
326
327    // ── 7. record_dial_result: unknown peer is no-op ──────────────────────────
328
329    #[test]
330    fn test_record_dial_result_unknown_peer_noop() {
331        let mut mgr = PeerDiscoveryManager::new(3);
332        mgr.record_dial_result("ghost", true);
333        assert_eq!(mgr.stats().dial_successes, 0);
334        assert_eq!(mgr.stats().dial_failures, 0);
335    }
336
337    // ── 8. should_retry: under max_attempts, not succeeded → true ─────────────
338
339    #[test]
340    fn test_should_retry_under_max_attempts() {
341        let peer = DiscoveredPeer {
342            peer_id: "p".to_string(),
343            addresses: vec![],
344            source: DiscoverySource::Mdns,
345            discovered_at_secs: 0,
346            dial_attempts: 1,
347            last_dial_result: Some(false),
348        };
349        assert!(peer.should_retry(3));
350    }
351
352    // ── 9. should_retry: at max_attempts → false ──────────────────────────────
353
354    #[test]
355    fn test_should_retry_at_max_attempts() {
356        let peer = DiscoveredPeer {
357            peer_id: "p".to_string(),
358            addresses: vec![],
359            source: DiscoverySource::Mdns,
360            discovered_at_secs: 0,
361            dial_attempts: 3,
362            last_dial_result: Some(false),
363        };
364        assert!(!peer.should_retry(3));
365    }
366
367    // ── 10. should_retry: already succeeded → false ───────────────────────────
368
369    #[test]
370    fn test_should_retry_already_succeeded() {
371        let peer = DiscoveredPeer {
372            peer_id: "p".to_string(),
373            addresses: vec![],
374            source: DiscoverySource::Mdns,
375            discovered_at_secs: 0,
376            dial_attempts: 1,
377            last_dial_result: Some(true),
378        };
379        assert!(!peer.should_retry(3));
380    }
381
382    // ── 11. candidates_to_dial sorted by dial_attempts ascending ──────────────
383
384    #[test]
385    fn test_candidates_to_dial_sorted_ascending() {
386        let mut mgr = PeerDiscoveryManager::new(5);
387
388        for (id, attempts) in [("pa", 2u32), ("pb", 0u32), ("pc", 1u32)] {
389            let mut p = make_peer(id, DiscoverySource::Bootstrap);
390            p.dial_attempts = attempts;
391            mgr.peers.insert(id.to_string(), p);
392        }
393
394        let candidates = mgr.candidates_to_dial();
395        assert_eq!(candidates.len(), 3);
396        let attempt_counts: Vec<u32> = candidates.iter().map(|p| p.dial_attempts).collect();
397        assert_eq!(attempt_counts, vec![0, 1, 2]);
398    }
399
400    // ── 12. connected_peers filtered correctly ────────────────────────────────
401
402    #[test]
403    fn test_connected_peers_filtered() {
404        let mut mgr = PeerDiscoveryManager::new(3);
405        mgr.add_peer(make_peer("pa", DiscoverySource::Mdns));
406        mgr.add_peer(make_peer("pb", DiscoverySource::Mdns));
407        mgr.add_peer(make_peer("pc", DiscoverySource::Mdns));
408        mgr.record_dial_result("pa", true);
409        mgr.record_dial_result("pb", false);
410
411        let connected = mgr.connected_peers();
412        assert_eq!(connected.len(), 1);
413        assert_eq!(connected[0].peer_id, "pa");
414    }
415
416    // ── 13. failed_peers filtered correctly ───────────────────────────────────
417
418    #[test]
419    fn test_failed_peers_filtered() {
420        let mut mgr = PeerDiscoveryManager::new(2);
421        mgr.add_peer(make_peer("pa", DiscoverySource::Bootstrap));
422        mgr.add_peer(make_peer("pb", DiscoverySource::Bootstrap));
423        mgr.add_peer(make_peer("pc", DiscoverySource::Bootstrap));
424
425        // Exhaust pa's attempts without success.
426        mgr.record_dial_result("pa", false);
427        mgr.record_dial_result("pa", false);
428
429        // pb succeeds on first try.
430        mgr.record_dial_result("pb", true);
431
432        // pc has one failed attempt (still within budget).
433        mgr.record_dial_result("pc", false);
434
435        let failed = mgr.failed_peers();
436        assert_eq!(failed.len(), 1);
437        assert_eq!(failed[0].peer_id, "pa");
438    }
439
440    // ── 14. peers_by_source filters by source ─────────────────────────────────
441
442    #[test]
443    fn test_peers_by_source() {
444        let mut mgr = PeerDiscoveryManager::new(3);
445        mgr.add_peer(make_peer("p1", DiscoverySource::Mdns));
446        mgr.add_peer(make_peer("p2", DiscoverySource::Bootstrap));
447        mgr.add_peer(make_peer("p3", DiscoverySource::Mdns));
448        mgr.add_peer(make_peer("p4", DiscoverySource::DhtRandomWalk));
449
450        let mdns_peers = mgr.peers_by_source(DiscoverySource::Mdns);
451        assert_eq!(mdns_peers.len(), 2);
452
453        let bootstrap_peers = mgr.peers_by_source(DiscoverySource::Bootstrap);
454        assert_eq!(bootstrap_peers.len(), 1);
455
456        let dht_peers = mgr.peers_by_source(DiscoverySource::DhtRandomWalk);
457        assert_eq!(dht_peers.len(), 1);
458
459        let manual_peers = mgr.peers_by_source(DiscoverySource::Manual);
460        assert_eq!(manual_peers.len(), 0);
461    }
462
463    // ── 15. remove_peer returns true / false ──────────────────────────────────
464
465    #[test]
466    fn test_remove_peer_returns_correct_bool() {
467        let mut mgr = PeerDiscoveryManager::new(3);
468        mgr.add_peer(make_peer("p1", DiscoverySource::Manual));
469
470        assert!(mgr.remove_peer("p1"));
471        assert!(!mgr.remove_peer("p1")); // already gone
472        assert!(!mgr.remove_peer("ghost")); // never existed
473        assert!(mgr.peers.is_empty());
474    }
475
476    // ── 16. merge_addresses adds new, deduplicates existing ──────────────────
477
478    #[test]
479    fn test_merge_addresses_deduplicates() {
480        let mut mgr = PeerDiscoveryManager::new(3);
481        let mut peer = make_peer("p1", DiscoverySource::Mdns);
482        peer.addresses = vec!["/ip4/1.2.3.4/tcp/4001".to_string()];
483        mgr.add_peer(peer);
484
485        // First merge: one new address, one duplicate.
486        mgr.merge_addresses(
487            "p1",
488            &[
489                "/ip4/1.2.3.4/tcp/4001".to_string(), // duplicate
490                "/ip4/5.6.7.8/tcp/4001".to_string(), // new
491            ],
492        );
493        let p = mgr.peers.get("p1").expect("peer must exist");
494        assert_eq!(p.addresses.len(), 2);
495
496        // Second merge: same two addresses again → no growth.
497        mgr.merge_addresses(
498            "p1",
499            &[
500                "/ip4/1.2.3.4/tcp/4001".to_string(),
501                "/ip4/5.6.7.8/tcp/4001".to_string(),
502            ],
503        );
504        let p = mgr.peers.get("p1").expect("peer must exist");
505        assert_eq!(p.addresses.len(), 2);
506
507        // Unknown peer: no-op (must not panic).
508        mgr.merge_addresses("ghost", &["/ip4/9.9.9.9/tcp/4001".to_string()]);
509    }
510
511    // ── 17. stats() totals correct after multiple operations ──────────────────
512
513    #[test]
514    fn test_stats_totals_after_multiple_operations() {
515        let mut mgr = PeerDiscoveryManager::new(3);
516
517        // Add five distinct peers from different sources.
518        mgr.add_peer(make_peer("p1", DiscoverySource::Mdns));
519        mgr.add_peer(make_peer("p2", DiscoverySource::Bootstrap));
520        mgr.add_peer(make_peer("p3", DiscoverySource::DhtRandomWalk));
521        mgr.add_peer(make_peer("p4", DiscoverySource::PeerExchange));
522        mgr.add_peer(make_peer("p5", DiscoverySource::Manual));
523
524        // Two duplicates.
525        mgr.add_peer(make_peer("p1", DiscoverySource::Mdns));
526        mgr.add_peer(make_peer("p3", DiscoverySource::Bootstrap));
527
528        // Three dial results.
529        mgr.record_dial_result("p1", true);
530        mgr.record_dial_result("p2", false);
531        mgr.record_dial_result("p3", true);
532
533        let s = mgr.stats();
534        assert_eq!(s.total_discovered, 5);
535        assert_eq!(s.from_mdns, 1);
536        assert_eq!(s.from_bootstrap, 1);
537        assert_eq!(s.from_dht, 1);
538        assert_eq!(s.from_peer_exchange, 1);
539        assert_eq!(s.from_manual, 1);
540        assert_eq!(s.duplicates_skipped, 2);
541        assert_eq!(s.dial_successes, 2);
542        assert_eq!(s.dial_failures, 1);
543    }
544}