1use parking_lot::RwLock;
12use std::cmp::Reverse;
13use std::collections::HashMap;
14use std::sync::atomic::{AtomicU64, Ordering};
15use std::sync::Arc;
16use std::time::{Duration, Instant};
17use tracing::{debug, info, warn};
18
19#[derive(Debug, Clone)]
25pub struct BootstrapPeer {
26 pub peer_id: String,
28 pub multiaddr: String,
30 pub priority: u8,
32 pub last_attempt: Option<Instant>,
34 pub last_success: Option<Instant>,
36 pub consecutive_failures: u32,
38}
39
40impl BootstrapPeer {
41 pub fn new(peer_id: impl Into<String>, multiaddr: impl Into<String>, priority: u8) -> Self {
43 Self {
44 peer_id: peer_id.into(),
45 multiaddr: multiaddr.into(),
46 priority,
47 last_attempt: None,
48 last_success: None,
49 consecutive_failures: 0,
50 }
51 }
52
53 #[inline]
58 pub fn is_available(&self) -> bool {
59 self.consecutive_failures < 5
60 }
61
62 pub fn backoff_duration(&self) -> Duration {
67 if self.consecutive_failures == 0 {
68 return Duration::from_secs(1);
69 }
70 let secs = 2u64.saturating_pow(self.consecutive_failures);
72 Duration::from_secs(secs.min(30))
73 }
74
75 fn backoff_elapsed(&self) -> bool {
78 match self.last_attempt {
79 None => true,
80 Some(t) => t.elapsed() >= self.backoff_duration(),
81 }
82 }
83}
84
85#[derive(Debug, Clone)]
91pub struct DiscoveryRecord {
92 pub peer_id: String,
94 pub multiaddrs: Vec<String>,
96 pub discovered_via: String,
98 pub discovered_at: Instant,
100 pub ping_ms: Option<u64>,
102}
103
104impl DiscoveryRecord {
105 pub fn new(
107 peer_id: impl Into<String>,
108 multiaddrs: Vec<String>,
109 discovered_via: impl Into<String>,
110 ping_ms: Option<u64>,
111 ) -> Self {
112 Self {
113 peer_id: peer_id.into(),
114 multiaddrs,
115 discovered_via: discovered_via.into(),
116 discovered_at: Instant::now(),
117 ping_ms,
118 }
119 }
120}
121
122#[derive(Debug, Default)]
131pub struct BootstrapStats {
132 pub total_attempts: AtomicU64,
134 pub total_successes: AtomicU64,
136 pub total_failures: AtomicU64,
138 pub total_discovered: AtomicU64,
140}
141
142impl BootstrapStats {
143 pub fn new() -> Self {
145 Self::default()
146 }
147
148 pub fn snapshot(&self) -> BootstrapStatsSnapshot {
150 BootstrapStatsSnapshot {
151 total_attempts: self.total_attempts.load(Ordering::Relaxed),
152 total_successes: self.total_successes.load(Ordering::Relaxed),
153 total_failures: self.total_failures.load(Ordering::Relaxed),
154 total_discovered: self.total_discovered.load(Ordering::Relaxed),
155 }
156 }
157}
158
159#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
161pub struct BootstrapStatsSnapshot {
162 pub total_attempts: u64,
164 pub total_successes: u64,
166 pub total_failures: u64,
168 pub total_discovered: u64,
170}
171
172pub struct BootstrapCoordinator {
189 bootstrap_peers: RwLock<Vec<BootstrapPeer>>,
191 discovered: RwLock<HashMap<String, DiscoveryRecord>>,
193 pub target_peer_count: usize,
195 stats: Arc<BootstrapStats>,
197}
198
199impl BootstrapCoordinator {
200 pub fn new(target_peer_count: usize) -> Self {
202 Self {
203 bootstrap_peers: RwLock::new(Vec::new()),
204 discovered: RwLock::new(HashMap::new()),
205 target_peer_count,
206 stats: Arc::new(BootstrapStats::new()),
207 }
208 }
209
210 pub fn with_defaults() -> Self {
212 Self::new(20)
213 }
214
215 pub fn add_bootstrap_peer(&self, peer: BootstrapPeer) {
225 let mut peers = self.bootstrap_peers.write();
226 if let Some(pos) = peers.iter().position(|p| p.peer_id == peer.peer_id) {
228 peers[pos] = peer;
229 } else {
230 peers.push(peer);
231 }
232 peers.sort_unstable_by_key(|p| Reverse(p.priority));
234 debug!(
235 "bootstrap_coordinator: bootstrap peers count={}",
236 peers.len()
237 );
238 }
239
240 pub fn record_attempt(&self, peer_id: &str, success: bool) {
248 let mut peers = self.bootstrap_peers.write();
249 if let Some(peer) = peers.iter_mut().find(|p| p.peer_id == peer_id) {
250 peer.last_attempt = Some(Instant::now());
251 if success {
252 peer.consecutive_failures = 0;
253 peer.last_success = Some(Instant::now());
254 self.stats.total_successes.fetch_add(1, Ordering::Relaxed);
255 info!(
256 "bootstrap_coordinator: success peer_id={} failures_reset",
257 peer_id
258 );
259 } else {
260 peer.consecutive_failures = peer.consecutive_failures.saturating_add(1);
261 self.stats.total_failures.fetch_add(1, Ordering::Relaxed);
262 warn!(
263 "bootstrap_coordinator: failure peer_id={} consecutive={}",
264 peer_id, peer.consecutive_failures
265 );
266 }
267 self.stats.total_attempts.fetch_add(1, Ordering::Relaxed);
268 } else {
269 debug!(
270 "bootstrap_coordinator: record_attempt called for unknown peer_id={}",
271 peer_id
272 );
273 }
274 }
275
276 pub fn next_bootstrap_peer(&self) -> Option<BootstrapPeer> {
282 let peers = self.bootstrap_peers.read();
283 peers
285 .iter()
286 .find(|p| p.is_available() && p.backoff_elapsed())
287 .cloned()
288 }
289
290 pub fn record_discovery(&self, record: DiscoveryRecord) {
298 let mut discovered = self.discovered.write();
299 let is_new = !discovered.contains_key(&record.peer_id);
300 if is_new {
301 self.stats.total_discovered.fetch_add(1, Ordering::Relaxed);
302 debug!(
303 "bootstrap_coordinator: new peer peer_id={} via={}",
304 record.peer_id, record.discovered_via
305 );
306 }
307 discovered.insert(record.peer_id.clone(), record);
308 }
309
310 pub fn known_peer_count(&self) -> usize {
312 self.discovered.read().len()
313 }
314
315 pub fn needs_more_peers(&self) -> bool {
317 self.known_peer_count() < self.target_peer_count
318 }
319
320 pub fn candidates_for_dial(&self, n: usize) -> Vec<DiscoveryRecord> {
326 let discovered = self.discovered.read();
327 let mut candidates: Vec<DiscoveryRecord> = discovered.values().cloned().collect();
328 candidates.sort_unstable_by_key(|r| r.ping_ms.unwrap_or(u64::MAX));
330 candidates.truncate(n);
331 candidates
332 }
333
334 pub fn stats(&self) -> BootstrapStatsSnapshot {
340 self.stats.snapshot()
341 }
342}
343
344impl Default for BootstrapCoordinator {
345 fn default() -> Self {
346 Self::with_defaults()
347 }
348}
349
350#[cfg(test)]
355mod tests {
356 use super::*;
357 use std::thread;
358
359 fn peer(id: &str, multiaddr: &str, priority: u8) -> BootstrapPeer {
361 BootstrapPeer::new(id, multiaddr, priority)
362 }
363
364 fn discovery(id: &str, via: &str, ping: Option<u64>) -> DiscoveryRecord {
366 DiscoveryRecord::new(id, vec!["/ip4/127.0.0.1/tcp/4001".to_string()], via, ping)
367 }
368
369 #[test]
374 fn test_backoff_duration_zero_failures() {
375 let p = peer("a", "/ip4/1.1.1.1/tcp/4001", 128);
376 assert_eq!(p.backoff_duration(), Duration::from_secs(1));
377 }
378
379 #[test]
380 fn test_backoff_duration_exponential() {
381 let mut p = peer("a", "/ip4/1.1.1.1/tcp/4001", 128);
382 for (failures, expected_secs) in [(1u32, 2u64), (2, 4), (3, 8), (4, 16)] {
384 p.consecutive_failures = failures;
385 assert_eq!(
386 p.backoff_duration(),
387 Duration::from_secs(expected_secs),
388 "failures={failures}"
389 );
390 }
391 }
392
393 #[test]
394 fn test_backoff_duration_capped_at_30s() {
395 let mut p = peer("a", "/ip4/1.1.1.1/tcp/4001", 128);
396 p.consecutive_failures = 5;
398 assert_eq!(p.backoff_duration(), Duration::from_secs(30));
399 p.consecutive_failures = 100;
401 assert_eq!(p.backoff_duration(), Duration::from_secs(30));
402 }
403
404 #[test]
405 fn test_is_available_true_below_threshold() {
406 let mut p = peer("a", "/ip4/1.1.1.1/tcp/4001", 128);
407 for failures in 0u32..5 {
408 p.consecutive_failures = failures;
409 assert!(p.is_available(), "failures={failures} should be available");
410 }
411 }
412
413 #[test]
414 fn test_is_available_false_at_threshold() {
415 let mut p = peer("a", "/ip4/1.1.1.1/tcp/4001", 128);
416 p.consecutive_failures = 5;
417 assert!(!p.is_available());
418 p.consecutive_failures = 10;
419 assert!(!p.is_available());
420 }
421
422 #[test]
427 fn test_add_bootstrap_peer_single() {
428 let coord = BootstrapCoordinator::with_defaults();
429 coord.add_bootstrap_peer(peer("p1", "/ip4/1.1.1.1/tcp/4001", 100));
430 let next = coord.next_bootstrap_peer().expect("should have a peer");
431 assert_eq!(next.peer_id, "p1");
432 }
433
434 #[test]
435 fn test_add_bootstrap_peer_ordering_by_priority() {
436 let coord = BootstrapCoordinator::with_defaults();
437 coord.add_bootstrap_peer(peer("low", "/ip4/1.1.1.1/tcp/4001", 10));
439 coord.add_bootstrap_peer(peer("high", "/ip4/2.2.2.2/tcp/4001", 200));
440 coord.add_bootstrap_peer(peer("mid", "/ip4/3.3.3.3/tcp/4001", 100));
441
442 let next = coord.next_bootstrap_peer().expect("should return peer");
443 assert_eq!(next.peer_id, "high", "highest priority must come first");
444 }
445
446 #[test]
447 fn test_add_bootstrap_peer_replaces_existing() {
448 let coord = BootstrapCoordinator::with_defaults();
449 coord.add_bootstrap_peer(peer("p1", "/ip4/1.1.1.1/tcp/4001", 50));
450 coord.add_bootstrap_peer(peer("p1", "/ip4/1.1.1.1/tcp/4001", 200));
452 let next = coord.next_bootstrap_peer().expect("should have peer");
453 assert_eq!(next.priority, 200);
454 }
455
456 #[test]
457 fn test_record_attempt_success_resets_failures() {
458 let coord = BootstrapCoordinator::with_defaults();
459 coord.add_bootstrap_peer(peer("p1", "/ip4/1.1.1.1/tcp/4001", 100));
460 coord.record_attempt("p1", false);
462 coord.record_attempt("p1", false);
463 coord.record_attempt("p1", false);
464 coord.record_attempt("p1", true);
466
467 let peers = coord.bootstrap_peers.read();
468 let p = peers.iter().find(|p| p.peer_id == "p1").expect("found");
469 assert_eq!(p.consecutive_failures, 0, "success must reset failures");
470 assert!(p.last_success.is_some(), "last_success must be set");
471 }
472
473 #[test]
474 fn test_record_attempt_failure_increments_counter() {
475 let coord = BootstrapCoordinator::with_defaults();
476 coord.add_bootstrap_peer(peer("p1", "/ip4/1.1.1.1/tcp/4001", 100));
477 coord.record_attempt("p1", false);
478 coord.record_attempt("p1", false);
479
480 let peers = coord.bootstrap_peers.read();
481 let p = peers.iter().find(|p| p.peer_id == "p1").expect("found");
482 assert_eq!(p.consecutive_failures, 2);
483 }
484
485 #[test]
486 fn test_next_bootstrap_peer_skips_unavailable() {
487 let coord = BootstrapCoordinator::with_defaults();
488 coord.add_bootstrap_peer(peer("bad", "/ip4/1.1.1.1/tcp/4001", 255));
490 coord.add_bootstrap_peer(peer("good", "/ip4/2.2.2.2/tcp/4001", 100));
491
492 for _ in 0..5 {
494 coord.record_attempt("bad", false);
495 }
496 {
498 let peers = coord.bootstrap_peers.read();
499 let bad = peers.iter().find(|p| p.peer_id == "bad").expect("found");
500 assert!(!bad.is_available());
501 }
502
503 let next = coord
504 .next_bootstrap_peer()
505 .expect("coordinator must skip bad peer");
506 assert_eq!(next.peer_id, "good", "unavailable peer must be skipped");
507 }
508
509 #[test]
510 fn test_next_bootstrap_peer_skips_within_backoff() {
511 let coord = BootstrapCoordinator::with_defaults();
512 coord.add_bootstrap_peer(peer("p1", "/ip4/1.1.1.1/tcp/4001", 100));
513 coord.record_attempt("p1", false);
515
516 let next = coord.next_bootstrap_peer();
519 assert!(
520 next.is_none(),
521 "peer should be skipped while in backoff window"
522 );
523 }
524
525 #[test]
526 fn test_next_bootstrap_peer_available_after_backoff() {
527 let coord = BootstrapCoordinator::with_defaults();
528 coord.add_bootstrap_peer(peer("fresh", "/ip4/1.1.1.1/tcp/4001", 100));
531 let next = coord.next_bootstrap_peer();
532 assert!(next.is_some(), "fresh peer with no history should be ready");
533 }
534
535 #[test]
540 fn test_record_discovery_stores_record() {
541 let coord = BootstrapCoordinator::with_defaults();
542 coord.record_discovery(discovery("peer-abc", "bootstrap", Some(12)));
543
544 let discovered = coord.discovered.read();
545 let rec = discovered.get("peer-abc").expect("record should be stored");
546 assert_eq!(rec.discovered_via, "bootstrap");
547 assert_eq!(rec.ping_ms, Some(12));
548 }
549
550 #[test]
551 fn test_known_peer_count() {
552 let coord = BootstrapCoordinator::with_defaults();
553 assert_eq!(coord.known_peer_count(), 0);
554 coord.record_discovery(discovery("p1", "dht", None));
555 coord.record_discovery(discovery("p2", "gossip", Some(5)));
556 assert_eq!(coord.known_peer_count(), 2);
557 }
558
559 #[test]
560 fn test_needs_more_peers_threshold() {
561 let coord = BootstrapCoordinator::new(3);
562 assert!(coord.needs_more_peers());
563 coord.record_discovery(discovery("p1", "dht", None));
564 coord.record_discovery(discovery("p2", "dht", None));
565 assert!(coord.needs_more_peers());
566 coord.record_discovery(discovery("p3", "dht", None));
567 assert!(
568 !coord.needs_more_peers(),
569 "exactly at target — no longer needed"
570 );
571 }
572
573 #[test]
574 fn test_candidates_for_dial_sorted_by_ping() {
575 let coord = BootstrapCoordinator::with_defaults();
576 coord.record_discovery(discovery("slow", "dht", Some(200)));
577 coord.record_discovery(discovery("fast", "dht", Some(10)));
578 coord.record_discovery(discovery("unknown", "dht", None));
579 coord.record_discovery(discovery("medium", "dht", Some(50)));
580
581 let candidates = coord.candidates_for_dial(10);
582 let pings: Vec<Option<u64>> = candidates.iter().map(|r| r.ping_ms).collect();
584 assert_eq!(pings, vec![Some(10), Some(50), Some(200), None]);
585 }
586
587 #[test]
588 fn test_candidates_for_dial_respects_limit() {
589 let coord = BootstrapCoordinator::with_defaults();
590 for i in 0u64..10 {
591 coord.record_discovery(discovery(&format!("peer-{i}"), "dht", Some(i * 10)));
592 }
593 let candidates = coord.candidates_for_dial(3);
594 assert_eq!(candidates.len(), 3);
595 }
596
597 #[test]
602 fn test_stats_accumulation() {
603 let coord = BootstrapCoordinator::with_defaults();
604 coord.add_bootstrap_peer(peer("p1", "/ip4/1.1.1.1/tcp/4001", 100));
605 coord.add_bootstrap_peer(peer("p2", "/ip4/2.2.2.2/tcp/4001", 90));
606
607 coord.record_attempt("p1", true);
608 coord.record_attempt("p1", false);
609 coord.record_attempt("p2", false);
610
611 coord.record_discovery(discovery("d1", "dht", None));
612 coord.record_discovery(discovery("d2", "gossip", Some(5)));
613
614 let snap = coord.stats();
615 assert_eq!(snap.total_attempts, 3);
616 assert_eq!(snap.total_successes, 1);
617 assert_eq!(snap.total_failures, 2);
618 assert_eq!(snap.total_discovered, 2);
619 }
620
621 #[test]
622 fn test_stats_discovery_dedup() {
623 let coord = BootstrapCoordinator::with_defaults();
625 coord.record_discovery(discovery("p1", "bootstrap", Some(5)));
626 coord.record_discovery(discovery("p1", "dht", Some(3)));
627
628 let snap = coord.stats();
629 assert_eq!(snap.total_discovered, 1);
630 assert_eq!(coord.known_peer_count(), 1);
631 }
632
633 #[test]
638 fn test_concurrent_record_discovery() {
639 let coord = Arc::new(BootstrapCoordinator::new(1000));
640 let mut handles = Vec::with_capacity(8);
641
642 for thread_idx in 0usize..8 {
643 let c = Arc::clone(&coord);
644 handles.push(thread::spawn(move || {
645 for i in 0u64..50 {
646 let id = format!("peer-{thread_idx}-{i}");
647 c.record_discovery(DiscoveryRecord::new(&id, vec![], "gossip", Some(i)));
648 }
649 }));
650 }
651 for h in handles {
652 h.join().expect("thread panicked");
653 }
654
655 assert_eq!(coord.known_peer_count(), 400);
656 assert_eq!(coord.stats().total_discovered, 400);
657 }
658}