Skip to main content

ipfrs_network/
batch_resolver.rs

1//! Batch CID resolver and prefetch scheduler for DHT performance optimization
2//!
3//! This module provides:
4//! - [`BatchCidResolver`]: batches multiple CID lookups to reduce DHT overhead
5//! - [`PrefetchScheduler`]: predicts and schedules prefetch candidates based on access patterns
6
7use std::collections::{HashMap, VecDeque};
8use std::sync::atomic::{AtomicU64, Ordering};
9use std::sync::{Arc, Mutex};
10use std::time::{Duration, Instant};
11
12// ─── CachedResult ────────────────────────────────────────────────────────────
13
14/// A resolved entry stored in the cache.
15#[derive(Debug, Clone)]
16pub struct CachedResult {
17    /// Provider multiaddrs / peer IDs for this CID
18    pub providers: Vec<String>,
19    /// When the result was stored
20    pub resolved_at: Instant,
21    /// How long the entry is considered fresh
22    pub ttl: Duration,
23}
24
25impl CachedResult {
26    /// Returns `true` when the entry has expired relative to `now`.
27    #[inline]
28    pub fn is_expired(&self, now: Instant) -> bool {
29        now.duration_since(self.resolved_at) >= self.ttl
30    }
31}
32
33// ─── PendingLookup ────────────────────────────────────────────────────────────
34
35/// A CID that has been queued but not yet resolved.
36#[derive(Debug, Clone)]
37pub struct PendingLookup {
38    /// The CID string to look up
39    pub cid: String,
40    /// When the lookup was enqueued
41    pub queued_at: Instant,
42}
43
44// ─── LookupHandle ────────────────────────────────────────────────────────────
45
46/// A lightweight handle returned to the caller when they queue a CID lookup.
47///
48/// This is a plain value type — callers can use it to track which CID they
49/// queued and when, without needing an async channel.
50#[derive(Debug, Clone)]
51pub struct LookupHandle {
52    /// The CID string that was queued
53    pub cid: String,
54    /// When it was enqueued
55    pub queued_at: Instant,
56}
57
58// ─── BatchResolverStats ───────────────────────────────────────────────────────
59
60/// Atomic counters for [`BatchCidResolver`] activity.
61#[derive(Debug, Default)]
62pub struct BatchResolverStats {
63    /// Total CIDs added via [`BatchCidResolver::queue_lookup`]
64    pub total_queued: AtomicU64,
65    /// Total CIDs for which results have been recorded
66    pub total_resolved: AtomicU64,
67    /// Total cache hits returned by [`BatchCidResolver::get_cached`]
68    pub total_cache_hits: AtomicU64,
69    /// Total calls to [`BatchCidResolver::drain_batch`] that returned ≥1 item
70    pub total_batches_drained: AtomicU64,
71    /// Total cache entries removed by [`BatchCidResolver::evict_expired`]
72    pub total_evictions: AtomicU64,
73}
74
75/// A point-in-time snapshot of [`BatchResolverStats`].
76#[derive(Debug, Clone, PartialEq, Eq)]
77pub struct BatchResolverStatsSnapshot {
78    /// Total CIDs queued
79    pub total_queued: u64,
80    /// Total CIDs resolved
81    pub total_resolved: u64,
82    /// Total cache hits
83    pub total_cache_hits: u64,
84    /// Total non-empty drain operations
85    pub total_batches_drained: u64,
86    /// Total cache entries evicted
87    pub total_evictions: u64,
88}
89
90impl BatchResolverStats {
91    /// Take an instantaneous snapshot of all counters.
92    pub fn snapshot(&self) -> BatchResolverStatsSnapshot {
93        BatchResolverStatsSnapshot {
94            total_queued: self.total_queued.load(Ordering::Relaxed),
95            total_resolved: self.total_resolved.load(Ordering::Relaxed),
96            total_cache_hits: self.total_cache_hits.load(Ordering::Relaxed),
97            total_batches_drained: self.total_batches_drained.load(Ordering::Relaxed),
98            total_evictions: self.total_evictions.load(Ordering::Relaxed),
99        }
100    }
101}
102
103// ─── BatchCidResolver ─────────────────────────────────────────────────────────
104
105/// Batches CID provider lookups and caches results with TTL-based expiry.
106///
107/// # Thread safety
108///
109/// All internal state is guarded by [`Mutex`]; the struct itself can be shared
110/// behind an [`Arc`] without additional synchronization.
111pub struct BatchCidResolver {
112    /// CIDs waiting to be resolved, in insertion order
113    pending: Mutex<Vec<PendingLookup>>,
114    /// Resolved CID → provider list, keyed by CID string
115    cache: Mutex<HashMap<String, CachedResult>>,
116    /// Time-to-live applied to newly stored cache entries
117    pub cache_ttl: Duration,
118    /// Maximum number of pending items consumed per `drain_batch` call
119    pub max_batch_size: usize,
120    /// Operational statistics
121    pub stats: Arc<BatchResolverStats>,
122}
123
124impl BatchCidResolver {
125    /// Create a new resolver with default TTL (5 min) and batch size (32).
126    pub fn new() -> Self {
127        Self::with_config(Duration::from_secs(300), 32)
128    }
129
130    /// Create a resolver with custom TTL and batch size.
131    pub fn with_config(cache_ttl: Duration, max_batch_size: usize) -> Self {
132        Self {
133            pending: Mutex::new(Vec::new()),
134            cache: Mutex::new(HashMap::new()),
135            cache_ttl,
136            max_batch_size,
137            stats: Arc::new(BatchResolverStats::default()),
138        }
139    }
140
141    /// Queue a CID for resolution.
142    ///
143    /// Returns a [`LookupHandle`] the caller can use to track the request.
144    /// If the CID is already in the cache the item is still queued; callers
145    /// should call `get_cached` first if they want to avoid duplicate work.
146    pub fn queue_lookup(&self, cid: &str) -> LookupHandle {
147        let now = Instant::now();
148        let lookup = PendingLookup {
149            cid: cid.to_owned(),
150            queued_at: now,
151        };
152        {
153            let mut guard = self.pending.lock().unwrap_or_else(|e| e.into_inner());
154            guard.push(lookup);
155        }
156        self.stats.total_queued.fetch_add(1, Ordering::Relaxed);
157        LookupHandle {
158            cid: cid.to_owned(),
159            queued_at: now,
160        }
161    }
162
163    /// Atomically drain up to `max_batch_size` pending lookups.
164    ///
165    /// Returns the drained items in FIFO order.  Increments
166    /// `total_batches_drained` only when at least one item is returned.
167    pub fn drain_batch(&self) -> Vec<PendingLookup> {
168        let mut guard = self.pending.lock().unwrap_or_else(|e| e.into_inner());
169        if guard.is_empty() {
170            return Vec::new();
171        }
172        let n = guard.len().min(self.max_batch_size);
173        // Drain from the front to preserve FIFO order
174        let drained: Vec<PendingLookup> = guard.drain(..n).collect();
175        drop(guard);
176        if !drained.is_empty() {
177            self.stats
178                .total_batches_drained
179                .fetch_add(1, Ordering::Relaxed);
180        }
181        drained
182    }
183
184    /// Store a resolved provider list for `cid`.
185    ///
186    /// Any existing entry (including unexpired ones) is overwritten.
187    pub fn record_result(&self, cid: &str, providers: Vec<String>) {
188        let entry = CachedResult {
189            providers,
190            resolved_at: Instant::now(),
191            ttl: self.cache_ttl,
192        };
193        {
194            let mut guard = self.cache.lock().unwrap_or_else(|e| e.into_inner());
195            guard.insert(cid.to_owned(), entry);
196        }
197        self.stats.total_resolved.fetch_add(1, Ordering::Relaxed);
198    }
199
200    /// Return the cached provider list for `cid` if it has not expired.
201    pub fn get_cached(&self, cid: &str) -> Option<Vec<String>> {
202        let now = Instant::now();
203        let guard = self.cache.lock().unwrap_or_else(|e| e.into_inner());
204        let entry = guard.get(cid)?;
205        if entry.is_expired(now) {
206            return None;
207        }
208        let providers = entry.providers.clone();
209        drop(guard);
210        self.stats.total_cache_hits.fetch_add(1, Ordering::Relaxed);
211        Some(providers)
212    }
213
214    /// Remove all cache entries that have exceeded their TTL.
215    pub fn evict_expired(&self) {
216        let now = Instant::now();
217        let mut guard = self.cache.lock().unwrap_or_else(|e| e.into_inner());
218        let before = guard.len();
219        guard.retain(|_, v| !v.is_expired(now));
220        let after = guard.len();
221        let evicted = (before - after) as u64;
222        drop(guard);
223        if evicted > 0 {
224            self.stats
225                .total_evictions
226                .fetch_add(evicted, Ordering::Relaxed);
227        }
228    }
229
230    /// Return the number of CIDs currently in the pending queue.
231    pub fn pending_count(&self) -> usize {
232        let guard = self.pending.lock().unwrap_or_else(|e| e.into_inner());
233        guard.len()
234    }
235
236    /// Return the number of entries currently held in the cache
237    /// (expired entries are counted until `evict_expired` is called).
238    pub fn cache_size(&self) -> usize {
239        let guard = self.cache.lock().unwrap_or_else(|e| e.into_inner());
240        guard.len()
241    }
242}
243
244impl Default for BatchCidResolver {
245    fn default() -> Self {
246        Self::new()
247    }
248}
249
250// ─── PrefetchScheduler ────────────────────────────────────────────────────────
251
252/// Tracks CID access patterns and suggests prefetch candidates.
253///
254/// A sliding window of the last 256 accesses is maintained.  Within each
255/// access event the scheduler updates co-access counts for up to the
256/// 4 most-recent *other* CIDs (i.e., pairs within the last 5 accesses).
257pub struct PrefetchScheduler {
258    /// Rolling log of (cid, timestamp) — newest at the back
259    access_log: Mutex<VecDeque<(String, Instant)>>,
260    /// co_access[a][b] = number of times b was accessed within 5 steps of a
261    co_access: Mutex<HashMap<String, HashMap<String, u32>>>,
262}
263
264impl PrefetchScheduler {
265    /// Create a new scheduler.
266    pub fn new() -> Self {
267        Self {
268            access_log: Mutex::new(VecDeque::new()),
269            co_access: Mutex::new(HashMap::new()),
270        }
271    }
272
273    /// Record that `cid` was accessed.
274    ///
275    /// Appends to the rolling log (trimmed to 256 entries) and updates
276    /// co-access counts for all pairs formed by the current access and the
277    /// preceding 4 entries (window of 5).
278    pub fn record_access(&self, cid: &str) {
279        let now = Instant::now();
280        let mut log = self.access_log.lock().unwrap_or_else(|e| e.into_inner());
281
282        // Collect the (up to 4) CIDs that form a window with the new one
283        let window_size = 4.min(log.len());
284        let recent: Vec<String> = log
285            .iter()
286            .rev()
287            .take(window_size)
288            .map(|(c, _)| c.clone())
289            .collect();
290
291        // Append the new entry
292        log.push_back((cid.to_owned(), now));
293        // Trim to rolling window of 256
294        while log.len() > 256 {
295            log.pop_front();
296        }
297        drop(log);
298
299        // Update co-access counts
300        if !recent.is_empty() {
301            let mut co = self.co_access.lock().unwrap_or_else(|e| e.into_inner());
302            for peer in &recent {
303                // cid co-accessed with peer
304                co.entry(cid.to_owned())
305                    .or_default()
306                    .entry(peer.clone())
307                    .and_modify(|c| *c += 1)
308                    .or_insert(1);
309                // peer co-accessed with cid
310                co.entry(peer.clone())
311                    .or_default()
312                    .entry(cid.to_owned())
313                    .and_modify(|c| *c += 1)
314                    .or_insert(1);
315            }
316        }
317    }
318
319    /// Return the top `top_n` CIDs most frequently co-accessed with `cid`,
320    /// sorted by co-access count descending.
321    pub fn prefetch_candidates(&self, cid: &str, top_n: usize) -> Vec<String> {
322        if top_n == 0 {
323            return Vec::new();
324        }
325        let co = self.co_access.lock().unwrap_or_else(|e| e.into_inner());
326        let peers = match co.get(cid) {
327            Some(map) => map,
328            None => return Vec::new(),
329        };
330        let mut sorted: Vec<(String, u32)> = peers.iter().map(|(k, &v)| (k.clone(), v)).collect();
331        sorted.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
332        sorted.into_iter().take(top_n).map(|(k, _)| k).collect()
333    }
334
335    /// Return the total number of entries in the access log.
336    pub fn access_count(&self) -> usize {
337        let log = self.access_log.lock().unwrap_or_else(|e| e.into_inner());
338        log.len()
339    }
340
341    /// Remove log entries older than `max_age`.
342    ///
343    /// Note: co-access counts are *not* adjusted — they represent historical
344    /// signal that remains valid even after pruning old log entries.
345    pub fn prune_log(&self, max_age: Duration) {
346        let now = Instant::now();
347        let mut log = self.access_log.lock().unwrap_or_else(|e| e.into_inner());
348        log.retain(|(_, ts)| now.duration_since(*ts) < max_age);
349    }
350}
351
352impl Default for PrefetchScheduler {
353    fn default() -> Self {
354        Self::new()
355    }
356}
357
358// ─── Tests ────────────────────────────────────────────────────────────────────
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363    use std::thread;
364    use std::time::Duration;
365
366    // Helper: build a resolver with a very short TTL for expiry tests
367    fn short_ttl_resolver() -> BatchCidResolver {
368        BatchCidResolver::with_config(Duration::from_millis(50), 32)
369    }
370
371    // ── BatchCidResolver tests ────────────────────────────────────────────────
372
373    #[test]
374    fn test_queue_and_pending_count() {
375        let r = BatchCidResolver::new();
376        assert_eq!(r.pending_count(), 0);
377        r.queue_lookup("QmA");
378        r.queue_lookup("QmB");
379        assert_eq!(r.pending_count(), 2);
380        assert_eq!(r.stats.snapshot().total_queued, 2);
381    }
382
383    #[test]
384    fn test_drain_batch_respects_max_batch_size() {
385        let r = BatchCidResolver::with_config(Duration::from_secs(300), 3);
386        for i in 0..7u32 {
387            r.queue_lookup(&format!("Qm{}", i));
388        }
389        let batch = r.drain_batch();
390        assert_eq!(batch.len(), 3, "first drain should return max_batch_size");
391        assert_eq!(r.pending_count(), 4, "remaining items still queued");
392    }
393
394    #[test]
395    fn test_drain_batch_empty_returns_empty_vec() {
396        let r = BatchCidResolver::new();
397        let batch = r.drain_batch();
398        assert!(batch.is_empty());
399        // Empty drain must NOT increment total_batches_drained
400        assert_eq!(r.stats.snapshot().total_batches_drained, 0);
401    }
402
403    #[test]
404    fn test_drain_batch_increments_stat_on_nonempty() {
405        let r = BatchCidResolver::new();
406        r.queue_lookup("QmX");
407        let _ = r.drain_batch();
408        assert_eq!(r.stats.snapshot().total_batches_drained, 1);
409    }
410
411    #[test]
412    fn test_drain_batch_preserves_fifo_order() {
413        let r = BatchCidResolver::with_config(Duration::from_secs(300), 10);
414        let cids = ["QmA", "QmB", "QmC", "QmD"];
415        for c in &cids {
416            r.queue_lookup(c);
417        }
418        let batch = r.drain_batch();
419        let drained_cids: Vec<&str> = batch.iter().map(|p| p.cid.as_str()).collect();
420        assert_eq!(drained_cids, cids);
421    }
422
423    #[test]
424    fn test_cache_hit_returns_providers() {
425        let r = BatchCidResolver::new();
426        let providers = vec!["peer1".to_string(), "peer2".to_string()];
427        r.record_result("QmFoo", providers.clone());
428        let cached = r.get_cached("QmFoo");
429        assert_eq!(cached, Some(providers));
430        assert_eq!(r.stats.snapshot().total_cache_hits, 1);
431    }
432
433    #[test]
434    fn test_cache_miss_returns_none() {
435        let r = BatchCidResolver::new();
436        assert!(r.get_cached("QmMissing").is_none());
437        assert_eq!(r.stats.snapshot().total_cache_hits, 0);
438    }
439
440    #[test]
441    fn test_expired_cache_entries_evicted() {
442        let r = short_ttl_resolver();
443        r.record_result("QmExpire", vec!["peer".to_string()]);
444        assert_eq!(r.cache_size(), 1);
445
446        // Wait for TTL to expire
447        thread::sleep(Duration::from_millis(60));
448
449        // get_cached should not return expired entry
450        assert!(r.get_cached("QmExpire").is_none());
451
452        // evict_expired should remove it
453        r.evict_expired();
454        assert_eq!(r.cache_size(), 0);
455        assert_eq!(r.stats.snapshot().total_evictions, 1);
456    }
457
458    #[test]
459    fn test_evict_expired_only_removes_stale_entries() {
460        let r = short_ttl_resolver();
461        r.record_result("QmOld", vec!["p1".to_string()]);
462        thread::sleep(Duration::from_millis(60));
463        // Add a fresh entry AFTER the old one expired
464        r.record_result("QmNew", vec!["p2".to_string()]);
465        r.evict_expired();
466        assert_eq!(r.cache_size(), 1);
467        assert!(r.get_cached("QmNew").is_some());
468    }
469
470    #[test]
471    fn test_stats_accumulate_correctly() {
472        let r = BatchCidResolver::new();
473        r.queue_lookup("Qm1");
474        r.queue_lookup("Qm2");
475        r.drain_batch();
476        r.record_result("Qm1", vec!["peer".to_string()]);
477        r.get_cached("Qm1");
478        let snap = r.stats.snapshot();
479        assert_eq!(snap.total_queued, 2);
480        assert_eq!(snap.total_resolved, 1);
481        assert_eq!(snap.total_cache_hits, 1);
482        assert_eq!(snap.total_batches_drained, 1);
483    }
484
485    // ── PrefetchScheduler tests ───────────────────────────────────────────────
486
487    #[test]
488    fn test_prefetch_records_co_access_patterns() {
489        let s = PrefetchScheduler::new();
490        s.record_access("A");
491        s.record_access("B");
492        // B should appear as a candidate for A (and vice versa)
493        let candidates = s.prefetch_candidates("A", 5);
494        assert!(candidates.contains(&"B".to_string()));
495    }
496
497    #[test]
498    fn test_prefetch_candidates_sorted_by_frequency() {
499        let s = PrefetchScheduler::new();
500        // Build co-access between BASE→C many times, and BASE→B only once.
501        // We avoid self-co-access by always interleaving with a neutral "NOISE" CID.
502        //
503        // Pattern (6 repetitions): NOISE, BASE, C   → each gives co(BASE,C) += 1
504        for _ in 0..6 {
505            s.record_access("NOISE");
506            s.record_access("BASE");
507            s.record_access("C");
508        }
509        // One occurrence of BASE near B
510        s.record_access("NOISE2");
511        s.record_access("BASE");
512        s.record_access("B");
513
514        let candidates = s.prefetch_candidates("BASE", 3);
515        // Filter out NOISE entries — we only care about C vs B ranking
516        let filtered: Vec<&str> = candidates
517            .iter()
518            .map(|s| s.as_str())
519            .filter(|&c| c == "C" || c == "B")
520            .collect();
521        assert!(
522            !filtered.is_empty(),
523            "expected at least C or B in candidates"
524        );
525        // C should rank higher than B (more co-accesses)
526        assert_eq!(filtered[0], "C", "C should be the top co-access candidate");
527    }
528
529    #[test]
530    fn test_prefetch_candidates_empty_for_unknown_cid() {
531        let s = PrefetchScheduler::new();
532        s.record_access("X");
533        let candidates = s.prefetch_candidates("UNKNOWN", 5);
534        assert!(candidates.is_empty());
535    }
536
537    #[test]
538    fn test_prefetch_candidates_top_n_respected() {
539        let s = PrefetchScheduler::new();
540        // Create co-access relationships with many peers
541        let base = "BASE";
542        for i in 0..10u32 {
543            s.record_access(base);
544            s.record_access(&format!("PEER{}", i));
545        }
546        let candidates = s.prefetch_candidates(base, 3);
547        assert!(candidates.len() <= 3);
548    }
549
550    #[test]
551    fn test_prefetch_access_count() {
552        let s = PrefetchScheduler::new();
553        assert_eq!(s.access_count(), 0);
554        s.record_access("A");
555        s.record_access("B");
556        s.record_access("C");
557        assert_eq!(s.access_count(), 3);
558    }
559
560    #[test]
561    fn test_prune_log_removes_old_entries() {
562        let s = PrefetchScheduler::new();
563        s.record_access("A");
564        s.record_access("B");
565        thread::sleep(Duration::from_millis(30));
566        // Prune anything older than 20 ms — both entries should be removed
567        s.prune_log(Duration::from_millis(20));
568        assert_eq!(s.access_count(), 0);
569    }
570
571    #[test]
572    fn test_prune_log_keeps_recent_entries() {
573        let s = PrefetchScheduler::new();
574        s.record_access("A");
575        // Prune anything older than 1 second — entry is fresh, should survive
576        s.prune_log(Duration::from_secs(1));
577        assert_eq!(s.access_count(), 1);
578    }
579
580    #[test]
581    fn test_access_log_capped_at_256() {
582        let s = PrefetchScheduler::new();
583        for i in 0..300u32 {
584            s.record_access(&format!("Qm{}", i));
585        }
586        assert_eq!(s.access_count(), 256);
587    }
588
589    #[test]
590    fn test_lookup_handle_fields() {
591        let r = BatchCidResolver::new();
592        let handle = r.queue_lookup("QmHandle");
593        assert_eq!(handle.cid, "QmHandle");
594        // queued_at should be very recent
595        assert!(handle.queued_at.elapsed() < Duration::from_secs(1));
596    }
597}