use std::borrow::Borrow;
use std::hash::Hash;
use std::sync::LazyLock;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant};
use lru::LruCache;
const EVICTION_WARN_INTERVAL: Duration = Duration::from_secs(300);
const EVICTION_WARN_THRESHOLD: u64 = 16;
pub(crate) struct ModuleCache<K: Hash + Eq, V> {
inner: LruCache<K, (V, usize)>,
total_bytes: usize,
budget_bytes: usize,
label: &'static str,
evictions_in_window: u64,
window_started_at: Option<Instant>,
}
impl<K: Hash + Eq, V> ModuleCache<K, V> {
pub(crate) fn new(budget_bytes: usize) -> Self {
Self::with_label(budget_bytes, "module")
}
pub(crate) fn with_label(budget_bytes: usize, label: &'static str) -> Self {
let budget_bytes = budget_bytes.max(1);
MODULE_CACHE_METRICS.record_occupancy(label, 0, 0, budget_bytes);
Self {
inner: LruCache::unbounded(),
total_bytes: 0,
budget_bytes,
label,
evictions_in_window: 0,
window_started_at: None,
}
}
pub(crate) fn get<Q>(&mut self, key: &Q) -> Option<&V>
where
K: Borrow<Q>,
Q: Hash + Eq + ?Sized,
{
self.inner.get(key).map(|(v, _)| v)
}
pub(crate) fn insert(&mut self, key: K, value: V, size_bytes: usize) {
if let Some((_, old_size)) = self.inner.put(key, (value, size_bytes)) {
self.total_bytes = self.total_bytes.saturating_sub(old_size);
}
self.total_bytes = self.total_bytes.saturating_add(size_bytes);
self.evict_to_budget();
MODULE_CACHE_METRICS.record_occupancy(
self.label,
self.inner.len(),
self.total_bytes,
self.budget_bytes,
);
}
fn evict_to_budget(&mut self) {
let mut evicted_this_insert: u64 = 0;
while self.total_bytes > self.budget_bytes && self.inner.len() > 1 {
if let Some((_, (_, evicted_size))) = self.inner.pop_lru() {
self.total_bytes = self.total_bytes.saturating_sub(evicted_size);
evicted_this_insert += 1;
} else {
break;
}
}
if evicted_this_insert > 0 {
self.note_evictions(evicted_this_insert);
MODULE_CACHE_METRICS.add_evictions(self.label, evicted_this_insert);
}
}
fn note_evictions(&mut self, count: u64) {
let now = Instant::now();
let window_start = *self.window_started_at.get_or_insert(now);
self.evictions_in_window = self.evictions_in_window.saturating_add(count);
if now.duration_since(window_start) >= EVICTION_WARN_INTERVAL {
if self.evictions_in_window >= EVICTION_WARN_THRESHOLD {
tracing::warn!(
cache = self.label,
evictions = self.evictions_in_window,
window_secs = EVICTION_WARN_INTERVAL.as_secs(),
budget_bytes = self.budget_bytes,
total_bytes = self.total_bytes,
entries = self.inner.len(),
"compiled-WASM {} cache is persistently evicting at its byte budget — \
the hosted working set exceeds the cache, so modules are being \
recompiled on access. Raise FREENET_MODULE_CACHE_BUDGET_BYTES / \
module-cache-budget-bytes if this node has spare RAM, or accept the \
recompile cost if it is memory-bound.",
self.label,
);
}
self.evictions_in_window = 0;
self.window_started_at = Some(now);
}
}
pub(crate) fn remove<Q>(&mut self, key: &Q) -> Option<V>
where
K: Borrow<Q>,
Q: Hash + Eq + ?Sized,
{
let (value, size) = self.inner.pop(key)?;
self.total_bytes = self.total_bytes.saturating_sub(size);
MODULE_CACHE_METRICS.record_occupancy(
self.label,
self.inner.len(),
self.total_bytes,
self.budget_bytes,
);
Some(value)
}
#[cfg(test)]
pub(crate) fn total_bytes(&self) -> usize {
self.total_bytes
}
#[cfg(test)]
pub(crate) fn len(&self) -> usize {
self.inner.len()
}
#[cfg(test)]
pub(crate) fn budget_bytes(&self) -> usize {
self.budget_bytes
}
}
pub(crate) static MODULE_CACHE_METRICS: LazyLock<ModuleCacheMetrics> =
LazyLock::new(ModuleCacheMetrics::new);
#[derive(Default)]
struct CacheGauges {
entries: AtomicU64,
total_bytes: AtomicU64,
budget_bytes: AtomicU64,
evictions_total: AtomicU64,
}
pub(crate) struct ModuleCacheMetrics {
contract: CacheGauges,
delegate: CacheGauges,
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct ModuleCacheMetricsSnapshot {
pub contract_entries: u64,
pub contract_total_bytes: u64,
pub contract_budget_bytes: u64,
pub contract_evictions_total: u64,
pub delegate_entries: u64,
pub delegate_total_bytes: u64,
pub delegate_budget_bytes: u64,
pub delegate_evictions_total: u64,
}
impl ModuleCacheMetrics {
fn new() -> Self {
Self {
contract: CacheGauges::default(),
delegate: CacheGauges::default(),
}
}
fn gauges_for(&self, label: &str) -> Option<&CacheGauges> {
match label {
"contract" => Some(&self.contract),
"delegate" => Some(&self.delegate),
_ => None,
}
}
fn record_occupancy(
&self,
label: &str,
entries: usize,
total_bytes: usize,
budget_bytes: usize,
) {
if let Some(g) = self.gauges_for(label) {
g.entries.store(entries as u64, Ordering::Relaxed);
g.total_bytes.store(total_bytes as u64, Ordering::Relaxed);
g.budget_bytes.store(budget_bytes as u64, Ordering::Relaxed);
}
}
fn add_evictions(&self, label: &str, count: u64) {
if let Some(g) = self.gauges_for(label) {
g.evictions_total.fetch_add(count, Ordering::Relaxed);
}
}
pub(crate) fn snapshot(&self) -> ModuleCacheMetricsSnapshot {
let load = |g: &CacheGauges| {
(
g.entries.load(Ordering::Relaxed),
g.total_bytes.load(Ordering::Relaxed),
g.budget_bytes.load(Ordering::Relaxed),
g.evictions_total.load(Ordering::Relaxed),
)
};
let (ce, ctb, cbb, cev) = load(&self.contract);
let (de, dtb, dbb, dev) = load(&self.delegate);
ModuleCacheMetricsSnapshot {
contract_entries: ce,
contract_total_bytes: ctb,
contract_budget_bytes: cbb,
contract_evictions_total: cev,
delegate_entries: de,
delegate_total_bytes: dtb,
delegate_budget_bytes: dbb,
delegate_evictions_total: dev,
}
}
}
pub const MIN_DEFAULT_MODULE_CACHE_BUDGET_BYTES: usize = 64 * 1024 * 1024;
pub const MAX_DEFAULT_MODULE_CACHE_BUDGET_BYTES: usize = 384 * 1024 * 1024;
const DEFAULT_MODULE_CACHE_RAM_DIVISOR: usize = 8;
pub const DELEGATE_MODULE_CACHE_BUDGET_DIVISOR: usize = 4;
const FALLBACK_TOTAL_RAM_BYTES: usize = 1024 * 1024 * 1024;
pub fn default_module_cache_budget_bytes() -> usize {
let total_ram = read_total_ram_bytes().unwrap_or(FALLBACK_TOTAL_RAM_BYTES);
(total_ram / DEFAULT_MODULE_CACHE_RAM_DIVISOR).clamp(
MIN_DEFAULT_MODULE_CACHE_BUDGET_BYTES,
MAX_DEFAULT_MODULE_CACHE_BUDGET_BYTES,
)
}
fn read_total_ram_bytes() -> Option<usize> {
#[cfg(target_os = "linux")]
{
let meminfo = std::fs::read_to_string("/proc/meminfo").ok()?;
for line in meminfo.lines() {
if let Some(rest) = line.strip_prefix("MemTotal:") {
let kib: usize = rest.split_whitespace().next()?.parse().ok()?;
return kib.checked_mul(1024);
}
}
None
}
#[cfg(all(unix, not(target_os = "linux")))]
{
let pages = unsafe { libc::sysconf(libc::_SC_PHYS_PAGES) };
let page_size = unsafe { libc::sysconf(libc::_SC_PAGESIZE) };
if pages > 0 && page_size > 0 {
(pages as usize).checked_mul(page_size as usize)
} else {
None
}
}
#[cfg(not(unix))]
{
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn many_small_entries_fit_when_total_within_budget() {
let mut cache: ModuleCache<u64, ()> = ModuleCache::new(5_000_000);
for k in 0..5000u64 {
cache.insert(k, (), 1000);
}
assert_eq!(
cache.len(),
5000,
"5000 small entries (far above the old 1024 count cap) should all fit"
);
assert_eq!(cache.total_bytes(), 5_000_000);
assert!(cache.total_bytes() <= cache.budget_bytes());
for k in 0..5000u64 {
assert!(cache.get(&k).is_some(), "key {k} should still be cached");
}
}
#[test]
fn large_entries_evicted_below_old_count_cap() {
let budget = 10 * 1024 * 1024;
let entry = 4 * 1024 * 1024;
let mut cache: ModuleCache<u64, ()> = ModuleCache::new(budget);
cache.insert(0, (), entry);
cache.insert(1, (), entry);
assert_eq!(cache.len(), 2);
assert_eq!(cache.total_bytes(), 2 * entry);
cache.insert(2, (), entry);
assert!(
cache.total_bytes() <= budget,
"total_bytes {} must stay within budget {}",
cache.total_bytes(),
budget
);
assert_eq!(cache.len(), 2, "evicted below the old 1024 count cap");
assert!(cache.get(&0).is_none(), "LRU entry 0 should be evicted");
assert!(cache.get(&1).is_some(), "entry 1 should remain");
assert!(cache.get(&2).is_some(), "entry 2 should remain");
}
#[test]
fn get_refreshes_lru_recency() {
let budget = 10 * 1024 * 1024;
let entry = 4 * 1024 * 1024;
let mut cache: ModuleCache<u64, ()> = ModuleCache::new(budget);
cache.insert(0, (), entry);
cache.insert(1, (), entry);
assert!(cache.get(&0).is_some());
cache.insert(2, (), entry);
assert!(
cache.get(&1).is_none(),
"untouched entry 1 should be evicted"
);
assert!(
cache.get(&0).is_some(),
"recently-touched entry 0 should remain"
);
assert!(cache.get(&2).is_some());
}
#[test]
fn replacing_key_updates_total_bytes() {
let mut cache: ModuleCache<u64, ()> = ModuleCache::new(1_000_000);
cache.insert(0, (), 100);
assert_eq!(cache.total_bytes(), 100);
cache.insert(0, (), 250);
assert_eq!(cache.len(), 1, "replacement, not a second entry");
assert_eq!(
cache.total_bytes(),
250,
"old size removed, new size added — no double count"
);
}
#[test]
fn oversized_single_value_is_retained() {
let mut cache: ModuleCache<u64, ()> = ModuleCache::new(1000);
cache.insert(0, (), 5000);
assert_eq!(cache.len(), 1, "oversized value still stored");
assert_eq!(cache.total_bytes(), 5000);
assert!(cache.get(&0).is_some());
cache.insert(1, (), 200);
assert_eq!(cache.len(), 1);
assert!(cache.get(&0).is_none(), "oversized LRU evicted");
assert!(cache.get(&1).is_some());
assert_eq!(cache.total_bytes(), 200);
}
#[test]
fn remove_decrements_total_bytes() {
let mut cache: ModuleCache<u64, &'static str> = ModuleCache::new(1_000_000);
cache.insert(0, "a", 100);
cache.insert(1, "b", 250);
assert_eq!(cache.total_bytes(), 350);
assert_eq!(cache.len(), 2);
assert_eq!(cache.remove(&0), Some("a"));
assert_eq!(
cache.total_bytes(),
250,
"removed entry's 100 bytes subtracted"
);
assert_eq!(cache.len(), 1);
assert_eq!(cache.remove(&42), None);
assert_eq!(cache.total_bytes(), 250);
assert_eq!(cache.len(), 1);
assert_eq!(cache.remove(&1), Some("b"));
assert_eq!(cache.total_bytes(), 0);
assert_eq!(cache.len(), 0);
}
#[test]
fn default_budget_is_within_clamp() {
let b = default_module_cache_budget_bytes();
assert!(
(MIN_DEFAULT_MODULE_CACHE_BUDGET_BYTES..=MAX_DEFAULT_MODULE_CACHE_BUDGET_BYTES)
.contains(&b),
"default budget {b} must be within [{MIN_DEFAULT_MODULE_CACHE_BUDGET_BYTES}, \
{MAX_DEFAULT_MODULE_CACHE_BUDGET_BYTES}]"
);
}
#[test]
fn combined_default_ceiling_is_safe() {
let contract = MAX_DEFAULT_MODULE_CACHE_BUDGET_BYTES;
let delegate = contract / DELEGATE_MODULE_CACHE_BUDGET_DIVISOR;
let combined = contract + delegate;
assert!(
combined < 512 * 1024 * 1024,
"combined default ceiling {combined} must stay under 512 MiB so the \
#4441 OOM fix doesn't itself OOM a small box (was 768 MiB pre-fix)"
);
}
#[test]
fn zero_budget_keeps_one_entry() {
let mut cache: ModuleCache<u64, ()> = ModuleCache::new(0);
assert_eq!(cache.budget_bytes(), 1, "budget clamped up to 1");
cache.insert(0, (), 100);
cache.insert(1, (), 100);
assert_eq!(cache.len(), 1, "at most one entry survives a zero budget");
assert!(cache.get(&1).is_some(), "most-recent entry kept");
assert!(cache.get(&0).is_none());
}
#[test]
fn module_cache_metrics_routing_and_isolation() {
let m = ModuleCacheMetrics::new();
m.record_occupancy("contract", 3, 300, 1000);
m.add_evictions("contract", 2);
let s = m.snapshot();
assert_eq!(s.contract_entries, 3);
assert_eq!(s.contract_total_bytes, 300);
assert_eq!(s.contract_budget_bytes, 1000);
assert_eq!(s.contract_evictions_total, 2);
assert_eq!(s.delegate_entries, 0);
assert_eq!(s.delegate_evictions_total, 0);
m.record_occupancy("delegate", 1, 50, 250);
m.add_evictions("delegate", 5);
m.add_evictions("contract", 4);
m.record_occupancy("contract", 1, 100, 1000);
let s = m.snapshot();
assert_eq!(s.contract_evictions_total, 6, "evictions accumulate");
assert_eq!(s.contract_entries, 1, "occupancy is last-write-wins");
assert_eq!(s.delegate_entries, 1);
assert_eq!(s.delegate_evictions_total, 5);
m.record_occupancy("module", 999, 999, 999);
m.add_evictions("module", 999);
let s = m.snapshot();
assert_eq!(s.contract_entries, 1, "unknown label is a no-op");
assert_eq!(s.delegate_entries, 1, "unknown label is a no-op");
}
#[test]
fn evicting_contract_cache_increments_global_eviction_counter() {
let before = MODULE_CACHE_METRICS.snapshot().contract_evictions_total;
let mut cache: ModuleCache<u64, ()> = ModuleCache::with_label(10, "contract");
cache.insert(0, (), 8);
cache.insert(1, (), 8); assert_eq!(cache.len(), 1, "the test setup must actually evict");
let after = MODULE_CACHE_METRICS.snapshot().contract_evictions_total;
assert!(
after > before,
"eviction on a contract-labeled cache must bump the global counter \
(before={before}, after={after})"
);
}
}