1use parking_lot::RwLock;
32use std::collections::HashMap;
33use std::time::{Duration, Instant};
34use thiserror::Error;
35
36pub const DEFAULT_MAX_PROVIDERS: usize = 20;
42
43pub const DEFAULT_ENTRY_TTL: Duration = Duration::from_secs(86_400);
45
46const RECENT_SEEN_THRESHOLD: Duration = Duration::from_secs(300);
48
49const TTL_NEARLY_EXPIRED_FRACTION: f64 = 0.10;
51
52#[derive(Debug, Error, PartialEq, Eq)]
58pub enum RoutingError {
59 #[error("capacity exceeded for CID '{cid}': max {max} providers")]
61 CapacityExceeded {
62 cid: String,
64 max: usize,
66 },
67
68 #[error("duplicate provider '{peer_id}' for CID '{cid}'")]
70 DuplicateProvider {
71 cid: String,
73 peer_id: String,
75 },
76}
77
78#[derive(Debug, Clone)]
84pub struct RoutingEntry {
85 pub peer_id: String,
87 pub multiaddrs: Vec<String>,
89 pub latency_ms: Option<u64>,
91 pub last_seen: Instant,
93 pub ttl: Duration,
95}
96
97impl RoutingEntry {
98 pub fn new(peer_id: String, multiaddrs: Vec<String>) -> Self {
100 Self {
101 peer_id,
102 multiaddrs,
103 latency_ms: None,
104 last_seen: Instant::now(),
105 ttl: DEFAULT_ENTRY_TTL,
106 }
107 }
108
109 pub fn with_ttl(peer_id: String, multiaddrs: Vec<String>, ttl: Duration) -> Self {
111 Self {
112 peer_id,
113 multiaddrs,
114 latency_ms: None,
115 last_seen: Instant::now(),
116 ttl,
117 }
118 }
119
120 pub fn with_latency(
122 peer_id: String,
123 multiaddrs: Vec<String>,
124 latency_ms: u64,
125 ttl: Duration,
126 ) -> Self {
127 Self {
128 peer_id,
129 multiaddrs,
130 latency_ms: Some(latency_ms),
131 last_seen: Instant::now(),
132 ttl,
133 }
134 }
135
136 pub fn affinity_score(&self) -> f64 {
146 let mut score = 1.0_f64;
147
148 if let Some(lat) = self.latency_ms {
150 score -= 0.001 * (lat as f64);
151 }
152
153 let elapsed = self.last_seen.elapsed();
155 let ttl_secs = self.ttl.as_secs_f64();
156 if ttl_secs > 0.0 {
157 let remaining_fraction = 1.0 - (elapsed.as_secs_f64() / ttl_secs);
158 if remaining_fraction < TTL_NEARLY_EXPIRED_FRACTION {
159 score -= 0.5;
160 }
161 }
162
163 if elapsed < RECENT_SEEN_THRESHOLD {
165 score += 0.2;
166 }
167
168 score
169 }
170
171 pub fn is_expired(&self, now: Instant) -> bool {
173 let age = now.saturating_duration_since(self.last_seen);
175 age >= self.ttl
176 }
177}
178
179#[derive(Debug, Clone, PartialEq, Eq)]
185pub struct RoutingTableStats {
186 pub cid_count: usize,
188 pub provider_count: usize,
190 pub expired_evicted: u64,
192}
193
194pub struct ContentRoutingTable {
201 entries: RwLock<HashMap<String, Vec<RoutingEntry>>>,
203 max_providers_per_cid: usize,
205 pub local_peer_id: String,
207 expired_evicted: RwLock<u64>,
209}
210
211impl ContentRoutingTable {
212 pub fn new(local_peer_id: String, max_providers_per_cid: usize) -> Self {
219 Self {
220 entries: RwLock::new(HashMap::new()),
221 max_providers_per_cid,
222 local_peer_id,
223 expired_evicted: RwLock::new(0),
224 }
225 }
226
227 pub fn with_defaults(local_peer_id: String) -> Self {
229 Self::new(local_peer_id, DEFAULT_MAX_PROVIDERS)
230 }
231
232 pub fn add_provider(&self, cid: &str, entry: RoutingEntry) -> Result<(), RoutingError> {
241 let mut map = self.entries.write();
242 let bucket = map.entry(cid.to_string()).or_default();
243
244 if bucket.iter().any(|e| e.peer_id == entry.peer_id) {
246 return Err(RoutingError::DuplicateProvider {
247 cid: cid.to_string(),
248 peer_id: entry.peer_id,
249 });
250 }
251
252 if bucket.len() >= self.max_providers_per_cid {
254 return Err(RoutingError::CapacityExceeded {
255 cid: cid.to_string(),
256 max: self.max_providers_per_cid,
257 });
258 }
259
260 bucket.push(entry);
261 Ok(())
262 }
263
264 pub fn remove_provider(&self, cid: &str, peer_id: &str) {
268 let mut map = self.entries.write();
269 if let Some(bucket) = map.get_mut(cid) {
270 bucket.retain(|e| e.peer_id != peer_id);
271 if bucket.is_empty() {
272 map.remove(cid);
273 }
274 }
275 }
276
277 pub fn evict_expired(&self, now: Instant) {
281 let mut map = self.entries.write();
282 let mut total_evicted: u64 = 0;
283
284 map.retain(|_cid, bucket| {
285 let before = bucket.len();
286 bucket.retain(|e| !e.is_expired(now));
287 total_evicted += (before - bucket.len()) as u64;
288 !bucket.is_empty()
289 });
290
291 if total_evicted > 0 {
292 let mut counter = self.expired_evicted.write();
293 *counter = counter.saturating_add(total_evicted);
294 }
295 }
296
297 pub fn get_providers(&self, cid: &str) -> Vec<RoutingEntry> {
301 let map = self.entries.read();
302 let Some(bucket) = map.get(cid) else {
303 return Vec::new();
304 };
305 let mut sorted = bucket.clone();
306 sorted.sort_by(|a, b| {
307 b.affinity_score()
308 .partial_cmp(&a.affinity_score())
309 .unwrap_or(std::cmp::Ordering::Equal)
310 });
311 sorted
312 }
313
314 pub fn best_provider(&self, cid: &str) -> Option<RoutingEntry> {
316 let map = self.entries.read();
317 let bucket = map.get(cid)?;
318 bucket
319 .iter()
320 .max_by(|a, b| {
321 a.affinity_score()
322 .partial_cmp(&b.affinity_score())
323 .unwrap_or(std::cmp::Ordering::Equal)
324 })
325 .cloned()
326 }
327
328 pub fn cid_count(&self) -> usize {
330 self.entries.read().len()
331 }
332
333 pub fn provider_count(&self) -> usize {
335 self.entries.read().values().map(Vec::len).sum()
336 }
337
338 pub fn stats(&self) -> RoutingTableStats {
340 let map = self.entries.read();
341 let cid_count = map.len();
342 let provider_count: usize = map.values().map(Vec::len).sum();
343 let expired_evicted = *self.expired_evicted.read();
344 RoutingTableStats {
345 cid_count,
346 provider_count,
347 expired_evicted,
348 }
349 }
350}
351
352#[cfg(test)]
357mod tests {
358 use super::*;
359 use std::time::{Duration, Instant};
360
361 fn make_table() -> ContentRoutingTable {
362 ContentRoutingTable::new("local-peer".to_string(), 5)
363 }
364
365 fn make_entry(peer: &str) -> RoutingEntry {
366 RoutingEntry::new(
367 peer.to_string(),
368 vec![format!("/ip4/1.2.3.4/tcp/400{peer}")],
369 )
370 }
371
372 fn make_entry_with_latency(peer: &str, latency_ms: u64) -> RoutingEntry {
373 RoutingEntry::with_latency(
374 peer.to_string(),
375 vec![format!("/ip4/1.2.3.4/tcp/4001")],
376 latency_ms,
377 DEFAULT_ENTRY_TTL,
378 )
379 }
380
381 #[test]
384 fn test_add_and_retrieve_providers() {
385 let table = make_table();
386 let cid = "QmTest1";
387
388 table
389 .add_provider(cid, make_entry("peer-a"))
390 .expect("test: add peer-a provider");
391 table
392 .add_provider(cid, make_entry("peer-b"))
393 .expect("test: add peer-b provider");
394
395 let providers = table.get_providers(cid);
396 assert_eq!(providers.len(), 2);
397
398 let ids: Vec<&str> = providers.iter().map(|e| e.peer_id.as_str()).collect();
399 assert!(ids.contains(&"peer-a"));
400 assert!(ids.contains(&"peer-b"));
401 }
402
403 #[test]
406 fn test_get_providers_unknown_cid() {
407 let table = make_table();
408 assert!(table.get_providers("QmUnknown").is_empty());
409 }
410
411 #[test]
414 fn test_duplicate_provider_rejected() {
415 let table = make_table();
416 let cid = "QmDup";
417
418 table
419 .add_provider(cid, make_entry("peer-x"))
420 .expect("test: add peer-x provider first time");
421 let err = table.add_provider(cid, make_entry("peer-x")).unwrap_err();
422
423 match err {
424 RoutingError::DuplicateProvider { cid: c, peer_id: p } => {
425 assert_eq!(c, cid);
426 assert_eq!(p, "peer-x");
427 }
428 other => panic!("unexpected error: {other}"),
429 }
430 }
431
432 #[test]
435 fn test_capacity_limit_enforced() {
436 let table = ContentRoutingTable::new("local".to_string(), 3);
437 let cid = "QmCap";
438
439 for i in 0..3 {
440 table
441 .add_provider(cid, make_entry(&format!("peer-{i}")))
442 .expect("test: add provider within capacity");
443 }
444
445 let err = table
446 .add_provider(cid, make_entry("peer-overflow"))
447 .unwrap_err();
448
449 match err {
450 RoutingError::CapacityExceeded { cid: c, max } => {
451 assert_eq!(c, cid);
452 assert_eq!(max, 3);
453 }
454 other => panic!("unexpected error: {other}"),
455 }
456 }
457
458 #[test]
461 fn test_affinity_ordering_by_latency() {
462 let table = make_table();
463 let cid = "QmLatency";
464
465 table
467 .add_provider(cid, make_entry_with_latency("peer-slow", 500))
468 .expect("test: add peer-slow provider");
469 table
470 .add_provider(cid, make_entry_with_latency("peer-fast", 10))
471 .expect("test: add peer-fast provider");
472
473 let providers = table.get_providers(cid);
474 assert_eq!(providers[0].peer_id, "peer-fast");
475 assert_eq!(providers[1].peer_id, "peer-slow");
476 }
477
478 #[test]
481 fn test_affinity_score_values() {
482 let entry_no_lat = RoutingEntry::new("p".to_string(), vec![]);
483 assert!((entry_no_lat.affinity_score() - 1.2).abs() < 1e-9);
485
486 let entry_lat = make_entry_with_latency("p2", 100);
487 assert!((entry_lat.affinity_score() - 1.1).abs() < 1e-9);
489 }
490
491 #[test]
494 fn test_evict_expired_removes_stale() {
495 let table = make_table();
496 let cid = "QmEvict";
497
498 let short_ttl = Duration::from_millis(1);
500 let stale = RoutingEntry::with_ttl("stale-peer".to_string(), vec![], short_ttl);
501 let fresh = make_entry("fresh-peer");
502
503 table
504 .add_provider(cid, stale)
505 .expect("test: add stale provider");
506 table
507 .add_provider(cid, fresh)
508 .expect("test: add fresh provider");
509
510 std::thread::sleep(Duration::from_millis(5));
512 let now = Instant::now();
513
514 table.evict_expired(now);
515
516 let providers = table.get_providers(cid);
517 assert_eq!(providers.len(), 1);
518 assert_eq!(providers[0].peer_id, "fresh-peer");
519 }
520
521 #[test]
524 fn test_evict_expired_removes_empty_cid() {
525 let table = make_table();
526 let cid = "QmFullEvict";
527
528 let short_ttl = Duration::from_millis(1);
529 let e = RoutingEntry::with_ttl("only-peer".to_string(), vec![], short_ttl);
530 table
531 .add_provider(cid, e)
532 .expect("test: add only-peer provider");
533
534 std::thread::sleep(Duration::from_millis(5));
535 table.evict_expired(Instant::now());
536
537 assert_eq!(table.cid_count(), 0);
538 assert_eq!(table.provider_count(), 0);
539 }
540
541 #[test]
544 fn test_stats_expired_evicted_counter() {
545 let table = make_table();
546 let cid = "QmStats";
547
548 let short_ttl = Duration::from_millis(1);
549 for i in 0..3 {
550 let e = RoutingEntry::with_ttl(format!("peer-{i}"), vec![], short_ttl);
551 table
552 .add_provider(cid, e)
553 .expect("test: add short-ttl provider for stats");
554 }
555
556 std::thread::sleep(Duration::from_millis(5));
557 table.evict_expired(Instant::now());
558
559 let stats = table.stats();
560 assert_eq!(stats.expired_evicted, 3);
561 assert_eq!(stats.cid_count, 0);
562 assert_eq!(stats.provider_count, 0);
563 }
564
565 #[test]
568 fn test_best_provider_highest_affinity() {
569 let table = make_table();
570 let cid = "QmBest";
571
572 table
573 .add_provider(cid, make_entry_with_latency("slow", 800))
574 .expect("test: add slow provider");
575 table
576 .add_provider(cid, make_entry_with_latency("fast", 5))
577 .expect("test: add fast provider");
578 table
579 .add_provider(cid, make_entry_with_latency("medium", 200))
580 .expect("test: add medium provider");
581
582 let best = table
583 .best_provider(cid)
584 .expect("should have a best provider");
585 assert_eq!(best.peer_id, "fast");
586 }
587
588 #[test]
591 fn test_best_provider_unknown_cid() {
592 let table = make_table();
593 assert!(table.best_provider("QmNotHere").is_none());
594 }
595
596 #[test]
599 fn test_remove_provider() {
600 let table = make_table();
601 let cid = "QmRemove";
602
603 table
604 .add_provider(cid, make_entry("keep"))
605 .expect("test: add keep provider");
606 table
607 .add_provider(cid, make_entry("gone"))
608 .expect("test: add gone provider");
609
610 table.remove_provider(cid, "gone");
611
612 let providers = table.get_providers(cid);
613 assert_eq!(providers.len(), 1);
614 assert_eq!(providers[0].peer_id, "keep");
615 }
616
617 #[test]
620 fn test_remove_provider_absent_noop() {
621 let table = make_table();
622 let cid = "QmNoop";
623 table
624 .add_provider(cid, make_entry("only"))
625 .expect("test: add only provider");
626 table.remove_provider(cid, "nonexistent");
628 assert_eq!(table.provider_count(), 1);
629 }
630
631 #[test]
634 fn test_remove_provider_cleans_up_empty_bucket() {
635 let table = make_table();
636 let cid = "QmCleanup";
637 table
638 .add_provider(cid, make_entry("last"))
639 .expect("test: add last provider");
640 table.remove_provider(cid, "last");
641 assert_eq!(table.cid_count(), 0);
642 }
643
644 #[test]
647 fn test_counts_correctness() {
648 let table = make_table();
649
650 table
651 .add_provider("Qm1", make_entry("a"))
652 .expect("test: add provider a to Qm1");
653 table
654 .add_provider("Qm1", make_entry("b"))
655 .expect("test: add provider b to Qm1");
656 table
657 .add_provider("Qm2", make_entry("c"))
658 .expect("test: add provider c to Qm2");
659
660 assert_eq!(table.cid_count(), 2);
661 assert_eq!(table.provider_count(), 3);
662
663 let stats = table.stats();
664 assert_eq!(stats.cid_count, 2);
665 assert_eq!(stats.provider_count, 3);
666 assert_eq!(stats.expired_evicted, 0);
667 }
668
669 #[test]
672 fn test_is_expired() {
673 let short = Duration::from_millis(1);
674 let entry = RoutingEntry::with_ttl("p".to_string(), vec![], short);
675
676 let just_now = Instant::now();
678 assert!(!entry.is_expired(
680 just_now
681 .checked_sub(Duration::from_nanos(1))
682 .unwrap_or(just_now)
683 ));
684
685 std::thread::sleep(Duration::from_millis(5));
686 assert!(entry.is_expired(Instant::now()));
687 }
688
689 #[test]
692 fn test_evict_across_multiple_cids() {
693 let table = make_table();
694 let short = Duration::from_millis(1);
695
696 for cid in &["QmA", "QmB", "QmC"] {
697 let e = RoutingEntry::with_ttl(format!("peer-{cid}"), vec![], short);
698 table
699 .add_provider(cid, e)
700 .expect("test: add short-ttl provider across multi-cid");
701 }
702 table
704 .add_provider("QmD", make_entry("long-lived"))
705 .expect("test: add long-lived provider");
706
707 std::thread::sleep(Duration::from_millis(5));
708 table.evict_expired(Instant::now());
709
710 assert_eq!(table.cid_count(), 1);
711 assert_eq!(table.provider_count(), 1);
712 let stats = table.stats();
713 assert_eq!(stats.expired_evicted, 3);
714 }
715}