Skip to main content

ipfrs_network/
adaptive_lookup.rs

1//! Adaptive Kademlia lookup scheduler that tunes alpha (parallelism) based on observed latency.
2//!
3//! ## Overview
4//!
5//! Kademlia's `alpha` parameter controls how many concurrent RPCs are issued per lookup
6//! iteration.  The standard value is 3, but a static setting is suboptimal:
7//!
8//! * A **congested** network benefits from *lower* alpha so that fewer in-flight
9//!   requests compete for the same scarce bandwidth.
10//! * A **fast** network with low latency can sustain *higher* alpha, reducing total
11//!   lookup latency through more parallelism.
12//!
13//! This module provides:
14//!
15//! - [`AdaptiveLookupScheduler`] — adjusts alpha in `[1, 8]` based on a rolling
16//!   p90 latency window.
17//! - [`PeerLatencyTracker`] — per-peer latency tracking with median-based ranking.
18//! - [`LookupSchedulerStats`] — snapshot statistics (no atomics, clone-friendly).
19
20use std::collections::{HashMap, VecDeque};
21use std::sync::atomic::{AtomicU32, Ordering};
22use std::sync::Mutex;
23use std::time::{Duration, Instant};
24
25// ---------------------------------------------------------------------------
26// Constants
27// ---------------------------------------------------------------------------
28
29/// Minimum parallelism degree for Kademlia lookups.
30pub const ALPHA_MIN: u32 = 1;
31/// Maximum parallelism degree for Kademlia lookups.
32pub const ALPHA_MAX: u32 = 8;
33/// Default parallelism degree (standard Kademlia value).
34pub const ALPHA_DEFAULT: u32 = 3;
35
36/// Sliding-window capacity for the global latency window.
37const WINDOW_CAPACITY: usize = 64;
38/// Sliding-window capacity per peer in [`PeerLatencyTracker`].
39const PEER_WINDOW_CAPACITY: usize = 32;
40
41/// If p90 exceeds this threshold, alpha is decremented.
42const HIGH_LATENCY_THRESHOLD_MS: u64 = 500;
43/// If p90 is below this threshold, alpha is incremented.
44const LOW_LATENCY_THRESHOLD_MS: u64 = 100;
45
46// ---------------------------------------------------------------------------
47// LookupSchedulerStats
48// ---------------------------------------------------------------------------
49
50/// Point-in-time snapshot of [`AdaptiveLookupScheduler`] state.
51///
52/// All fields are plain values — no atomics — so the struct is cheaply cloneable
53/// and serialisable.
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct LookupSchedulerStats {
56    /// Current Kademlia alpha (parallelism degree).
57    pub current_alpha: u32,
58    /// Number of samples currently in the sliding window.
59    pub window_size: usize,
60    /// 50th-percentile latency in milliseconds (0 when window is empty).
61    pub p50_ms: u64,
62    /// 90th-percentile latency in milliseconds (0 when window is empty).
63    pub p90_ms: u64,
64    /// 99th-percentile latency in milliseconds (0 when window is empty).
65    pub p99_ms: u64,
66}
67
68// ---------------------------------------------------------------------------
69// AdaptiveLookupScheduler
70// ---------------------------------------------------------------------------
71
72/// Maintains a rolling latency window and adaptively tunes Kademlia `alpha`.
73///
74/// # Thread Safety
75///
76/// The scheduler is designed for concurrent use.  `alpha` uses an `AtomicU32`
77/// for lock-free reads while `window` is protected by a `Mutex`.
78pub struct AdaptiveLookupScheduler {
79    /// Current parallelism degree, always in `[ALPHA_MIN, ALPHA_MAX]`.
80    alpha: AtomicU32,
81    /// Sliding window of the last [`WINDOW_CAPACITY`] lookup durations.
82    window: Mutex<VecDeque<Duration>>,
83}
84
85impl std::fmt::Debug for AdaptiveLookupScheduler {
86    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
87        f.debug_struct("AdaptiveLookupScheduler")
88            .field("alpha", &self.alpha.load(Ordering::Relaxed))
89            .finish()
90    }
91}
92
93impl Default for AdaptiveLookupScheduler {
94    fn default() -> Self {
95        Self::new()
96    }
97}
98
99impl AdaptiveLookupScheduler {
100    /// Create a new scheduler with `alpha = ALPHA_DEFAULT` and an empty window.
101    pub fn new() -> Self {
102        Self {
103            alpha: AtomicU32::new(ALPHA_DEFAULT),
104            window: Mutex::new(VecDeque::with_capacity(WINDOW_CAPACITY)),
105        }
106    }
107
108    /// Record a single lookup latency sample.
109    ///
110    /// The sample is appended to the sliding window; the oldest sample is evicted
111    /// when the window is full.  After updating the window, `Self::adjust` is
112    /// called to possibly update alpha.
113    pub fn record_latency(&self, d: Duration) {
114        let mut win = self
115            .window
116            .lock()
117            .expect("AdaptiveLookupScheduler window mutex poisoned");
118        if win.len() == WINDOW_CAPACITY {
119            win.pop_front();
120        }
121        win.push_back(d);
122        // Re-borrow as slice for the p90 computation inside adjust.
123        // We compute the percentile inline here to avoid a second lock acquisition.
124        let p90 = percentile_duration(&win, 90);
125        drop(win);
126        self.adjust_with_p90(p90);
127    }
128
129    /// Return the current alpha value.
130    pub fn current_alpha(&self) -> u32 {
131        self.alpha.load(Ordering::Relaxed)
132    }
133
134    /// Return a statistics snapshot.
135    pub fn stats(&self) -> LookupSchedulerStats {
136        let win = self
137            .window
138            .lock()
139            .expect("AdaptiveLookupScheduler window mutex poisoned");
140        let window_size = win.len();
141        let (p50_ms, p90_ms, p99_ms) = if window_size == 0 {
142            (0, 0, 0)
143        } else {
144            (
145                percentile_duration(&win, 50).as_millis() as u64,
146                percentile_duration(&win, 90).as_millis() as u64,
147                percentile_duration(&win, 99).as_millis() as u64,
148            )
149        };
150        LookupSchedulerStats {
151            current_alpha: self.alpha.load(Ordering::Relaxed),
152            window_size,
153            p50_ms,
154            p90_ms,
155            p99_ms,
156        }
157    }
158
159    // -----------------------------------------------------------------------
160    // Private helpers
161    // -----------------------------------------------------------------------
162
163    /// Core adjustment logic: given a pre-computed p90, nudge alpha up or down.
164    fn adjust_with_p90(&self, p90: Duration) {
165        let p90_ms = p90.as_millis() as u64;
166        // Use a compare-exchange loop so concurrent callers converge correctly.
167        let current = self.alpha.load(Ordering::Relaxed);
168        if p90_ms > HIGH_LATENCY_THRESHOLD_MS && current > ALPHA_MIN {
169            // High latency → reduce parallelism to ease congestion.
170            let _ = self.alpha.compare_exchange(
171                current,
172                current - 1,
173                Ordering::Relaxed,
174                Ordering::Relaxed,
175            );
176        } else if p90_ms < LOW_LATENCY_THRESHOLD_MS && current < ALPHA_MAX {
177            // Low latency → increase parallelism to exploit spare capacity.
178            let _ = self.alpha.compare_exchange(
179                current,
180                current + 1,
181                Ordering::Relaxed,
182                Ordering::Relaxed,
183            );
184        }
185        // Clamp defensively (should never be needed but protects invariants).
186        self.alpha
187            .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |v| {
188                Some(v.clamp(ALPHA_MIN, ALPHA_MAX))
189            })
190            .ok();
191    }
192}
193
194// ---------------------------------------------------------------------------
195// PeerLatencyTracker
196// ---------------------------------------------------------------------------
197
198/// Per-peer latency record stored in [`PeerLatencyTracker`].
199struct PeerRecord {
200    /// Rolling window of recent latency samples.
201    samples: VecDeque<Duration>,
202    /// Timestamp of the most recent sample (for staleness pruning).
203    last_seen: Instant,
204}
205
206impl PeerRecord {
207    fn new() -> Self {
208        Self {
209            samples: VecDeque::with_capacity(PEER_WINDOW_CAPACITY),
210            last_seen: Instant::now(),
211        }
212    }
213
214    fn push(&mut self, d: Duration) {
215        if self.samples.len() == PEER_WINDOW_CAPACITY {
216            self.samples.pop_front();
217        }
218        self.samples.push_back(d);
219        self.last_seen = Instant::now();
220    }
221
222    /// Return the p90 latency for this peer, or `None` if no samples.
223    fn p90(&self) -> Option<Duration> {
224        if self.samples.is_empty() {
225            None
226        } else {
227            Some(percentile_duration(&self.samples, 90))
228        }
229    }
230
231    /// Return the median (p50) latency for this peer, or `None` if no samples.
232    fn median(&self) -> Option<Duration> {
233        if self.samples.is_empty() {
234            None
235        } else {
236            Some(percentile_duration(&self.samples, 50))
237        }
238    }
239}
240
241/// Tracks per-peer latency with sliding windows and enables peer ranking.
242///
243/// This is complementary to [`AdaptiveLookupScheduler`]: the scheduler decides
244/// *how many* concurrent requests to send, while `PeerLatencyTracker` decides
245/// *which peers* to prefer.
246pub struct PeerLatencyTracker {
247    /// Keyed by peer-ID string representation.
248    records: HashMap<String, PeerRecord>,
249}
250
251impl std::fmt::Debug for PeerLatencyTracker {
252    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
253        f.debug_struct("PeerLatencyTracker")
254            .field("peer_count", &self.records.len())
255            .finish()
256    }
257}
258
259impl Default for PeerLatencyTracker {
260    fn default() -> Self {
261        Self::new()
262    }
263}
264
265impl PeerLatencyTracker {
266    /// Create an empty tracker.
267    pub fn new() -> Self {
268        Self {
269            records: HashMap::new(),
270        }
271    }
272
273    /// Record a latency sample for `peer_id`.
274    pub fn record(&mut self, peer_id: &str, latency: Duration) {
275        self.records
276            .entry(peer_id.to_owned())
277            .or_insert_with(PeerRecord::new)
278            .push(latency);
279    }
280
281    /// Return the p90 latency for a specific peer, or `None` if unknown.
282    pub fn p90_for_peer(&self, peer_id: &str) -> Option<Duration> {
283        self.records.get(peer_id)?.p90()
284    }
285
286    /// Return the `n` peer IDs with the lowest median latency.
287    ///
288    /// Peers with no recorded samples are excluded.  If fewer than `n` peers
289    /// have samples, all qualifying peers are returned.
290    pub fn fastest_peers(&self, n: usize) -> Vec<String> {
291        let mut ranked: Vec<(&String, Duration)> = self
292            .records
293            .iter()
294            .filter_map(|(id, rec)| rec.median().map(|m| (id, m)))
295            .collect();
296
297        // Sort ascending by median latency (fastest first).
298        ranked.sort_by_key(|&(_, d)| d);
299
300        ranked
301            .into_iter()
302            .take(n)
303            .map(|(id, _)| id.clone())
304            .collect()
305    }
306
307    /// Remove peers whose most recent sample is older than `max_age`.
308    pub fn prune_stale(&mut self, max_age: Duration) {
309        let now = Instant::now();
310        self.records
311            .retain(|_, rec| now.duration_since(rec.last_seen) <= max_age);
312    }
313
314    /// Number of peers currently tracked.
315    pub fn peer_count(&self) -> usize {
316        self.records.len()
317    }
318}
319
320// ---------------------------------------------------------------------------
321// Internal statistics helpers
322// ---------------------------------------------------------------------------
323
324/// Compute the Nth percentile of a `VecDeque<Duration>`.
325///
326/// Uses the "nearest rank" method.  The deque must be non-empty.
327fn percentile_duration(samples: &VecDeque<Duration>, pct: u8) -> Duration {
328    debug_assert!(
329        !samples.is_empty(),
330        "percentile_duration called on empty window"
331    );
332    debug_assert!(pct <= 100, "percentile must be in [0, 100]");
333
334    let mut sorted: Vec<Duration> = samples.iter().copied().collect();
335    sorted.sort_unstable();
336
337    let n = sorted.len();
338    if n == 1 {
339        return sorted[0];
340    }
341
342    // Nearest-rank formula: index = ceil(pct / 100 * n) - 1, clamped.
343    let index = if pct == 0 {
344        0
345    } else {
346        let raw = (pct as usize * n).div_ceil(100);
347        raw.saturating_sub(1).min(n - 1)
348    };
349    sorted[index]
350}
351
352// ---------------------------------------------------------------------------
353// Tests
354// ---------------------------------------------------------------------------
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359
360    // Helper: build a scheduler whose window already contains `n` copies of `d`.
361    fn scheduler_with_uniform_latency(d: Duration, n: usize) -> AdaptiveLookupScheduler {
362        let s = AdaptiveLookupScheduler::new();
363        for _ in 0..n {
364            s.record_latency(d);
365        }
366        s
367    }
368
369    // -----------------------------------------------------------------------
370    // AdaptiveLookupScheduler — alpha adjustment
371    // -----------------------------------------------------------------------
372
373    /// If every recorded latency is above the high threshold the alpha should
374    /// have been decremented from the default (3) toward the minimum (1).
375    #[test]
376    fn test_alpha_decrements_on_high_p90() {
377        // 600 ms is well above HIGH_LATENCY_THRESHOLD_MS (500 ms).
378        let s = scheduler_with_uniform_latency(Duration::from_millis(600), 20);
379        assert!(
380            s.current_alpha() < ALPHA_DEFAULT,
381            "alpha should have decreased, got {}",
382            s.current_alpha()
383        );
384    }
385
386    /// Repeated high-latency samples should drive alpha down to ALPHA_MIN.
387    #[test]
388    fn test_alpha_reaches_minimum_under_sustained_high_latency() {
389        let s = AdaptiveLookupScheduler::new();
390        // Feed enough high-latency samples to exhaust all decrement opportunities.
391        for _ in 0..20 {
392            s.record_latency(Duration::from_millis(800));
393        }
394        assert_eq!(
395            s.current_alpha(),
396            ALPHA_MIN,
397            "alpha should have saturated at ALPHA_MIN"
398        );
399    }
400
401    /// Low-latency samples should increment alpha from the default.
402    #[test]
403    fn test_alpha_increments_on_low_p90() {
404        // 50 ms is below LOW_LATENCY_THRESHOLD_MS (100 ms).
405        let s = scheduler_with_uniform_latency(Duration::from_millis(50), 20);
406        assert!(
407            s.current_alpha() > ALPHA_DEFAULT,
408            "alpha should have increased, got {}",
409            s.current_alpha()
410        );
411    }
412
413    /// Repeated low-latency samples should drive alpha up to ALPHA_MAX.
414    #[test]
415    fn test_alpha_reaches_maximum_under_sustained_low_latency() {
416        let s = AdaptiveLookupScheduler::new();
417        for _ in 0..20 {
418            s.record_latency(Duration::from_millis(10));
419        }
420        assert_eq!(
421            s.current_alpha(),
422            ALPHA_MAX,
423            "alpha should have saturated at ALPHA_MAX"
424        );
425    }
426
427    /// Alpha must never fall below ALPHA_MIN regardless of input.
428    #[test]
429    fn test_alpha_never_below_minimum() {
430        let s = AdaptiveLookupScheduler::new();
431        for _ in 0..100 {
432            s.record_latency(Duration::from_secs(10));
433        }
434        assert!(s.current_alpha() >= ALPHA_MIN);
435    }
436
437    /// Alpha must never exceed ALPHA_MAX regardless of input.
438    #[test]
439    fn test_alpha_never_above_maximum() {
440        let s = AdaptiveLookupScheduler::new();
441        for _ in 0..100 {
442            s.record_latency(Duration::from_micros(1));
443        }
444        assert!(s.current_alpha() <= ALPHA_MAX);
445    }
446
447    /// Mid-range latency (between the two thresholds) should leave alpha stable.
448    #[test]
449    fn test_alpha_stable_on_mid_range_latency() {
450        // 250 ms is between 100 ms and 500 ms.
451        let s = scheduler_with_uniform_latency(Duration::from_millis(250), 30);
452        assert_eq!(
453            s.current_alpha(),
454            ALPHA_DEFAULT,
455            "alpha should remain at default for mid-range latency"
456        );
457    }
458
459    /// The sliding window is capped at WINDOW_CAPACITY; old samples are evicted.
460    #[test]
461    fn test_window_capacity_is_bounded() {
462        let s = AdaptiveLookupScheduler::new();
463        // Insert more than WINDOW_CAPACITY samples.
464        for _ in 0..WINDOW_CAPACITY + 10 {
465            s.record_latency(Duration::from_millis(200));
466        }
467        let stats = s.stats();
468        assert_eq!(
469            stats.window_size, WINDOW_CAPACITY,
470            "window should be capped at WINDOW_CAPACITY"
471        );
472    }
473
474    /// stats() on an empty scheduler should return sensible zeros.
475    #[test]
476    fn test_stats_on_empty_scheduler() {
477        let s = AdaptiveLookupScheduler::new();
478        let stats = s.stats();
479        assert_eq!(stats.current_alpha, ALPHA_DEFAULT);
480        assert_eq!(stats.window_size, 0);
481        assert_eq!(stats.p50_ms, 0);
482        assert_eq!(stats.p90_ms, 0);
483        assert_eq!(stats.p99_ms, 0);
484    }
485
486    /// stats() should reflect the recorded latencies.
487    #[test]
488    fn test_stats_reflect_recorded_latencies() {
489        let s = AdaptiveLookupScheduler::new();
490        // Insert 10 samples: 100 ms, 200 ms, …, 1000 ms.
491        for i in 1..=10u64 {
492            s.record_latency(Duration::from_millis(i * 100));
493        }
494        let stats = s.stats();
495        assert_eq!(stats.window_size, 10);
496        // p50 of {100,200,…,1000} → 500 ms (nearest rank index 4 of 10).
497        assert!(
498            stats.p50_ms >= 400 && stats.p50_ms <= 600,
499            "unexpected p50: {}",
500            stats.p50_ms
501        );
502        // p90 → should be around 900 ms.
503        assert!(
504            stats.p90_ms >= 800 && stats.p90_ms <= 1000,
505            "unexpected p90: {}",
506            stats.p90_ms
507        );
508    }
509
510    // -----------------------------------------------------------------------
511    // PeerLatencyTracker
512    // -----------------------------------------------------------------------
513
514    /// Record latency for a single peer and verify p90 retrieval.
515    #[test]
516    fn test_peer_p90_single_peer() {
517        let mut tracker = PeerLatencyTracker::new();
518        for i in 1..=10u64 {
519            tracker.record("peer-A", Duration::from_millis(i * 100));
520        }
521        let p90 = tracker
522            .p90_for_peer("peer-A")
523            .expect("peer-A should be tracked");
524        assert!(
525            p90.as_millis() >= 800 && p90.as_millis() <= 1000,
526            "unexpected p90: {}",
527            p90.as_millis()
528        );
529    }
530
531    /// Unknown peer returns None.
532    #[test]
533    fn test_peer_p90_unknown_peer_returns_none() {
534        let tracker = PeerLatencyTracker::new();
535        assert!(tracker.p90_for_peer("ghost-peer").is_none());
536    }
537
538    /// `fastest_peers` returns peers ordered by ascending median latency.
539    #[test]
540    fn test_fastest_peers_ordering() {
541        let mut tracker = PeerLatencyTracker::new();
542
543        // peer-A: consistently fast (~10 ms).
544        for _ in 0..5 {
545            tracker.record("peer-A", Duration::from_millis(10));
546        }
547        // peer-B: medium (~200 ms).
548        for _ in 0..5 {
549            tracker.record("peer-B", Duration::from_millis(200));
550        }
551        // peer-C: slow (~800 ms).
552        for _ in 0..5 {
553            tracker.record("peer-C", Duration::from_millis(800));
554        }
555
556        let fastest = tracker.fastest_peers(3);
557        assert_eq!(fastest.len(), 3);
558        assert_eq!(fastest[0], "peer-A", "peer-A should be fastest");
559        assert_eq!(fastest[2], "peer-C", "peer-C should be slowest");
560    }
561
562    /// `fastest_peers(n)` with n > tracked peers returns all tracked peers.
563    #[test]
564    fn test_fastest_peers_fewer_than_requested() {
565        let mut tracker = PeerLatencyTracker::new();
566        tracker.record("only-peer", Duration::from_millis(50));
567        let result = tracker.fastest_peers(10);
568        assert_eq!(result.len(), 1);
569        assert_eq!(result[0], "only-peer");
570    }
571
572    /// `prune_stale` with a zero-duration max_age should remove all records.
573    #[test]
574    fn test_prune_stale_removes_all_with_zero_max_age() {
575        let mut tracker = PeerLatencyTracker::new();
576        tracker.record("peer-X", Duration::from_millis(100));
577        tracker.record("peer-Y", Duration::from_millis(200));
578
579        // Sleep briefly so that last_seen is in the past, then prune with 0 max_age.
580        // We use Duration::ZERO which means any record older than "now" is pruned.
581        // Since Instant::now() at push time is strictly before now at prune time,
582        // a max_age of Duration::ZERO should remove all entries.
583        // (On very fast machines the delta could be 0 ns; use a tiny positive guard.)
584        std::thread::sleep(Duration::from_millis(1));
585        tracker.prune_stale(Duration::ZERO);
586        assert_eq!(tracker.peer_count(), 0, "all peers should have been pruned");
587    }
588
589    /// `prune_stale` with a generous max_age retains recent records.
590    #[test]
591    fn test_prune_stale_retains_recent_entries() {
592        let mut tracker = PeerLatencyTracker::new();
593        tracker.record("peer-A", Duration::from_millis(50));
594        tracker.record("peer-B", Duration::from_millis(100));
595
596        // Prune with 1-hour max_age — nothing should be removed.
597        tracker.prune_stale(Duration::from_secs(3600));
598        assert_eq!(
599            tracker.peer_count(),
600            2,
601            "no peers should have been pruned within max_age"
602        );
603    }
604
605    /// Per-peer window is capped at PEER_WINDOW_CAPACITY.
606    #[test]
607    fn test_peer_window_capacity_is_bounded() {
608        let mut tracker = PeerLatencyTracker::new();
609        // Insert twice the capacity.
610        for i in 0..(PEER_WINDOW_CAPACITY * 2) as u64 {
611            tracker.record("peer-A", Duration::from_millis(i));
612        }
613        // If p90 is reachable the window wasn't corrupted; also verify by
614        // ensuring the oldest samples (very small durations) were evicted.
615        let p90 = tracker.p90_for_peer("peer-A").expect("should exist");
616        // The last PEER_WINDOW_CAPACITY values start at index PEER_WINDOW_CAPACITY
617        // (i.e., 32 ms, 33 ms, …, 63 ms).  p90 should be ≥ the majority of those.
618        assert!(
619            p90.as_millis() >= 50,
620            "old (small) samples should have been evicted; p90={}ms",
621            p90.as_millis()
622        );
623    }
624}