diff --git a/rust/prop_grid_rs/src/fetcher.rs b/rust/prop_grid_rs/src/fetcher.rs index a99cdab8..20143f8a 100644 --- a/rust/prop_grid_rs/src/fetcher.rs +++ b/rust/prop_grid_rs/src/fetcher.rs @@ -294,11 +294,14 @@ impl IdxCache { } pub fn get(&self, url: &str) -> Option { - if let Ok(map) = self.inner.lock() { + if let Ok(mut map) = self.inner.lock() { if let Some(entry) = map.get(url) { if entry.inserted.elapsed() < IDX_TTL { return Some(entry.body.clone()); } + // Expired entry — evict so unique idx URLs don't + // accumulate across hourly HRRR cycles. + map.remove(url); } } if let Some(disk) = &self.disk { @@ -332,6 +335,24 @@ impl IdxCache { ); } } + + #[cfg(test)] + fn len(&self) -> usize { + self.inner.lock().map(|m| m.len()).unwrap_or(0) + } + + #[cfg(test)] + fn put_with_age(&self, url: String, body: String, age: Duration) { + if let Ok(mut map) = self.inner.lock() { + map.insert( + url, + IdxCacheEntry { + body, + inserted: Instant::now() - age, + }, + ); + } + } } // ── HTTP client ────────────────────────────────────────────────────── @@ -722,6 +743,21 @@ mod tests { assert!(cache.get("missing").is_none()); } + #[test] + fn idx_cache_removes_expired_entries_on_get() { + // Without eviction the HashMap grows forever as unique idx URLs + // accumulate each hour. Calling get() on an expired entry must + // remove it so the in-memory map bounds itself to the hot set. + let cache = IdxCache::new(); + cache.put_with_age("stale".into(), "old".into(), IDX_TTL + Duration::from_secs(1)); + cache.put("fresh".into(), "new".into()); + + assert_eq!(cache.len(), 2); + assert!(cache.get("stale").is_none()); + assert_eq!(cache.len(), 1, "expired entry should have been evicted"); + assert_eq!(cache.get("fresh").as_deref(), Some("new")); + } + #[test] fn disk_idx_cache_round_trips() { let tmp = tempfile::TempDir::new().unwrap();