From 3ec976ccc47a74b38e849855a0c7db7409bd5b21 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Wed, 22 Apr 2026 08:57:35 -0500 Subject: [PATCH] fix(prop-grid-rs): evict expired IdxCache entries on get MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The in-memory idx cache kept entries around forever: get() returned None on expiry but left the HashMap entry in place. Each hourly HRRR cycle adds ~38 new idx URLs (19 forecast hours × surface + pressure), so the map grew ~4 MiB/day indefinitely on each Rust replica. Not the main cause of the OOM loop (glibc fragmentation was; jemalloc landed last commit) but worth sealing so a long- lived pod's idx cache can't drift toward the 3 GiB ceiling over weeks. Evicts on-touch via `get()` — simple, amortized, no background sweep thread needed. --- rust/prop_grid_rs/src/fetcher.rs | 38 +++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) 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();