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 = 100 * 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>,
}
#[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 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,
}
}
pub fn record_access(
&mut self,
key: ContractKey,
size_bytes: u64,
access_type: AccessType,
) -> RecordAccessResult {
let now = self.time_source.now();
let mut evicted = Vec::new();
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;
self.lru_order.retain(|k| k != &key);
self.lru_order.push_back(key);
RecordAccessResult {
is_new: false,
evicted,
}
} else {
while self.current_bytes + size_bytes > self.budget_bytes && !self.lru_order.is_empty()
{
if let Some(oldest_key) = self.lru_order.front().cloned() {
if let Some(oldest) = self.contracts.get(&oldest_key) {
let age = now.saturating_duration_since(oldest.last_accessed);
if age >= self.min_ttl {
if let Some(removed) = self.contracts.remove(&oldest_key) {
self.current_bytes =
self.current_bytes.saturating_sub(removed.size_bytes);
self.lru_order.pop_front();
evicted.push(oldest_key);
}
} else {
break;
}
} else {
self.lru_order.pop_front();
}
} else {
break;
}
}
let contract = HostedContract {
size_bytes,
last_accessed: now,
access_type,
local_client_access: false,
local_client_last_access: None,
};
self.contracts.insert(key, contract);
self.lru_order.push_back(key);
self.current_bytes = self.current_bytes.saturating_add(size_bytes);
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();
self.lru_order.retain(|k| k != key);
self.lru_order.push_back(*key);
}
}
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>
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;
self.contracts.remove(key);
self.current_bytes = self.current_bytes.saturating_sub(size);
evicted.push(*key);
false
} else {
true
}
} else {
false }
});
evicted
}
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,
};
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);
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);
let first_access = cache.get(&key).unwrap().last_accessed;
time.advance_time(Duration::from_secs(10));
cache.record_access(key, 100, AccessType::Put);
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);
cache.record_access(key2, 100, AccessType::Get);
assert_eq!(cache.current_bytes(), 200);
time.advance_time(Duration::from_secs(30));
let result = cache.record_access(key3, 100, AccessType::Get);
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);
cache.record_access(key2, 100, AccessType::Get);
time.advance_time(Duration::from_secs(61));
let result = cache.record_access(key3, 100, AccessType::Get);
assert!(result.is_new);
assert_eq!(result.evicted, vec![key1]);
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);
cache.record_access(key2, 100, AccessType::Get);
cache.record_access(key1, 100, AccessType::Subscribe);
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);
assert_eq!(result.evicted, vec![key2]);
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);
cache.record_access(key2, 100, AccessType::Get);
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);
assert_eq!(
result.evicted,
vec![key2],
"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);
cache.record_access(small2, 100, AccessType::Get);
cache.record_access(small3, 100, AccessType::Get);
assert_eq!(cache.current_bytes(), 300);
time.advance_time(Duration::from_secs(61));
let result = cache.record_access(large, 200, AccessType::Put);
assert_eq!(result.evicted.len(), 2);
assert_eq!(result.evicted[0], small1); assert_eq!(result.evicted[1], small2);
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);
cache.record_access(key2, 100, AccessType::Get);
cache.record_access(key3, 100, AccessType::Get);
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]);
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);
cache.record_access(key2, 100, AccessType::Get);
cache.record_access(key3, 100, AccessType::Get);
time.advance_time(Duration::from_secs(61));
let evicted = cache.sweep_expired(|k| *k == key1);
assert_eq!(evicted, vec![key2]);
assert!(cache.contains(&key1));
assert!(!cache.contains(&key2));
assert!(cache.contains(&key3));
assert_eq!(cache.current_bytes(), 200);
}
#[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);
assert_eq!(cache.get(&key).unwrap().access_type, AccessType::Get);
cache.record_access(key, 100, AccessType::Put);
assert_eq!(cache.get(&key).unwrap().access_type, AccessType::Put);
cache.record_access(key, 100, AccessType::Subscribe);
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);
assert_eq!(cache.current_bytes(), 100);
assert_eq!(cache.get(&key).unwrap().size_bytes, 100);
cache.record_access(key, 200, AccessType::Put);
assert_eq!(cache.current_bytes(), 200);
assert_eq!(cache.get(&key).unwrap().size_bytes, 200);
cache.record_access(key, 150, AccessType::Put);
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);
cache.record_access(key2, 100, AccessType::Put);
cache.record_access(key3, 100, AccessType::Subscribe);
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_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);
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"
);
}
}