use freenet_stdlib::prelude::ContractKey;
use std::collections::HashMap;
use std::time::Duration;
use tokio::time::Instant;
use super::demand::NEUTRAL_DEMAND;
use crate::util::time_source::TimeSource;
use crate::wasm_runtime::read_total_ram_bytes;
pub const MIN_DEFAULT_HOSTING_BUDGET_BYTES: u64 = 128 * 1024 * 1024;
pub const MAX_DEFAULT_HOSTING_BUDGET_BYTES: u64 = 1024 * 1024 * 1024;
pub const LEGACY_FLAT_HOSTING_BUDGET_BYTES: u64 = 1024 * 1024 * 1024;
const DEFAULT_HOSTING_BUDGET_RAM_DIVISOR: u64 = 8;
const FALLBACK_TOTAL_RAM_BYTES: u64 = 1024 * 1024 * 1024;
pub fn default_hosting_budget_bytes() -> u64 {
let total_ram = read_total_ram_bytes()
.map(|v| v as u64)
.unwrap_or(FALLBACK_TOTAL_RAM_BYTES);
budget_for_ram(total_ram)
}
fn budget_for_ram(total_ram: u64) -> u64 {
(total_ram / DEFAULT_HOSTING_BUDGET_RAM_DIVISOR).clamp(
MIN_DEFAULT_HOSTING_BUDGET_BYTES,
MAX_DEFAULT_HOSTING_BUDGET_BYTES,
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct HostingCacheStats {
pub budget_bytes: u64,
pub current_bytes: u64,
pub contract_count: u64,
pub budget_evictions_total: u64,
pub evictions_of_recently_read_total: u64,
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct HostingContractScore {
pub key: ContractKey,
pub keep_score: f64,
pub predicted_demand: f64,
pub size_bytes: u64,
pub read_count: u32,
pub last_access_seq: u64,
pub past_min_ttl: bool,
}
pub const TTL_RENEWAL_MULTIPLIER: u32 = 4;
pub const DEFAULT_MIN_TTL: Duration = Duration::from_secs(
super::SUBSCRIPTION_RENEWAL_INTERVAL.as_secs() * TTL_RENEWAL_MULTIPLIER as u64,
);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AccessType {
Get,
Put,
#[cfg_attr(not(test), allow(dead_code))]
Subscribe,
}
#[derive(Debug)]
pub struct RecordAccessResult {
pub is_new: bool,
pub evicted: Vec<(ContractKey, u64)>,
pub observed_read_rate: Option<f64>,
}
#[derive(Debug, Clone)]
pub struct HostedContract {
pub size_bytes: u64,
pub last_accessed: Instant,
pub last_access_seq: u64,
pub keep_score: f64,
pub predicted_demand: f64,
pub read_count: u32,
pub inserted_at: Instant,
pub access_type: AccessType,
pub local_client_access: bool,
pub local_client_last_access: Option<Instant>,
pub write_generation: u64,
pub abandoned_at: Option<Instant>,
}
pub struct HostingCache<T: TimeSource> {
budget_bytes: u64,
current_bytes: u64,
min_ttl: Duration,
contracts: HashMap<ContractKey, HostedContract>,
access_seq: u64,
eviction_floor: f64,
budget_evictions_total: u64,
evictions_of_recently_read_total: u64,
time_source: T,
}
impl<T: TimeSource> HostingCache<T> {
pub fn new(budget_bytes: u64, min_ttl: Duration, time_source: T) -> Self {
Self {
budget_bytes,
current_bytes: 0,
min_ttl,
contracts: HashMap::new(),
access_seq: 0,
eviction_floor: 0.0,
budget_evictions_total: 0,
evictions_of_recently_read_total: 0,
time_source,
}
}
fn next_seq(&mut self) -> u64 {
self.access_seq = self.access_seq.saturating_add(1);
self.access_seq
}
fn evict_over_budget<F>(&mut self, should_retain: &F) -> Vec<(ContractKey, u64)>
where
F: Fn(&ContractKey) -> bool,
{
if self.current_bytes <= self.budget_bytes {
return Vec::new();
}
let now = self.time_source.now();
let mut candidates: Vec<(ContractKey, f64, u64)> = self
.contracts
.iter()
.filter(|(key, entry)| {
let age = now.saturating_duration_since(entry.last_accessed);
age >= self.min_ttl && !should_retain(key)
})
.map(|(key, entry)| (*key, entry.keep_score, entry.last_access_seq))
.collect();
candidates.sort_by(|a, b| {
a.1.total_cmp(&b.1)
.then_with(|| a.2.cmp(&b.2))
.then_with(|| a.0.as_bytes().cmp(b.0.as_bytes()))
});
let mut evicted = Vec::new();
for (key, keep_score, _) in candidates {
if self.current_bytes <= self.budget_bytes {
break; }
if let Some(entry) = self.contracts.remove(&key) {
self.current_bytes = self.current_bytes.saturating_sub(entry.size_bytes);
self.budget_evictions_total = self.budget_evictions_total.saturating_add(1);
if entry.read_count >= 2 {
self.evictions_of_recently_read_total =
self.evictions_of_recently_read_total.saturating_add(1);
}
if keep_score > self.eviction_floor {
self.eviction_floor = keep_score;
}
evicted.push((key, entry.write_generation));
}
}
evicted
}
#[cfg_attr(not(test), allow(dead_code))]
pub fn record_access<F>(
&mut self,
key: ContractKey,
size_bytes: u64,
access_type: AccessType,
write_generation: u64,
should_retain: F,
) -> RecordAccessResult
where
F: Fn(&ContractKey) -> bool,
{
self.record_access_with_demand(
key,
size_bytes,
access_type,
write_generation,
NEUTRAL_DEMAND,
should_retain,
)
}
pub fn record_access_with_demand<F>(
&mut self,
key: ContractKey,
size_bytes: u64,
access_type: AccessType,
write_generation: u64,
predicted_demand: f64,
should_retain: F,
) -> RecordAccessResult
where
F: Fn(&ContractKey) -> bool,
{
let now = self.time_source.now();
let seq = self.next_seq();
let is_read = matches!(access_type, AccessType::Get | AccessType::Subscribe);
if let Some(existing) = self.contracts.get_mut(&key) {
if existing.size_bytes != size_bytes {
self.current_bytes = self
.current_bytes
.saturating_add(size_bytes)
.saturating_sub(existing.size_bytes);
existing.size_bytes = size_bytes;
}
existing.last_accessed = now;
existing.last_access_seq = seq;
existing.access_type = access_type;
existing.write_generation = write_generation;
existing.predicted_demand = predicted_demand;
let observed_read_rate = if is_read {
existing.read_count = existing.read_count.saturating_add(1);
existing.abandoned_at = None;
existing.keep_score = self.eviction_floor + predicted_demand;
Self::rate_sample(existing.read_count, now, existing.inserted_at)
} else {
None
};
RecordAccessResult {
is_new: false,
evicted: Vec::new(),
observed_read_rate,
}
} else {
let contract = HostedContract {
size_bytes,
last_accessed: now,
last_access_seq: seq,
keep_score: self.eviction_floor + predicted_demand,
predicted_demand,
read_count: if is_read { 1 } else { 0 },
inserted_at: now,
access_type,
local_client_access: false,
local_client_last_access: None,
write_generation,
abandoned_at: None,
};
self.contracts.insert(key, contract);
self.current_bytes = self.current_bytes.saturating_add(size_bytes);
let evicted = self.evict_over_budget(&should_retain);
RecordAccessResult {
is_new: true,
evicted,
observed_read_rate: None,
}
}
}
fn rate_sample(read_count: u32, now: Instant, inserted_at: Instant) -> Option<f64> {
let residency = now.saturating_duration_since(inserted_at);
if residency.is_zero() {
return None;
}
let residency_secs = residency.as_secs_f64().max(1.0);
Some(read_count as f64 / residency_secs)
}
pub fn mark_local_client_access(&mut self, key: &ContractKey) {
if let Some(existing) = self.contracts.get_mut(key) {
existing.local_client_access = true;
existing.local_client_last_access = Some(self.time_source.now());
}
}
pub fn has_local_client_access(&self, key: &ContractKey) -> bool {
self.contracts
.get(key)
.map(|c| c.local_client_access)
.unwrap_or(false)
}
pub fn has_recent_local_client_access(
&self,
key: &ContractKey,
max_age: std::time::Duration,
) -> bool {
let now = self.time_source.now();
self.contracts
.get(key)
.and_then(|c| c.local_client_last_access)
.map(|t| now.saturating_duration_since(t) < max_age)
.unwrap_or(false)
}
pub fn touch_with_demand(&mut self, key: &ContractKey, predicted_demand: f64) -> Option<f64> {
let floor = self.eviction_floor;
let seq = self.next_seq();
let now = self.time_source.now();
if let Some(existing) = self.contracts.get_mut(key) {
existing.last_accessed = now;
existing.last_access_seq = seq;
existing.read_count = existing.read_count.saturating_add(1);
existing.abandoned_at = None;
existing.predicted_demand = predicted_demand;
existing.keep_score = floor + predicted_demand;
Self::rate_sample(existing.read_count, now, existing.inserted_at)
} else {
None
}
}
#[cfg_attr(not(test), allow(dead_code))]
pub fn touch(&mut self, key: &ContractKey) -> Option<f64> {
let stored = self.contracts.get(key)?.predicted_demand;
self.touch_with_demand(key, stored)
}
pub fn record_abandonment(&mut self, key: &ContractKey) {
let floor = self.eviction_floor;
if let Some(existing) = self.contracts.get_mut(key) {
if existing.abandoned_at.is_none() {
existing.abandoned_at = Some(self.time_source.now());
existing.keep_score = floor;
}
}
}
pub fn refresh_entry_generation(&mut self, key: &ContractKey, new_gen: u64) {
if let Some(existing) = self.contracts.get_mut(key) {
existing.write_generation = new_gen;
}
}
pub fn contains(&self, key: &ContractKey) -> bool {
self.contracts.contains_key(key)
}
#[allow(dead_code)] pub fn get(&self, key: &ContractKey) -> Option<&HostedContract> {
self.contracts.get(key)
}
pub fn len(&self) -> usize {
self.contracts.len()
}
#[allow(dead_code)] pub fn is_empty(&self) -> bool {
self.contracts.is_empty()
}
#[allow(dead_code)] pub fn current_bytes(&self) -> u64 {
self.current_bytes
}
#[allow(dead_code)] pub fn budget_bytes(&self) -> u64 {
self.budget_bytes
}
pub fn stats(&self) -> HostingCacheStats {
HostingCacheStats {
budget_bytes: self.budget_bytes,
current_bytes: self.current_bytes,
contract_count: self.contracts.len() as u64,
budget_evictions_total: self.budget_evictions_total,
evictions_of_recently_read_total: self.evictions_of_recently_read_total,
}
}
pub(crate) fn eviction_ordered_scores(&self) -> Vec<HostingContractScore> {
let now = self.time_source.now();
let mut rows: Vec<HostingContractScore> = self
.contracts
.iter()
.map(|(k, v)| HostingContractScore {
key: *k,
keep_score: v.keep_score,
predicted_demand: v.predicted_demand,
size_bytes: v.size_bytes,
read_count: v.read_count,
last_access_seq: v.last_access_seq,
past_min_ttl: now.saturating_duration_since(v.last_accessed) >= self.min_ttl,
})
.collect();
rows.sort_by(|a, b| {
a.keep_score
.total_cmp(&b.keep_score)
.then_with(|| a.last_access_seq.cmp(&b.last_access_seq))
.then_with(|| a.key.as_bytes().cmp(b.key.as_bytes()))
});
rows
}
#[cfg(test)]
pub fn keys_eviction_order(&self) -> Vec<ContractKey> {
let mut entries: Vec<_> = self
.contracts
.iter()
.map(|(k, v)| (*k, v.keep_score, v.last_access_seq))
.collect();
entries.sort_by(|a, b| {
a.1.total_cmp(&b.1)
.then_with(|| a.2.cmp(&b.2))
.then_with(|| a.0.as_bytes().cmp(b.0.as_bytes()))
});
entries.into_iter().map(|(k, _, _)| k).collect()
}
#[cfg(test)]
pub fn eviction_floor(&self) -> f64 {
self.eviction_floor
}
pub fn iter(&self) -> impl Iterator<Item = ContractKey> + '_ {
self.contracts.keys().cloned()
}
pub fn sweep_expired<F>(&mut self, should_retain: F) -> Vec<(ContractKey, u64)>
where
F: Fn(&ContractKey) -> bool,
{
self.evict_over_budget(&should_retain)
}
pub fn load_persisted_entry_with_demand(
&mut self,
key: ContractKey,
size_bytes: u64,
access_type: AccessType,
last_access_age: Duration,
local_client_access: bool,
predicted_demand: f64,
) {
if self.contracts.contains_key(&key) {
return;
}
let now = self.time_source.now();
let last_accessed = now.checked_sub(last_access_age).unwrap_or(now);
let local_client_last_access = if local_client_access { Some(now) } else { None };
let contract = HostedContract {
size_bytes,
last_accessed,
last_access_seq: 0,
keep_score: self.eviction_floor + predicted_demand,
predicted_demand,
read_count: 0,
inserted_at: now,
access_type,
local_client_access,
local_client_last_access,
write_generation: 0,
abandoned_at: None,
};
self.contracts.insert(key, contract);
self.current_bytes = self.current_bytes.saturating_add(size_bytes);
}
#[cfg_attr(not(test), allow(dead_code))]
pub fn load_persisted_entry(
&mut self,
key: ContractKey,
size_bytes: u64,
access_type: AccessType,
last_access_age: Duration,
local_client_access: bool,
) {
self.load_persisted_entry_with_demand(
key,
size_bytes,
access_type,
last_access_age,
local_client_access,
NEUTRAL_DEMAND,
)
}
pub fn finalize_loading(&mut self) {
let mut entries: Vec<_> = self
.contracts
.iter()
.map(|(k, v)| (*k, v.last_accessed))
.collect();
entries.sort_by_key(|(_, last_accessed)| *last_accessed);
for (key, _) in entries {
let seq = self.next_seq();
if let Some(entry) = self.contracts.get_mut(&key) {
entry.last_access_seq = seq;
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::util::time_source::SharedMockTimeSource;
use freenet_stdlib::prelude::{CodeHash, ContractInstanceId};
fn make_key(seed: u8) -> ContractKey {
ContractKey::from_id_and_code(
ContractInstanceId::new([seed; 32]),
CodeHash::new([seed.wrapping_add(1); 32]),
)
}
fn make_cache(
budget: u64,
min_ttl: Duration,
) -> (HostingCache<SharedMockTimeSource>, SharedMockTimeSource) {
let time_source = SharedMockTimeSource::new();
let cache = HostingCache::new(budget, min_ttl, time_source.clone());
(cache, time_source)
}
#[test]
fn test_empty_cache() {
let (cache, _) = make_cache(1000, Duration::from_secs(60));
assert!(cache.is_empty());
assert_eq!(cache.len(), 0);
assert_eq!(cache.current_bytes(), 0);
assert!(!cache.contains(&make_key(1)));
}
#[test]
fn test_add_single_contract() {
let (mut cache, _) = make_cache(1000, Duration::from_secs(60));
let key = make_key(1);
let result = cache.record_access(key, 100, AccessType::Get, 0, |_| false);
assert!(result.is_new);
assert!(result.evicted.is_empty());
assert!(cache.contains(&key));
assert_eq!(cache.len(), 1);
assert_eq!(cache.current_bytes(), 100);
let info = cache.get(&key).unwrap();
assert_eq!(info.size_bytes, 100);
assert_eq!(info.access_type, AccessType::Get);
}
#[test]
fn test_refresh_existing_contract() {
let (mut cache, time) = make_cache(1000, Duration::from_secs(60));
let key = make_key(1);
cache.record_access(key, 100, AccessType::Get, 0, |_| false);
let first_access = cache.get(&key).unwrap().last_accessed;
time.advance_time(Duration::from_secs(10));
cache.record_access(key, 100, AccessType::Put, 0, |_| false);
assert_eq!(cache.len(), 1);
assert_eq!(cache.current_bytes(), 100);
let info = cache.get(&key).unwrap();
assert_eq!(info.access_type, AccessType::Put);
assert!(info.last_accessed > first_access);
}
#[test]
fn test_lru_eviction_respects_ttl() {
let (mut cache, time) = make_cache(200, Duration::from_secs(60));
let key1 = make_key(1);
let key2 = make_key(2);
let key3 = make_key(3);
cache.record_access(key1, 100, AccessType::Get, 0, |_| false);
cache.record_access(key2, 100, AccessType::Get, 0, |_| false);
assert_eq!(cache.current_bytes(), 200);
time.advance_time(Duration::from_secs(30));
let result = cache.record_access(key3, 100, AccessType::Get, 0, |_| false);
assert!(
result.evicted.is_empty(),
"Should not evict entries under TTL"
);
assert_eq!(
cache.len(),
3,
"Cache should exceed budget when all under TTL"
);
assert!(cache.contains(&key1));
assert!(cache.contains(&key2));
assert!(cache.contains(&key3));
}
#[test]
fn test_lru_eviction_after_ttl() {
let (mut cache, time) = make_cache(200, Duration::from_secs(60));
let key1 = make_key(1);
let key2 = make_key(2);
let key3 = make_key(3);
cache.record_access(key1, 100, AccessType::Get, 0, |_| false);
cache.record_access(key2, 100, AccessType::Get, 0, |_| false);
time.advance_time(Duration::from_secs(61));
let result = cache.record_access(key3, 100, AccessType::Get, 0, |_| false);
assert!(result.is_new);
assert_eq!(result.evicted, vec![(key1, 0)]);
assert_eq!(cache.len(), 2);
assert!(!cache.contains(&key1));
assert!(cache.contains(&key2));
assert!(cache.contains(&key3));
}
#[test]
fn test_access_refreshes_lru_position() {
let (mut cache, time) = make_cache(200, Duration::from_secs(60));
let key1 = make_key(1);
let key2 = make_key(2);
let key3 = make_key(3);
cache.record_access(key1, 100, AccessType::Get, 0, |_| false);
cache.record_access(key2, 100, AccessType::Get, 0, |_| false);
cache.record_access(key1, 100, AccessType::Subscribe, 0, |_| false);
let order = cache.keys_eviction_order();
assert_eq!(order, vec![key2, key1]);
time.advance_time(Duration::from_secs(61));
let result = cache.record_access(key3, 100, AccessType::Get, 0, |_| false);
assert_eq!(result.evicted, vec![(key2, 0)]);
assert!(cache.contains(&key1));
assert!(!cache.contains(&key2));
assert!(cache.contains(&key3));
}
#[test]
fn test_touch_refreshes_ttl() {
let (mut cache, time) = make_cache(200, Duration::from_secs(60));
let key1 = make_key(1);
let key2 = make_key(2);
let key3 = make_key(3);
cache.record_access(key1, 100, AccessType::Get, 0, |_| false);
cache.record_access(key2, 100, AccessType::Get, 0, |_| false);
time.advance_time(Duration::from_secs(50));
cache.touch(&key1);
time.advance_time(Duration::from_secs(15));
let result = cache.record_access(key3, 100, AccessType::Get, 0, |_| false);
assert_eq!(
result.evicted,
vec![(key2, 0)],
"Should evict key2 which is past TTL"
);
assert!(
cache.contains(&key1),
"key1 should remain (touched recently)"
);
assert!(cache.contains(&key3));
}
#[test]
fn test_large_contract_evicts_multiple() {
let (mut cache, time) = make_cache(300, Duration::from_secs(60));
let small1 = make_key(1);
let small2 = make_key(2);
let small3 = make_key(3);
let large = make_key(4);
cache.record_access(small1, 100, AccessType::Get, 0, |_| false);
cache.record_access(small2, 100, AccessType::Get, 0, |_| false);
cache.record_access(small3, 100, AccessType::Get, 0, |_| false);
assert_eq!(cache.current_bytes(), 300);
time.advance_time(Duration::from_secs(61));
let result = cache.record_access(large, 200, AccessType::Put, 0, |_| false);
assert_eq!(result.evicted.len(), 2);
assert_eq!(result.evicted[0], (small1, 0)); assert_eq!(result.evicted[1], (small2, 0));
assert!(!cache.contains(&small1));
assert!(!cache.contains(&small2));
assert!(cache.contains(&small3));
assert!(cache.contains(&large));
}
#[test]
fn test_sweep_expired() {
let (mut cache, time) = make_cache(200, Duration::from_secs(60));
let key1 = make_key(1);
let key2 = make_key(2);
let key3 = make_key(3);
cache.record_access(key1, 100, AccessType::Get, 0, |_| false);
cache.record_access(key2, 100, AccessType::Get, 0, |_| false);
cache.record_access(key3, 100, AccessType::Get, 0, |_| false);
assert_eq!(cache.len(), 3);
assert_eq!(cache.current_bytes(), 300);
let evicted = cache.sweep_expired(|_| false);
assert!(evicted.is_empty());
assert_eq!(cache.len(), 3);
time.advance_time(Duration::from_secs(61));
let evicted = cache.sweep_expired(|_| false);
assert_eq!(evicted, vec![(key1, 0)]);
assert_eq!(cache.current_bytes(), 200);
}
#[test]
fn test_sweep_respects_should_retain() {
let (mut cache, time) = make_cache(200, Duration::from_secs(60));
let key1 = make_key(1);
let key2 = make_key(2);
let key3 = make_key(3);
cache.record_access(key1, 100, AccessType::Get, 0, |_| false);
cache.record_access(key2, 100, AccessType::Get, 0, |_| false);
cache.record_access(key3, 100, AccessType::Get, 0, |_| false);
time.advance_time(Duration::from_secs(61));
let evicted = cache.sweep_expired(|k| *k == key1);
assert_eq!(evicted, vec![(key2, 0)]);
assert!(cache.contains(&key1));
assert!(!cache.contains(&key2));
assert!(cache.contains(&key3));
assert_eq!(cache.current_bytes(), 200);
}
#[test]
fn test_record_access_respects_should_retain() {
let (mut cache, time) = make_cache(200, Duration::from_secs(60));
let retained = make_key(1);
let evictable = make_key(2);
let trigger = make_key(3);
cache.record_access(retained, 100, AccessType::Get, 0, |_| false);
cache.record_access(evictable, 100, AccessType::Get, 0, |_| false);
assert_eq!(cache.current_bytes(), 200);
time.advance_time(Duration::from_secs(61));
let result = cache.record_access(trigger, 100, AccessType::Get, 0, |k| *k == retained);
assert_eq!(
result.evicted,
vec![(evictable, 0)],
"in-use (retained) contract must be skipped; the unretained \
past-TTL contract must be evicted instead"
);
assert!(
cache.contains(&retained),
"retained contract must survive even past TTL and over budget"
);
assert!(!cache.contains(&evictable));
assert!(cache.contains(&trigger));
}
#[test]
fn test_record_access_never_evicts_the_new_entry() {
let (mut cache, time) = make_cache(100, Duration::from_secs(60));
let first = make_key(1);
let second = make_key(2);
cache.record_access(first, 100, AccessType::Get, 0, |_| false);
time.advance_time(Duration::from_secs(61));
let result = cache.record_access(second, 100, AccessType::Get, 0, |_| false);
assert_eq!(result.evicted, vec![(first, 0)]);
assert!(cache.contains(&second));
assert!(!cache.contains(&first));
}
#[test]
fn test_touch_non_existent_is_no_op() {
let (mut cache, _) = make_cache(1000, Duration::from_secs(60));
let key = make_key(1);
cache.touch(&key);
assert!(cache.is_empty());
assert!(!cache.contains(&key));
}
#[test]
fn test_access_types() {
let (mut cache, _) = make_cache(1000, Duration::from_secs(60));
let key = make_key(1);
cache.record_access(key, 100, AccessType::Get, 0, |_| false);
assert_eq!(cache.get(&key).unwrap().access_type, AccessType::Get);
cache.record_access(key, 100, AccessType::Put, 0, |_| false);
assert_eq!(cache.get(&key).unwrap().access_type, AccessType::Put);
cache.record_access(key, 100, AccessType::Subscribe, 0, |_| false);
assert_eq!(cache.get(&key).unwrap().access_type, AccessType::Subscribe);
}
#[test]
fn test_contract_size_change() {
let (mut cache, _) = make_cache(1000, Duration::from_secs(60));
let key = make_key(1);
cache.record_access(key, 100, AccessType::Get, 0, |_| false);
assert_eq!(cache.current_bytes(), 100);
assert_eq!(cache.get(&key).unwrap().size_bytes, 100);
cache.record_access(key, 200, AccessType::Put, 0, |_| false);
assert_eq!(cache.current_bytes(), 200);
assert_eq!(cache.get(&key).unwrap().size_bytes, 200);
cache.record_access(key, 150, AccessType::Put, 0, |_| false);
assert_eq!(cache.current_bytes(), 150);
assert_eq!(cache.get(&key).unwrap().size_bytes, 150);
}
#[test]
fn test_iter_returns_all_hosted_keys() {
let (mut cache, _) = make_cache(1000, Duration::from_secs(60));
assert_eq!(cache.iter().count(), 0);
let key1 = make_key(1);
let key2 = make_key(2);
let key3 = make_key(3);
cache.record_access(key1, 100, AccessType::Get, 0, |_| false);
cache.record_access(key2, 100, AccessType::Put, 0, |_| false);
cache.record_access(key3, 100, AccessType::Subscribe, 0, |_| false);
let keys: Vec<ContractKey> = cache.iter().collect();
assert_eq!(keys.len(), 3);
assert!(keys.contains(&key1));
assert!(keys.contains(&key2));
assert!(keys.contains(&key3));
}
#[test]
fn test_record_access_carries_write_generation_through_eviction() {
let (mut cache, time) = make_cache(100, Duration::from_secs(60));
let evicted_key = make_key(1);
let trigger_key = make_key(2);
let captured_generation: u64 = 0xABCD_EF42;
let first = cache.record_access(
evicted_key,
100,
AccessType::Get,
captured_generation,
|_| false,
);
assert!(first.evicted.is_empty(), "first insert evicts nothing");
assert_eq!(
cache
.get(&evicted_key)
.expect("just inserted")
.write_generation,
captured_generation,
"captured generation must be stored on the HostedContract entry"
);
time.advance_time(Duration::from_secs(61));
let result = cache.record_access(
trigger_key,
100,
AccessType::Get,
999, |_| false,
);
assert_eq!(
result.evicted,
vec![(evicted_key, captured_generation)],
"evicted tuple must carry the generation captured atomically \
when the evicted entry was inserted"
);
}
#[test]
fn test_record_access_refresh_updates_write_generation() {
let (mut cache, _) = make_cache(1000, Duration::from_secs(60));
let key = make_key(7);
cache.record_access(key, 100, AccessType::Get, 1, |_| false);
assert_eq!(cache.get(&key).unwrap().write_generation, 1);
cache.record_access(key, 100, AccessType::Put, 5, |_| false);
assert_eq!(
cache.get(&key).unwrap().write_generation,
5,
"re-access must refresh the captured generation snapshot"
);
}
#[test]
fn test_local_client_access_age_gate_expiry() {
let lease = Duration::from_secs(480); let (mut cache, time) = make_cache(10000, Duration::from_secs(60));
let key = make_key(1);
cache.record_access(key, 100, AccessType::Get, 0, |_| false);
cache.mark_local_client_access(&key);
assert!(cache.has_local_client_access(&key));
assert!(cache.has_recent_local_client_access(&key, lease));
time.advance_time(lease - Duration::from_secs(1));
assert!(cache.has_recent_local_client_access(&key, lease));
time.advance_time(Duration::from_secs(2));
assert!(
!cache.has_recent_local_client_access(&key, lease),
"Contract should exit renewal after lease expires"
);
assert!(
cache.has_local_client_access(&key),
"Flag should remain sticky even after age gate expires"
);
cache.mark_local_client_access(&key);
assert!(
cache.has_recent_local_client_access(&key, lease),
"Re-marking should refresh the age gate"
);
}
#[test]
fn eviction_ordered_scores_matches_key_order_and_carries_fields() {
let (mut cache, _) = make_cache(10_000, Duration::from_secs(60));
let key1 = make_key(1);
let key2 = make_key(2);
let key3 = make_key(3);
cache.record_access(key1, 111, AccessType::Get, 0, |_| false);
cache.record_access(key2, 222, AccessType::Get, 0, |_| false);
cache.record_access(key3, 333, AccessType::Get, 0, |_| false);
let scores = cache.eviction_ordered_scores();
let score_keys: Vec<_> = scores.iter().map(|s| s.key).collect();
assert_eq!(
score_keys,
cache.keys_eviction_order(),
"eviction_ordered_scores must use the same order as keys_eviction_order"
);
for s in &scores {
let entry = cache.get(&s.key).expect("scored key is in the cache");
assert_eq!(s.keep_score, entry.keep_score);
assert_eq!(s.size_bytes, entry.size_bytes);
assert_eq!(s.read_count, entry.read_count);
assert_eq!(s.predicted_demand, entry.predicted_demand);
assert_eq!(s.last_access_seq, entry.last_access_seq);
assert!(
!s.past_min_ttl,
"a freshly-accessed entry is within min_ttl, so it is not \
eviction-eligible yet"
);
}
}
#[test]
fn eviction_ordered_scores_flags_past_min_ttl_after_ttl_elapses() {
let min_ttl = Duration::from_secs(60);
let (mut cache, time_source) = make_cache(10_000, min_ttl);
let key = make_key(1);
cache.record_access(key, 111, AccessType::Get, 0, |_| false);
let before = cache.eviction_ordered_scores();
assert!(
!before[0].past_min_ttl,
"within min_ttl → not past the TTL gate"
);
time_source.advance_time(min_ttl + Duration::from_secs(1));
let after = cache.eviction_ordered_scores();
assert!(
after[0].past_min_ttl,
"past min_ttl → now eligible for the TTL gate"
);
}
#[test]
fn test_record_abandonment_moves_entry_to_eviction_front() {
let (mut cache, _) = make_cache(1000, Duration::from_secs(60));
let key1 = make_key(1);
let key2 = make_key(2);
let key3 = make_key(3);
cache.record_access(key1, 100, AccessType::Get, 0, |_| false);
cache.record_access(key2, 100, AccessType::Get, 0, |_| false);
cache.record_access(key3, 100, AccessType::Get, 0, |_| false);
assert_eq!(cache.keys_eviction_order(), vec![key1, key2, key3]);
cache.record_abandonment(&key3);
assert_eq!(cache.keys_eviction_order(), vec![key3, key1, key2]);
let info = cache.get(&key3).unwrap();
assert!(
info.abandoned_at.is_some(),
"Abandonment must record a timestamp"
);
assert_eq!(
info.keep_score,
cache.eviction_floor(),
"abandonment must strip demand credit to the floor"
);
}
#[test]
fn test_record_abandonment_is_idempotent() {
let (mut cache, time) = make_cache(1000, Duration::from_secs(60));
let key = make_key(1);
cache.record_access(key, 100, AccessType::Get, 0, |_| false);
cache.record_abandonment(&key);
let first = cache.get(&key).unwrap().abandoned_at;
assert!(first.is_some());
time.advance_time(Duration::from_secs(5));
cache.record_abandonment(&key);
let second = cache.get(&key).unwrap().abandoned_at;
assert_eq!(first, second);
}
#[test]
fn test_record_abandonment_missing_key_is_noop() {
let (mut cache, _) = make_cache(1000, Duration::from_secs(60));
let key = make_key(42);
cache.record_abandonment(&key);
assert!(!cache.contains(&key));
assert_eq!(cache.len(), 0);
}
#[test]
fn test_record_access_clears_abandoned_marker() {
let (mut cache, _) = make_cache(1000, Duration::from_secs(60));
let key = make_key(1);
cache.record_access(key, 100, AccessType::Get, 0, |_| false);
cache.record_abandonment(&key);
assert!(cache.get(&key).unwrap().abandoned_at.is_some());
cache.record_access(key, 100, AccessType::Get, 0, |_| false);
assert!(
cache.get(&key).unwrap().abandoned_at.is_none(),
"record_access must clear abandoned_at"
);
}
#[test]
fn test_touch_clears_abandoned_marker() {
let (mut cache, _) = make_cache(1000, Duration::from_secs(60));
let key = make_key(1);
cache.record_access(key, 100, AccessType::Get, 0, |_| false);
cache.record_abandonment(&key);
cache.touch(&key);
assert!(
cache.get(&key).unwrap().abandoned_at.is_none(),
"touch must clear abandoned_at"
);
}
#[test]
fn test_abandoned_entry_evicted_before_active_under_pressure() {
let (mut cache, time) = make_cache(200, Duration::from_secs(60));
let key1 = make_key(1);
let key2 = make_key(2);
let key3 = make_key(3);
cache.record_access(key1, 100, AccessType::Get, 0, |_| false);
cache.record_access(key2, 100, AccessType::Get, 0, |_| false);
cache.record_abandonment(&key2);
time.advance_time(Duration::from_secs(61));
let result = cache.record_access(key3, 100, AccessType::Get, 0, |_| false);
assert!(result.is_new);
assert_eq!(
result.evicted,
vec![(key2, 0)],
"abandoned entry must evict first"
);
assert!(cache.contains(&key1));
assert!(!cache.contains(&key2));
assert!(cache.contains(&key3));
}
#[test]
fn test_idle_persistence_preserved_when_under_pressure() {
let (mut cache, time) = make_cache(1000, Duration::from_secs(60));
let key1 = make_key(1);
let key2 = make_key(2);
cache.record_access(key1, 100, AccessType::Get, 0, |_| false);
cache.record_access(key2, 100, AccessType::Get, 0, |_| false);
cache.record_abandonment(&key2);
time.advance_time(Duration::from_secs(3600));
let evicted = cache.sweep_expired(|_| false);
assert!(evicted.is_empty(), "no pressure → no eviction");
assert!(cache.contains(&key1));
assert!(cache.contains(&key2));
}
const MIB: u64 = 1024 * 1024;
const GIB: u64 = 1024 * 1024 * 1024;
#[test]
fn budget_for_ram_scales_and_clamps() {
assert_eq!(budget_for_ram(512 * MIB), MIN_DEFAULT_HOSTING_BUDGET_BYTES);
assert_eq!(budget_for_ram(GIB), MIN_DEFAULT_HOSTING_BUDGET_BYTES);
assert_eq!(budget_for_ram(2 * GIB), 256 * MIB);
assert_eq!(budget_for_ram(4 * GIB), 512 * MIB);
assert_eq!(budget_for_ram(8 * GIB), MAX_DEFAULT_HOSTING_BUDGET_BYTES);
assert_eq!(budget_for_ram(128 * GIB), MAX_DEFAULT_HOSTING_BUDGET_BYTES);
}
#[test]
fn budget_for_ram_is_ram_scaled_not_fixed() {
let small = budget_for_ram(2 * GIB);
let large = budget_for_ram(6 * GIB);
assert!(
small < large,
"budget must vary with RAM within the band, got small={small} large={large}"
);
assert!(
small < MAX_DEFAULT_HOSTING_BUDGET_BYTES,
"a memory-constrained box must get less than the old flat 1 GiB default"
);
assert_ne!(budget_for_ram(2 * GIB), budget_for_ram(3 * GIB));
assert_ne!(budget_for_ram(3 * GIB), budget_for_ram(4 * GIB));
}
#[test]
fn default_hosting_budget_within_clamp() {
let b = default_hosting_budget_bytes();
assert!(
(MIN_DEFAULT_HOSTING_BUDGET_BYTES..=MAX_DEFAULT_HOSTING_BUDGET_BYTES).contains(&b),
"default {b} must be within [{}, {}]",
MIN_DEFAULT_HOSTING_BUDGET_BYTES,
MAX_DEFAULT_HOSTING_BUDGET_BYTES,
);
}
#[test]
fn test_budget_eviction_counter_tracks_over_budget_evictions() {
let (mut cache, time) = make_cache(200, Duration::from_secs(60));
let key1 = make_key(1);
let key2 = make_key(2);
let key3 = make_key(3);
cache.record_access(key1, 100, AccessType::Get, 0, |_| false);
cache.record_access(key2, 100, AccessType::Get, 0, |_| false);
assert_eq!(cache.stats().budget_evictions_total, 0);
cache.record_access(key3, 100, AccessType::Get, 0, |_| false);
assert_eq!(
cache.stats().budget_evictions_total,
0,
"TTL-protected entries must not count as budget evictions"
);
time.advance_time(Duration::from_secs(61));
let evicted = cache.sweep_expired(|_| false);
assert_eq!(
evicted.len(),
1,
"one 100-byte entry brings 300 back to 200"
);
let stats = cache.stats();
assert_eq!(stats.budget_evictions_total, 1);
assert_eq!(stats.current_bytes, 200);
assert_eq!(stats.contract_count, 2);
assert_eq!(stats.budget_bytes, 200);
let evicted = cache.sweep_expired(|_| false);
assert!(evicted.is_empty(), "at budget -> no further eviction");
assert_eq!(
cache.stats().budget_evictions_total,
1,
"counter must not advance with no budget pressure"
);
}
#[test]
fn fuel_gauge_keep_score_set_on_insert_and_refreshed_on_read() {
let (mut cache, _) = make_cache(10_000, Duration::from_secs(60));
let key = make_key(1);
cache.record_access_with_demand(key, 100, AccessType::Get, 0, 3.0, |_| false);
assert_eq!(cache.get(&key).unwrap().keep_score, 3.0);
assert_eq!(cache.get(&key).unwrap().predicted_demand, 3.0);
assert_eq!(cache.get(&key).unwrap().read_count, 1);
cache.record_access_with_demand(key, 100, AccessType::Get, 0, 5.0, |_| false);
assert_eq!(cache.get(&key).unwrap().keep_score, 5.0);
assert_eq!(cache.get(&key).unwrap().read_count, 2);
}
#[test]
fn fuel_gauge_evicts_lowest_demand_not_lru() {
let (mut cache, time) = make_cache(200, Duration::from_secs(60));
let high = make_key(1);
let low = make_key(2);
let trigger = make_key(3);
cache.record_access_with_demand(high, 100, AccessType::Get, 0, 10.0, |_| false);
cache.record_access_with_demand(low, 100, AccessType::Get, 0, 1.0, |_| false);
assert_eq!(cache.current_bytes(), 200);
time.advance_time(Duration::from_secs(61));
let result =
cache.record_access_with_demand(trigger, 100, AccessType::Get, 0, 1.0, |_| false);
assert_eq!(
result.evicted,
vec![(low, 0)],
"lowest-demand contract must evict, not the least-recently-used one"
);
assert!(cache.contains(&high), "high-demand contract must survive");
assert!(!cache.contains(&low));
}
#[test]
fn fuel_gauge_cold_start_keeps_near_key_over_far_key() {
let prior = super::super::demand::ProximityPrior::new();
assert_eq!(prior.len(), 0, "cold estimator has no samples");
let near_demand = prior.predict(0.02); let far_demand = prior.predict(0.45); assert!(
near_demand > far_demand,
"cold distance prior must slope down: near={near_demand}, far={far_demand}"
);
let (mut cache, time) = make_cache(200, Duration::from_secs(60));
let near = make_key(1);
let far = make_key(2);
let trigger = make_key(3);
cache.record_access_with_demand(near, 100, AccessType::Get, 0, near_demand, |_| false);
cache.record_access_with_demand(far, 100, AccessType::Get, 0, far_demand, |_| false);
assert_eq!(cache.current_bytes(), 200);
assert!(
cache.get(&near).unwrap().keep_score > cache.get(&far).unwrap().keep_score,
"near-key contract must carry more cold demand credit than far-key"
);
time.advance_time(Duration::from_secs(61));
let result = cache.record_access_with_demand(
trigger,
100,
AccessType::Get,
0,
NEUTRAL_DEMAND,
|_| false,
);
assert_eq!(
result.evicted,
vec![(far, 0)],
"cold start must evict the FAR-key contract (lower distance prior) first"
);
assert!(
cache.contains(&near),
"the NEAR-key contract out-survives the FAR-key one at cold start"
);
assert!(!cache.contains(&far));
}
#[test]
fn fuel_gauge_keeps_repeatedly_read_contract_over_junk() {
let (mut cache, time) = make_cache(200, Duration::from_secs(60));
let room = make_key(1); let junk = make_key(2); let trigger = make_key(3);
cache.record_access_with_demand(room, 100, AccessType::Get, 0, 10.0, |_| false);
for _ in 0..3 {
time.advance_time(Duration::from_secs(5));
cache.touch(&room);
}
time.advance_time(Duration::from_secs(5));
cache.record_access_with_demand(junk, 100, AccessType::Get, 0, 1.0, |_| false);
time.advance_time(Duration::from_secs(61));
let room_entry = cache.get(&room).expect("room hosted");
let junk_entry = cache.get(&junk).expect("junk hosted");
assert!(
room_entry.last_access_seq < junk_entry.last_access_seq,
"room must be the least-recently-accessed of the two, so recency \
alone (byte-LRU) would evict it first"
);
assert!(
room_entry.keep_score > junk_entry.keep_score,
"room must carry more demand credit than junk ({} vs {})",
room_entry.keep_score,
junk_entry.keep_score,
);
let result =
cache.record_access_with_demand(trigger, 100, AccessType::Get, 0, 1.0, |_| false);
assert_eq!(
result.evicted,
vec![(junk, 0)],
"low-demand junk must evict, NOT the older-but-high-demand room"
);
assert!(
cache.contains(&room),
"the repeatedly-read high-demand room survives even though it is the \
least-recently-accessed past-TTL entry (byte-LRU would have evicted it)"
);
assert!(!cache.contains(&junk));
assert_eq!(
cache.stats().evictions_of_recently_read_total,
0,
"a repeatedly-read contract must never be evicted (no miscalibration)"
);
}
#[test]
fn eviction_floor_ratchets_up_on_eviction() {
let (mut cache, time) = make_cache(100, Duration::from_secs(60));
assert_eq!(cache.eviction_floor(), 0.0);
let a = make_key(1);
cache.record_access_with_demand(a, 100, AccessType::Get, 0, 7.0, |_| false);
time.advance_time(Duration::from_secs(61));
let b = make_key(2);
let r = cache.record_access_with_demand(b, 100, AccessType::Get, 0, 2.0, |_| false);
assert_eq!(r.evicted, vec![(a, 0)]);
assert_eq!(
cache.eviction_floor(),
7.0,
"floor ratchets to evicted score"
);
time.advance_time(Duration::from_secs(61));
let c = make_key(3);
let r = cache.record_access_with_demand(c, 100, AccessType::Get, 0, 1.0, |_| false);
assert_eq!(r.evicted, vec![(b, 0)], "b (keep_score 2) evicts");
assert_eq!(
cache.eviction_floor(),
7.0,
"floor must not drop below its high-water mark"
);
}
#[test]
fn put_seeds_but_earns_no_read_demand_credit() {
let (mut cache, _) = make_cache(10_000, Duration::from_secs(60));
let key = make_key(1);
cache.record_access_with_demand(key, 100, AccessType::Put, 0, 2.0, |_| false);
assert_eq!(cache.get(&key).unwrap().read_count, 0, "PUT is not a read");
let seed_score = cache.get(&key).unwrap().keep_score;
assert_eq!(seed_score, 2.0);
cache.record_access_with_demand(key, 100, AccessType::Put, 0, 9.0, |_| false);
assert_eq!(
cache.get(&key).unwrap().read_count,
0,
"re-PUT must not count as a read"
);
assert_eq!(
cache.get(&key).unwrap().keep_score,
seed_score,
"re-PUT (seed) must not refresh keep_score to the frontier"
);
cache.record_access_with_demand(key, 100, AccessType::Get, 0, 9.0, |_| false);
assert_eq!(cache.get(&key).unwrap().read_count, 1);
assert_eq!(cache.get(&key).unwrap().keep_score, 9.0);
}
#[test]
fn evictions_of_recently_read_counts_only_repeat_demand() {
let (mut cache, time) = make_cache(100, Duration::from_secs(60));
let read_twice = make_key(1);
cache.record_access(read_twice, 100, AccessType::Get, 0, |_| false);
cache.touch(&read_twice); time.advance_time(Duration::from_secs(61));
let junk = make_key(2);
let r = cache.record_access_with_demand(junk, 100, AccessType::Get, 0, 5.0, |_| false);
assert_eq!(r.evicted, vec![(read_twice, 0)]);
assert_eq!(
cache.stats().evictions_of_recently_read_total,
1,
"evicting a twice-read contract is a miscalibration signal"
);
time.advance_time(Duration::from_secs(61));
let seed = make_key(3);
let r = cache.record_access_with_demand(seed, 100, AccessType::Get, 0, 9.0, |_| false);
assert_eq!(r.evicted, vec![(junk, 0)], "junk (read once) evicts");
assert_eq!(
cache.stats().evictions_of_recently_read_total,
1,
"a one-off seed eviction must not count as recently-read"
);
}
#[test]
fn subscribe_pin_exempts_lowest_score_from_eviction() {
let (mut cache, time) = make_cache(200, Duration::from_secs(60));
let pinned = make_key(1); let other = make_key(2);
let trigger = make_key(3);
cache.record_access_with_demand(pinned, 100, AccessType::Get, 0, 0.1, |_| false);
cache.record_access_with_demand(other, 100, AccessType::Get, 0, 5.0, |_| false);
time.advance_time(Duration::from_secs(61));
let result = cache
.record_access_with_demand(trigger, 100, AccessType::Get, 0, 5.0, |k| *k == pinned);
assert_eq!(
result.evicted,
vec![(other, 0)],
"an active subscription pins the contract even at the lowest keep_score"
);
assert!(cache.contains(&pinned));
assert!(!cache.contains(&other));
}
}