fix(prop-grid-rs): evict expired IdxCache entries on get

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.
This commit is contained in:
Graham McIntire 2026-04-22 08:57:35 -05:00
parent 641789449a
commit 3ec976ccc4
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59

View file

@ -294,11 +294,14 @@ impl IdxCache {
}
pub fn get(&self, url: &str) -> Option<String> {
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();