Skip to main content

ipfrs_network/
providers.rs

1//! Provider record cache with TTL
2//!
3//! This module implements a cache for DHT provider records to reduce
4//! network load by avoiding redundant GET_PROVIDERS queries.
5
6use cid::Cid;
7use libp2p::PeerId;
8use parking_lot::RwLock;
9use serde::Serialize;
10use std::collections::{HashMap, HashSet};
11use std::time::{Duration, Instant};
12use tracing::{debug, info};
13
14/// Default TTL for provider records (1 hour)
15const DEFAULT_TTL: Duration = Duration::from_secs(3600);
16
17/// Default maximum cache entries
18const DEFAULT_MAX_ENTRIES: usize = 10000;
19
20/// Configuration for the provider cache
21#[derive(Debug, Clone)]
22pub struct ProviderCacheConfig {
23    /// Time-to-live for cached provider records
24    pub ttl: Duration,
25    /// Maximum number of entries in the cache
26    pub max_entries: usize,
27    /// Minimum providers per CID before refresh
28    pub min_providers: usize,
29}
30
31impl Default for ProviderCacheConfig {
32    fn default() -> Self {
33        Self {
34            ttl: DEFAULT_TTL,
35            max_entries: DEFAULT_MAX_ENTRIES,
36            min_providers: 1,
37        }
38    }
39}
40
41/// A cached provider record
42#[derive(Debug, Clone)]
43struct CachedProviders {
44    /// Provider peer IDs
45    providers: HashSet<PeerId>,
46    /// Time when the record was cached
47    cached_at: Instant,
48    /// Last access time (for LRU eviction)
49    last_accessed: Instant,
50    /// Number of times this record was accessed
51    access_count: u64,
52}
53
54impl CachedProviders {
55    fn new(providers: HashSet<PeerId>) -> Self {
56        let now = Instant::now();
57        Self {
58            providers,
59            cached_at: now,
60            last_accessed: now,
61            access_count: 0,
62        }
63    }
64
65    fn is_expired(&self, ttl: Duration) -> bool {
66        self.cached_at.elapsed() > ttl
67    }
68
69    fn touch(&mut self) {
70        self.last_accessed = Instant::now();
71        self.access_count += 1;
72    }
73}
74
75/// Provider record cache
76pub struct ProviderCache {
77    /// Configuration
78    config: ProviderCacheConfig,
79    /// Cached provider records
80    cache: RwLock<HashMap<Cid, CachedProviders>>,
81    /// Cache statistics
82    stats: RwLock<CacheStats>,
83}
84
85/// Internal mutable statistics
86#[derive(Default)]
87struct CacheStats {
88    hits: u64,
89    misses: u64,
90    evictions: u64,
91    expirations: u64,
92}
93
94impl ProviderCache {
95    /// Create a new provider cache with default configuration
96    pub fn new() -> Self {
97        Self::with_config(ProviderCacheConfig::default())
98    }
99
100    /// Create a new provider cache with custom configuration
101    pub fn with_config(config: ProviderCacheConfig) -> Self {
102        Self {
103            config,
104            cache: RwLock::new(HashMap::new()),
105            stats: RwLock::new(CacheStats::default()),
106        }
107    }
108
109    /// Get providers for a CID from cache
110    ///
111    /// Returns None if not cached or expired
112    pub fn get(&self, cid: &Cid) -> Option<Vec<PeerId>> {
113        let mut cache = self.cache.write();
114        let mut stats = self.stats.write();
115
116        if let Some(entry) = cache.get_mut(cid) {
117            if entry.is_expired(self.config.ttl) {
118                // Expired entry
119                cache.remove(cid);
120                stats.expirations += 1;
121                stats.misses += 1;
122                debug!("Provider cache expired for {}", cid);
123                return None;
124            }
125
126            // Cache hit
127            entry.touch();
128            stats.hits += 1;
129            debug!(
130                "Provider cache hit for {} ({} providers)",
131                cid,
132                entry.providers.len()
133            );
134            return Some(entry.providers.iter().cloned().collect());
135        }
136
137        // Cache miss
138        stats.misses += 1;
139        None
140    }
141
142    /// Check if we have valid (non-expired) providers cached
143    pub fn has_providers(&self, cid: &Cid) -> bool {
144        let cache = self.cache.read();
145        if let Some(entry) = cache.get(cid) {
146            !entry.is_expired(self.config.ttl) && !entry.providers.is_empty()
147        } else {
148            false
149        }
150    }
151
152    /// Check if providers need refresh (expired or below minimum)
153    pub fn needs_refresh(&self, cid: &Cid) -> bool {
154        let cache = self.cache.read();
155        if let Some(entry) = cache.get(cid) {
156            entry.is_expired(self.config.ttl) || entry.providers.len() < self.config.min_providers
157        } else {
158            true
159        }
160    }
161
162    /// Add or update providers for a CID
163    pub fn put(&self, cid: Cid, providers: Vec<PeerId>) {
164        let provider_set: HashSet<PeerId> = providers.into_iter().collect();
165
166        if provider_set.is_empty() {
167            debug!("Not caching empty provider list for {}", cid);
168            return;
169        }
170
171        let mut cache = self.cache.write();
172
173        // Check if we need to evict
174        if cache.len() >= self.config.max_entries {
175            self.evict_lru(&mut cache);
176        }
177
178        let count = provider_set.len();
179        cache.insert(cid, CachedProviders::new(provider_set));
180        info!("Cached {} providers for {}", count, cid);
181    }
182
183    /// Add a single provider to an existing cache entry
184    pub fn add_provider(&self, cid: &Cid, provider: PeerId) {
185        let mut cache = self.cache.write();
186
187        if let Some(entry) = cache.get_mut(cid) {
188            if !entry.is_expired(self.config.ttl) {
189                entry.providers.insert(provider);
190                entry.touch();
191                debug!("Added provider {} to cache for {}", provider, cid);
192            }
193        }
194    }
195
196    /// Remove a provider from cache (e.g., when it disconnects)
197    pub fn remove_provider(&self, cid: &Cid, provider: &PeerId) {
198        let mut cache = self.cache.write();
199
200        if let Some(entry) = cache.get_mut(cid) {
201            entry.providers.remove(provider);
202            debug!("Removed provider {} from cache for {}", provider, cid);
203        }
204    }
205
206    /// Remove all entries for a CID
207    pub fn invalidate(&self, cid: &Cid) {
208        let mut cache = self.cache.write();
209        cache.remove(cid);
210        debug!("Invalidated cache for {}", cid);
211    }
212
213    /// Remove all expired entries
214    pub fn cleanup_expired(&self) {
215        let mut cache = self.cache.write();
216        let mut stats = self.stats.write();
217        let ttl = self.config.ttl;
218
219        let before = cache.len();
220        cache.retain(|_, entry| !entry.is_expired(ttl));
221        let removed = before - cache.len();
222
223        if removed > 0 {
224            stats.expirations += removed as u64;
225            info!("Cleaned up {} expired provider cache entries", removed);
226        }
227    }
228
229    /// Clear the entire cache
230    pub fn clear(&self) {
231        let mut cache = self.cache.write();
232        cache.clear();
233        info!("Provider cache cleared");
234    }
235
236    /// Get cache statistics
237    pub fn stats(&self) -> ProviderCacheStats {
238        let cache = self.cache.read();
239        let stats = self.stats.read();
240
241        let total_providers: usize = cache.values().map(|e| e.providers.len()).sum();
242        let hit_rate = if stats.hits + stats.misses > 0 {
243            stats.hits as f64 / (stats.hits + stats.misses) as f64
244        } else {
245            0.0
246        };
247
248        ProviderCacheStats {
249            entries: cache.len(),
250            max_entries: self.config.max_entries,
251            total_providers,
252            hits: stats.hits,
253            misses: stats.misses,
254            hit_rate,
255            evictions: stats.evictions,
256            expirations: stats.expirations,
257        }
258    }
259
260    /// Get the number of cached CIDs
261    pub fn len(&self) -> usize {
262        self.cache.read().len()
263    }
264
265    /// Check if cache is empty
266    pub fn is_empty(&self) -> bool {
267        self.cache.read().is_empty()
268    }
269
270    /// Evict least recently used entries
271    fn evict_lru(&self, cache: &mut HashMap<Cid, CachedProviders>) {
272        // Evict 10% of entries or at least 1
273        let to_evict = (self.config.max_entries / 10).max(1);
274
275        let mut entries: Vec<_> = cache
276            .iter()
277            .map(|(cid, entry)| (*cid, entry.last_accessed))
278            .collect();
279
280        // Sort by last accessed (oldest first)
281        entries.sort_by_key(|a| a.1);
282
283        let mut stats = self.stats.write();
284        for (cid, _) in entries.into_iter().take(to_evict) {
285            cache.remove(&cid);
286            stats.evictions += 1;
287        }
288
289        debug!("Evicted {} LRU cache entries", to_evict);
290    }
291}
292
293impl Default for ProviderCache {
294    fn default() -> Self {
295        Self::new()
296    }
297}
298
299/// Provider cache statistics
300#[derive(Debug, Clone, Serialize)]
301pub struct ProviderCacheStats {
302    /// Number of cached CIDs
303    pub entries: usize,
304    /// Maximum entries allowed
305    pub max_entries: usize,
306    /// Total providers across all entries
307    pub total_providers: usize,
308    /// Cache hits
309    pub hits: u64,
310    /// Cache misses
311    pub misses: u64,
312    /// Hit rate (0.0 - 1.0)
313    pub hit_rate: f64,
314    /// Number of entries evicted due to capacity
315    pub evictions: u64,
316    /// Number of entries expired
317    pub expirations: u64,
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323    use multihash_codetable::{Code, MultihashDigest};
324
325    fn make_cid(data: &[u8]) -> Cid {
326        let hash = Code::Sha2_256.digest(data);
327        Cid::new_v1(0x55, hash)
328    }
329
330    fn random_peer_id() -> PeerId {
331        PeerId::random()
332    }
333
334    #[test]
335    fn test_provider_cache_basic() {
336        let cache = ProviderCache::new();
337        let cid = make_cid(b"test data");
338        let peer1 = random_peer_id();
339        let peer2 = random_peer_id();
340
341        // Initially empty
342        assert!(cache.get(&cid).is_none());
343        assert!(cache.needs_refresh(&cid));
344
345        // Add providers
346        cache.put(cid, vec![peer1, peer2]);
347
348        // Should be cached now
349        let providers = cache
350            .get(&cid)
351            .expect("test: cache should contain providers after put");
352        assert_eq!(providers.len(), 2);
353        assert!(providers.contains(&peer1));
354        assert!(providers.contains(&peer2));
355        assert!(cache.has_providers(&cid));
356        assert!(!cache.needs_refresh(&cid));
357    }
358
359    #[test]
360    fn test_provider_cache_add_remove() {
361        let cache = ProviderCache::new();
362        let cid = make_cid(b"test");
363        let peer1 = random_peer_id();
364        let peer2 = random_peer_id();
365        let peer3 = random_peer_id();
366
367        cache.put(cid, vec![peer1, peer2]);
368
369        // Add a provider
370        cache.add_provider(&cid, peer3);
371        let providers = cache
372            .get(&cid)
373            .expect("test: cache should have 3 providers after add");
374        assert_eq!(providers.len(), 3);
375
376        // Remove a provider
377        cache.remove_provider(&cid, &peer1);
378        let providers = cache
379            .get(&cid)
380            .expect("test: cache should have 2 providers after remove");
381        assert_eq!(providers.len(), 2);
382        assert!(!providers.contains(&peer1));
383    }
384
385    #[test]
386    fn test_provider_cache_expiration() {
387        let config = ProviderCacheConfig {
388            ttl: Duration::from_millis(50),
389            ..Default::default()
390        };
391        let cache = ProviderCache::with_config(config);
392        let cid = make_cid(b"expiring");
393        let peer = random_peer_id();
394
395        cache.put(cid, vec![peer]);
396        assert!(cache.get(&cid).is_some());
397
398        // Wait for expiration
399        std::thread::sleep(Duration::from_millis(100));
400
401        // Should be expired now
402        assert!(cache.get(&cid).is_none());
403        assert!(cache.needs_refresh(&cid));
404
405        let stats = cache.stats();
406        assert!(stats.expirations > 0);
407    }
408
409    #[test]
410    fn test_provider_cache_lru_eviction() {
411        let config = ProviderCacheConfig {
412            ttl: Duration::from_secs(3600),
413            max_entries: 5,
414            ..Default::default()
415        };
416        let cache = ProviderCache::with_config(config);
417        let peer = random_peer_id();
418
419        // Add 5 entries
420        for i in 0..5 {
421            let cid = make_cid(&[i as u8]);
422            cache.put(cid, vec![peer]);
423        }
424
425        assert_eq!(cache.len(), 5);
426
427        // Access some entries to update their LRU time
428        let cid_2 = make_cid(&[2]);
429        let cid_3 = make_cid(&[3]);
430        cache.get(&cid_2);
431        cache.get(&cid_3);
432
433        // Add a new entry, triggering eviction
434        let new_cid = make_cid(&[100]);
435        cache.put(new_cid, vec![peer]);
436
437        // Should have evicted LRU entries
438        assert!(cache.len() <= 5);
439
440        let stats = cache.stats();
441        assert!(stats.evictions > 0);
442    }
443
444    #[test]
445    fn test_provider_cache_stats() {
446        let cache = ProviderCache::new();
447        let cid1 = make_cid(b"one");
448        let cid2 = make_cid(b"two");
449        let peer = random_peer_id();
450
451        // Miss
452        cache.get(&cid1);
453
454        // Put and hit
455        cache.put(cid1, vec![peer]);
456        cache.get(&cid1);
457
458        // Another miss
459        cache.get(&cid2);
460
461        let stats = cache.stats();
462        assert_eq!(stats.hits, 1);
463        assert_eq!(stats.misses, 2);
464        assert_eq!(stats.entries, 1);
465    }
466
467    #[test]
468    fn test_provider_cache_invalidate() {
469        let cache = ProviderCache::new();
470        let cid = make_cid(b"invalidate me");
471        let peer = random_peer_id();
472
473        cache.put(cid, vec![peer]);
474        assert!(cache.has_providers(&cid));
475
476        cache.invalidate(&cid);
477        assert!(!cache.has_providers(&cid));
478    }
479
480    #[test]
481    fn test_provider_cache_cleanup() {
482        let config = ProviderCacheConfig {
483            ttl: Duration::from_millis(10),
484            ..Default::default()
485        };
486        let cache = ProviderCache::with_config(config);
487        let peer = random_peer_id();
488
489        // Add entries
490        for i in 0..5 {
491            let cid = make_cid(&[i as u8]);
492            cache.put(cid, vec![peer]);
493        }
494
495        assert_eq!(cache.len(), 5);
496
497        // Wait for expiration
498        std::thread::sleep(Duration::from_millis(50));
499
500        // Cleanup
501        cache.cleanup_expired();
502        assert_eq!(cache.len(), 0);
503    }
504
505    #[test]
506    fn test_provider_cache_empty_providers_not_cached() {
507        let cache = ProviderCache::new();
508        let cid = make_cid(b"empty");
509
510        cache.put(cid, vec![]);
511        assert!(!cache.has_providers(&cid));
512        assert_eq!(cache.len(), 0);
513    }
514}