Skip to main content

crawlkit_engine/
dns.rs

1use std::net::SocketAddr;
2use std::sync::Arc;
3use std::time::{Duration, Instant};
4
5use dashmap::DashMap;
6use parking_lot::RwLock;
7use tokio::net::lookup_host;
8use url::Url;
9
10/// Cached DNS resolution result.
11#[derive(Debug, Clone)]
12struct DnsEntry {
13    /// Resolved socket addresses.
14    addresses: Vec<SocketAddr>,
15    /// When this entry was resolved.
16    resolved_at: Instant,
17    /// Time-to-live for this cache entry.
18    ttl: Duration,
19}
20
21impl DnsEntry {
22    /// Returns `true` if the cache entry has expired.
23    fn is_expired(&self) -> bool {
24        self.resolved_at.elapsed() > self.ttl
25    }
26}
27
28/// A concurrent DNS cache with background prefetching.
29///
30/// Caches DNS resolution results with configurable TTL and provides
31/// background prefetching for upcoming URLs. Uses `DashMap` for
32/// concurrent access without external locking.
33///
34/// # Examples
35///
36/// ```rust
37/// use crawlkit_engine::DnsCache;
38/// use std::time::Duration;
39///
40/// let cache = DnsCache::new(Duration::from_secs(300), 10_000);
41/// cache.insert("example.com", vec!["93.184.216.34:443".parse().unwrap()]);
42/// assert!(cache.get_cached("example.com").is_some());
43/// ```
44pub struct DnsCache {
45    /// Domain -> cached resolution.
46    cache: DashMap<String, DnsEntry>,
47    /// Default TTL for DNS cache entries.
48    default_ttl: Duration,
49    /// Maximum number of entries in the cache.
50    max_entries: usize,
51    /// Prefetch queue: domains queued for background resolution.
52    prefetch_queue: Arc<RwLock<Vec<String>>>,
53    /// Track memory usage (approximate bytes).
54    memory_usage: std::sync::atomic::AtomicUsize,
55}
56
57impl DnsCache {
58    /// Creates a new DNS cache with the given TTL and capacity.
59    pub fn new(default_ttl: Duration, max_entries: usize) -> Self {
60        Self {
61            cache: DashMap::with_capacity(max_entries.min(1024)),
62            default_ttl,
63            max_entries,
64            prefetch_queue: Arc::new(RwLock::new(Vec::new())),
65            memory_usage: std::sync::atomic::AtomicUsize::new(0),
66        }
67    }
68
69    /// Creates a DNS cache with sensible defaults (5 min TTL, 10k entries).
70    pub fn with_defaults() -> Self {
71        Self::new(Duration::from_secs(300), 10_000)
72    }
73
74    /// Resolves a domain, using the cache if available and not expired.
75    pub async fn resolve(&self, domain: &str) -> Result<Vec<SocketAddr>, DnsError> {
76        // Check cache first
77        if let Some(entry) = self.cache.get(domain) {
78            if !entry.is_expired() {
79                return Ok(entry.addresses.clone());
80            }
81        }
82
83        // Cache miss or expired — resolve via system DNS
84        let addr = format!("{domain}:443");
85        let addresses: Vec<SocketAddr> = lookup_host(&addr)
86            .await
87            .map_err(|e| DnsError::ResolutionFailed {
88                domain: domain.to_string(),
89                source: e,
90            })?
91            .collect();
92
93        if addresses.is_empty() {
94            return Err(DnsError::NoRecords {
95                domain: domain.to_string(),
96            });
97        }
98
99        self.insert(domain, addresses.clone());
100        Ok(addresses)
101    }
102
103    /// Resolves a domain from a URL.
104    pub async fn resolve_url(&self, url: &Url) -> Result<Vec<SocketAddr>, DnsError> {
105        let domain = url.host_str().ok_or_else(|| DnsError::NoRecords {
106            domain: url.to_string(),
107        })?;
108        self.resolve(domain).await
109    }
110
111    /// Inserts a pre-resolved entry into the cache.
112    pub fn insert(&self, domain: &str, addresses: Vec<SocketAddr>) {
113        // Evict oldest entries if at capacity
114        if self.cache.len() >= self.max_entries {
115            self.evict_expired();
116            // If still at capacity, remove oldest entry
117            if self.cache.len() >= self.max_entries {
118                if let Some(oldest) = self.find_oldest_entry() {
119                    self.cache.remove(&oldest);
120                }
121            }
122        }
123
124        let entry = DnsEntry {
125            addresses,
126            resolved_at: Instant::now(),
127            ttl: self.default_ttl,
128        };
129
130        // Update memory tracking
131        let new_size = std::mem::size_of::<DnsEntry>() + domain.len();
132        let old_size = self
133            .cache
134            .get(domain)
135            .map(|e| {
136                std::mem::size_of::<DnsEntry>()
137                    + domain.len()
138                    + e.addresses.len() * std::mem::size_of::<SocketAddr>()
139            })
140            .unwrap_or(0);
141
142        self.memory_usage.fetch_add(
143            new_size.saturating_sub(old_size),
144            std::sync::atomic::Ordering::Relaxed,
145        );
146
147        self.cache.insert(domain.to_string(), entry);
148    }
149
150    /// Returns cached addresses for a domain without doing DNS resolution.
151    pub fn get_cached(&self, domain: &str) -> Option<Vec<SocketAddr>> {
152        self.cache.get(domain).and_then(|entry| {
153            if entry.is_expired() {
154                None
155            } else {
156                Some(entry.addresses.clone())
157            }
158        })
159    }
160
161    /// Queues domains for background prefetching.
162    pub fn enqueue_prefetch(&self, domains: Vec<String>) {
163        let mut queue = self.prefetch_queue.write();
164        for domain in domains {
165            if !self.cache.contains_key(&domain) {
166                queue.push(domain);
167            }
168        }
169    }
170
171    /// Dequeues domains for prefetching (called by the background task).
172    pub fn dequeue_prefetch(&self, batch_size: usize) -> Vec<String> {
173        let mut queue = self.prefetch_queue.write();
174        let drain_count = batch_size.min(queue.len());
175        queue.drain(..drain_count).collect()
176    }
177
178    /// Returns the number of entries in the cache.
179    pub fn len(&self) -> usize {
180        self.cache.len()
181    }
182
183    /// Returns `true` if the cache is empty.
184    pub fn is_empty(&self) -> bool {
185        self.cache.is_empty()
186    }
187
188    /// Returns the approximate memory usage in bytes.
189    pub fn memory_usage(&self) -> usize {
190        self.memory_usage.load(std::sync::atomic::Ordering::Relaxed)
191    }
192
193    /// Removes expired entries from the cache.
194    fn evict_expired(&self) {
195        let mut evicted = Vec::new();
196        for entry in self.cache.iter() {
197            if entry.value().is_expired() {
198                evicted.push(entry.key().clone());
199            }
200        }
201        for key in evicted {
202            if let Some(entry) = self.cache.remove(&key) {
203                let size = std::mem::size_of::<DnsEntry>()
204                    + key.len()
205                    + entry.1.addresses.len() * std::mem::size_of::<SocketAddr>();
206                self.memory_usage
207                    .fetch_sub(size, std::sync::atomic::Ordering::Relaxed);
208            }
209        }
210    }
211
212    /// Finds the oldest entry in the cache.
213    fn find_oldest_entry(&self) -> Option<String> {
214        self.cache
215            .iter()
216            .min_by_key(|e| e.value().resolved_at)
217            .map(|e| e.key().clone())
218    }
219}
220
221impl Default for DnsCache {
222    fn default() -> Self {
223        Self::with_defaults()
224    }
225}
226
227/// Background DNS prefetcher that resolves queued domains.
228pub struct DnsPrefetcher {
229    cache: Arc<DnsCache>,
230    /// Interval between prefetch batches.
231    prefetch_interval: Duration,
232    /// Number of domains to resolve per batch.
233    batch_size: usize,
234}
235
236impl DnsPrefetcher {
237    /// Creates a new prefetcher.
238    pub fn new(cache: Arc<DnsCache>, prefetch_interval: Duration, batch_size: usize) -> Self {
239        Self {
240            cache,
241            prefetch_interval,
242            batch_size,
243        }
244    }
245
246    /// Creates a prefetcher with sensible defaults.
247    pub fn with_defaults(cache: Arc<DnsCache>) -> Self {
248        Self::new(cache, Duration::from_millis(100), 10)
249    }
250
251    /// Spawns the background prefetch task.
252    ///
253    /// Returns a `JoinHandle` that can be used to abort the task.
254    pub fn spawn(self) -> tokio::task::JoinHandle<()> {
255        tokio::spawn(async move {
256            loop {
257                let domains = self.cache.dequeue_prefetch(self.batch_size);
258                if !domains.is_empty() {
259                    for domain in &domains {
260                        if let Err(e) = self.cache.resolve(domain).await {
261                            tracing::debug!(
262                                domain = domain,
263                                error = %e,
264                                "DNS prefetch failed"
265                            );
266                        }
267                    }
268                }
269                tokio::time::sleep(self.prefetch_interval).await;
270            }
271        })
272    }
273}
274
275/// Errors that can occur during DNS resolution.
276#[derive(Debug, thiserror::Error)]
277pub enum DnsError {
278    /// DNS resolution failed.
279    #[error("DNS resolution failed for {domain}: {source}")]
280    ResolutionFailed {
281        /// The domain that failed to resolve.
282        domain: String,
283        /// The underlying I/O error.
284        source: std::io::Error,
285    },
286    /// No DNS records found.
287    #[error("no DNS records found for {domain}")]
288    NoRecords {
289        /// The domain with no records.
290        domain: String,
291    },
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297
298    #[test]
299    fn test_dns_cache_insert_and_get() {
300        let cache = DnsCache::new(Duration::from_secs(60), 100);
301        let addrs = vec!["127.0.0.1:443".parse().unwrap()];
302        cache.insert("example.com", addrs.clone());
303
304        let cached = cache.get_cached("example.com");
305        assert_eq!(cached, Some(addrs));
306    }
307
308    #[test]
309    fn test_dns_cache_expiry() {
310        let cache = DnsCache::new(Duration::from_millis(1), 100);
311        let addrs = vec!["127.0.0.1:443".parse().unwrap()];
312        cache.insert("example.com", addrs);
313
314        // Wait for expiry
315        std::thread::sleep(Duration::from_millis(5));
316        assert!(cache.get_cached("example.com").is_none());
317    }
318
319    #[test]
320    fn test_dns_cache_capacity_eviction() {
321        let cache = DnsCache::new(Duration::from_secs(60), 2);
322
323        cache.insert("a.com", vec!["1.1.1.1:443".parse().unwrap()]);
324        cache.insert("b.com", vec!["2.2.2.2:443".parse().unwrap()]);
325        assert_eq!(cache.len(), 2);
326
327        // Adding a third should evict one
328        cache.insert("c.com", vec!["3.3.3.3:443".parse().unwrap()]);
329        assert_eq!(cache.len(), 2);
330    }
331
332    #[test]
333    fn test_prefetch_queue() {
334        let cache = DnsCache::new(Duration::from_secs(60), 100);
335        cache.enqueue_prefetch(vec!["a.com".to_string(), "b.com".to_string()]);
336
337        let batch = cache.dequeue_prefetch(10);
338        assert_eq!(batch.len(), 2);
339        assert!(cache.dequeue_prefetch(10).is_empty());
340    }
341
342    #[test]
343    fn test_memory_tracking() {
344        let cache = DnsCache::new(Duration::from_secs(60), 100);
345        let initial = cache.memory_usage();
346        cache.insert("example.com", vec!["1.1.1.1:443".parse().unwrap()]);
347        assert!(cache.memory_usage() > initial);
348    }
349
350    #[tokio::test]
351    async fn test_dns_cache_resolve() {
352        let cache = DnsCache::new(Duration::from_secs(30), 100);
353        // Resolve localhost — should work on most systems
354        let result = cache.resolve("localhost").await;
355        assert!(result.is_ok());
356        let addrs = result.unwrap();
357        assert!(!addrs.is_empty());
358    }
359}