1#[derive(Debug, Clone)]
9pub struct CacheEntry {
10 pub peer_id: String,
12 pub addresses: Vec<String>,
14 pub discovered_tick: u64,
16 pub ttl_ticks: u64,
18 pub source: String,
20 pub access_count: u64,
22}
23
24impl CacheEntry {
25 fn is_expired(&self, current_tick: u64) -> bool {
27 current_tick >= self.discovered_tick.saturating_add(self.ttl_ticks)
28 }
29}
30
31#[derive(Debug, Clone)]
33pub struct DiscoveryCacheConfig {
34 pub max_entries: usize,
36 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#[derive(Debug, Clone)]
51pub struct LruDiscoveryCacheStats {
52 pub entry_count: usize,
54 pub hits: u64,
56 pub misses: u64,
58 pub hit_rate: f64,
60}
61
62#[derive(Debug)]
68pub struct LruPeerDiscoveryCache {
69 config: DiscoveryCacheConfig,
70 entries: Vec<CacheEntry>,
72 current_tick: u64,
73 hits: u64,
74 misses: u64,
75}
76
77impl LruPeerDiscoveryCache {
78 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 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 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 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 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 let mut entry = self.entries.remove(pos);
138 entry.access_count += 1;
139 self.entries.push(entry);
140 self.hits += 1;
141
142 self.entries.last()
144 } else {
145 self.misses += 1;
146 None
147 }
148 }
149
150 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 pub fn contains(&self, peer_id: &str) -> bool {
163 self.entries.iter().any(|e| e.peer_id == peer_id)
164 }
165
166 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 pub fn entry_count(&self) -> usize {
174 self.entries.len()
175 }
176
177 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 pub fn entries_by_source(&self, source: &str) -> Vec<&CacheEntry> {
189 self.entries.iter().filter(|e| e.source == source).collect()
190 }
191
192 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#[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 #[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 #[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 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 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 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 #[test]
301 fn ttl_expiry_on_get() {
302 let mut c = small_cache(10);
303 c.put("p1", vec![], "dht", Some(3));
304
305 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(); c.tick_cleanup(); 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(); c.tick_cleanup(); assert_eq!(c.entry_count(), 1);
330 assert!(c.contains("p2"));
331 assert!(!c.contains("p1"));
332 }
333
334 #[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); }
346
347 #[test]
348 fn access_count_starts_at_zero() {
349 let mut c = default_cache();
350 c.put("p1", vec![], "dht", None);
351 assert!(c.contains("p1"));
353 let e = c.get("p1").expect("should exist");
354 assert_eq!(e.access_count, 1); }
356
357 #[test]
360 fn hit_miss_tracking() {
361 let mut c = default_cache();
362 c.put("p1", vec![], "dht", None);
363 let _ = c.get("p1"); let _ = c.get("p1"); let _ = c.get("missing"); 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"); let _ = c.get("missing"); 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(); let _ = c.get("p1"); assert_eq!(c.stats().misses, 1);
394 assert_eq!(c.stats().hits, 0);
395 }
396
397 #[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 assert!(c.contains("p1"));
408
409 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 #[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 #[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 #[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 #[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 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 #[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 #[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"); let _ = c.get("nope"); 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 #[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(); c.tick_cleanup(); c.put("p1", vec!["new_addr".into()], "dht", Some(3));
567
568 c.tick_cleanup(); c.tick_cleanup(); 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}