Skip to main content

ipfrs_network/
lookup_cache.rs

1//! DHT lookup result caching and parallel alpha query execution.
2//!
3//! This module provides:
4//! - `LookupCache`: TTL-based CID → providers cache for DHT lookups
5//! - `ParallelLookupExecutor`: Parallel DHT provider lookups with caching
6
7use std::collections::HashMap;
8use std::sync::atomic::{AtomicU64, Ordering};
9use std::sync::Arc;
10
11use futures::future::join_all;
12use parking_lot::RwLock;
13
14/// A cached result for a CID provider lookup.
15#[derive(Debug, Clone)]
16pub struct CachedProviders {
17    /// The CID string key for this entry.
18    pub cid_str: String,
19    /// List of peer ID strings that provide this CID.
20    pub providers: Vec<String>,
21    /// Millisecond timestamp at which this entry was inserted.
22    pub cached_at_ms: u64,
23    /// Time-to-live for this entry in milliseconds.
24    pub ttl_ms: u64,
25    /// Number of times this entry has been served from cache.
26    pub hit_count: u64,
27}
28
29impl CachedProviders {
30    /// Create a new `CachedProviders` entry.
31    pub fn new(
32        cid_str: impl Into<String>,
33        providers: Vec<String>,
34        now_ms: u64,
35        ttl_ms: u64,
36    ) -> Self {
37        Self {
38            cid_str: cid_str.into(),
39            providers,
40            cached_at_ms: now_ms,
41            ttl_ms,
42            hit_count: 0,
43        }
44    }
45
46    /// Returns `true` when the entry has expired at `now_ms`.
47    #[inline]
48    pub fn is_expired(&self, now_ms: u64) -> bool {
49        now_ms > self.cached_at_ms + self.ttl_ms
50    }
51
52    /// Returns the age of this entry in milliseconds, saturating at zero.
53    #[inline]
54    pub fn age_ms(&self, now_ms: u64) -> u64 {
55        now_ms.saturating_sub(self.cached_at_ms)
56    }
57}
58
59/// Configuration for the [`LookupCache`].
60#[derive(Debug, Clone)]
61pub struct LookupCacheConfig {
62    /// TTL for positive results (providers found). Default: 300 000 ms (5 minutes).
63    pub positive_ttl_ms: u64,
64    /// TTL for negative results (no providers). Default: 30 000 ms (30 seconds).
65    pub negative_ttl_ms: u64,
66    /// Maximum number of entries before eviction is triggered. Default: 10 000.
67    pub max_entries: usize,
68}
69
70impl Default for LookupCacheConfig {
71    fn default() -> Self {
72        Self {
73            positive_ttl_ms: 300_000,
74            negative_ttl_ms: 30_000,
75            max_entries: 10_000,
76        }
77    }
78}
79
80/// TTL-based CID → providers cache for DHT lookups.
81pub struct LookupCache {
82    config: LookupCacheConfig,
83    entries: RwLock<HashMap<String, CachedProviders>>,
84    hits: AtomicU64,
85    misses: AtomicU64,
86    evictions: AtomicU64,
87}
88
89/// Snapshot statistics for a [`LookupCache`].
90#[derive(Debug, Clone)]
91pub struct LookupCacheStats {
92    /// Current number of entries (including expired ones not yet evicted).
93    pub total_entries: usize,
94    /// Total cache hits served.
95    pub hits: u64,
96    /// Total cache misses.
97    pub misses: u64,
98    /// Total entries removed by eviction.
99    pub evictions: u64,
100    /// `hits / (hits + misses)`, or `0.0` when no lookups have been made.
101    pub hit_rate: f64,
102}
103
104impl LookupCache {
105    /// Create a new `LookupCache` wrapped in an `Arc`.
106    pub fn new(config: LookupCacheConfig) -> Arc<Self> {
107        Arc::new(Self {
108            config,
109            entries: RwLock::new(HashMap::new()),
110            hits: AtomicU64::new(0),
111            misses: AtomicU64::new(0),
112            evictions: AtomicU64::new(0),
113        })
114    }
115
116    /// Look up providers for a CID.
117    ///
118    /// Returns `None` on a cache miss or when the entry has expired.
119    /// On a hit the `hit_count` for the entry is incremented and the
120    /// provider list is returned.
121    pub fn get(&self, cid_str: &str, now_ms: u64) -> Option<Vec<String>> {
122        // First try with a read lock to avoid write contention.
123        {
124            let entries = self.entries.read();
125            if let Some(entry) = entries.get(cid_str) {
126                if entry.is_expired(now_ms) {
127                    // Expired — treat as miss; removal happens lazily.
128                    self.misses.fetch_add(1, Ordering::Relaxed);
129                    return None;
130                }
131                // Hot path: record the providers before upgrading the lock.
132                let providers = entry.providers.clone();
133                drop(entries);
134
135                // Upgrade to write lock to bump hit_count.
136                {
137                    let mut entries = self.entries.write();
138                    if let Some(entry) = entries.get_mut(cid_str) {
139                        entry.hit_count = entry.hit_count.saturating_add(1);
140                    }
141                }
142                self.hits.fetch_add(1, Ordering::Relaxed);
143                return Some(providers);
144            }
145        }
146
147        self.misses.fetch_add(1, Ordering::Relaxed);
148        None
149    }
150
151    /// Store a positive result (providers found) for `cid_str`.
152    ///
153    /// If the cache is at capacity the new entry still replaces any existing
154    /// entry for the same CID; overall capacity enforcement is left to
155    /// [`evict_expired`](Self::evict_expired).
156    pub fn put(&self, cid_str: impl Into<String>, providers: Vec<String>, now_ms: u64) {
157        let key = cid_str.into();
158        let ttl_ms = self.config.positive_ttl_ms;
159        let entry = CachedProviders::new(key.clone(), providers, now_ms, ttl_ms);
160        let mut entries = self.entries.write();
161
162        // Enforce max_entries by evicting expired items first when at capacity.
163        if !entries.contains_key(&key) && entries.len() >= self.config.max_entries {
164            let expired_keys: Vec<String> = entries
165                .iter()
166                .filter(|(_, v)| v.is_expired(now_ms))
167                .map(|(k, _)| k.clone())
168                .collect();
169            let removed = expired_keys.len();
170            for k in expired_keys {
171                entries.remove(&k);
172            }
173            self.evictions.fetch_add(removed as u64, Ordering::Relaxed);
174        }
175
176        entries.insert(key, entry);
177    }
178
179    /// Store a negative result (no providers found) for `cid_str`.
180    ///
181    /// Negative entries use [`LookupCacheConfig::negative_ttl_ms`] as TTL and
182    /// an empty provider list.
183    pub fn put_negative(&self, cid_str: impl Into<String>, now_ms: u64) {
184        let key = cid_str.into();
185        let ttl_ms = self.config.negative_ttl_ms;
186        let entry = CachedProviders::new(key.clone(), Vec::new(), now_ms, ttl_ms);
187        let mut entries = self.entries.write();
188        entries.insert(key, entry);
189    }
190
191    /// Invalidate the entry for `cid_str`.
192    ///
193    /// Returns `true` if an entry existed and was removed.
194    pub fn invalidate(&self, cid_str: &str) -> bool {
195        let mut entries = self.entries.write();
196        entries.remove(cid_str).is_some()
197    }
198
199    /// Remove all expired entries.
200    ///
201    /// Returns the number of entries removed.
202    pub fn evict_expired(&self, now_ms: u64) -> usize {
203        let mut entries = self.entries.write();
204        let expired_keys: Vec<String> = entries
205            .iter()
206            .filter(|(_, v)| v.is_expired(now_ms))
207            .map(|(k, _)| k.clone())
208            .collect();
209        let count = expired_keys.len();
210        for k in expired_keys {
211            entries.remove(&k);
212        }
213        self.evictions.fetch_add(count as u64, Ordering::Relaxed);
214        count
215    }
216
217    /// Return a snapshot of current cache statistics.
218    pub fn stats(&self) -> LookupCacheStats {
219        let total_entries = self.entries.read().len();
220        let hits = self.hits.load(Ordering::Relaxed);
221        let misses = self.misses.load(Ordering::Relaxed);
222        let evictions = self.evictions.load(Ordering::Relaxed);
223        let total = hits + misses;
224        let hit_rate = if total == 0 {
225            0.0
226        } else {
227            hits as f64 / total as f64
228        };
229        LookupCacheStats {
230            total_entries,
231            hits,
232            misses,
233            evictions,
234            hit_rate,
235        }
236    }
237
238    /// Total number of cached entries (including expired ones not yet evicted).
239    pub fn len(&self) -> usize {
240        self.entries.read().len()
241    }
242
243    /// Whether the cache is empty.
244    pub fn is_empty(&self) -> bool {
245        self.entries.read().is_empty()
246    }
247}
248
249// ── ParallelLookupExecutor ────────────────────────────────────────────────────
250
251/// Configuration for [`ParallelLookupExecutor`].
252#[derive(Debug, Clone)]
253pub struct ParallelLookupConfig {
254    /// Number of concurrent DHT queries (alpha). Default: 3 (Kademlia default).
255    pub alpha: usize,
256    /// Maximum total lookups per request. Default: 20.
257    pub max_lookups: usize,
258    /// Per-lookup timeout in milliseconds. Default: 5 000.
259    pub timeout_ms: u64,
260}
261
262impl Default for ParallelLookupConfig {
263    fn default() -> Self {
264        Self {
265            alpha: 3,
266            max_lookups: 20,
267            timeout_ms: 5_000,
268        }
269    }
270}
271
272/// Result of a parallel provider lookup.
273#[derive(Debug, Clone)]
274pub struct ParallelLookupResult {
275    /// The CID that was looked up.
276    pub cid_str: String,
277    /// Deduplicated list of peer ID strings that provide the CID.
278    pub providers: Vec<String>,
279    /// `true` when the result was served from the local cache.
280    pub from_cache: bool,
281    /// Number of DHT queries actually issued (0 on a cache hit).
282    pub lookup_count: usize,
283    /// Wall-clock time in milliseconds from call start to return.
284    pub elapsed_ms: u64,
285}
286
287/// Executes parallel DHT provider lookups with integrated caching.
288pub struct ParallelLookupExecutor {
289    cache: Arc<LookupCache>,
290    config: ParallelLookupConfig,
291    total_lookups: AtomicU64,
292    cache_saves: AtomicU64,
293}
294
295impl ParallelLookupExecutor {
296    /// Create a new `ParallelLookupExecutor` wrapped in an `Arc`.
297    pub fn new(cache: Arc<LookupCache>, config: ParallelLookupConfig) -> Arc<Self> {
298        Arc::new(Self {
299            cache,
300            config,
301            total_lookups: AtomicU64::new(0),
302            cache_saves: AtomicU64::new(0),
303        })
304    }
305
306    /// Look up providers for `cid_str`, using the cache first.
307    ///
308    /// # Cache hit
309    /// Returns immediately with `from_cache = true` and `lookup_count = 0`.
310    ///
311    /// # Cache miss
312    /// Runs `alpha` parallel calls to `query_fn(cid_str)`, merges and
313    /// deduplicates the results, stores them in the cache, and returns with
314    /// `from_cache = false` and `lookup_count = alpha`.
315    pub async fn lookup<F, Fut>(
316        &self,
317        cid_str: &str,
318        now_ms: u64,
319        query_fn: F,
320    ) -> ParallelLookupResult
321    where
322        F: Fn(String) -> Fut + Send + Sync,
323        Fut: std::future::Future<Output = Vec<String>> + Send,
324    {
325        self.total_lookups.fetch_add(1, Ordering::Relaxed);
326        let t0 = now_ms; // caller-provided; real wall clock would be std::time::Instant::now()
327
328        // 1. Check cache.
329        if let Some(providers) = self.cache.get(cid_str, now_ms) {
330            self.cache_saves.fetch_add(1, Ordering::Relaxed);
331            return ParallelLookupResult {
332                cid_str: cid_str.to_string(),
333                providers,
334                from_cache: true,
335                lookup_count: 0,
336                elapsed_ms: 0,
337            };
338        }
339
340        // 2. Run `alpha` parallel DHT queries.
341        let alpha = self.config.alpha;
342        let futures: Vec<_> = (0..alpha).map(|_| query_fn(cid_str.to_string())).collect();
343        let results: Vec<Vec<String>> = join_all(futures).await;
344
345        // 3. Merge and deduplicate.
346        let mut seen = std::collections::HashSet::new();
347        let mut providers: Vec<String> = Vec::new();
348        for batch in results {
349            for peer in batch {
350                if seen.insert(peer.clone()) {
351                    providers.push(peer);
352                }
353            }
354        }
355
356        // 4. Store in cache.
357        if providers.is_empty() {
358            self.cache.put_negative(cid_str, now_ms);
359        } else {
360            self.cache.put(cid_str, providers.clone(), now_ms);
361        }
362
363        // 5. Return result.
364        // elapsed_ms is 0 here because we use caller-supplied `now_ms`;
365        // a real implementation would compute Instant::now() - t0.
366        let elapsed_ms = now_ms.saturating_sub(t0);
367        ParallelLookupResult {
368            cid_str: cid_str.to_string(),
369            providers,
370            from_cache: false,
371            lookup_count: alpha,
372            elapsed_ms,
373        }
374    }
375
376    /// Total number of cache hits served (lookups avoided).
377    pub fn cache_saves(&self) -> u64 {
378        self.cache_saves.load(Ordering::Relaxed)
379    }
380
381    /// Total number of lookup calls (cache hit or miss).
382    pub fn total_lookups(&self) -> u64 {
383        self.total_lookups.load(Ordering::Relaxed)
384    }
385}
386
387// ── Tests ─────────────────────────────────────────────────────────────────────
388
389#[cfg(test)]
390mod tests {
391    use super::*;
392
393    fn now() -> u64 {
394        // Use a fixed base time so tests are deterministic.
395        1_000_000_u64
396    }
397
398    fn cache() -> Arc<LookupCache> {
399        LookupCache::new(LookupCacheConfig::default())
400    }
401
402    // ── LookupCache unit tests ─────────────────────────────────────────────
403
404    #[test]
405    fn test_cache_miss_on_empty() {
406        let c = cache();
407        assert!(c.get("QmEmpty", now()).is_none());
408        let stats = c.stats();
409        assert_eq!(stats.misses, 1);
410        assert_eq!(stats.hits, 0);
411    }
412
413    #[test]
414    fn test_cache_put_and_get() {
415        let c = cache();
416        let providers = vec!["peer1".to_string(), "peer2".to_string()];
417        c.put("QmFoo", providers.clone(), now());
418        let result = c.get("QmFoo", now()).expect("should be a cache hit");
419        assert_eq!(result, providers);
420        assert_eq!(c.stats().hits, 1);
421    }
422
423    #[test]
424    fn test_cache_expired_entry() {
425        let c = cache();
426        // Use a custom config with ttl_ms = 0 for the entry via direct construction.
427        let entry_time = now();
428        let providers = vec!["peer_x".to_string()];
429        {
430            let mut entries = c.entries.write();
431            entries.insert(
432                "QmExpired".to_string(),
433                CachedProviders::new("QmExpired", providers, entry_time, 0),
434            );
435        }
436        // Query at exactly entry_time + 1 so the entry is expired.
437        let result = c.get("QmExpired", entry_time + 1);
438        assert!(result.is_none(), "expired entry should be a miss");
439    }
440
441    #[test]
442    fn test_cache_negative_result() {
443        let c = cache();
444        c.put_negative("QmNeg", now());
445        let result = c
446            .get("QmNeg", now())
447            .expect("negative entry should be present");
448        assert!(result.is_empty(), "negative entry should have no providers");
449    }
450
451    #[test]
452    fn test_cache_invalidate() {
453        let c = cache();
454        c.put("QmInv", vec!["peerA".to_string()], now());
455        assert!(c.get("QmInv", now()).is_some());
456        let removed = c.invalidate("QmInv");
457        assert!(removed);
458        assert!(c.get("QmInv", now()).is_none());
459        // Invalidating a non-existent key returns false.
460        assert!(!c.invalidate("QmDoesNotExist"));
461    }
462
463    #[test]
464    fn test_cache_evict_expired() {
465        let c = cache();
466        let t = now();
467        // Insert one normal and one immediately-expired entry.
468        c.put("QmGood", vec!["peerG".to_string()], t);
469        {
470            let mut entries = c.entries.write();
471            entries.insert(
472                "QmBad".to_string(),
473                CachedProviders::new("QmBad", vec!["peerB".to_string()], t, 0),
474            );
475        }
476        assert_eq!(c.len(), 2);
477        let removed = c.evict_expired(t + 1);
478        assert_eq!(removed, 1);
479        assert_eq!(c.len(), 1);
480        assert!(c.get("QmGood", t + 1).is_some());
481        assert_eq!(c.stats().evictions, 1);
482    }
483
484    #[test]
485    fn test_cache_stats_hit_rate() {
486        let c = cache();
487        let t = now();
488        c.put("QmRate", vec!["peerR".to_string()], t);
489        // One hit, one miss.
490        c.get("QmRate", t);
491        c.get("QmMissing", t);
492        let stats = c.stats();
493        assert_eq!(stats.hits, 1);
494        assert_eq!(stats.misses, 1);
495        let expected = 0.5_f64;
496        let diff = (stats.hit_rate - expected).abs();
497        assert!(
498            diff < 1e-9,
499            "hit_rate should be 0.5, got {}",
500            stats.hit_rate
501        );
502    }
503
504    #[test]
505    fn test_cache_put_overwrites() {
506        let c = cache();
507        let t = now();
508        c.put("QmOver", vec!["old".to_string()], t);
509        c.put("QmOver", vec!["new1".to_string(), "new2".to_string()], t);
510        let result = c.get("QmOver", t).expect("should hit");
511        assert_eq!(result, vec!["new1".to_string(), "new2".to_string()]);
512    }
513
514    // ── ParallelLookupExecutor tests ───────────────────────────────────────
515
516    #[tokio::test]
517    async fn test_parallel_lookup_cache_hit() {
518        let c = LookupCache::new(LookupCacheConfig::default());
519        let t = now();
520        c.put("QmHit", vec!["peerCached".to_string()], t);
521
522        let exec = ParallelLookupExecutor::new(Arc::clone(&c), ParallelLookupConfig::default());
523
524        let result = exec
525            .lookup("QmHit", t, |_cid| async {
526                vec!["shouldNotBeCalled".to_string()]
527            })
528            .await;
529
530        assert!(result.from_cache);
531        assert_eq!(result.lookup_count, 0);
532        assert_eq!(result.providers, vec!["peerCached".to_string()]);
533        assert_eq!(exec.cache_saves(), 1);
534    }
535
536    #[tokio::test]
537    async fn test_parallel_lookup_cache_miss() {
538        let c = LookupCache::new(LookupCacheConfig::default());
539        let t = now();
540
541        let config = ParallelLookupConfig {
542            alpha: 3,
543            ..Default::default()
544        };
545        let exec = ParallelLookupExecutor::new(Arc::clone(&c), config);
546
547        // Each alpha query returns a distinct provider.
548        let call_count = Arc::new(AtomicU64::new(0));
549        let call_count_clone = Arc::clone(&call_count);
550        let result = exec
551            .lookup("QmMiss", t, move |_cid| {
552                let idx = call_count_clone.fetch_add(1, Ordering::Relaxed);
553                async move { vec![format!("peer_{}", idx)] }
554            })
555            .await;
556
557        assert!(!result.from_cache);
558        assert_eq!(result.lookup_count, 3);
559        // All three distinct providers should be present.
560        assert_eq!(result.providers.len(), 3);
561        // Result should now be cached.
562        let cached = c.get("QmMiss", t).expect("should be cached after miss");
563        assert_eq!(cached.len(), 3);
564    }
565
566    #[tokio::test]
567    async fn test_parallel_lookup_dedup() {
568        let c = LookupCache::new(LookupCacheConfig::default());
569        let t = now();
570
571        let config = ParallelLookupConfig {
572            alpha: 3,
573            ..Default::default()
574        };
575        let exec = ParallelLookupExecutor::new(Arc::clone(&c), config);
576
577        // All alpha queries return the same provider → should be deduplicated.
578        let result = exec
579            .lookup("QmDedup", t, |_cid| async { vec!["samePeer".to_string()] })
580            .await;
581
582        assert_eq!(result.providers.len(), 1);
583        assert_eq!(result.providers[0], "samePeer");
584    }
585
586    #[tokio::test]
587    async fn test_cache_saves_counter() {
588        let c = LookupCache::new(LookupCacheConfig::default());
589        let t = now();
590        c.put("QmSave", vec!["p1".to_string()], t);
591
592        let exec = ParallelLookupExecutor::new(Arc::clone(&c), ParallelLookupConfig::default());
593
594        // Three cache-hit lookups.
595        for _ in 0..3 {
596            exec.lookup("QmSave", t, |_| async { vec![] }).await;
597        }
598
599        assert_eq!(exec.cache_saves(), 3);
600        assert_eq!(exec.total_lookups(), 3);
601    }
602
603    #[test]
604    fn test_default_config() {
605        let cache_cfg = LookupCacheConfig::default();
606        assert_eq!(cache_cfg.positive_ttl_ms, 300_000);
607        assert_eq!(cache_cfg.negative_ttl_ms, 30_000);
608        assert_eq!(cache_cfg.max_entries, 10_000);
609
610        let lookup_cfg = ParallelLookupConfig::default();
611        assert_eq!(lookup_cfg.alpha, 3);
612        assert_eq!(lookup_cfg.max_lookups, 20);
613        assert_eq!(lookup_cfg.timeout_ms, 5_000);
614    }
615}