use cid::Cid;
use dashmap::DashMap;
use ipfrs_core::error::{Error, Result};
use libp2p::PeerId;
use parking_lot::RwLock;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::mpsc;
use tracing::{debug, info};
const DEFAULT_PROVIDER_TTL: Duration = Duration::from_secs(24 * 60 * 60);
const DEFAULT_QUERY_CACHE_TTL: Duration = Duration::from_secs(5 * 60);
#[derive(Debug, Clone)]
pub struct DhtConfig {
pub provider_ttl: Duration,
pub query_cache_ttl: Duration,
pub enable_provider_refresh: bool,
pub provider_refresh_interval: Duration,
pub max_cached_queries: usize,
}
impl Default for DhtConfig {
fn default() -> Self {
Self {
provider_ttl: DEFAULT_PROVIDER_TTL,
query_cache_ttl: DEFAULT_QUERY_CACHE_TTL,
enable_provider_refresh: true,
provider_refresh_interval: Duration::from_secs(12 * 60 * 60), max_cached_queries: 10_000,
}
}
}
#[derive(Debug, Clone)]
struct CachedQuery {
peers: Vec<PeerId>,
cached_at: Instant,
hit_count: usize,
}
#[derive(Debug, Clone)]
struct ProviderRecord {
cid: Cid,
last_announced: Instant,
}
pub struct DhtManager {
config: DhtConfig,
query_cache: Arc<DashMap<String, CachedQuery>>,
peer_cache: Arc<DashMap<PeerId, Instant>>,
provider_records: Arc<RwLock<HashMap<String, ProviderRecord>>>,
stats: Arc<RwLock<DhtStats>>,
refresh_handle: Option<tokio::task::JoinHandle<()>>,
cmd_tx: Option<mpsc::Sender<DhtCommand>>,
}
#[derive(Debug, Clone, Default, serde::Serialize)]
pub struct DhtStats {
pub total_queries: u64,
pub cache_hits: u64,
pub cache_misses: u64,
pub provider_refreshes: u64,
pub active_providers: usize,
pub cached_queries: usize,
pub cached_peers: usize,
pub successful_queries: u64,
pub failed_queries: u64,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct DhtHealth {
pub health_score: f64,
pub query_success_rate: f64,
pub cache_hit_rate: f64,
pub peer_count: usize,
pub cached_query_count: usize,
pub provider_count: usize,
pub status: DhtHealthStatus,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize)]
pub enum DhtHealthStatus {
Healthy,
Degraded,
Unhealthy,
Unknown,
}
pub(crate) enum DhtCommand {
TrackProvider { cid: Cid },
StopTracking { cid: String },
#[allow(dead_code)]
RefreshProviders { response_tx: mpsc::Sender<Vec<Cid>> },
Shutdown,
}
impl DhtManager {
pub fn new(config: DhtConfig) -> Self {
let manager = Self {
config,
query_cache: Arc::new(DashMap::new()),
peer_cache: Arc::new(DashMap::new()),
provider_records: Arc::new(RwLock::new(HashMap::new())),
stats: Arc::new(RwLock::new(DhtStats::default())),
refresh_handle: None,
cmd_tx: None,
};
info!(
"DHT Manager initialized (provider_ttl={:?}, query_cache_ttl={:?})",
manager.config.provider_ttl, manager.config.query_cache_ttl
);
manager
}
pub fn start_provider_refresh(&mut self) {
if !self.config.enable_provider_refresh {
info!("Provider refresh disabled");
return;
}
let (cmd_tx, mut cmd_rx) = mpsc::channel::<DhtCommand>(100);
let provider_records = Arc::clone(&self.provider_records);
let stats = Arc::clone(&self.stats);
let refresh_interval = self.config.provider_refresh_interval;
let handle = tokio::spawn(async move {
info!(
"Starting provider refresh task (interval={:?})",
refresh_interval
);
let mut interval = tokio::time::interval(refresh_interval);
loop {
tokio::select! {
_ = interval.tick() => {
let now = Instant::now();
let mut refresh_needed = Vec::new();
{
let records = provider_records.read();
for (key, record) in records.iter() {
if now.duration_since(record.last_announced) >= refresh_interval {
refresh_needed.push((key.clone(), record.cid));
}
}
}
if !refresh_needed.is_empty() {
info!("Refreshing {} provider records", refresh_needed.len());
stats.write().provider_refreshes += refresh_needed.len() as u64;
let mut records = provider_records.write();
for (key, _cid) in refresh_needed {
if let Some(record) = records.get_mut(&key) {
record.last_announced = now;
}
}
}
}
Some(cmd) = cmd_rx.recv() => {
match cmd {
DhtCommand::TrackProvider { cid } => {
let key = cid.to_string();
let mut records = provider_records.write();
records.insert(key.clone(), ProviderRecord {
cid,
last_announced: Instant::now(),
});
debug!("Tracking provider record: {}", key);
stats.write().active_providers = records.len();
}
DhtCommand::StopTracking { cid } => {
let mut records = provider_records.write();
records.remove(&cid);
debug!("Stopped tracking provider: {}", cid);
stats.write().active_providers = records.len();
}
DhtCommand::RefreshProviders { response_tx } => {
let cids: Vec<Cid> = {
let records = provider_records.read();
records.values().map(|r| r.cid).collect()
};
let _ = response_tx.send(cids).await;
}
DhtCommand::Shutdown => {
info!("Shutting down provider refresh task");
break;
}
}
}
}
}
});
self.refresh_handle = Some(handle);
self.cmd_tx = Some(cmd_tx);
}
pub async fn track_provider(&self, cid: Cid) -> Result<()> {
if let Some(cmd_tx) = &self.cmd_tx {
cmd_tx
.send(DhtCommand::TrackProvider { cid })
.await
.map_err(|e| Error::Network(format!("Failed to track provider: {}", e)))?;
}
Ok(())
}
pub async fn stop_tracking(&self, cid: &Cid) -> Result<()> {
if let Some(cmd_tx) = &self.cmd_tx {
cmd_tx
.send(DhtCommand::StopTracking {
cid: cid.to_string(),
})
.await
.map_err(|e| Error::Network(format!("Failed to stop tracking: {}", e)))?;
}
Ok(())
}
pub fn cache_query_result(&self, cid: &Cid, peers: Vec<PeerId>) {
let key = cid.to_string();
if self.query_cache.len() >= self.config.max_cached_queries {
let now = Instant::now();
let mut to_remove = Vec::new();
for entry in self.query_cache.iter() {
if now.duration_since(entry.value().cached_at) > self.config.query_cache_ttl * 2 {
to_remove.push(entry.key().clone());
}
}
for key in to_remove {
self.query_cache.remove(&key);
}
}
self.query_cache.insert(
key.clone(),
CachedQuery {
peers,
cached_at: Instant::now(),
hit_count: 0,
},
);
debug!("Cached query result for {}", key);
self.stats.write().cached_queries = self.query_cache.len();
}
pub fn get_cached_query(&self, cid: &Cid) -> Option<Vec<PeerId>> {
let key = cid.to_string();
let mut stats = self.stats.write();
stats.total_queries += 1;
if let Some(mut cached) = self.query_cache.get_mut(&key) {
let age = Instant::now().duration_since(cached.cached_at);
if age < self.config.query_cache_ttl {
cached.hit_count += 1;
stats.cache_hits += 1;
debug!(
"Cache hit for {} (age={:?}, hits={})",
key, age, cached.hit_count
);
return Some(cached.peers.clone());
} else {
debug!("Cache entry expired for {} (age={:?})", key, age);
drop(cached);
self.query_cache.remove(&key);
}
}
stats.cache_misses += 1;
None
}
pub fn cache_peer(&self, peer_id: PeerId) {
self.peer_cache.insert(peer_id, Instant::now());
self.stats.write().cached_peers = self.peer_cache.len();
}
pub fn is_peer_cached(&self, peer_id: &PeerId) -> bool {
self.peer_cache.contains_key(peer_id)
}
pub fn get_cached_peers(&self) -> Vec<PeerId> {
self.peer_cache.iter().map(|entry| *entry.key()).collect()
}
pub fn cleanup_cache(&self) {
let now = Instant::now();
let mut removed_queries = 0;
let mut removed_peers = 0;
let to_remove: Vec<String> = self
.query_cache
.iter()
.filter(|entry| {
now.duration_since(entry.value().cached_at) > self.config.query_cache_ttl
})
.map(|entry| entry.key().clone())
.collect();
for key in to_remove {
self.query_cache.remove(&key);
removed_queries += 1;
}
let peer_ttl = Duration::from_secs(3600);
let to_remove: Vec<PeerId> = self
.peer_cache
.iter()
.filter(|entry| now.duration_since(*entry.value()) > peer_ttl)
.map(|entry| *entry.key())
.collect();
for peer_id in to_remove {
self.peer_cache.remove(&peer_id);
removed_peers += 1;
}
if removed_queries > 0 || removed_peers > 0 {
debug!(
"Cache cleanup: removed {} queries, {} peers",
removed_queries, removed_peers
);
}
let mut stats = self.stats.write();
stats.cached_queries = self.query_cache.len();
stats.cached_peers = self.peer_cache.len();
}
pub fn get_stats(&self) -> DhtStats {
self.stats.read().clone()
}
pub fn record_query_success(&self) {
self.stats.write().successful_queries += 1;
}
pub fn record_query_failure(&self) {
self.stats.write().failed_queries += 1;
}
pub fn get_health(&self) -> DhtHealth {
let stats = self.stats.read();
let total_tracked_queries = stats.successful_queries + stats.failed_queries;
let query_success_rate = if total_tracked_queries > 0 {
stats.successful_queries as f64 / total_tracked_queries as f64
} else {
1.0 };
let total_cache_queries = stats.cache_hits + stats.cache_misses;
let cache_hit_rate = if total_cache_queries > 0 {
stats.cache_hits as f64 / total_cache_queries as f64
} else {
0.0
};
let health_score = if total_tracked_queries > 10 {
let query_weight = 0.6;
let cache_weight = 0.2;
let peer_weight = 0.2;
let peer_score = if stats.cached_peers > 0 { 1.0 } else { 0.0 };
query_success_rate * query_weight
+ cache_hit_rate * cache_weight
+ peer_score * peer_weight
} else {
1.0 };
let status = if total_tracked_queries < 10 {
DhtHealthStatus::Unknown
} else if health_score >= 0.8 {
DhtHealthStatus::Healthy
} else if health_score >= 0.5 {
DhtHealthStatus::Degraded
} else {
DhtHealthStatus::Unhealthy
};
DhtHealth {
health_score,
query_success_rate,
cache_hit_rate,
peer_count: stats.cached_peers,
cached_query_count: stats.cached_queries,
provider_count: stats.active_providers,
status,
}
}
pub fn is_healthy(&self) -> bool {
let health = self.get_health();
matches!(
health.status,
DhtHealthStatus::Healthy | DhtHealthStatus::Unknown
)
}
pub async fn shutdown(&mut self) {
if let Some(tx) = self.cmd_tx.take() {
let _ = tx.send(DhtCommand::Shutdown).await;
}
if let Some(handle) = self.refresh_handle.take() {
handle.abort();
}
info!("DHT Manager shut down");
}
}
impl Drop for DhtManager {
fn drop(&mut self) {
if let Some(handle) = self.refresh_handle.take() {
handle.abort();
}
}
}
#[derive(Debug, Default)]
pub struct ReannounceStats {
pub total_provided: usize,
pub due_count: usize,
pub avg_age_secs: f64,
}
pub struct ProviderReannouncer {
provided_cids: std::collections::HashMap<String, Instant>,
reannounce_interval: Duration,
max_per_cycle: usize,
}
impl ProviderReannouncer {
pub fn new(reannounce_interval: Duration) -> Self {
Self {
provided_cids: std::collections::HashMap::new(),
reannounce_interval,
max_per_cycle: 500,
}
}
pub fn with_max_per_cycle(reannounce_interval: Duration, max_per_cycle: usize) -> Self {
Self {
provided_cids: std::collections::HashMap::new(),
reannounce_interval,
max_per_cycle,
}
}
pub fn record_provide(&mut self, cid: &str) {
self.provided_cids.insert(cid.to_string(), Instant::now());
}
pub fn due_for_reannouncement(&self) -> Vec<String> {
let now = Instant::now();
let mut due: Vec<String> = self
.provided_cids
.iter()
.filter(|(_, last)| now.duration_since(**last) >= self.reannounce_interval)
.map(|(cid, _)| cid.clone())
.collect();
due.sort_unstable();
due.truncate(self.max_per_cycle);
due
}
pub fn mark_reannounced(&mut self, cids: &[String]) {
let now = Instant::now();
for cid in cids {
if let Some(entry) = self.provided_cids.get_mut(cid) {
*entry = now;
}
}
}
pub fn remove(&mut self, cid: &str) {
self.provided_cids.remove(cid);
}
pub fn count(&self) -> usize {
self.provided_cids.len()
}
pub fn stats(&self) -> ReannounceStats {
let now = Instant::now();
let total_provided = self.provided_cids.len();
if total_provided == 0 {
return ReannounceStats {
total_provided: 0,
due_count: 0,
avg_age_secs: 0.0,
};
}
let mut due_count = 0usize;
let mut age_sum_secs = 0.0f64;
for last in self.provided_cids.values() {
let age = now.duration_since(*last).as_secs_f64();
age_sum_secs += age;
if now.duration_since(*last) >= self.reannounce_interval {
due_count += 1;
}
}
ReannounceStats {
total_provided,
due_count,
avg_age_secs: age_sum_secs / total_provided as f64,
}
}
}
impl DhtManager {
pub fn record_provide(&self, cid: &str) {
let _ = cid; }
pub fn get_due_for_reannouncement(&self) -> Vec<String> {
Vec::new()
}
pub fn mark_reannounced(&self, _cids: &[String]) {
}
}
#[cfg(test)]
mod reannounce_tests {
use super::*;
#[test]
fn test_record_and_due_for_reannouncement() {
let mut r = ProviderReannouncer::new(Duration::ZERO);
r.record_provide("cid-aaa");
r.record_provide("cid-bbb");
let due = r.due_for_reannouncement();
assert_eq!(due.len(), 2);
assert!(due.contains(&"cid-aaa".to_string()));
assert!(due.contains(&"cid-bbb".to_string()));
}
#[test]
fn test_not_due_with_large_interval() {
let mut r = ProviderReannouncer::new(Duration::from_secs(86_400));
r.record_provide("cid-fresh");
let due = r.due_for_reannouncement();
assert!(due.is_empty(), "should not be due with a 24-hour interval");
}
#[test]
fn test_mark_reannounced_resets_timer() {
let mut r = ProviderReannouncer::new(Duration::ZERO);
r.record_provide("cid-x");
assert!(!r.due_for_reannouncement().is_empty());
r.reannounce_interval = Duration::from_secs(86_400);
let cids = vec!["cid-x".to_string()];
r.mark_reannounced(&cids);
assert!(r.due_for_reannouncement().is_empty());
}
#[test]
fn test_remove_cid() {
let mut r = ProviderReannouncer::new(Duration::ZERO);
r.record_provide("cid-del");
assert_eq!(r.count(), 1);
r.remove("cid-del");
assert_eq!(r.count(), 0);
let due = r.due_for_reannouncement();
assert!(due.is_empty());
}
#[test]
fn test_stats() {
let mut r = ProviderReannouncer::new(Duration::ZERO);
let s = r.stats();
assert_eq!(s.total_provided, 0);
assert_eq!(s.due_count, 0);
assert_eq!(s.avg_age_secs, 0.0);
r.record_provide("cid-1");
r.record_provide("cid-2");
let s = r.stats();
assert_eq!(s.total_provided, 2);
assert_eq!(s.due_count, 2); assert!(s.avg_age_secs >= 0.0);
}
#[test]
fn test_zero_interval_everything_due() {
let mut r = ProviderReannouncer::new(Duration::ZERO);
for i in 0..10 {
r.record_provide(&format!("cid-{}", i));
}
let due = r.due_for_reannouncement();
assert_eq!(due.len(), 10, "all CIDs must be due with zero interval");
}
#[test]
fn test_max_per_cycle_cap() {
let mut r = ProviderReannouncer::with_max_per_cycle(Duration::ZERO, 3);
for i in 0..10 {
r.record_provide(&format!("cid-{:03}", i));
}
let due = r.due_for_reannouncement();
assert_eq!(due.len(), 3, "max_per_cycle cap must be respected");
}
#[test]
fn test_record_provide_idempotent() {
let mut r = ProviderReannouncer::new(Duration::from_secs(3600));
r.record_provide("cid-idem");
r.record_provide("cid-idem"); assert_eq!(r.count(), 1);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_dht_manager_creation() {
let config = DhtConfig::default();
let manager = DhtManager::new(config);
let stats = manager.get_stats();
assert_eq!(stats.total_queries, 0);
assert_eq!(stats.cache_hits, 0);
}
#[tokio::test]
async fn test_query_caching() {
let manager = DhtManager::new(DhtConfig::default());
let cid = Cid::default();
let peers = vec![PeerId::random(), PeerId::random()];
manager.cache_query_result(&cid, peers.clone());
let cached = manager.get_cached_query(&cid);
assert!(cached.is_some());
assert_eq!(
cached
.expect("test: cached query result should be Some after cache_query_result")
.len(),
peers.len()
);
let stats = manager.get_stats();
assert_eq!(stats.cache_hits, 1);
assert_eq!(stats.total_queries, 1);
}
#[tokio::test]
async fn test_query_cache_expiration() {
let config = DhtConfig {
query_cache_ttl: Duration::from_millis(100),
..Default::default()
};
let manager = DhtManager::new(config);
let cid = Cid::default();
let peers = vec![PeerId::random()];
manager.cache_query_result(&cid, peers);
assert!(manager.get_cached_query(&cid).is_some());
tokio::time::sleep(Duration::from_millis(150)).await;
assert!(manager.get_cached_query(&cid).is_none());
}
#[tokio::test]
async fn test_peer_caching() {
let manager = DhtManager::new(DhtConfig::default());
let peer1 = PeerId::random();
let peer2 = PeerId::random();
manager.cache_peer(peer1);
manager.cache_peer(peer2);
assert!(manager.is_peer_cached(&peer1));
assert!(manager.is_peer_cached(&peer2));
let cached_peers = manager.get_cached_peers();
assert_eq!(cached_peers.len(), 2);
}
#[tokio::test]
async fn test_provider_tracking() {
let mut manager = DhtManager::new(DhtConfig::default());
manager.start_provider_refresh();
let cid = Cid::default();
manager
.track_provider(cid)
.await
.expect("test: track_provider should succeed");
tokio::time::sleep(Duration::from_millis(50)).await;
let stats = manager.get_stats();
assert_eq!(stats.active_providers, 1);
manager.shutdown().await;
}
#[tokio::test]
async fn test_cache_cleanup() {
let config = DhtConfig {
query_cache_ttl: Duration::from_millis(100),
..Default::default()
};
let manager = DhtManager::new(config);
let cid = Cid::default();
let peers = vec![PeerId::random()];
manager.cache_query_result(&cid, peers);
assert_eq!(manager.get_stats().cached_queries, 1);
tokio::time::sleep(Duration::from_millis(150)).await;
manager.cleanup_cache();
assert_eq!(manager.get_stats().cached_queries, 0);
}
#[tokio::test]
async fn test_cache_size_limit() {
let config = DhtConfig {
max_cached_queries: 5,
..Default::default()
};
let manager = DhtManager::new(config);
for i in 0..10 {
let key = format!("test-{}", i);
manager.query_cache.insert(
key,
CachedQuery {
peers: vec![PeerId::random()],
cached_at: Instant::now(),
hit_count: 0,
},
);
}
assert!(manager.query_cache.len() <= 15);
}
#[tokio::test]
async fn test_health_monitoring_unknown() {
let manager = DhtManager::new(DhtConfig::default());
let health = manager.get_health();
assert_eq!(health.status, DhtHealthStatus::Unknown);
assert!(manager.is_healthy()); }
#[tokio::test]
async fn test_health_monitoring_healthy() {
let manager = DhtManager::new(DhtConfig::default());
for _ in 0..15 {
manager.record_query_success();
}
let cid = Cid::default();
let peers = vec![PeerId::random()];
manager.cache_query_result(&cid, peers);
let _ = manager.get_cached_query(&cid);
manager.cache_peer(PeerId::random());
let health = manager.get_health();
assert_eq!(health.status, DhtHealthStatus::Healthy);
assert!(health.health_score >= 0.8);
assert_eq!(health.query_success_rate, 1.0);
assert!(manager.is_healthy());
}
#[tokio::test]
async fn test_health_monitoring_degraded() {
let manager = DhtManager::new(DhtConfig::default());
for _ in 0..7 {
manager.record_query_success();
}
for _ in 0..5 {
manager.record_query_failure();
}
let health = manager.get_health();
assert!(health.query_success_rate > 0.5);
assert!(health.query_success_rate < 1.0);
}
#[tokio::test]
async fn test_health_monitoring_unhealthy() {
let manager = DhtManager::new(DhtConfig::default());
for _ in 0..2 {
manager.record_query_success();
}
for _ in 0..10 {
manager.record_query_failure();
}
let health = manager.get_health();
assert!(matches!(
health.status,
DhtHealthStatus::Unhealthy | DhtHealthStatus::Degraded
));
assert!(health.query_success_rate < 0.5);
assert!(!manager.is_healthy());
}
#[tokio::test]
async fn test_health_cache_hit_rate() {
let manager = DhtManager::new(DhtConfig::default());
for _ in 0..15 {
manager.record_query_success();
}
let cid1 = Cid::default();
let peers = vec![PeerId::random()];
manager.cache_query_result(&cid1, peers);
let _ = manager.get_cached_query(&cid1);
manager.stats.write().total_queries += 1;
manager.stats.write().cache_misses += 1;
let health = manager.get_health();
assert_eq!(health.cache_hit_rate, 0.5);
}
}