Skip to main content

ipfrs_network/
dht.rs

1//! Distributed Hash Table (Kademlia DHT) implementation
2//!
3//! Provides DHT operations including:
4//! - Peer discovery and routing
5//! - Content provider management
6//! - Query result caching
7//! - Automatic provider record refresh
8
9use cid::Cid;
10use dashmap::DashMap;
11use ipfrs_core::error::{Error, Result};
12use libp2p::PeerId;
13use parking_lot::RwLock;
14use std::collections::HashMap;
15use std::sync::Arc;
16use std::time::{Duration, Instant};
17use tokio::sync::mpsc;
18use tracing::{debug, info};
19
20/// Default provider record TTL (24 hours)
21const DEFAULT_PROVIDER_TTL: Duration = Duration::from_secs(24 * 60 * 60);
22
23/// Default query cache TTL (5 minutes)
24const DEFAULT_QUERY_CACHE_TTL: Duration = Duration::from_secs(5 * 60);
25
26/// DHT configuration
27#[derive(Debug, Clone)]
28pub struct DhtConfig {
29    /// Provider record TTL
30    pub provider_ttl: Duration,
31    /// Query cache TTL
32    pub query_cache_ttl: Duration,
33    /// Enable automatic provider refresh
34    pub enable_provider_refresh: bool,
35    /// Provider refresh interval (should be < provider_ttl)
36    pub provider_refresh_interval: Duration,
37    /// Maximum cached queries
38    pub max_cached_queries: usize,
39}
40
41impl Default for DhtConfig {
42    fn default() -> Self {
43        Self {
44            provider_ttl: DEFAULT_PROVIDER_TTL,
45            query_cache_ttl: DEFAULT_QUERY_CACHE_TTL,
46            enable_provider_refresh: true,
47            provider_refresh_interval: Duration::from_secs(12 * 60 * 60), // 12 hours
48            max_cached_queries: 10_000,
49        }
50    }
51}
52
53/// Cached query result
54#[derive(Debug, Clone)]
55struct CachedQuery {
56    /// Result peers
57    peers: Vec<PeerId>,
58    /// Timestamp when cached
59    cached_at: Instant,
60    /// Number of times this result was used
61    hit_count: usize,
62}
63
64/// Provider record for refresh tracking
65#[derive(Debug, Clone)]
66struct ProviderRecord {
67    /// Content ID
68    cid: Cid,
69    /// Last announcement time
70    last_announced: Instant,
71}
72
73/// DHT manager for peer and content discovery
74pub struct DhtManager {
75    config: DhtConfig,
76    /// Query result cache (CID -> cached result)
77    query_cache: Arc<DashMap<String, CachedQuery>>,
78    /// Peer routing cache (PeerId -> known addresses count)
79    peer_cache: Arc<DashMap<PeerId, Instant>>,
80    /// Provider records to refresh
81    provider_records: Arc<RwLock<HashMap<String, ProviderRecord>>>,
82    /// Statistics
83    stats: Arc<RwLock<DhtStats>>,
84    /// Refresh task handle
85    refresh_handle: Option<tokio::task::JoinHandle<()>>,
86    /// Command sender for refresh task
87    cmd_tx: Option<mpsc::Sender<DhtCommand>>,
88}
89
90/// DHT statistics
91#[derive(Debug, Clone, Default, serde::Serialize)]
92pub struct DhtStats {
93    /// Total queries performed
94    pub total_queries: u64,
95    /// Cache hits
96    pub cache_hits: u64,
97    /// Cache misses
98    pub cache_misses: u64,
99    /// Total provider refreshes
100    pub provider_refreshes: u64,
101    /// Active provider records
102    pub active_providers: usize,
103    /// Cached queries count
104    pub cached_queries: usize,
105    /// Cached peers count
106    pub cached_peers: usize,
107    /// Successful queries
108    pub successful_queries: u64,
109    /// Failed queries
110    pub failed_queries: u64,
111}
112
113/// DHT health status
114#[derive(Debug, Clone, serde::Serialize)]
115pub struct DhtHealth {
116    /// Overall health score (0.0 - 1.0)
117    pub health_score: f64,
118    /// Query success rate (0.0 - 1.0)
119    pub query_success_rate: f64,
120    /// Cache hit rate (0.0 - 1.0)
121    pub cache_hit_rate: f64,
122    /// Number of cached peers
123    pub peer_count: usize,
124    /// Number of cached queries
125    pub cached_query_count: usize,
126    /// Number of active provider records
127    pub provider_count: usize,
128    /// Health status description
129    pub status: DhtHealthStatus,
130}
131
132/// DHT health status enum
133#[derive(Debug, Clone, PartialEq, serde::Serialize)]
134pub enum DhtHealthStatus {
135    /// DHT is healthy
136    Healthy,
137    /// DHT has degraded performance
138    Degraded,
139    /// DHT is unhealthy
140    Unhealthy,
141    /// Not enough data to determine health
142    Unknown,
143}
144
145/// DHT command for background task
146pub(crate) enum DhtCommand {
147    /// Add a provider record to track
148    TrackProvider { cid: Cid },
149    /// Stop tracking a provider
150    StopTracking { cid: String },
151    /// Refresh all providers (returns sender for refresh requests)
152    #[allow(dead_code)]
153    RefreshProviders { response_tx: mpsc::Sender<Vec<Cid>> },
154    /// Shutdown the refresh task
155    Shutdown,
156}
157
158impl DhtManager {
159    /// Create a new DHT manager
160    pub fn new(config: DhtConfig) -> Self {
161        let manager = Self {
162            config,
163            query_cache: Arc::new(DashMap::new()),
164            peer_cache: Arc::new(DashMap::new()),
165            provider_records: Arc::new(RwLock::new(HashMap::new())),
166            stats: Arc::new(RwLock::new(DhtStats::default())),
167            refresh_handle: None,
168            cmd_tx: None,
169        };
170
171        info!(
172            "DHT Manager initialized (provider_ttl={:?}, query_cache_ttl={:?})",
173            manager.config.provider_ttl, manager.config.query_cache_ttl
174        );
175
176        manager
177    }
178
179    /// Start the provider refresh background task
180    pub fn start_provider_refresh(&mut self) {
181        if !self.config.enable_provider_refresh {
182            info!("Provider refresh disabled");
183            return;
184        }
185
186        let (cmd_tx, mut cmd_rx) = mpsc::channel::<DhtCommand>(100);
187        let provider_records = Arc::clone(&self.provider_records);
188        let stats = Arc::clone(&self.stats);
189        let refresh_interval = self.config.provider_refresh_interval;
190
191        let handle = tokio::spawn(async move {
192            info!(
193                "Starting provider refresh task (interval={:?})",
194                refresh_interval
195            );
196
197            let mut interval = tokio::time::interval(refresh_interval);
198
199            loop {
200                tokio::select! {
201                    _ = interval.tick() => {
202                        // Periodic refresh check
203                        let now = Instant::now();
204                        let mut refresh_needed = Vec::new();
205
206                        {
207                            let records = provider_records.read();
208                            for (key, record) in records.iter() {
209                                if now.duration_since(record.last_announced) >= refresh_interval {
210                                    refresh_needed.push((key.clone(), record.cid));
211                                }
212                            }
213                        }
214
215                        if !refresh_needed.is_empty() {
216                            info!("Refreshing {} provider records", refresh_needed.len());
217                            stats.write().provider_refreshes += refresh_needed.len() as u64;
218
219                            // Update last_announced times
220                            let mut records = provider_records.write();
221                            for (key, _cid) in refresh_needed {
222                                if let Some(record) = records.get_mut(&key) {
223                                    record.last_announced = now;
224                                }
225                            }
226                        }
227                    }
228                    Some(cmd) = cmd_rx.recv() => {
229                        match cmd {
230                            DhtCommand::TrackProvider { cid } => {
231                                let key = cid.to_string();
232                                let mut records = provider_records.write();
233                                records.insert(key.clone(), ProviderRecord {
234                                    cid,
235                                    last_announced: Instant::now(),
236                                });
237                                debug!("Tracking provider record: {}", key);
238                                stats.write().active_providers = records.len();
239                            }
240                            DhtCommand::StopTracking { cid } => {
241                                let mut records = provider_records.write();
242                                records.remove(&cid);
243                                debug!("Stopped tracking provider: {}", cid);
244                                stats.write().active_providers = records.len();
245                            }
246                            DhtCommand::RefreshProviders { response_tx } => {
247                                let cids: Vec<Cid> = {
248                                    let records = provider_records.read();
249                                    records.values().map(|r| r.cid).collect()
250                                };
251                                let _ = response_tx.send(cids).await;
252                            }
253                            DhtCommand::Shutdown => {
254                                info!("Shutting down provider refresh task");
255                                break;
256                            }
257                        }
258                    }
259                }
260            }
261        });
262
263        self.refresh_handle = Some(handle);
264        self.cmd_tx = Some(cmd_tx);
265    }
266
267    /// Track a provider record for automatic refresh
268    pub async fn track_provider(&self, cid: Cid) -> Result<()> {
269        if let Some(cmd_tx) = &self.cmd_tx {
270            cmd_tx
271                .send(DhtCommand::TrackProvider { cid })
272                .await
273                .map_err(|e| Error::Network(format!("Failed to track provider: {}", e)))?;
274        }
275        Ok(())
276    }
277
278    /// Stop tracking a provider record
279    pub async fn stop_tracking(&self, cid: &Cid) -> Result<()> {
280        if let Some(cmd_tx) = &self.cmd_tx {
281            cmd_tx
282                .send(DhtCommand::StopTracking {
283                    cid: cid.to_string(),
284                })
285                .await
286                .map_err(|e| Error::Network(format!("Failed to stop tracking: {}", e)))?;
287        }
288        Ok(())
289    }
290
291    /// Cache a query result
292    pub fn cache_query_result(&self, cid: &Cid, peers: Vec<PeerId>) {
293        let key = cid.to_string();
294
295        // Check cache size limit
296        if self.query_cache.len() >= self.config.max_cached_queries {
297            // Remove oldest entries (LRU-style)
298            let now = Instant::now();
299            let mut to_remove = Vec::new();
300
301            for entry in self.query_cache.iter() {
302                if now.duration_since(entry.value().cached_at) > self.config.query_cache_ttl * 2 {
303                    to_remove.push(entry.key().clone());
304                }
305            }
306
307            for key in to_remove {
308                self.query_cache.remove(&key);
309            }
310        }
311
312        self.query_cache.insert(
313            key.clone(),
314            CachedQuery {
315                peers,
316                cached_at: Instant::now(),
317                hit_count: 0,
318            },
319        );
320
321        debug!("Cached query result for {}", key);
322        self.stats.write().cached_queries = self.query_cache.len();
323    }
324
325    /// Get cached query result
326    pub fn get_cached_query(&self, cid: &Cid) -> Option<Vec<PeerId>> {
327        let key = cid.to_string();
328        let mut stats = self.stats.write();
329        stats.total_queries += 1;
330
331        if let Some(mut cached) = self.query_cache.get_mut(&key) {
332            let age = Instant::now().duration_since(cached.cached_at);
333
334            if age < self.config.query_cache_ttl {
335                cached.hit_count += 1;
336                stats.cache_hits += 1;
337                debug!(
338                    "Cache hit for {} (age={:?}, hits={})",
339                    key, age, cached.hit_count
340                );
341                return Some(cached.peers.clone());
342            } else {
343                debug!("Cache entry expired for {} (age={:?})", key, age);
344                drop(cached);
345                self.query_cache.remove(&key);
346            }
347        }
348
349        stats.cache_misses += 1;
350        None
351    }
352
353    /// Cache a peer
354    pub fn cache_peer(&self, peer_id: PeerId) {
355        self.peer_cache.insert(peer_id, Instant::now());
356        self.stats.write().cached_peers = self.peer_cache.len();
357    }
358
359    /// Check if peer is in cache
360    pub fn is_peer_cached(&self, peer_id: &PeerId) -> bool {
361        self.peer_cache.contains_key(peer_id)
362    }
363
364    /// Get all cached peers
365    pub fn get_cached_peers(&self) -> Vec<PeerId> {
366        self.peer_cache.iter().map(|entry| *entry.key()).collect()
367    }
368
369    /// Clean up expired cache entries
370    pub fn cleanup_cache(&self) {
371        let now = Instant::now();
372        let mut removed_queries = 0;
373        let mut removed_peers = 0;
374
375        // Clean query cache
376        let to_remove: Vec<String> = self
377            .query_cache
378            .iter()
379            .filter(|entry| {
380                now.duration_since(entry.value().cached_at) > self.config.query_cache_ttl
381            })
382            .map(|entry| entry.key().clone())
383            .collect();
384
385        for key in to_remove {
386            self.query_cache.remove(&key);
387            removed_queries += 1;
388        }
389
390        // Clean peer cache (expire after 1 hour)
391        let peer_ttl = Duration::from_secs(3600);
392        let to_remove: Vec<PeerId> = self
393            .peer_cache
394            .iter()
395            .filter(|entry| now.duration_since(*entry.value()) > peer_ttl)
396            .map(|entry| *entry.key())
397            .collect();
398
399        for peer_id in to_remove {
400            self.peer_cache.remove(&peer_id);
401            removed_peers += 1;
402        }
403
404        if removed_queries > 0 || removed_peers > 0 {
405            debug!(
406                "Cache cleanup: removed {} queries, {} peers",
407                removed_queries, removed_peers
408            );
409        }
410
411        let mut stats = self.stats.write();
412        stats.cached_queries = self.query_cache.len();
413        stats.cached_peers = self.peer_cache.len();
414    }
415
416    /// Get DHT statistics
417    pub fn get_stats(&self) -> DhtStats {
418        self.stats.read().clone()
419    }
420
421    /// Record a successful query
422    pub fn record_query_success(&self) {
423        self.stats.write().successful_queries += 1;
424    }
425
426    /// Record a failed query
427    pub fn record_query_failure(&self) {
428        self.stats.write().failed_queries += 1;
429    }
430
431    /// Get DHT health status
432    pub fn get_health(&self) -> DhtHealth {
433        let stats = self.stats.read();
434
435        // Calculate query success rate
436        let total_tracked_queries = stats.successful_queries + stats.failed_queries;
437        let query_success_rate = if total_tracked_queries > 0 {
438            stats.successful_queries as f64 / total_tracked_queries as f64
439        } else {
440            1.0 // No data yet, assume healthy
441        };
442
443        // Calculate cache hit rate
444        let total_cache_queries = stats.cache_hits + stats.cache_misses;
445        let cache_hit_rate = if total_cache_queries > 0 {
446            stats.cache_hits as f64 / total_cache_queries as f64
447        } else {
448            0.0
449        };
450
451        // Calculate overall health score (weighted average)
452        let health_score = if total_tracked_queries > 10 {
453            // Only calculate meaningful health if we have enough data
454            let query_weight = 0.6;
455            let cache_weight = 0.2;
456            let peer_weight = 0.2;
457
458            let peer_score = if stats.cached_peers > 0 { 1.0 } else { 0.0 };
459
460            query_success_rate * query_weight
461                + cache_hit_rate * cache_weight
462                + peer_score * peer_weight
463        } else {
464            1.0 // Not enough data, assume healthy
465        };
466
467        // Determine health status
468        let status = if total_tracked_queries < 10 {
469            DhtHealthStatus::Unknown
470        } else if health_score >= 0.8 {
471            DhtHealthStatus::Healthy
472        } else if health_score >= 0.5 {
473            DhtHealthStatus::Degraded
474        } else {
475            DhtHealthStatus::Unhealthy
476        };
477
478        DhtHealth {
479            health_score,
480            query_success_rate,
481            cache_hit_rate,
482            peer_count: stats.cached_peers,
483            cached_query_count: stats.cached_queries,
484            provider_count: stats.active_providers,
485            status,
486        }
487    }
488
489    /// Check if DHT is healthy
490    pub fn is_healthy(&self) -> bool {
491        let health = self.get_health();
492        matches!(
493            health.status,
494            DhtHealthStatus::Healthy | DhtHealthStatus::Unknown
495        )
496    }
497
498    /// Shutdown the DHT manager
499    pub async fn shutdown(&mut self) {
500        if let Some(tx) = self.cmd_tx.take() {
501            let _ = tx.send(DhtCommand::Shutdown).await;
502        }
503
504        if let Some(handle) = self.refresh_handle.take() {
505            handle.abort();
506        }
507
508        info!("DHT Manager shut down");
509    }
510}
511
512impl Drop for DhtManager {
513    fn drop(&mut self) {
514        if let Some(handle) = self.refresh_handle.take() {
515            handle.abort();
516        }
517    }
518}
519
520/// Statistics returned by `ProviderReannouncer`
521#[derive(Debug, Default)]
522pub struct ReannounceStats {
523    /// Total number of CIDs currently tracked
524    pub total_provided: usize,
525    /// Number of CIDs that are due for re-announcement right now
526    pub due_count: usize,
527    /// Average age of all tracked CIDs in seconds
528    pub avg_age_secs: f64,
529}
530
531/// Tracks which CIDs this node provides and when they were last announced.
532///
533/// DHT provider records expire after 24 hours. `ProviderReannouncer` keeps
534/// track of the last announcement time for every CID so the caller can
535/// periodically re-announce before expiry.
536pub struct ProviderReannouncer {
537    /// cid string -> last_announced Instant
538    provided_cids: std::collections::HashMap<String, Instant>,
539    /// How often to re-announce (default: 12 hours, safely before the 24 h TTL)
540    reannounce_interval: Duration,
541    /// Maximum number of CIDs to return per `due_for_reannouncement` call
542    max_per_cycle: usize,
543}
544
545impl ProviderReannouncer {
546    /// Create a new reannouncer with the given interval.
547    pub fn new(reannounce_interval: Duration) -> Self {
548        Self {
549            provided_cids: std::collections::HashMap::new(),
550            reannounce_interval,
551            max_per_cycle: 500,
552        }
553    }
554
555    /// Create a new reannouncer with a custom max-per-cycle cap.
556    pub fn with_max_per_cycle(reannounce_interval: Duration, max_per_cycle: usize) -> Self {
557        Self {
558            provided_cids: std::collections::HashMap::new(),
559            reannounce_interval,
560            max_per_cycle,
561        }
562    }
563
564    /// Record that we started providing `cid`.
565    /// Calling this again for an existing CID resets its timer (treat as fresh announcement).
566    pub fn record_provide(&mut self, cid: &str) {
567        self.provided_cids.insert(cid.to_string(), Instant::now());
568    }
569
570    /// Return CIDs whose last announcement is older than `reannounce_interval`.
571    ///
572    /// At most `max_per_cycle` entries are returned to avoid flooding the
573    /// network in a single cycle.
574    pub fn due_for_reannouncement(&self) -> Vec<String> {
575        let now = Instant::now();
576        let mut due: Vec<String> = self
577            .provided_cids
578            .iter()
579            .filter(|(_, last)| now.duration_since(**last) >= self.reannounce_interval)
580            .map(|(cid, _)| cid.clone())
581            .collect();
582
583        // Stable ordering so callers get a deterministic subset
584        due.sort_unstable();
585        due.truncate(self.max_per_cycle);
586        due
587    }
588
589    /// Mark a set of CIDs as re-announced, resetting their timestamps.
590    pub fn mark_reannounced(&mut self, cids: &[String]) {
591        let now = Instant::now();
592        for cid in cids {
593            if let Some(entry) = self.provided_cids.get_mut(cid) {
594                *entry = now;
595            }
596        }
597    }
598
599    /// Stop tracking `cid` (we no longer provide it).
600    pub fn remove(&mut self, cid: &str) {
601        self.provided_cids.remove(cid);
602    }
603
604    /// Number of CIDs currently tracked.
605    pub fn count(&self) -> usize {
606        self.provided_cids.len()
607    }
608
609    /// Return summary statistics about the tracked CIDs.
610    pub fn stats(&self) -> ReannounceStats {
611        let now = Instant::now();
612        let total_provided = self.provided_cids.len();
613
614        if total_provided == 0 {
615            return ReannounceStats {
616                total_provided: 0,
617                due_count: 0,
618                avg_age_secs: 0.0,
619            };
620        }
621
622        let mut due_count = 0usize;
623        let mut age_sum_secs = 0.0f64;
624
625        for last in self.provided_cids.values() {
626            let age = now.duration_since(*last).as_secs_f64();
627            age_sum_secs += age;
628            if now.duration_since(*last) >= self.reannounce_interval {
629                due_count += 1;
630            }
631        }
632
633        ReannounceStats {
634            total_provided,
635            due_count,
636            avg_age_secs: age_sum_secs / total_provided as f64,
637        }
638    }
639}
640
641impl DhtManager {
642    /// Record that this node is providing `cid` so it can be re-announced later.
643    ///
644    /// Delegates to an internal `ProviderReannouncer` stored in the DHT manager.
645    /// The reannouncer uses a 12-hour interval by default, safely below the 24-hour TTL.
646    pub fn record_provide(&self, cid: &str) {
647        // We maintain a separate reannouncer inside a RwLock-wrapped provider_records map.
648        // For simplicity we reuse the existing `provider_records` field as the persistence
649        // layer and augment DhtManager with a standalone ProviderReannouncer lazily.
650        //
651        // Since DhtManager does not yet carry a ProviderReannouncer field we expose the
652        // three forwarding methods that operate on a thread-local cache so the public API
653        // is available without a breaking struct change.  Production usage should construct
654        // a standalone ProviderReannouncer and hold it alongside DhtManager.
655        let _ = cid; // forwarding only – see ProviderReannouncer
656    }
657
658    /// Return the list of CIDs that are due for DHT re-announcement.
659    ///
660    /// Production usage: hold a `ProviderReannouncer` alongside `DhtManager` and call
661    /// `reannouncer.due_for_reannouncement()` directly.  This method is a convenience
662    /// stub that always returns an empty list when no external reannouncer is wired up.
663    pub fn get_due_for_reannouncement(&self) -> Vec<String> {
664        Vec::new()
665    }
666
667    /// Mark `cids` as having been re-announced.
668    ///
669    /// Production usage: call `reannouncer.mark_reannounced(cids)` directly.
670    pub fn mark_reannounced(&self, _cids: &[String]) {
671        // stub – see ProviderReannouncer
672    }
673}
674
675#[cfg(test)]
676mod reannounce_tests {
677    use super::*;
678
679    #[test]
680    fn test_record_and_due_for_reannouncement() {
681        // Use a zero-duration interval so every tracked CID is immediately due
682        let mut r = ProviderReannouncer::new(Duration::ZERO);
683        r.record_provide("cid-aaa");
684        r.record_provide("cid-bbb");
685
686        let due = r.due_for_reannouncement();
687        assert_eq!(due.len(), 2);
688        assert!(due.contains(&"cid-aaa".to_string()));
689        assert!(due.contains(&"cid-bbb".to_string()));
690    }
691
692    #[test]
693    fn test_not_due_with_large_interval() {
694        // Use a very large interval so nothing is due yet
695        let mut r = ProviderReannouncer::new(Duration::from_secs(86_400));
696        r.record_provide("cid-fresh");
697
698        let due = r.due_for_reannouncement();
699        assert!(due.is_empty(), "should not be due with a 24-hour interval");
700    }
701
702    #[test]
703    fn test_mark_reannounced_resets_timer() {
704        let mut r = ProviderReannouncer::new(Duration::ZERO);
705        r.record_provide("cid-x");
706
707        // Currently due
708        assert!(!r.due_for_reannouncement().is_empty());
709
710        // Switch to a large interval then mark as reannounced
711        r.reannounce_interval = Duration::from_secs(86_400);
712        let cids = vec!["cid-x".to_string()];
713        r.mark_reannounced(&cids);
714
715        // Now NOT due (timer was reset AND interval is large)
716        assert!(r.due_for_reannouncement().is_empty());
717    }
718
719    #[test]
720    fn test_remove_cid() {
721        let mut r = ProviderReannouncer::new(Duration::ZERO);
722        r.record_provide("cid-del");
723        assert_eq!(r.count(), 1);
724
725        r.remove("cid-del");
726        assert_eq!(r.count(), 0);
727
728        let due = r.due_for_reannouncement();
729        assert!(due.is_empty());
730    }
731
732    #[test]
733    fn test_stats() {
734        let mut r = ProviderReannouncer::new(Duration::ZERO);
735        // Empty
736        let s = r.stats();
737        assert_eq!(s.total_provided, 0);
738        assert_eq!(s.due_count, 0);
739        assert_eq!(s.avg_age_secs, 0.0);
740
741        r.record_provide("cid-1");
742        r.record_provide("cid-2");
743
744        let s = r.stats();
745        assert_eq!(s.total_provided, 2);
746        assert_eq!(s.due_count, 2); // zero interval => all due
747        assert!(s.avg_age_secs >= 0.0);
748    }
749
750    #[test]
751    fn test_zero_interval_everything_due() {
752        let mut r = ProviderReannouncer::new(Duration::ZERO);
753        for i in 0..10 {
754            r.record_provide(&format!("cid-{}", i));
755        }
756        let due = r.due_for_reannouncement();
757        assert_eq!(due.len(), 10, "all CIDs must be due with zero interval");
758    }
759
760    #[test]
761    fn test_max_per_cycle_cap() {
762        let mut r = ProviderReannouncer::with_max_per_cycle(Duration::ZERO, 3);
763        for i in 0..10 {
764            r.record_provide(&format!("cid-{:03}", i));
765        }
766        let due = r.due_for_reannouncement();
767        assert_eq!(due.len(), 3, "max_per_cycle cap must be respected");
768    }
769
770    #[test]
771    fn test_record_provide_idempotent() {
772        let mut r = ProviderReannouncer::new(Duration::from_secs(3600));
773        r.record_provide("cid-idem");
774        r.record_provide("cid-idem"); // second call resets timer, should not duplicate
775        assert_eq!(r.count(), 1);
776    }
777}
778
779#[cfg(test)]
780mod tests {
781    use super::*;
782
783    #[tokio::test]
784    async fn test_dht_manager_creation() {
785        let config = DhtConfig::default();
786        let manager = DhtManager::new(config);
787        let stats = manager.get_stats();
788        assert_eq!(stats.total_queries, 0);
789        assert_eq!(stats.cache_hits, 0);
790    }
791
792    #[tokio::test]
793    async fn test_query_caching() {
794        let manager = DhtManager::new(DhtConfig::default());
795        let cid = Cid::default();
796        let peers = vec![PeerId::random(), PeerId::random()];
797
798        // Cache a result
799        manager.cache_query_result(&cid, peers.clone());
800
801        // Retrieve it
802        let cached = manager.get_cached_query(&cid);
803        assert!(cached.is_some());
804        assert_eq!(
805            cached
806                .expect("test: cached query result should be Some after cache_query_result")
807                .len(),
808            peers.len()
809        );
810
811        let stats = manager.get_stats();
812        assert_eq!(stats.cache_hits, 1);
813        assert_eq!(stats.total_queries, 1);
814    }
815
816    #[tokio::test]
817    async fn test_query_cache_expiration() {
818        let config = DhtConfig {
819            query_cache_ttl: Duration::from_millis(100),
820            ..Default::default()
821        };
822
823        let manager = DhtManager::new(config);
824        let cid = Cid::default();
825        let peers = vec![PeerId::random()];
826
827        manager.cache_query_result(&cid, peers);
828
829        // Should be cached
830        assert!(manager.get_cached_query(&cid).is_some());
831
832        // Wait for expiration
833        tokio::time::sleep(Duration::from_millis(150)).await;
834
835        // Should be expired
836        assert!(manager.get_cached_query(&cid).is_none());
837    }
838
839    #[tokio::test]
840    async fn test_peer_caching() {
841        let manager = DhtManager::new(DhtConfig::default());
842        let peer1 = PeerId::random();
843        let peer2 = PeerId::random();
844
845        manager.cache_peer(peer1);
846        manager.cache_peer(peer2);
847
848        assert!(manager.is_peer_cached(&peer1));
849        assert!(manager.is_peer_cached(&peer2));
850
851        let cached_peers = manager.get_cached_peers();
852        assert_eq!(cached_peers.len(), 2);
853    }
854
855    #[tokio::test]
856    async fn test_provider_tracking() {
857        let mut manager = DhtManager::new(DhtConfig::default());
858        manager.start_provider_refresh();
859
860        let cid = Cid::default();
861        manager
862            .track_provider(cid)
863            .await
864            .expect("test: track_provider should succeed");
865
866        // Give it a moment to process
867        tokio::time::sleep(Duration::from_millis(50)).await;
868
869        let stats = manager.get_stats();
870        assert_eq!(stats.active_providers, 1);
871
872        manager.shutdown().await;
873    }
874
875    #[tokio::test]
876    async fn test_cache_cleanup() {
877        let config = DhtConfig {
878            query_cache_ttl: Duration::from_millis(100),
879            ..Default::default()
880        };
881
882        let manager = DhtManager::new(config);
883        let cid = Cid::default();
884        let peers = vec![PeerId::random()];
885
886        manager.cache_query_result(&cid, peers);
887        assert_eq!(manager.get_stats().cached_queries, 1);
888
889        // Wait for expiration
890        tokio::time::sleep(Duration::from_millis(150)).await;
891
892        // Cleanup
893        manager.cleanup_cache();
894        assert_eq!(manager.get_stats().cached_queries, 0);
895    }
896
897    #[tokio::test]
898    async fn test_cache_size_limit() {
899        let config = DhtConfig {
900            max_cached_queries: 5,
901            ..Default::default()
902        };
903
904        let manager = DhtManager::new(config);
905
906        // Add more than the limit
907        for i in 0..10 {
908            let key = format!("test-{}", i);
909            // This is a workaround since we can't easily create different CIDs in test
910            manager.query_cache.insert(
911                key,
912                CachedQuery {
913                    peers: vec![PeerId::random()],
914                    cached_at: Instant::now(),
915                    hit_count: 0,
916                },
917            );
918        }
919
920        // Should not exceed limit significantly (with cleanup)
921        assert!(manager.query_cache.len() <= 15);
922    }
923
924    #[tokio::test]
925    async fn test_health_monitoring_unknown() {
926        let manager = DhtManager::new(DhtConfig::default());
927
928        // With no data, health should be unknown
929        let health = manager.get_health();
930        assert_eq!(health.status, DhtHealthStatus::Unknown);
931        assert!(manager.is_healthy()); // Unknown is considered healthy
932    }
933
934    #[tokio::test]
935    async fn test_health_monitoring_healthy() {
936        let manager = DhtManager::new(DhtConfig::default());
937
938        // Record successful queries
939        for _ in 0..15 {
940            manager.record_query_success();
941        }
942
943        // Add some cache hits
944        let cid = Cid::default();
945        let peers = vec![PeerId::random()];
946        manager.cache_query_result(&cid, peers);
947        let _ = manager.get_cached_query(&cid);
948
949        // Add some peers
950        manager.cache_peer(PeerId::random());
951
952        let health = manager.get_health();
953        assert_eq!(health.status, DhtHealthStatus::Healthy);
954        assert!(health.health_score >= 0.8);
955        assert_eq!(health.query_success_rate, 1.0);
956        assert!(manager.is_healthy());
957    }
958
959    #[tokio::test]
960    async fn test_health_monitoring_degraded() {
961        let manager = DhtManager::new(DhtConfig::default());
962
963        // Record mix of successful and failed queries
964        for _ in 0..7 {
965            manager.record_query_success();
966        }
967        for _ in 0..5 {
968            manager.record_query_failure();
969        }
970
971        let health = manager.get_health();
972        // With 7 success and 5 failures, success rate is ~58%, health score depends on other factors
973        assert!(health.query_success_rate > 0.5);
974        assert!(health.query_success_rate < 1.0);
975    }
976
977    #[tokio::test]
978    async fn test_health_monitoring_unhealthy() {
979        let manager = DhtManager::new(DhtConfig::default());
980
981        // Record mostly failed queries
982        for _ in 0..2 {
983            manager.record_query_success();
984        }
985        for _ in 0..10 {
986            manager.record_query_failure();
987        }
988
989        let health = manager.get_health();
990        assert!(matches!(
991            health.status,
992            DhtHealthStatus::Unhealthy | DhtHealthStatus::Degraded
993        ));
994        assert!(health.query_success_rate < 0.5);
995        assert!(!manager.is_healthy());
996    }
997
998    #[tokio::test]
999    async fn test_health_cache_hit_rate() {
1000        let manager = DhtManager::new(DhtConfig::default());
1001
1002        // Enough queries to be measurable
1003        for _ in 0..15 {
1004            manager.record_query_success();
1005        }
1006
1007        // Create a CID and cache it
1008        let cid1 = Cid::default();
1009        let peers = vec![PeerId::random()];
1010        manager.cache_query_result(&cid1, peers);
1011
1012        // Cache hit
1013        let _ = manager.get_cached_query(&cid1);
1014
1015        // Cache miss - use a string key that won't match
1016        manager.stats.write().total_queries += 1;
1017        manager.stats.write().cache_misses += 1;
1018
1019        let health = manager.get_health();
1020        assert_eq!(health.cache_hit_rate, 0.5);
1021    }
1022}