use std::borrow::Borrow;
use std::hash::Hash;
use std::sync::Arc;
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>,
metrics: Option<Arc<ModuleCacheMetrics>>,
}
impl<K: Hash + Eq, V> ModuleCache<K, V> {
pub(crate) fn new(budget_bytes: usize) -> Self {
Self::with_label(budget_bytes, "module", None)
}
pub(crate) fn with_label(
budget_bytes: usize,
label: &'static str,
metrics: Option<Arc<ModuleCacheMetrics>>,
) -> Self {
let budget_bytes = budget_bytes.max(1);
let cache = Self {
inner: LruCache::unbounded(),
total_bytes: 0,
budget_bytes,
label,
evictions_in_window: 0,
window_started_at: None,
metrics,
};
cache.record_occupancy(0, 0, budget_bytes);
cache
}
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();
self.record_occupancy(self.inner.len(), self.total_bytes, self.budget_bytes);
}
fn record_occupancy(&self, entries: usize, total_bytes: usize, budget_bytes: usize) {
if let Some(metrics) = &self.metrics {
metrics.record_occupancy(self.label, entries, total_bytes, budget_bytes);
}
}
fn add_evictions(&self, count: u64) {
if let Some(metrics) = &self.metrics {
metrics.add_evictions(self.label, count);
}
}
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);
self.add_evictions(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);
self.record_occupancy(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
}
}
#[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 {
pub(crate) 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(crate) fn contract_cache_occupancy_pct(metrics: &ModuleCacheMetrics) -> Option<u64> {
let snapshot = metrics.snapshot();
occupancy_pct(
snapshot.contract_total_bytes,
snapshot.contract_budget_bytes,
)
}
fn occupancy_pct(total_bytes: u64, budget_bytes: u64) -> Option<u64> {
if budget_bytes == 0 {
return None;
}
Some(total_bytes.saturating_mul(100) / budget_bytes)
}
pub const MIN_DEFAULT_MODULE_CACHE_BUDGET_BYTES: usize = 64 * 1024 * 1024;
pub const MAX_DEFAULT_MODULE_CACHE_BUDGET_BYTES: usize = 1536 * 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);
budget_for_ram(total_ram)
}
fn budget_for_ram(total_ram: usize) -> usize {
(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 phys = read_proc_meminfo_total_bytes();
match (phys, read_cgroup_memory_limit_bytes()) {
(Some(p), Some(c)) => Some(p.min(c)),
(Some(p), None) => Some(p),
(None, Some(c)) => Some(c),
(None, None) => 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(target_os = "linux")]
fn read_proc_meminfo_total_bytes() -> Option<usize> {
let meminfo = std::fs::read_to_string("/proc/meminfo").ok()?;
parse_meminfo_total_bytes(&meminfo)
}
#[cfg(target_os = "linux")]
fn parse_meminfo_total_bytes(meminfo: &str) -> Option<usize> {
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(target_os = "linux")]
fn read_cgroup_memory_limit_bytes() -> Option<usize> {
let (v2_rel, v1_rel) = cgroup_relative_paths();
let v2 = min_cgroup_limit_over_ancestors("/sys/fs/cgroup", "memory.max", &v2_rel);
let v1 =
min_cgroup_limit_over_ancestors("/sys/fs/cgroup/memory", "memory.limit_in_bytes", &v1_rel);
match (v2, v1) {
(Some(a), Some(b)) => Some(a.min(b)),
(Some(a), None) => Some(a),
(None, Some(b)) => Some(b),
(None, None) => None,
}
}
#[cfg(target_os = "linux")]
fn min_cgroup_limit_over_ancestors(mount: &str, file: &str, rel: &str) -> Option<usize> {
let mut best: Option<usize> = None;
for path in cgroup_ancestor_limit_paths(mount, file, rel) {
if let Ok(s) = std::fs::read_to_string(&path) {
if let Some(v) = parse_cgroup_limit(&s) {
best = Some(best.map_or(v, |b: usize| b.min(v)));
}
}
}
best
}
#[cfg(target_os = "linux")]
fn cgroup_ancestor_limit_paths(mount: &str, file: &str, rel: &str) -> Vec<String> {
let mut paths = Vec::new();
let mut current = rel.trim_end_matches('/').to_string();
loop {
paths.push(format!("{mount}{current}/{file}"));
if current.is_empty() {
break; }
match current.rfind('/') {
Some(idx) => current.truncate(idx),
None => current.clear(),
}
}
paths
}
#[cfg(target_os = "linux")]
fn cgroup_relative_paths() -> (String, String) {
match std::fs::read_to_string("/proc/self/cgroup") {
Ok(s) => parse_proc_self_cgroup(&s),
Err(_) => ("/".to_string(), "/".to_string()),
}
}
#[cfg(target_os = "linux")]
fn parse_proc_self_cgroup(contents: &str) -> (String, String) {
let mut v2 = "/".to_string();
let mut v1 = "/".to_string();
for line in contents.lines() {
let mut parts = line.splitn(3, ':');
let (Some(_id), Some(controllers), Some(path)) = (parts.next(), parts.next(), parts.next())
else {
continue;
};
if controllers.is_empty() {
v2 = path.to_string();
} else if controllers.split(',').any(|c| c == "memory") {
v1 = path.to_string();
}
}
(v2, v1)
}
#[cfg(target_os = "linux")]
const CGROUP_UNLIMITED_THRESHOLD_BYTES: usize = 1 << 60;
#[cfg(target_os = "linux")]
fn parse_cgroup_limit(contents: &str) -> Option<usize> {
let trimmed = contents.trim();
if trimmed == "max" {
return None;
}
let bytes: usize = trimmed.parse().ok()?;
if bytes == 0 || bytes >= CGROUP_UNLIMITED_THRESHOLD_BYTES {
return None;
}
Some(bytes)
}
#[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_on_small_box() {
for total_ram_gib_eighths in 1..=96u64 {
let total_ram = (total_ram_gib_eighths as usize) * (128 * 1024 * 1024);
let contract = budget_for_ram(total_ram);
let delegate = contract / DELEGATE_MODULE_CACHE_BUDGET_DIVISOR;
let combined = contract + delegate;
assert!(
combined <= total_ram,
"combined default {combined} must never exceed total RAM \
({total_ram}) — a MIN raise above a small box's RAM would OOM it"
);
if total_ram
>= 4 * (MIN_DEFAULT_MODULE_CACHE_BUDGET_BYTES
+ MIN_DEFAULT_MODULE_CACHE_BUDGET_BYTES / DELEGATE_MODULE_CACHE_BUDGET_DIVISOR)
{
assert!(
combined <= total_ram / 4,
"combined default {combined} must stay within 1/4 of RAM \
({total_ram}); the divisor, not the MAX clamp, protects small boxes"
);
}
}
}
#[ignore = "superseded by #4481: MAX raised 384 MiB -> 1.5 GiB; see replacement tests"]
#[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 max_clamp_combined_ceiling_is_safe_at_binding_host() {
let contract = MAX_DEFAULT_MODULE_CACHE_BUDGET_BYTES;
let delegate = contract / DELEGATE_MODULE_CACHE_BUDGET_DIVISOR;
let combined = contract + delegate;
let binding_host_ram =
MAX_DEFAULT_MODULE_CACHE_BUDGET_BYTES * DEFAULT_MODULE_CACHE_RAM_DIVISOR;
assert!(
combined <= binding_host_ram / 4,
"combined default at the MAX clamp ({combined}) must stay within 1/4 \
of the smallest host where MAX binds ({binding_host_ram}); a MAX \
raise that breaks this would OOM a just-large-enough box"
);
}
#[test]
fn large_gateway_default_exceeds_old_clamp() {
const OLD_MAX: usize = 384 * 1024 * 1024;
const MEASURED_MODULE_SIZE: usize = 1536 * 1024;
let nova_ram = 125 * 1024 * 1024 * 1024usize;
let budget = budget_for_ram(nova_ram);
assert_eq!(
budget, MAX_DEFAULT_MODULE_CACHE_BUDGET_BYTES,
"a >12 GiB host lands on the MAX clamp"
);
assert!(
budget > OLD_MAX,
"the new default ceiling {budget} must exceed the pre-#4441 384 MiB \
clamp that caused gateway module-cache thrash"
);
let modules_held = budget / MEASURED_MODULE_SIZE;
assert!(
modules_held >= 900,
"the default ceiling must hold a realistic gateway working set \
(~1000 modules at ~1.5 MiB each); holds {modules_held}"
);
}
#[cfg(target_os = "linux")]
#[test]
fn parse_cgroup_limit_distinguishes_real_limits_from_sentinels() {
assert_eq!(
parse_cgroup_limit("2147483648\n"),
Some(2 * 1024 * 1024 * 1024)
);
assert_eq!(parse_cgroup_limit(" 536870912 "), Some(512 * 1024 * 1024));
assert_eq!(parse_cgroup_limit("max\n"), None);
assert_eq!(parse_cgroup_limit("9223372036854771712"), None);
assert_eq!(parse_cgroup_limit(&format!("{}", 1usize << 61)), None);
assert_eq!(parse_cgroup_limit("0"), None);
assert_eq!(parse_cgroup_limit(""), None);
assert_eq!(parse_cgroup_limit("not-a-number"), None);
}
#[cfg(target_os = "linux")]
#[test]
fn parse_meminfo_total_reads_kib_as_bytes() {
let sample = "MemFree: 100 kB\nMemTotal: 16331752 kB\nBuffers: 1 kB\n";
assert_eq!(parse_meminfo_total_bytes(sample), Some(16331752 * 1024));
assert_eq!(parse_meminfo_total_bytes("SwapTotal: 0 kB\n"), None);
}
#[cfg(target_os = "linux")]
#[test]
fn parse_proc_self_cgroup_resolves_own_path() {
let v2_only = "0::/system.slice/freenet-gateway.service\n";
assert_eq!(
parse_proc_self_cgroup(v2_only),
(
"/system.slice/freenet-gateway.service".to_string(),
"/".to_string()
)
);
let hybrid = "12:cpu,memory:/docker/abc123\n0::/docker/abc123\n";
assert_eq!(
parse_proc_self_cgroup(hybrid),
("/docker/abc123".to_string(), "/docker/abc123".to_string())
);
let v1 = "8:memory:/kubepods/pod1\n9:cpuset:/elsewhere\n";
assert_eq!(
parse_proc_self_cgroup(v1),
("/".to_string(), "/kubepods/pod1".to_string())
);
assert_eq!(
parse_proc_self_cgroup("0::/\n"),
("/".to_string(), "/".to_string())
);
assert_eq!(
parse_proc_self_cgroup(""),
("/".to_string(), "/".to_string())
);
let weird = "0::/odd:name/x\n";
assert_eq!(
parse_proc_self_cgroup(weird),
("/odd:name/x".to_string(), "/".to_string())
);
}
#[cfg(target_os = "linux")]
#[test]
fn cgroup_ancestor_paths_walk_leaf_to_root() {
assert_eq!(
cgroup_ancestor_limit_paths("/sys/fs/cgroup", "memory.max", "/a/b/c"),
vec![
"/sys/fs/cgroup/a/b/c/memory.max".to_string(),
"/sys/fs/cgroup/a/b/memory.max".to_string(),
"/sys/fs/cgroup/a/memory.max".to_string(),
"/sys/fs/cgroup/memory.max".to_string(),
]
);
assert_eq!(
cgroup_ancestor_limit_paths("/sys/fs/cgroup", "memory.max", "/"),
vec!["/sys/fs/cgroup/memory.max".to_string()]
);
assert_eq!(
cgroup_ancestor_limit_paths(
"/sys/fs/cgroup/memory",
"memory.limit_in_bytes",
"/system.slice"
),
vec![
"/sys/fs/cgroup/memory/system.slice/memory.limit_in_bytes".to_string(),
"/sys/fs/cgroup/memory/memory.limit_in_bytes".to_string(),
]
);
}
#[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_publishes_to_injected_metrics() {
let metrics = Arc::new(ModuleCacheMetrics::new());
let mut cache: ModuleCache<u64, ()> =
ModuleCache::with_label(10, "contract", Some(metrics.clone()));
assert_eq!(
metrics.snapshot().contract_evictions_total,
0,
"a fresh injected sink starts at zero — no global cross-talk"
);
assert_eq!(
metrics.snapshot().contract_budget_bytes,
10,
"construction publishes the configured budget"
);
cache.insert(0, (), 8);
cache.insert(1, (), 8); assert_eq!(cache.len(), 1, "the test setup must actually evict");
let s = metrics.snapshot();
assert_eq!(
s.contract_evictions_total, 1,
"exactly one eviction recorded in this isolated sink"
);
assert_eq!(s.contract_entries, 1, "occupancy is last-write-wins");
assert_eq!(
s.delegate_evictions_total, 0,
"the delegate gauges must not bleed from the contract cache"
);
}
#[test]
fn cache_without_metrics_sink_is_a_no_op() {
let mut cache: ModuleCache<u64, ()> = ModuleCache::with_label(10, "contract", None);
cache.insert(0, (), 8);
cache.insert(1, (), 8);
assert_eq!(cache.len(), 1, "eviction still happens without a sink");
}
#[test]
fn occupancy_pct_is_none_when_budget_uninitialized() {
assert_eq!(occupancy_pct(0, 0), None);
assert_eq!(occupancy_pct(1_000, 0), None);
}
#[test]
fn occupancy_pct_computes_percentage_of_budget() {
assert_eq!(occupancy_pct(0, 1_000), Some(0));
assert_eq!(occupancy_pct(500, 1_000), Some(50));
assert_eq!(occupancy_pct(900, 1_000), Some(90));
assert_eq!(occupancy_pct(1_000, 1_000), Some(100));
assert_eq!(occupancy_pct(1_500, 1_000), Some(150));
}
#[test]
fn occupancy_pct_handles_gib_scale_without_overflow() {
let budget = 1_536u64 * 1024 * 1024;
assert_eq!(occupancy_pct(budget, budget), Some(100));
assert_eq!(occupancy_pct(900_000_000, 1_000_000_000), Some(90));
}
#[test]
fn occupancy_pct_truncates_below_the_decision_boundary() {
assert_eq!(occupancy_pct(899, 1_000), Some(89));
assert_eq!(occupancy_pct(900, 1_000), Some(90));
assert_eq!(occupancy_pct(901, 1_000), Some(90));
}
}