Skip to main content

ipfrs_network/
routing_table.rs

1//! DHT-aware content routing table with geographic/latency affinity scoring.
2//!
3//! This module implements a `ContentRoutingTable` that maps CID strings to
4//! ordered lists of provider [`RoutingEntry`] records.  Entries are ranked by
5//! an *affinity score* that combines observed latency, recent activity and
6//! remaining TTL so that the best provider is always returned first.
7//!
8//! ## Design
9//!
10//! * **Lock granularity** – a single `RwLock<HashMap<…>>` wrapping all
11//!   entries keeps the implementation simple while still allowing concurrent
12//!   reads.  Writes (add / remove / evict) are short-lived.
13//! * **Capacity enforcement** – when the per-CID provider list reaches
14//!   `max_providers_per_cid`, a new entry is rejected with
15//!   [`RoutingError::CapacityExceeded`].
16//! * **Affinity scoring** – see [`RoutingEntry::affinity_score`] for the
17//!   exact formula.
18//!
19//! ## Example
20//!
21//! ```rust
22//! use ipfrs_network::routing_table::{ContentRoutingTable, RoutingEntry};
23//! use std::time::{Duration, Instant};
24//!
25//! let table = ContentRoutingTable::new("local-peer".to_string(), 20);
26//! let entry = RoutingEntry::new("peer-1".to_string(), vec!["/ip4/1.2.3.4/tcp/4001".to_string()]);
27//! table.add_provider("QmFoo", entry).unwrap();
28//! assert_eq!(table.provider_count(), 1);
29//! ```
30
31use parking_lot::RwLock;
32use std::collections::HashMap;
33use std::time::{Duration, Instant};
34use thiserror::Error;
35
36// ────────────────────────────────────────────────────────────────────────────
37// Constants
38// ────────────────────────────────────────────────────────────────────────────
39
40/// Default maximum number of providers recorded per CID.
41pub const DEFAULT_MAX_PROVIDERS: usize = 20;
42
43/// Default TTL for a routing entry (24 hours).
44pub const DEFAULT_ENTRY_TTL: Duration = Duration::from_secs(86_400);
45
46/// Threshold below which an entry is considered "recently seen" (5 minutes).
47const RECENT_SEEN_THRESHOLD: Duration = Duration::from_secs(300);
48
49/// Fraction of TTL remaining below which an entry is penalised.
50const TTL_NEARLY_EXPIRED_FRACTION: f64 = 0.10;
51
52// ────────────────────────────────────────────────────────────────────────────
53// Error type
54// ────────────────────────────────────────────────────────────────────────────
55
56/// Errors produced by [`ContentRoutingTable`] operations.
57#[derive(Debug, Error, PartialEq, Eq)]
58pub enum RoutingError {
59    /// The per-CID provider list is full.
60    #[error("capacity exceeded for CID '{cid}': max {max} providers")]
61    CapacityExceeded {
62        /// The CID whose bucket is full.
63        cid: String,
64        /// The configured limit.
65        max: usize,
66    },
67
68    /// The same peer is already registered as a provider for this CID.
69    #[error("duplicate provider '{peer_id}' for CID '{cid}'")]
70    DuplicateProvider {
71        /// The CID for which duplication was detected.
72        cid: String,
73        /// The peer that was already registered.
74        peer_id: String,
75    },
76}
77
78// ────────────────────────────────────────────────────────────────────────────
79// RoutingEntry
80// ────────────────────────────────────────────────────────────────────────────
81
82/// A single provider record held inside the routing table.
83#[derive(Debug, Clone)]
84pub struct RoutingEntry {
85    /// Libp2p peer identifier (string form).
86    pub peer_id: String,
87    /// Known multiaddrs for this peer.
88    pub multiaddrs: Vec<String>,
89    /// Observed round-trip latency in milliseconds, if measured.
90    pub latency_ms: Option<u64>,
91    /// Monotonic timestamp of the last time this entry was refreshed.
92    pub last_seen: Instant,
93    /// How long this entry remains valid after creation / refresh.
94    pub ttl: Duration,
95}
96
97impl RoutingEntry {
98    /// Create a new entry with default TTL and no latency measurement.
99    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    /// Create an entry with a custom TTL.
110    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    /// Create an entry with observed latency and a custom TTL.
121    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    /// Compute a floating-point affinity score used for ranking.
137    ///
138    /// Formula:
139    /// * Base score: **1.0**
140    /// * Subtract `0.001 × latency_ms` if latency is known.
141    /// * Subtract **0.5** if less than 10 % of the TTL remains.
142    /// * Add **0.2** if the entry was seen within the last 5 minutes.
143    ///
144    /// Higher scores rank first.
145    pub fn affinity_score(&self) -> f64 {
146        let mut score = 1.0_f64;
147
148        // Penalise for observed latency.
149        if let Some(lat) = self.latency_ms {
150            score -= 0.001 * (lat as f64);
151        }
152
153        // Penalise for near-expiry.
154        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        // Bonus for recency.
164        if elapsed < RECENT_SEEN_THRESHOLD {
165            score += 0.2;
166        }
167
168        score
169    }
170
171    /// Return `true` when the entry's TTL has elapsed relative to `now`.
172    pub fn is_expired(&self, now: Instant) -> bool {
173        // Duration since the entry was last refreshed
174        let age = now.saturating_duration_since(self.last_seen);
175        age >= self.ttl
176    }
177}
178
179// ────────────────────────────────────────────────────────────────────────────
180// RoutingTableStats
181// ────────────────────────────────────────────────────────────────────────────
182
183/// Summary statistics for a [`ContentRoutingTable`].
184#[derive(Debug, Clone, PartialEq, Eq)]
185pub struct RoutingTableStats {
186    /// Number of distinct CIDs tracked.
187    pub cid_count: usize,
188    /// Total number of provider entries across all CIDs.
189    pub provider_count: usize,
190    /// Cumulative count of entries removed by [`ContentRoutingTable::evict_expired`].
191    pub expired_evicted: u64,
192}
193
194// ────────────────────────────────────────────────────────────────────────────
195// ContentRoutingTable
196// ────────────────────────────────────────────────────────────────────────────
197
198/// DHT-aware content routing table with per-CID provider lists and affinity
199/// scoring.
200pub struct ContentRoutingTable {
201    /// CID string → ordered provider list (unsorted at rest; sorted on read).
202    entries: RwLock<HashMap<String, Vec<RoutingEntry>>>,
203    /// Maximum number of providers stored per CID.
204    max_providers_per_cid: usize,
205    /// Peer ID of the local node (reserved for future self-announcement logic).
206    pub local_peer_id: String,
207    /// Monotonically increasing count of expired entries evicted.
208    expired_evicted: RwLock<u64>,
209}
210
211impl ContentRoutingTable {
212    /// Create a new routing table.
213    ///
214    /// # Arguments
215    ///
216    /// * `local_peer_id` – string peer ID of the local node.
217    /// * `max_providers_per_cid` – bucket capacity per CID.
218    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    /// Create a new routing table with [`DEFAULT_MAX_PROVIDERS`].
228    pub fn with_defaults(local_peer_id: String) -> Self {
229        Self::new(local_peer_id, DEFAULT_MAX_PROVIDERS)
230    }
231
232    // ── Mutations ────────────────────────────────────────────────────────────
233
234    /// Register `entry` as a provider for `cid`.
235    ///
236    /// # Errors
237    ///
238    /// * [`RoutingError::DuplicateProvider`] – the peer is already listed.
239    /// * [`RoutingError::CapacityExceeded`] – the bucket is full.
240    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        // Duplicate check.
245        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        // Capacity check.
253        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    /// Remove the provider identified by `peer_id` from the bucket for `cid`.
265    ///
266    /// This is a no-op if neither the CID nor the peer is found.
267    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    /// Evict all entries whose TTL has elapsed relative to `now`.
278    ///
279    /// Empty buckets are removed, and the internal eviction counter is updated.
280    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    // ── Queries ──────────────────────────────────────────────────────────────
298
299    /// Return all providers for `cid` sorted by affinity score (highest first).
300    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    /// Return the single provider with the highest affinity score, if any.
315    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    /// Return the number of distinct CIDs tracked.
329    pub fn cid_count(&self) -> usize {
330        self.entries.read().len()
331    }
332
333    /// Return the total number of provider entries across all CIDs.
334    pub fn provider_count(&self) -> usize {
335        self.entries.read().values().map(Vec::len).sum()
336    }
337
338    /// Return a snapshot of routing table statistics.
339    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// ────────────────────────────────────────────────────────────────────────────
353// Tests
354// ────────────────────────────────────────────────────────────────────────────
355
356#[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    // ── 1. Add and retrieve providers ────────────────────────────────────────
382
383    #[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    // ── 2. Empty result for unknown CID ──────────────────────────────────────
404
405    #[test]
406    fn test_get_providers_unknown_cid() {
407        let table = make_table();
408        assert!(table.get_providers("QmUnknown").is_empty());
409    }
410
411    // ── 3. Duplicate provider rejection ──────────────────────────────────────
412
413    #[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    // ── 4. Capacity limit enforcement ────────────────────────────────────────
433
434    #[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    // ── 5. Affinity score ordering (lower latency ranks higher) ──────────────
459
460    #[test]
461    fn test_affinity_ordering_by_latency() {
462        let table = make_table();
463        let cid = "QmLatency";
464
465        // peer-fast has 10 ms; peer-slow has 500 ms
466        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    // ── 6. Affinity score values ──────────────────────────────────────────────
479
480    #[test]
481    fn test_affinity_score_values() {
482        let entry_no_lat = RoutingEntry::new("p".to_string(), vec![]);
483        // No latency, recently seen → 1.0 + 0.2 = 1.2
484        assert!((entry_no_lat.affinity_score() - 1.2).abs() < 1e-9);
485
486        let entry_lat = make_entry_with_latency("p2", 100);
487        // latency 100 ms → −0.1, recently seen → +0.2  ⇒ 1.1
488        assert!((entry_lat.affinity_score() - 1.1).abs() < 1e-9);
489    }
490
491    // ── 7. evict_expired removes stale entries ───────────────────────────────
492
493    #[test]
494    fn test_evict_expired_removes_stale() {
495        let table = make_table();
496        let cid = "QmEvict";
497
498        // Add a fresh entry with a very short TTL.
499        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        // Advance time beyond the short TTL.
511        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    // ── 8. evict_expired removes empty CID buckets ───────────────────────────
522
523    #[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    // ── 9. Stats eviction counter increments ─────────────────────────────────
542
543    #[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    // ── 10. best_provider returns highest affinity ───────────────────────────
566
567    #[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    // ── 11. best_provider returns None for unknown CID ───────────────────────
589
590    #[test]
591    fn test_best_provider_unknown_cid() {
592        let table = make_table();
593        assert!(table.best_provider("QmNotHere").is_none());
594    }
595
596    // ── 12. remove_provider works ────────────────────────────────────────────
597
598    #[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    // ── 13. remove_provider on absent peer is a no-op ────────────────────────
618
619    #[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        // Should not panic or error.
627        table.remove_provider(cid, "nonexistent");
628        assert_eq!(table.provider_count(), 1);
629    }
630
631    // ── 14. remove_provider removes CID bucket when last entry is gone ────────
632
633    #[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    // ── 15. cid_count and provider_count correctness ─────────────────────────
645
646    #[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    // ── 16. is_expired ───────────────────────────────────────────────────────
670
671    #[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        // Not expired yet (created just now, check at the same instant).
677        let just_now = Instant::now();
678        // TTL is 1 ms so it is borderline; give it a small margin.
679        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    // ── 17. Evict across multiple CIDs ───────────────────────────────────────
690
691    #[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        // Also add one long-lived entry.
703        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}