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}