1use 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#[derive(Debug, Clone)]
16pub struct CachedProviders {
17 pub cid_str: String,
19 pub providers: Vec<String>,
21 pub cached_at_ms: u64,
23 pub ttl_ms: u64,
25 pub hit_count: u64,
27}
28
29impl CachedProviders {
30 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 #[inline]
48 pub fn is_expired(&self, now_ms: u64) -> bool {
49 now_ms > self.cached_at_ms + self.ttl_ms
50 }
51
52 #[inline]
54 pub fn age_ms(&self, now_ms: u64) -> u64 {
55 now_ms.saturating_sub(self.cached_at_ms)
56 }
57}
58
59#[derive(Debug, Clone)]
61pub struct LookupCacheConfig {
62 pub positive_ttl_ms: u64,
64 pub negative_ttl_ms: u64,
66 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
80pub struct LookupCache {
82 config: LookupCacheConfig,
83 entries: RwLock<HashMap<String, CachedProviders>>,
84 hits: AtomicU64,
85 misses: AtomicU64,
86 evictions: AtomicU64,
87}
88
89#[derive(Debug, Clone)]
91pub struct LookupCacheStats {
92 pub total_entries: usize,
94 pub hits: u64,
96 pub misses: u64,
98 pub evictions: u64,
100 pub hit_rate: f64,
102}
103
104impl LookupCache {
105 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 pub fn get(&self, cid_str: &str, now_ms: u64) -> Option<Vec<String>> {
122 {
124 let entries = self.entries.read();
125 if let Some(entry) = entries.get(cid_str) {
126 if entry.is_expired(now_ms) {
127 self.misses.fetch_add(1, Ordering::Relaxed);
129 return None;
130 }
131 let providers = entry.providers.clone();
133 drop(entries);
134
135 {
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 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 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 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 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 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 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 pub fn len(&self) -> usize {
240 self.entries.read().len()
241 }
242
243 pub fn is_empty(&self) -> bool {
245 self.entries.read().is_empty()
246 }
247}
248
249#[derive(Debug, Clone)]
253pub struct ParallelLookupConfig {
254 pub alpha: usize,
256 pub max_lookups: usize,
258 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#[derive(Debug, Clone)]
274pub struct ParallelLookupResult {
275 pub cid_str: String,
277 pub providers: Vec<String>,
279 pub from_cache: bool,
281 pub lookup_count: usize,
283 pub elapsed_ms: u64,
285}
286
287pub struct ParallelLookupExecutor {
289 cache: Arc<LookupCache>,
290 config: ParallelLookupConfig,
291 total_lookups: AtomicU64,
292 cache_saves: AtomicU64,
293}
294
295impl ParallelLookupExecutor {
296 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 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; 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 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 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 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 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 pub fn cache_saves(&self) -> u64 {
378 self.cache_saves.load(Ordering::Relaxed)
379 }
380
381 pub fn total_lookups(&self) -> u64 {
383 self.total_lookups.load(Ordering::Relaxed)
384 }
385}
386
387#[cfg(test)]
390mod tests {
391 use super::*;
392
393 fn now() -> u64 {
394 1_000_000_u64
396 }
397
398 fn cache() -> Arc<LookupCache> {
399 LookupCache::new(LookupCacheConfig::default())
400 }
401
402 #[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 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 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 assert!(!c.invalidate("QmDoesNotExist"));
461 }
462
463 #[test]
464 fn test_cache_evict_expired() {
465 let c = cache();
466 let t = now();
467 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 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 #[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 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 assert_eq!(result.providers.len(), 3);
561 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 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 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}