use freenet_stdlib::prelude::ContractKey;
use std::collections::{HashMap, VecDeque};
use std::time::Duration;
use tokio::time::Instant;
use crate::util::time_source::TimeSource;
pub const DEFAULT_HOSTING_BUDGET_BYTES: u64 = 1024 * 1024 * 1024;
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)>,
}
#[derive(Debug, Clone)]
pub struct HostedContract {
pub size_bytes: u64,
pub last_accessed: 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,
lru_order: VecDeque<ContractKey>,
contracts: HashMap<ContractKey, HostedContract>,
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,
lru_order: VecDeque::new(),
contracts: HashMap::new(),
time_source,
}
}
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 evicted = Vec::new();
self.lru_order.retain(|key| {
if self.current_bytes <= self.budget_bytes {
return true; }
if let Some(entry) = self.contracts.get(key) {
let age = now.saturating_duration_since(entry.last_accessed);
if age >= self.min_ttl && !should_retain(key) {
let size = entry.size_bytes;
let generation = entry.write_generation;
self.contracts.remove(key);
self.current_bytes = self.current_bytes.saturating_sub(size);
evicted.push((*key, generation));
false
} else {
true
}
} else {
false }
});
evicted
}
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,
{
let now = self.time_source.now();
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.access_type = access_type;
existing.write_generation = write_generation;
existing.abandoned_at = None;
self.lru_order.retain(|k| k != &key);
self.lru_order.push_back(key);
RecordAccessResult {
is_new: false,
evicted: Vec::new(),
}
} else {
let contract = HostedContract {
size_bytes,
last_accessed: now,
access_type,
local_client_access: false,
local_client_last_access: None,
write_generation,
abandoned_at: None,
};
self.contracts.insert(key, contract);
self.lru_order.push_back(key);
self.current_bytes = self.current_bytes.saturating_add(size_bytes);
let evicted = self.evict_over_budget(&should_retain);
RecordAccessResult {
is_new: true,
evicted,
}
}
}
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(&mut self, key: &ContractKey) {
if let Some(existing) = self.contracts.get_mut(key) {
existing.last_accessed = self.time_source.now();
existing.abandoned_at = None;
self.lru_order.retain(|k| k != key);
self.lru_order.push_back(*key);
}
}
pub fn record_abandonment(&mut self, key: &ContractKey) {
if let Some(existing) = self.contracts.get_mut(key) {
if existing.abandoned_at.is_none() {
existing.abandoned_at = Some(self.time_source.now());
self.lru_order.retain(|k| k != key);
self.lru_order.push_front(*key);
}
}
}
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
}
#[cfg(test)]
pub fn keys_lru_order(&self) -> Vec<ContractKey> {
self.lru_order.iter().cloned().collect()
}
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(
&mut self,
key: ContractKey,
size_bytes: u64,
access_type: AccessType,
last_access_age: Duration,
local_client_access: bool,
) {
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,
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);
}
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);
self.lru_order.clear();
for (key, _) in entries {
self.lru_order.push_back(key);
}
}
}
#[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_lru_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 test_record_abandonment_moves_entry_to_lru_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_lru_order(), vec![key1, key2, key3]);
cache.record_abandonment(&key3);
assert_eq!(cache.keys_lru_order(), vec![key3, key1, key2]);
let info = cache.get(&key3).unwrap();
assert!(
info.abandoned_at.is_some(),
"Abandonment must record a timestamp"
);
}
#[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));
}
}