Skip to main content

ipfrs_network/
discovery_lru.rs

1//! LRU Peer Discovery Cache
2//!
3//! Provides a tick-based LRU cache for recently discovered peer addresses with TTL expiry.
4//! This complements the existing `discovery_cache` module by adding LRU eviction semantics
5//! and tick-based expiration suitable for high-throughput peer discovery scenarios.
6
7/// A single cached peer entry.
8#[derive(Debug, Clone)]
9pub struct CacheEntry {
10    /// Peer identifier (libp2p PeerId as string).
11    pub peer_id: String,
12    /// Known multiaddrs for this peer.
13    pub addresses: Vec<String>,
14    /// Tick at which this entry was last inserted or refreshed.
15    pub discovered_tick: u64,
16    /// Number of ticks before this entry expires.
17    pub ttl_ticks: u64,
18    /// Discovery source label, e.g. "dht", "mdns", "pex".
19    pub source: String,
20    /// Number of times this entry has been accessed via `get`.
21    pub access_count: u64,
22}
23
24impl CacheEntry {
25    /// Returns `true` if the entry has expired at the given tick.
26    fn is_expired(&self, current_tick: u64) -> bool {
27        current_tick >= self.discovered_tick.saturating_add(self.ttl_ticks)
28    }
29}
30
31/// Configuration for [`LruPeerDiscoveryCache`].
32#[derive(Debug, Clone)]
33pub struct DiscoveryCacheConfig {
34    /// Maximum number of entries the cache may hold.
35    pub max_entries: usize,
36    /// Default TTL (in ticks) applied when no explicit TTL is supplied.
37    pub default_ttl_ticks: u64,
38}
39
40impl Default for DiscoveryCacheConfig {
41    fn default() -> Self {
42        Self {
43            max_entries: 500,
44            default_ttl_ticks: 300,
45        }
46    }
47}
48
49/// Snapshot of cache statistics.
50#[derive(Debug, Clone)]
51pub struct LruDiscoveryCacheStats {
52    /// Number of entries currently in the cache.
53    pub entry_count: usize,
54    /// Total cache hits since creation.
55    pub hits: u64,
56    /// Total cache misses since creation.
57    pub misses: u64,
58    /// Hit rate as `hits / (hits + misses)`, or 0.0 if no lookups.
59    pub hit_rate: f64,
60}
61
62/// LRU cache for recently discovered peer addresses with tick-based TTL.
63///
64/// Entries are stored in a `Vec` ordered by last access time — the *front*
65/// (index 0) is the **least recently used** and the *back* is the most recently
66/// used. When capacity is exceeded, the LRU entry (front) is evicted.
67#[derive(Debug)]
68pub struct LruPeerDiscoveryCache {
69    config: DiscoveryCacheConfig,
70    /// Ordered by last access: index 0 = LRU, last index = MRU.
71    entries: Vec<CacheEntry>,
72    current_tick: u64,
73    hits: u64,
74    misses: u64,
75}
76
77impl LruPeerDiscoveryCache {
78    /// Create a new cache with the given configuration.
79    pub fn new(config: DiscoveryCacheConfig) -> Self {
80        Self {
81            config,
82            entries: Vec::new(),
83            current_tick: 0,
84            hits: 0,
85            misses: 0,
86        }
87    }
88
89    /// Insert or update a peer entry.
90    ///
91    /// If the peer already exists its addresses, source, and TTL are updated and
92    /// the entry is moved to the back (MRU position). If the cache is at capacity,
93    /// the LRU entry (front) is evicted first.
94    pub fn put(&mut self, peer_id: &str, addresses: Vec<String>, source: &str, ttl: Option<u64>) {
95        let ttl_ticks = ttl.unwrap_or(self.config.default_ttl_ticks);
96
97        // Check if already present — update in place then move to back.
98        if let Some(pos) = self.entries.iter().position(|e| e.peer_id == peer_id) {
99            let mut entry = self.entries.remove(pos);
100            entry.addresses = addresses;
101            entry.source = source.to_string();
102            entry.discovered_tick = self.current_tick;
103            entry.ttl_ticks = ttl_ticks;
104            self.entries.push(entry);
105            return;
106        }
107
108        // Evict LRU if at capacity.
109        if self.entries.len() >= self.config.max_entries {
110            self.entries.remove(0);
111        }
112
113        self.entries.push(CacheEntry {
114            peer_id: peer_id.to_string(),
115            addresses,
116            discovered_tick: self.current_tick,
117            ttl_ticks,
118            source: source.to_string(),
119            access_count: 0,
120        });
121    }
122
123    /// Retrieve a peer entry, moving it to the MRU position.
124    ///
125    /// Returns `None` if the peer is not present or has expired (expired entries
126    /// are removed on access). Increments `access_count` and updates hit/miss
127    /// counters.
128    pub fn get(&mut self, peer_id: &str) -> Option<&CacheEntry> {
129        if let Some(pos) = self.entries.iter().position(|e| e.peer_id == peer_id) {
130            if self.entries[pos].is_expired(self.current_tick) {
131                self.entries.remove(pos);
132                self.misses += 1;
133                return None;
134            }
135
136            // Move to back (MRU).
137            let mut entry = self.entries.remove(pos);
138            entry.access_count += 1;
139            self.entries.push(entry);
140            self.hits += 1;
141
142            // Return reference to the entry we just pushed (last element).
143            self.entries.last()
144        } else {
145            self.misses += 1;
146            None
147        }
148    }
149
150    /// Remove a peer entry. Returns `true` if the entry existed.
151    pub fn remove(&mut self, peer_id: &str) -> bool {
152        if let Some(pos) = self.entries.iter().position(|e| e.peer_id == peer_id) {
153            self.entries.remove(pos);
154            true
155        } else {
156            false
157        }
158    }
159
160    /// Check whether a peer is in the cache **without** updating LRU order or
161    /// access counters.
162    pub fn contains(&self, peer_id: &str) -> bool {
163        self.entries.iter().any(|e| e.peer_id == peer_id)
164    }
165
166    /// Advance the internal tick counter by one and remove all expired entries.
167    pub fn tick_cleanup(&mut self) {
168        self.current_tick += 1;
169        self.entries.retain(|e| !e.is_expired(self.current_tick));
170    }
171
172    /// Number of entries currently in the cache.
173    pub fn entry_count(&self) -> usize {
174        self.entries.len()
175    }
176
177    /// Hit rate as `hits / (hits + misses)`, or 0.0 when no lookups have been
178    /// performed.
179    pub fn hit_rate(&self) -> f64 {
180        let total = self.hits + self.misses;
181        if total == 0 {
182            return 0.0;
183        }
184        self.hits as f64 / total as f64
185    }
186
187    /// Return references to all entries that match the given discovery source.
188    pub fn entries_by_source(&self, source: &str) -> Vec<&CacheEntry> {
189        self.entries.iter().filter(|e| e.source == source).collect()
190    }
191
192    /// Snapshot of current cache statistics.
193    pub fn stats(&self) -> LruDiscoveryCacheStats {
194        LruDiscoveryCacheStats {
195            entry_count: self.entries.len(),
196            hits: self.hits,
197            misses: self.misses,
198            hit_rate: self.hit_rate(),
199        }
200    }
201}
202
203// ---------------------------------------------------------------------------
204// Tests
205// ---------------------------------------------------------------------------
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210
211    fn default_cache() -> LruPeerDiscoveryCache {
212        LruPeerDiscoveryCache::new(DiscoveryCacheConfig::default())
213    }
214
215    fn small_cache(cap: usize) -> LruPeerDiscoveryCache {
216        LruPeerDiscoveryCache::new(DiscoveryCacheConfig {
217            max_entries: cap,
218            default_ttl_ticks: 10,
219        })
220    }
221
222    // -- basic put/get roundtrip --
223
224    #[test]
225    fn put_get_roundtrip() {
226        let mut c = default_cache();
227        c.put("peer1", vec!["addr1".into()], "dht", None);
228        let e = c.get("peer1").expect("should exist");
229        assert_eq!(e.peer_id, "peer1");
230        assert_eq!(e.addresses, vec!["addr1".to_string()]);
231        assert_eq!(e.source, "dht");
232    }
233
234    #[test]
235    fn get_nonexistent_returns_none() {
236        let mut c = default_cache();
237        assert!(c.get("missing").is_none());
238    }
239
240    #[test]
241    fn put_updates_existing_entry() {
242        let mut c = default_cache();
243        c.put("peer1", vec!["a1".into()], "dht", None);
244        c.put("peer1", vec!["a2".into(), "a3".into()], "mdns", Some(50));
245        let e = c.get("peer1").expect("should exist");
246        assert_eq!(e.addresses, vec!["a2".to_string(), "a3".to_string()]);
247        assert_eq!(e.source, "mdns");
248        assert_eq!(e.ttl_ticks, 50);
249    }
250
251    // -- LRU eviction order --
252
253    #[test]
254    fn lru_eviction_order() {
255        let mut c = small_cache(3);
256        c.put("p1", vec![], "dht", None);
257        c.put("p2", vec![], "dht", None);
258        c.put("p3", vec![], "dht", None);
259
260        // Cache full. Insert p4 — should evict p1 (LRU).
261        c.put("p4", vec![], "dht", None);
262        assert!(!c.contains("p1"));
263        assert!(c.contains("p2"));
264        assert!(c.contains("p3"));
265        assert!(c.contains("p4"));
266    }
267
268    #[test]
269    fn access_promotes_entry_preventing_eviction() {
270        let mut c = small_cache(3);
271        c.put("p1", vec![], "dht", None);
272        c.put("p2", vec![], "dht", None);
273        c.put("p3", vec![], "dht", None);
274
275        // Access p1 — moves it to MRU. LRU is now p2.
276        let _ = c.get("p1");
277
278        c.put("p4", vec![], "dht", None);
279        assert!(c.contains("p1"), "p1 should still be present after access");
280        assert!(!c.contains("p2"), "p2 should have been evicted as LRU");
281    }
282
283    #[test]
284    fn put_existing_moves_to_mru() {
285        let mut c = small_cache(3);
286        c.put("p1", vec![], "dht", None);
287        c.put("p2", vec![], "dht", None);
288        c.put("p3", vec![], "dht", None);
289
290        // Re-put p1 — moves to MRU. LRU becomes p2.
291        c.put("p1", vec!["new".into()], "dht", None);
292
293        c.put("p4", vec![], "dht", None);
294        assert!(c.contains("p1"));
295        assert!(!c.contains("p2"));
296    }
297
298    // -- TTL expiry --
299
300    #[test]
301    fn ttl_expiry_on_get() {
302        let mut c = small_cache(10);
303        c.put("p1", vec![], "dht", Some(3));
304
305        // Advance 3 ticks — entry should expire.
306        for _ in 0..3 {
307            c.tick_cleanup();
308        }
309        assert!(c.get("p1").is_none());
310    }
311
312    #[test]
313    fn ttl_not_expired_within_window() {
314        let mut c = small_cache(10);
315        c.put("p1", vec![], "dht", Some(5));
316        c.tick_cleanup(); // tick 1
317        c.tick_cleanup(); // tick 2
318        assert!(c.get("p1").is_some());
319    }
320
321    #[test]
322    fn tick_cleanup_removes_expired() {
323        let mut c = small_cache(10);
324        c.put("p1", vec![], "dht", Some(2));
325        c.put("p2", vec![], "dht", Some(100));
326
327        c.tick_cleanup(); // tick 1
328        c.tick_cleanup(); // tick 2 — p1 expires
329        assert_eq!(c.entry_count(), 1);
330        assert!(c.contains("p2"));
331        assert!(!c.contains("p1"));
332    }
333
334    // -- access_count --
335
336    #[test]
337    fn access_count_increments() {
338        let mut c = default_cache();
339        c.put("p1", vec![], "dht", None);
340        for _ in 0..5 {
341            let _ = c.get("p1");
342        }
343        let e = c.get("p1").expect("should exist");
344        assert_eq!(e.access_count, 6); // 5 + 1 from last get
345    }
346
347    #[test]
348    fn access_count_starts_at_zero() {
349        let mut c = default_cache();
350        c.put("p1", vec![], "dht", None);
351        // Peek via contains (no access_count bump).
352        assert!(c.contains("p1"));
353        let e = c.get("p1").expect("should exist");
354        assert_eq!(e.access_count, 1); // first get
355    }
356
357    // -- hit / miss tracking --
358
359    #[test]
360    fn hit_miss_tracking() {
361        let mut c = default_cache();
362        c.put("p1", vec![], "dht", None);
363        let _ = c.get("p1"); // hit
364        let _ = c.get("p1"); // hit
365        let _ = c.get("missing"); // miss
366        let s = c.stats();
367        assert_eq!(s.hits, 2);
368        assert_eq!(s.misses, 1);
369    }
370
371    #[test]
372    fn hit_rate_calculation() {
373        let mut c = default_cache();
374        c.put("p1", vec![], "dht", None);
375        let _ = c.get("p1"); // hit
376        let _ = c.get("missing"); // miss
377        let rate = c.hit_rate();
378        assert!((rate - 0.5).abs() < f64::EPSILON);
379    }
380
381    #[test]
382    fn hit_rate_empty_is_zero() {
383        let c = default_cache();
384        assert!((c.hit_rate() - 0.0).abs() < f64::EPSILON);
385    }
386
387    #[test]
388    fn expired_entry_counts_as_miss() {
389        let mut c = small_cache(10);
390        c.put("p1", vec![], "dht", Some(1));
391        c.tick_cleanup(); // tick 1 — expires
392        let _ = c.get("p1"); // miss (expired)
393        assert_eq!(c.stats().misses, 1);
394        assert_eq!(c.stats().hits, 0);
395    }
396
397    // -- contains --
398
399    #[test]
400    fn contains_does_not_update_order() {
401        let mut c = small_cache(3);
402        c.put("p1", vec![], "dht", None);
403        c.put("p2", vec![], "dht", None);
404        c.put("p3", vec![], "dht", None);
405
406        // contains on p1 should NOT promote it.
407        assert!(c.contains("p1"));
408
409        // Insert p4 — LRU is still p1.
410        c.put("p4", vec![], "dht", None);
411        assert!(!c.contains("p1"), "p1 should have been evicted");
412    }
413
414    #[test]
415    fn contains_returns_false_for_missing() {
416        let c = default_cache();
417        assert!(!c.contains("nope"));
418    }
419
420    // -- entries_by_source --
421
422    #[test]
423    fn entries_by_source_filtering() {
424        let mut c = default_cache();
425        c.put("p1", vec![], "dht", None);
426        c.put("p2", vec![], "mdns", None);
427        c.put("p3", vec![], "dht", None);
428        c.put("p4", vec![], "pex", None);
429
430        let dht_entries = c.entries_by_source("dht");
431        assert_eq!(dht_entries.len(), 2);
432        assert!(dht_entries.iter().all(|e| e.source == "dht"));
433    }
434
435    #[test]
436    fn entries_by_source_empty_result() {
437        let mut c = default_cache();
438        c.put("p1", vec![], "dht", None);
439        assert!(c.entries_by_source("relay").is_empty());
440    }
441
442    // -- remove --
443
444    #[test]
445    fn remove_existing() {
446        let mut c = default_cache();
447        c.put("p1", vec![], "dht", None);
448        assert!(c.remove("p1"));
449        assert!(!c.contains("p1"));
450        assert_eq!(c.entry_count(), 0);
451    }
452
453    #[test]
454    fn remove_nonexistent() {
455        let mut c = default_cache();
456        assert!(!c.remove("ghost"));
457    }
458
459    // -- empty cache --
460
461    #[test]
462    fn empty_cache_entry_count() {
463        let c = default_cache();
464        assert_eq!(c.entry_count(), 0);
465    }
466
467    #[test]
468    fn empty_cache_stats() {
469        let c = default_cache();
470        let s = c.stats();
471        assert_eq!(s.entry_count, 0);
472        assert_eq!(s.hits, 0);
473        assert_eq!(s.misses, 0);
474        assert!((s.hit_rate - 0.0).abs() < f64::EPSILON);
475    }
476
477    // -- capacity enforcement --
478
479    #[test]
480    fn capacity_enforcement() {
481        let mut c = small_cache(5);
482        for i in 0..10 {
483            c.put(&format!("p{i}"), vec![], "dht", None);
484        }
485        assert_eq!(c.entry_count(), 5);
486        // Only the last 5 should remain.
487        for i in 0..5 {
488            assert!(!c.contains(&format!("p{i}")));
489        }
490        for i in 5..10 {
491            assert!(c.contains(&format!("p{i}")));
492        }
493    }
494
495    #[test]
496    fn capacity_one() {
497        let mut c = small_cache(1);
498        c.put("p1", vec![], "dht", None);
499        c.put("p2", vec![], "dht", None);
500        assert_eq!(c.entry_count(), 1);
501        assert!(c.contains("p2"));
502        assert!(!c.contains("p1"));
503    }
504
505    // -- default config --
506
507    #[test]
508    fn default_config_values() {
509        let cfg = DiscoveryCacheConfig::default();
510        assert_eq!(cfg.max_entries, 500);
511        assert_eq!(cfg.default_ttl_ticks, 300);
512    }
513
514    // -- stats snapshot --
515
516    #[test]
517    fn stats_reflects_current_state() {
518        let mut c = small_cache(10);
519        c.put("p1", vec![], "dht", None);
520        c.put("p2", vec![], "dht", None);
521        let _ = c.get("p1"); // hit
522        let _ = c.get("nope"); // miss
523        let s = c.stats();
524        assert_eq!(s.entry_count, 2);
525        assert_eq!(s.hits, 1);
526        assert_eq!(s.misses, 1);
527        assert!((s.hit_rate - 0.5).abs() < f64::EPSILON);
528    }
529
530    // -- additional edge cases --
531
532    #[test]
533    fn tick_cleanup_on_empty_cache() {
534        let mut c = default_cache();
535        c.tick_cleanup();
536        assert_eq!(c.entry_count(), 0);
537    }
538
539    #[test]
540    fn multiple_tick_cleanups() {
541        let mut c = small_cache(10);
542        c.put("p1", vec![], "dht", Some(3));
543        c.put("p2", vec![], "dht", Some(5));
544
545        for _ in 0..3 {
546            c.tick_cleanup();
547        }
548        assert!(!c.contains("p1"));
549        assert!(c.contains("p2"));
550
551        for _ in 0..2 {
552            c.tick_cleanup();
553        }
554        assert!(!c.contains("p2"));
555        assert_eq!(c.entry_count(), 0);
556    }
557
558    #[test]
559    fn put_after_ttl_refresh_extends_lifetime() {
560        let mut c = small_cache(10);
561        c.put("p1", vec![], "dht", Some(3));
562        c.tick_cleanup(); // tick 1
563        c.tick_cleanup(); // tick 2
564
565        // Re-put refreshes discovered_tick to current_tick (2).
566        c.put("p1", vec!["new_addr".into()], "dht", Some(3));
567
568        c.tick_cleanup(); // tick 3 — would have expired without refresh
569        c.tick_cleanup(); // tick 4
570        assert!(c.get("p1").is_some(), "re-put should have extended TTL");
571    }
572
573    #[test]
574    fn hit_rate_all_hits() {
575        let mut c = default_cache();
576        c.put("p1", vec![], "dht", None);
577        for _ in 0..10 {
578            let _ = c.get("p1");
579        }
580        assert!((c.hit_rate() - 1.0).abs() < f64::EPSILON);
581    }
582
583    #[test]
584    fn hit_rate_all_misses() {
585        let mut c = default_cache();
586        for _ in 0..5 {
587            let _ = c.get("ghost");
588        }
589        assert!((c.hit_rate() - 0.0).abs() < f64::EPSILON);
590    }
591}