diff --git a/crates/core/src/node/testing_impl/in_memory.rs b/crates/core/src/node/testing_impl/in_memory.rs index 5f43b952b..8e6cf7773 100644 --- a/crates/core/src/node/testing_impl/in_memory.rs +++ b/crates/core/src/node/testing_impl/in_memory.rs @@ -104,6 +104,7 @@ where use crate::contract::ContractHandlerEvent; for (contract, state, subscription) in contracts { let key: ContractKey = contract.key(); + let state_size = state.size() as u64; self.op_manager .notify_contract_handler(ContractHandlerEvent::PutQuery { key, @@ -122,7 +123,7 @@ where .unwrap() ); if subscription { - self.op_manager.ring.seed_contract(key); + self.op_manager.ring.seed_contract(key, state_size); } if let Some(subscribers) = contract_subscribers.get(&key) { // add contract subscribers (test setup - no upstream_addr) diff --git a/crates/core/src/operations/get.rs b/crates/core/src/operations/get.rs index 924a786f0..52d32ee6f 100644 --- a/crates/core/src/operations/get.rs +++ b/crates/core/src/operations/get.rs @@ -1235,12 +1235,8 @@ impl Operation for GetOp { false }; - // Determine if we should put the contract locally - let should_put = if is_original_requester && subscribe_requested { - true - } else { - op_manager.ring.should_seed(&key) - }; + // Always cache contracts we encounter - LRU will handle eviction + let should_put = true; // Put contract locally if needed if should_put { @@ -1277,7 +1273,7 @@ impl Operation for GetOp { // State already cached and identical, mark as seeded if needed if !op_manager.ring.is_seeding_contract(&key) { tracing::debug!(tx = %id, %key, "Marking contract as seeded"); - op_manager.ring.seed_contract(key); + op_manager.ring.record_get_access(key, value.size() as u64); super::announce_contract_cached(op_manager, &key).await; let child_tx = super::start_subscription_request(op_manager, id, key); @@ -1303,7 +1299,7 @@ impl Operation for GetOp { // Start subscription if not already seeding if !is_subscribed_contract { tracing::debug!(tx = %id, %key, peer = ?op_manager.ring.connection_manager.get_own_addr(), "Contract not cached @ peer, caching"); - op_manager.ring.seed_contract(key); + op_manager.ring.record_get_access(key, value.size() as u64); super::announce_contract_cached(op_manager, &key).await; let child_tx = diff --git a/crates/core/src/operations/put.rs b/crates/core/src/operations/put.rs index 0602fc0f3..920d26602 100644 --- a/crates/core/src/operations/put.rs +++ b/crates/core/src/operations/put.rs @@ -17,7 +17,7 @@ use crate::node::IsOperationCompleted; use crate::{ client_events::HostResult, contract::ContractHandlerEvent, - message::{InnerMessage, NetMessage, NetMessageV1, Transaction}, + message::{InnerMessage, NetMessage, Transaction}, node::{NetworkBridge, OpManager}, ring::{Location, PeerKeyLocation}, }; @@ -220,15 +220,8 @@ impl Operation for PutOp { _ => false, }; - // Check if we're the initiator of this PUT operation - // We only cache locally when either WE initiate the PUT, or when forwarding just of the peer should be seeding - let should_seed = match &self.state { - Some(PutState::PrepareRequest { .. }) => true, - Some(PutState::AwaitingResponse { upstream, .. }) => { - upstream.is_none() || op_manager.ring.should_seed(&key) - } - _ => op_manager.ring.should_seed(&key), - }; + // Always cache contracts we encounter - LRU will handle eviction + let should_seed = true; let modified_value = if should_seed { // Cache locally when initiating a PUT. This ensures: @@ -270,7 +263,7 @@ impl Operation for PutOp { // Mark as seeded locally if not already if !is_already_seeding { - op_manager.ring.seed_contract(key); + op_manager.ring.seed_contract(key, value.size() as u64); super::announce_contract_cached(op_manager, &key).await; tracing::debug!( tx = %id, @@ -415,8 +408,8 @@ impl Operation for PutOp { // Get the contract key and check if we should handle it let key = contract.key(); let is_subscribed_contract = op_manager.ring.is_seeding_contract(&key); - let should_seed = op_manager.ring.should_seed(&key); - let should_handle_locally = !is_subscribed_contract && should_seed; + // Always cache contracts - LRU handles eviction + let should_handle_locally = !is_subscribed_contract; tracing::debug!( tx = %id, @@ -481,7 +474,7 @@ impl Operation for PutOp { let child_tx = super::start_subscription_request(op_manager, *id, key); tracing::debug!(tx = %id, %child_tx, "started subscription as child operation"); - op_manager.ring.seed_contract(key); + op_manager.ring.seed_contract(key, value.size() as u64); super::announce_contract_cached(op_manager, &key).await; true @@ -729,7 +722,7 @@ impl Operation for PutOp { peer = %op_manager.ring.connection_manager.own_location(), "Adding contract to local seed list" ); - op_manager.ring.seed_contract(key); + op_manager.ring.seed_contract(key, state.size() as u64); super::announce_contract_cached(op_manager, &key).await; } else { tracing::debug!( @@ -836,8 +829,8 @@ impl Operation for PutOp { let key = contract.key(); let peer_loc = op_manager.ring.connection_manager.own_location(); let is_seeding_contract = op_manager.ring.is_seeding_contract(&key); - let should_seed = op_manager.ring.should_seed(&key); - let should_handle_locally = should_seed && !is_seeding_contract; + // Always cache contracts - LRU handles eviction + let should_handle_locally = !is_seeding_contract; tracing::debug!( tx = %id, @@ -903,35 +896,12 @@ impl Operation for PutOp { .await?; } - // Start subscription and handle dropped contracts - let (dropped_contract, old_subscribers) = { - let child_tx = super::start_subscription_request(op_manager, *id, key); - tracing::debug!(tx = %id, %child_tx, "started subscription as child operation"); - let result = op_manager.ring.seed_contract(key); - super::announce_contract_cached(op_manager, &key).await; - result - }; - - // Notify subscribers of dropped contracts - if let Some(dropped_key) = dropped_contract { - for subscriber in old_subscribers { - if let Some(addr) = subscriber.socket_addr() { - conn_manager - .send( - addr, - NetMessage::V1(NetMessageV1::Unsubscribed { - transaction: Transaction::new::(), - key: dropped_key, - from: op_manager - .ring - .connection_manager - .own_location(), - }), - ) - .await?; - } - } - } + // Start subscription and record cache access + let child_tx = super::start_subscription_request(op_manager, *id, key); + tracing::debug!(tx = %id, %child_tx, "started subscription as child operation"); + let _evicted = op_manager.ring.seed_contract(key, new_value.size() as u64); + super::announce_contract_cached(op_manager, &key).await; + // Note: Evicted contracts are handled by SeedingManager (subscribers cleaned up internally) } else if last_hop && !already_put { // Last hop but not handling locally, still need to put put_contract( @@ -1277,7 +1247,9 @@ pub(crate) async fn request_put(op_manager: &OpManager, mut put_op: PutOp) -> Re peer = %op_manager.ring.connection_manager.own_location(), "Adding contract to local seed list" ); - op_manager.ring.seed_contract(key); + op_manager + .ring + .seed_contract(key, updated_value.size() as u64); super::announce_contract_cached(op_manager, &key).await; // Determine which peers need to be notified and broadcast the update diff --git a/crates/core/src/operations/test_utils.rs b/crates/core/src/operations/test_utils.rs index c703c3a19..4b82e959a 100644 --- a/crates/core/src/operations/test_utils.rs +++ b/crates/core/src/operations/test_utils.rs @@ -136,22 +136,25 @@ impl MockRing { &self.own_location } - pub fn should_seed(&self, _key: &ContractKey) -> bool { - // In tests, always willing to seed - true - } - pub fn is_seeding_contract(&self, key: &ContractKey) -> bool { self.seeding_contracts.lock().unwrap().contains(key) } - pub fn seed_contract(&self, key: ContractKey) { + pub fn seed_contract(&self, key: ContractKey, _size_bytes: u64) { let mut seeding = self.seeding_contracts.lock().unwrap(); if !seeding.contains(&key) { seeding.push(key); } } + pub fn record_get_access(&self, key: ContractKey, size_bytes: u64) { + self.seed_contract(key, size_bytes); + } + + pub fn record_subscribe_access(&self, key: ContractKey, size_bytes: u64) { + self.seed_contract(key, size_bytes); + } + /// Simulates k_closest_potentially_caching pub fn k_closest_potentially_caching( &self, @@ -274,7 +277,7 @@ mod tests { let key = make_contract_key(1); assert!(!ring.is_seeding_contract(&key)); - ring.seed_contract(key); + ring.seed_contract(key, 100); assert!(ring.is_seeding_contract(&key)); } diff --git a/crates/core/src/ring/mod.rs b/crates/core/src/ring/mod.rs index 0cc331da8..247b48b2d 100644 --- a/crates/core/src/ring/mod.rs +++ b/crates/core/src/ring/mod.rs @@ -37,10 +37,8 @@ mod connection; mod live_tx; mod location; mod peer_key_location; -mod score; mod seeding; - -use self::score::Score; +mod seeding_cache; pub use self::live_tx::LiveTransactionTracker; pub use connection::Connection; @@ -182,30 +180,33 @@ impl Ring { } } - /// Return if a contract is within appropiate seeding distance. - pub fn should_seed(&self, key: &ContractKey) -> bool { - match self.connection_manager.own_location().location() { - Some(own_loc) => self.seeding_manager.should_seed(key, own_loc), - None => { - tracing::debug!( - "should_seed: own location not yet available; deferring seeding decision" - ); - false - } - } + /// Record an access to a contract (GET, PUT, or SUBSCRIBE). + /// + /// This adds the contract to the seeding cache if not present, or refreshes + /// its LRU position if already cached. Returns the list of evicted contracts + /// that need cleanup (unsubscription, state removal, etc.). + /// + /// The `size_bytes` should be the size of the contract state. + pub fn seed_contract(&self, key: ContractKey, size_bytes: u64) -> Vec { + use seeding_cache::AccessType; + self.seeding_manager + .record_contract_access(key, size_bytes, AccessType::Put) } - /// Add a new subscription for this peer. - pub fn seed_contract(&self, key: ContractKey) -> (Option, Vec) { - match self.connection_manager.own_location().location() { - Some(own_loc) => self.seeding_manager.seed_contract(key, own_loc), - None => { - tracing::debug!( - "seed_contract: own location not yet available; skipping seeding for now" - ); - (None, Vec::new()) - } - } + /// Record a GET access to a contract. + pub fn record_get_access(&self, key: ContractKey, size_bytes: u64) -> Vec { + use seeding_cache::AccessType; + self.seeding_manager + .record_contract_access(key, size_bytes, AccessType::Get) + } + + /// Record a subscribe access for a contract (for future use when subscribe + /// operations directly record access rather than delegating to GET). + #[allow(dead_code)] + pub fn record_subscribe_access(&self, key: ContractKey, size_bytes: u64) -> Vec { + use seeding_cache::AccessType; + self.seeding_manager + .record_contract_access(key, size_bytes, AccessType::Subscribe) } /// Whether this node already is seeding to this contract or not. @@ -214,6 +215,12 @@ impl Ring { self.seeding_manager.is_seeding_contract(key) } + /// Remove a contract from the seeding cache (for future use in cleanup paths). + #[allow(dead_code)] + pub fn remove_seeded_contract(&self, key: &ContractKey) -> bool { + self.seeding_manager.remove_seeded_contract(key) + } + pub fn record_request( &self, recipient: PeerKeyLocation, diff --git a/crates/core/src/ring/seeding.rs b/crates/core/src/ring/seeding.rs index 9aa897597..4d2672052 100644 --- a/crates/core/src/ring/seeding.rs +++ b/crates/core/src/ring/seeding.rs @@ -1,9 +1,16 @@ -use super::{Location, PeerKeyLocation, Score}; +use super::seeding_cache::{AccessType, SeedingCache}; +use super::{Location, PeerKeyLocation}; use crate::transport::ObservedAddr; +use crate::util::time_source::InstantTimeSrc; use dashmap::{mapref::one::Ref as DmRef, DashMap}; use freenet_stdlib::prelude::ContractKey; +use parking_lot::RwLock; use tracing::{info, warn}; +/// Default seeding cache budget: 100MB +/// This can be made configurable via node configuration in the future. +const DEFAULT_SEEDING_BUDGET_BYTES: u64 = 100 * 1024 * 1024; + pub(crate) struct SeedingManager { /// The container for subscriber is a vec instead of something like a hashset /// that would allow for blind inserts of duplicate peers subscribing because @@ -11,8 +18,8 @@ pub(crate) struct SeedingManager { /// of subscribers more often than inserting, and anyways is a relatively short sequence /// then is more optimal to just use a vector for it's compact memory layout. subscribers: DashMap>, - /// Contracts this peer is seeding. - seeding_contract: DashMap, + /// LRU cache of contracts this peer is seeding, with byte-budget awareness. + seeding_cache: RwLock>, } impl SeedingManager { @@ -22,84 +29,64 @@ impl SeedingManager { /// All subscribers, including the upstream subscriber. const TOTAL_MAX_SUBSCRIPTIONS: usize = Self::MAX_SUBSCRIBERS + 1; - /// Max number of seeding contracts. - const MAX_SEEDING_CONTRACTS: usize = 100; - - /// Min number of seeding contracts. - const MIN_SEEDING_CONTRACTS: usize = Self::MAX_SEEDING_CONTRACTS / 4; - pub fn new() -> Self { Self { subscribers: DashMap::new(), - seeding_contract: DashMap::new(), + seeding_cache: RwLock::new(SeedingCache::new( + DEFAULT_SEEDING_BUDGET_BYTES, + InstantTimeSrc::new(), + )), } } - /// Return if a contract is within appropiate seeding distance. - pub fn should_seed(&self, key: &ContractKey, own_location: Location) -> bool { - const CACHING_DISTANCE: f64 = 0.05; - let caching_distance = super::Distance::new(CACHING_DISTANCE); - if self.seeding_contract.len() < Self::MIN_SEEDING_CONTRACTS { - return true; - } - let key_loc = Location::from(key); - if self.seeding_contract.len() < Self::MAX_SEEDING_CONTRACTS { - return own_location.distance(key_loc) <= caching_distance; - } - - let contract_score = self.calculate_seed_score(key, own_location); - let r = self - .seeding_contract - .iter() - .min_by_key(|v| *v.value()) - .unwrap(); - let min_score = *r.value(); - contract_score > min_score - } - - /// Add a new subscription for this peer. - pub fn seed_contract( + /// Record an access to a contract (GET, PUT, or SUBSCRIBE). + /// + /// This adds the contract to the seeding cache if not present, or refreshes + /// its LRU position if already cached. Returns the list of evicted contracts + /// that need cleanup (unsubscription, state removal, etc.). + /// + /// The `size_bytes` should be the size of the contract state. + /// + /// # Eviction handling + /// + /// Currently, eviction only removes local subscriber tracking. Full subscription + /// tree pruning (sending Unsubscribed to upstream peers) requires tracking + /// upstream->downstream relationships per contract, which is planned for #2164. + pub fn record_contract_access( &self, key: ContractKey, - own_location: Location, - ) -> (Option, Vec) { - let seed_score = self.calculate_seed_score(&key, own_location); - let mut old_subscribers = vec![]; - let mut contract_to_drop = None; - - // FIXME: reproduce this condition in tests - if self.seeding_contract.len() >= Self::MAX_SEEDING_CONTRACTS { - if let Some(dropped_contract) = self - .seeding_contract - .iter() - .min_by_key(|v| *v.value()) - .map(|entry| *entry.key()) - { - self.seeding_contract.remove(&dropped_contract); - if let Some((_, mut subscribers_of_contract)) = - self.subscribers.remove(&dropped_contract) - { - std::mem::swap(&mut subscribers_of_contract, &mut old_subscribers); - } - contract_to_drop = Some(dropped_contract); - } + size_bytes: u64, + access_type: AccessType, + ) -> Vec { + let evicted = self + .seeding_cache + .write() + .record_access(key, size_bytes, access_type); + + // Clean up subscribers for evicted contracts + for evicted_key in &evicted { + self.subscribers.remove(evicted_key); } - self.seeding_contract.insert(key, seed_score); - (contract_to_drop, old_subscribers) + evicted } - fn calculate_seed_score(&self, key: &ContractKey, own_location: Location) -> Score { - let key_loc = Location::from(key); - let distance = key_loc.distance(own_location); - let score = 0.5 - distance.as_f64(); - Score(score) - } - - /// Whether this node already is seeding to this contract or not. + /// Whether this node is currently caching/seeding this contract. #[inline] pub fn is_seeding_contract(&self, key: &ContractKey) -> bool { - self.seeding_contract.contains_key(key) + self.seeding_cache.read().contains(key) + } + + /// Remove a contract from the seeding cache (for future use in cleanup paths). + /// + /// Returns true if the contract was present and removed. + #[allow(dead_code)] + pub fn remove_seeded_contract(&self, key: &ContractKey) -> bool { + let removed = self.seeding_cache.write().remove(key).is_some(); + if removed { + self.subscribers.remove(key); + } + removed } /// Will return an error in case the max number of subscribers has been added. @@ -335,76 +322,79 @@ mod tests { } #[test] - fn test_should_seed_always_true_below_min() { + fn test_record_contract_access_adds_to_cache() { + use super::super::seeding_cache::AccessType; + let seeding_manager = SeedingManager::new(); - let own_location = Location::new(0.5); + let key = make_contract_key(1); - // With no contracts seeded, should always return true regardless of distance - assert!(seeding_manager.should_seed(&make_contract_key(1), own_location)); + // Initially not seeding + assert!(!seeding_manager.is_seeding_contract(&key)); - // Add contracts up to MIN_SEEDING_CONTRACTS - 1 (which is 24) - for i in 0..24 { - seeding_manager - .seeding_contract - .insert(make_contract_key(i), Score(0.1)); - } + // Record access + let evicted = seeding_manager.record_contract_access(key, 1000, AccessType::Get); - // Should still be true because we're below MIN (25) - assert_eq!(seeding_manager.seeding_contract.len(), 24); - assert!(seeding_manager.should_seed(&make_contract_key(200), own_location)); + // Now seeding + assert!(seeding_manager.is_seeding_contract(&key)); + assert!(evicted.is_empty()); // No eviction needed for small contract } #[test] - fn test_should_seed_distance_check_between_min_and_max() { + fn test_record_contract_access_evicts_when_over_budget() { + use super::super::seeding_cache::AccessType; + let seeding_manager = SeedingManager::new(); - // Seed MIN_SEEDING_CONTRACTS (25) contracts - for i in 0..25 { - seeding_manager - .seeding_contract - .insert(make_contract_key(i), Score(0.1)); - } - assert_eq!(seeding_manager.seeding_contract.len(), 25); - - // Now distance check applies - we need a contract location close to own_location - // Location::from(contract_key) gives a deterministic location based on key hash - let test_key = make_contract_key(100); - let key_location = Location::from(&test_key); - - // Own location very close to the key location should return true - let close_location = Location::new(key_location.as_f64()); - assert!(seeding_manager.should_seed(&test_key, close_location)); - - // Own location far from the key location should return false - // Adding 0.5 to wrap around (locations are on a ring 0.0-1.0) - let far_away = (key_location.as_f64() + 0.5).rem_euclid(1.0); - let far_location = Location::new(far_away); - assert!(!seeding_manager.should_seed(&test_key, far_location)); + // Add a contract that takes up most of the budget (100MB default) + let large_key = make_contract_key(1); + let evicted = seeding_manager.record_contract_access( + large_key, + 90 * 1024 * 1024, // 90MB + AccessType::Get, + ); + assert!(evicted.is_empty()); + assert!(seeding_manager.is_seeding_contract(&large_key)); + + // Add another large contract that should cause eviction + let another_large_key = make_contract_key(2); + let evicted = seeding_manager.record_contract_access( + another_large_key, + 20 * 1024 * 1024, // 20MB - total would be 110MB, over 100MB budget + AccessType::Put, + ); + + // The first contract should have been evicted + assert!(!evicted.is_empty()); + assert!(evicted.contains(&large_key)); + assert!(!seeding_manager.is_seeding_contract(&large_key)); + assert!(seeding_manager.is_seeding_contract(&another_large_key)); } #[test] - fn test_seed_contract_no_drop_below_max() { + fn test_eviction_clears_subscribers() { + use super::super::seeding_cache::AccessType; + let seeding_manager = SeedingManager::new(); - let own_location = Location::new(0.5); - // Add less than MAX contracts - for i in 0..50 { - seeding_manager - .seeding_contract - .insert(make_contract_key(i), Score(0.1)); - } + // Add a contract and a subscriber + let key = make_contract_key(1); + seeding_manager.record_contract_access(key, 90 * 1024 * 1024, AccessType::Get); - let new_key = make_contract_key(200); - let (dropped_contract, old_subscribers) = - seeding_manager.seed_contract(new_key, own_location); + let peer = PeerKeyLocation::new( + TransportKeypair::new().public().clone(), + SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), 5000), + ); + seeding_manager.add_subscriber(&key, peer, None).unwrap(); + assert!(seeding_manager.subscribers_of(&key).is_some()); - // No contract should be dropped when below max - assert!(dropped_contract.is_none()); - assert!(old_subscribers.is_empty()); + // Force eviction with another large contract + let new_key = make_contract_key(2); + let evicted = + seeding_manager.record_contract_access(new_key, 20 * 1024 * 1024, AccessType::Get); - // New contract should be added - assert!(seeding_manager.is_seeding_contract(&new_key)); - assert_eq!(seeding_manager.seeding_contract.len(), 51); + // Original contract should be evicted and subscribers cleared + assert!(evicted.contains(&key)); + assert!(seeding_manager.subscribers_of(&key).is_none()); } #[test] @@ -563,12 +553,14 @@ mod tests { #[test] fn test_is_seeding_contract() { + use super::super::seeding_cache::AccessType; + let seeding_manager = SeedingManager::new(); let key = make_contract_key(1); assert!(!seeding_manager.is_seeding_contract(&key)); - seeding_manager.seeding_contract.insert(key, Score(0.1)); + seeding_manager.record_contract_access(key, 1000, AccessType::Get); assert!(seeding_manager.is_seeding_contract(&key)); } diff --git a/crates/core/src/ring/seeding_cache.rs b/crates/core/src/ring/seeding_cache.rs new file mode 100644 index 000000000..450c61ac5 --- /dev/null +++ b/crates/core/src/ring/seeding_cache.rs @@ -0,0 +1,507 @@ +//! LRU-based seeding cache for contract state caching. +//! +//! This module implements byte-budget aware LRU caching for contracts. The design is based on +//! several key principles: +//! +//! 1. **Resource-aware eviction**: Large contracts consume more budget and may displace +//! multiple small contracts when space is needed. +//! +//! 2. **Demand-driven retention**: Contracts are kept based on access patterns (GET/PUT/SUBSCRIBE), +//! not arbitrary distance thresholds. +//! +//! 3. **Manipulation resistance**: Only GET, PUT, and SUBSCRIBE operations refresh a contract's +//! position in the cache. UPDATE operations (controlled by contract creators) do not. +//! +//! 4. **Self-regulating proximity**: Peers near a contract's location naturally see more GETs, +//! keeping nearby contracts fresh in their caches. + +use freenet_stdlib::prelude::ContractKey; +use std::collections::{HashMap, VecDeque}; +use std::time::Instant; + +use crate::util::time_source::TimeSource; + +/// Type of access that refreshes a contract's cache position. +/// +/// Only certain operations should refresh the LRU position to prevent manipulation: +/// - GET: User requesting the contract +/// - PUT: User writing new state +/// - SUBSCRIBE: User subscribing to updates +/// +/// UPDATE is explicitly excluded because contract creators control when updates happen, +/// which could be abused to keep contracts cached indefinitely. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AccessType { + Get, + Put, + #[allow(dead_code)] // Reserved for future direct subscribe tracking + Subscribe, +} + +/// Metadata about a cached contract. +#[derive(Debug, Clone)] +#[allow(dead_code)] // Fields accessed via public API +pub struct CachedContract { + /// The contract key + pub key: ContractKey, + /// Size of the contract state in bytes + pub size_bytes: u64, + /// Last time this contract was accessed (via GET/PUT/SUBSCRIBE) + pub last_accessed: Instant, + /// Type of the last access + pub last_access_type: AccessType, +} + +/// LRU cache for seeded contracts with byte-budget awareness. +/// +/// This cache maintains contracts that this peer is "seeding" - keeping available +/// for the network even after local subscribers have left. The cache has a byte +/// budget rather than a simple count limit, allowing it to fairly handle contracts +/// of varying sizes. +pub struct SeedingCache { + /// Maximum bytes to use for cached contracts + budget_bytes: u64, + /// Current total bytes used + current_bytes: u64, + /// LRU order - front is oldest, back is newest + lru_order: VecDeque, + /// Contract metadata indexed by key + contracts: HashMap, + /// Time source for testability + time_source: T, +} + +impl SeedingCache { + /// Create a new seeding cache with the given byte budget. + pub fn new(budget_bytes: u64, time_source: T) -> Self { + Self { + budget_bytes, + current_bytes: 0, + lru_order: VecDeque::new(), + contracts: HashMap::new(), + time_source, + } + } + + /// Record an access to a contract, potentially adding it to the cache. + /// + /// If the contract is already cached, this refreshes its LRU position. + /// If not cached, this adds it and evicts old contracts if necessary. + /// + /// Returns the list of contracts that were evicted to make room (if any). + pub fn record_access( + &mut self, + key: ContractKey, + size_bytes: u64, + access_type: AccessType, + ) -> Vec { + let now = self.time_source.now(); + let mut evicted = Vec::new(); + + if let Some(existing) = self.contracts.get_mut(&key) { + // Already cached - update size if changed and refresh position + if existing.size_bytes != size_bytes { + // Adjust byte accounting for size change + if size_bytes > existing.size_bytes { + self.current_bytes = self + .current_bytes + .saturating_add(size_bytes - existing.size_bytes); + } else { + self.current_bytes = self + .current_bytes + .saturating_sub(existing.size_bytes - size_bytes); + } + existing.size_bytes = size_bytes; + } + existing.last_accessed = now; + existing.last_access_type = access_type; + + // Move to back of LRU (most recently used) + // Note: This is O(n) which is acceptable for typical cache sizes (dozens to hundreds). + // For thousands of contracts, consider using the `lru` crate or a linked list. + self.lru_order.retain(|k| k != &key); + self.lru_order.push_back(key); + } else { + // Not cached - need to add it + // First, evict until we have room + while self.current_bytes + size_bytes > self.budget_bytes && !self.lru_order.is_empty() + { + if let Some(oldest_key) = self.lru_order.pop_front() { + if let Some(removed) = self.contracts.remove(&oldest_key) { + self.current_bytes = self.current_bytes.saturating_sub(removed.size_bytes); + evicted.push(oldest_key); + } + } + } + + // Add the new contract + let contract = CachedContract { + key, + size_bytes, + last_accessed: now, + last_access_type: access_type, + }; + self.contracts.insert(key, contract); + self.lru_order.push_back(key); + self.current_bytes = self.current_bytes.saturating_add(size_bytes); + } + + evicted + } + + /// Check if a contract is in the cache. + pub fn contains(&self, key: &ContractKey) -> bool { + self.contracts.contains_key(key) + } + + /// Get metadata about a cached contract. + #[allow(dead_code)] // Public API for introspection + pub fn get(&self, key: &ContractKey) -> Option<&CachedContract> { + self.contracts.get(key) + } + + /// Remove a contract from the cache. + /// + /// Returns the removed contract metadata, if it was present. + pub fn remove(&mut self, key: &ContractKey) -> Option { + if let Some(removed) = self.contracts.remove(key) { + self.lru_order.retain(|k| k != key); + self.current_bytes = self.current_bytes.saturating_sub(removed.size_bytes); + Some(removed) + } else { + None + } + } + + /// Get the current number of cached contracts. + #[allow(dead_code)] // Public API for introspection + pub fn len(&self) -> usize { + self.contracts.len() + } + + /// Check if the cache is empty. + #[allow(dead_code)] // Public API for introspection + pub fn is_empty(&self) -> bool { + self.contracts.is_empty() + } + + /// Get the current bytes used. + #[allow(dead_code)] // Public API for introspection + pub fn current_bytes(&self) -> u64 { + self.current_bytes + } + + /// Get the budget in bytes. + #[allow(dead_code)] // Public API for introspection + pub fn budget_bytes(&self) -> u64 { + self.budget_bytes + } + + /// Get all cached contract keys in LRU order (oldest first). + #[cfg(test)] + pub fn keys_lru_order(&self) -> Vec { + self.lru_order.iter().cloned().collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::util::time_source::MockTimeSource; + use freenet_stdlib::prelude::ContractInstanceId; + use std::time::Duration; + + fn make_key(seed: u8) -> ContractKey { + ContractKey::from(ContractInstanceId::new([seed; 32])) + } + + fn make_cache(budget: u64) -> SeedingCache { + let time_source = MockTimeSource::new(Instant::now()); + SeedingCache::new(budget, time_source) + } + + #[test] + fn test_empty_cache() { + let cache = make_cache(1000); + 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); + let key = make_key(1); + + let evicted = cache.record_access(key, 100, AccessType::Get); + + assert!(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.last_access_type, AccessType::Get); + } + + #[test] + fn test_refresh_existing_contract() { + let time_source = MockTimeSource::new(Instant::now()); + let mut cache = SeedingCache::new(1000, time_source.clone()); + let key = make_key(1); + + // First access + cache.record_access(key, 100, AccessType::Get); + let first_access = cache.get(&key).unwrap().last_accessed; + + // Advance time and access again + let mut new_time_source = time_source.clone(); + new_time_source.advance_time(Duration::from_secs(10)); + cache.time_source = new_time_source; + + cache.record_access(key, 100, AccessType::Put); + + // Should still be one contract, but updated + assert_eq!(cache.len(), 1); + assert_eq!(cache.current_bytes(), 100); // Size unchanged + + let info = cache.get(&key).unwrap(); + assert_eq!(info.last_access_type, AccessType::Put); + assert!(info.last_accessed > first_access); + } + + #[test] + fn test_lru_eviction_by_budget() { + let mut cache = make_cache(250); // Can hold 2.5 contracts of 100 bytes + + let key1 = make_key(1); + let key2 = make_key(2); + let key3 = make_key(3); + + // Add first two contracts - should fit + cache.record_access(key1, 100, AccessType::Get); + cache.record_access(key2, 100, AccessType::Get); + assert_eq!(cache.len(), 2); + assert_eq!(cache.current_bytes(), 200); + + // Add third - should evict first + let evicted = cache.record_access(key3, 100, AccessType::Get); + + assert_eq!(evicted.len(), 1); + assert_eq!(evicted[0], key1); + assert!(!cache.contains(&key1)); + assert!(cache.contains(&key2)); + assert!(cache.contains(&key3)); + assert_eq!(cache.len(), 2); + assert_eq!(cache.current_bytes(), 200); + } + + #[test] + fn test_large_contract_evicts_multiple() { + let mut cache = make_cache(300); + + let small1 = make_key(1); + let small2 = make_key(2); + let small3 = make_key(3); + let large = make_key(4); + + // Add three small contracts + cache.record_access(small1, 100, AccessType::Get); + cache.record_access(small2, 100, AccessType::Get); + cache.record_access(small3, 100, AccessType::Get); + assert_eq!(cache.len(), 3); + assert_eq!(cache.current_bytes(), 300); + + // Add one large contract - should evict two small ones + let evicted = cache.record_access(large, 200, AccessType::Put); + + assert_eq!(evicted.len(), 2); + assert_eq!(evicted[0], small1); // Oldest first + assert_eq!(evicted[1], small2); + assert!(!cache.contains(&small1)); + assert!(!cache.contains(&small2)); + assert!(cache.contains(&small3)); + assert!(cache.contains(&large)); + assert_eq!(cache.len(), 2); + assert_eq!(cache.current_bytes(), 300); + } + + #[test] + fn test_access_refreshes_lru_position() { + let mut cache = make_cache(250); + + let key1 = make_key(1); + let key2 = make_key(2); + let key3 = make_key(3); + + // Add two contracts + cache.record_access(key1, 100, AccessType::Get); + cache.record_access(key2, 100, AccessType::Get); + + // Access key1 again - should move it to back of LRU + cache.record_access(key1, 100, AccessType::Subscribe); + + // LRU order should now be [key2, key1] + let order = cache.keys_lru_order(); + assert_eq!(order, vec![key2, key1]); + + // Add key3 - should evict key2 (now oldest) + let evicted = cache.record_access(key3, 100, AccessType::Get); + + assert_eq!(evicted, vec![key2]); + assert!(cache.contains(&key1)); + assert!(!cache.contains(&key2)); + assert!(cache.contains(&key3)); + } + + #[test] + fn test_remove_contract() { + let mut cache = make_cache(1000); + let key = make_key(1); + + cache.record_access(key, 100, AccessType::Get); + assert!(cache.contains(&key)); + assert_eq!(cache.current_bytes(), 100); + + let removed = cache.remove(&key); + assert!(removed.is_some()); + assert_eq!(removed.unwrap().size_bytes, 100); + assert!(!cache.contains(&key)); + assert_eq!(cache.current_bytes(), 0); + assert!(cache.is_empty()); + } + + #[test] + fn test_remove_nonexistent() { + let mut cache = make_cache(1000); + let key = make_key(1); + + let removed = cache.remove(&key); + assert!(removed.is_none()); + } + + #[test] + fn test_access_types() { + let mut cache = make_cache(1000); + let key = make_key(1); + + // Test each access type is recorded correctly + cache.record_access(key, 100, AccessType::Get); + assert_eq!(cache.get(&key).unwrap().last_access_type, AccessType::Get); + + cache.record_access(key, 100, AccessType::Put); + assert_eq!(cache.get(&key).unwrap().last_access_type, AccessType::Put); + + cache.record_access(key, 100, AccessType::Subscribe); + assert_eq!( + cache.get(&key).unwrap().last_access_type, + AccessType::Subscribe + ); + } + + #[test] + fn test_zero_budget_edge_case() { + let mut cache = make_cache(0); + let key = make_key(1); + + // Edge case: With zero budget, the eviction loop condition + // `current_bytes + size_bytes > budget_bytes` is true (0 + 100 > 0), + // but there's nothing to evict since the cache is empty. + // The contract gets added anyway, exceeding the budget. + // This documents that budget is a soft limit when the cache is empty. + let evicted = cache.record_access(key, 100, AccessType::Get); + + assert!(evicted.is_empty()); // Nothing was evicted + assert!(cache.contains(&key)); // Contract was added + assert_eq!(cache.current_bytes(), 100); // Budget exceeded + } + + #[test] + fn test_exact_budget_fit() { + let mut cache = make_cache(100); + let key = make_key(1); + + let evicted = cache.record_access(key, 100, AccessType::Get); + + assert!(evicted.is_empty()); + assert!(cache.contains(&key)); + assert_eq!(cache.current_bytes(), 100); + assert_eq!(cache.budget_bytes(), 100); + } + + #[test] + fn test_contract_larger_than_budget() { + let mut cache = make_cache(100); + + // First add a small contract + let small = make_key(1); + cache.record_access(small, 50, AccessType::Get); + + // Now add one larger than budget - should evict small and still add + let large = make_key(2); + let evicted = cache.record_access(large, 150, AccessType::Get); + + assert_eq!(evicted, vec![small]); + // Design decision: Large contracts are still added even if they exceed budget. + // The budget is a soft limit - we evict as much as possible but don't refuse + // to cache. This prevents contracts from becoming unfindable just because + // they're large. The cache will naturally evict oversized contracts when + // new contracts arrive, as they'll be displaced first. + assert!(cache.contains(&large)); + assert_eq!(cache.current_bytes(), 150); + } + + #[test] + fn test_contract_size_change_increases() { + let mut cache = make_cache(1000); + let key = make_key(1); + + // Add contract with initial size + cache.record_access(key, 100, AccessType::Get); + assert_eq!(cache.current_bytes(), 100); + assert_eq!(cache.get(&key).unwrap().size_bytes, 100); + + // Contract state grows (e.g., through PUT operation) + cache.record_access(key, 200, AccessType::Put); + assert_eq!(cache.current_bytes(), 200); + assert_eq!(cache.get(&key).unwrap().size_bytes, 200); + } + + #[test] + fn test_contract_size_change_decreases() { + let mut cache = make_cache(1000); + let key = make_key(1); + + // Add contract with initial size + cache.record_access(key, 200, AccessType::Get); + assert_eq!(cache.current_bytes(), 200); + + // Contract state shrinks + 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_contract_size_change_triggers_no_eviction() { + // Size changes to existing contracts don't trigger eviction + // (only new contracts can trigger eviction) + let mut cache = make_cache(200); + let key1 = make_key(1); + let key2 = make_key(2); + + // Add two contracts at 100 bytes each + cache.record_access(key1, 100, AccessType::Get); + cache.record_access(key2, 100, AccessType::Get); + assert_eq!(cache.current_bytes(), 200); + + // key1 grows to 150 bytes - exceeds budget but doesn't evict key2 + // because we're updating an existing contract, not adding a new one + cache.record_access(key1, 150, AccessType::Put); + assert_eq!(cache.current_bytes(), 250); // Over budget + assert!(cache.contains(&key1)); + assert!(cache.contains(&key2)); // key2 NOT evicted + } +}