From 043a7e1ba5da32c291709d785f86d1fa668e5994 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sun, 12 Apr 2026 19:23:28 +0300 Subject: [PATCH] feat: raise cache default to 100K entries, evict stalest instead of dropping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 10K cap was too conservative — the blocklist alone holds 400K domains. At ~100 bytes per wire entry, 100K entries is ~10MB. When the cache is full and evict_expired doesn't free enough slots, evict_stalest removes the entry with the least remaining TTL instead of silently discarding the new insert. --- src/cache.rs | 30 +++++++++++++++++++++++++++++- src/config.rs | 2 +- src/wire.rs | 17 ++++++++++++----- 3 files changed, 42 insertions(+), 7 deletions(-) diff --git a/src/cache.rs b/src/cache.rs index 82795bc..42cea5f 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -100,7 +100,7 @@ impl DnsCache { if self.entry_count >= self.max_entries { self.evict_expired(); if self.entry_count >= self.max_entries { - return; + self.evict_stalest(); } } @@ -260,6 +260,34 @@ impl DnsCache { }); self.entry_count -= count; } + + /// Evict the single entry closest to (or furthest past) expiry. + fn evict_stalest(&mut self) { + let mut worst: Option<(String, QueryType, Duration)> = None; + for (domain, type_map) in &self.entries { + for (qtype, entry) in type_map { + let age = entry.inserted_at.elapsed(); + let remaining = entry.ttl.saturating_sub(age); + match &worst { + None => worst = Some((domain.clone(), *qtype, remaining)), + Some((_, _, w)) if remaining < *w => { + worst = Some((domain.clone(), *qtype, remaining)); + } + _ => {} + } + } + } + if let Some((domain, qtype, _)) = worst { + if let Some(type_map) = self.entries.get_mut(&domain) { + if type_map.remove(&qtype).is_some() { + self.entry_count -= 1; + } + if type_map.is_empty() { + self.entries.remove(&domain); + } + } + } + } } pub struct CacheInfo { diff --git a/src/config.rs b/src/config.rs index 5f9db73..237f3bd 100644 --- a/src/config.rs +++ b/src/config.rs @@ -302,7 +302,7 @@ impl Default for CacheConfig { } fn default_max_entries() -> usize { - 10000 + 100_000 } fn default_min_ttl() -> u32 { 60 diff --git a/src/wire.rs b/src/wire.rs index 8d299ce..6e2c213 100644 --- a/src/wire.rs +++ b/src/wire.rs @@ -1350,18 +1350,25 @@ mod tests { } #[test] - fn cache_max_entries_cap() { + fn cache_max_entries_evicts_stalest() { let mut cache = DnsCache::new(2, 1, 3600); - for i in 0..3 { + // Insert with decreasing TTL so test0.com is stalest + for (i, ttl) in [(0, 60), (1, 3600)] { let domain = format!("test{}.com", i); let pkt = response( i as u16, &domain, - vec![a_record(&domain, &format!("1.2.3.{}", i), 3600)], + vec![a_record(&domain, &format!("1.2.3.{}", i), ttl)], ); cache.insert(&domain, QueryType::A, &pkt); } - // Should not exceed max (third insert is silently dropped or evicts) - assert!(cache.len() <= 2); + assert_eq!(cache.len(), 2); + + // Third insert should evict test0.com (lowest remaining TTL) + let pkt = response(2, "test2.com", vec![a_record("test2.com", "1.2.3.2", 3600)]); + cache.insert("test2.com", QueryType::A, &pkt); + assert_eq!(cache.len(), 2); + assert!(cache.lookup("test0.com", QueryType::A).is_none()); // evicted + assert!(cache.lookup("test2.com", QueryType::A).is_some()); // inserted } }