#![allow(dead_code)]
use super::cache;
use freenet_stdlib::prelude::ContractKey;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum MemoryPressure {
UnderBudget,
AtCapacity,
Overflow,
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct AdmissionCandidate {
pub subscriber_count: usize,
pub fanout_cost: f64,
}
#[derive(Debug, Clone)]
pub(crate) struct AdmissionIncumbent {
pub key: ContractKey,
pub subscriber_count: usize,
pub last_get_seq: u64,
}
#[derive(Debug, Clone)]
pub(crate) struct AdmissionInputs {
pub candidate: AdmissionCandidate,
pub hosted: Vec<AdmissionIncumbent>,
pub pressure: MemoryPressure,
pub fanout_headroom: f64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum AdmissionOutcome {
Admit,
EvictToAdmit(Eviction),
Refuse(RefuseReason),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Eviction {
pub victim: ContractKey,
pub kind: EvictionKind,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum EvictionKind {
Displacement,
OomValve,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum RefuseReason {
FanoutExceedsHeadroom,
AtCapacityNoVictim,
}
pub(crate) fn admission_decision(inputs: &AdmissionInputs) -> AdmissionOutcome {
let candidate = &inputs.candidate;
debug_assert!(
candidate.subscriber_count >= 1,
"admission newcomer must arrive with >= 1 subscriber (a fresh subscription \
is 1); a 0-subscriber candidate can displace nothing (0 < 0 is vacuous)"
);
if candidate.fanout_cost > inputs.fanout_headroom {
return AdmissionOutcome::Refuse(RefuseReason::FanoutExceedsHeadroom);
}
if inputs.pressure == MemoryPressure::UnderBudget {
return AdmissionOutcome::Admit;
}
if let Some(victim) = pick_displacement_victim(&inputs.hosted, candidate.subscriber_count) {
return AdmissionOutcome::EvictToAdmit(Eviction {
victim,
kind: EvictionKind::Displacement,
});
}
if inputs.pressure == MemoryPressure::Overflow {
if let Some(victim) = pick_oom_valve_victim(&inputs.hosted) {
return AdmissionOutcome::EvictToAdmit(Eviction {
victim,
kind: EvictionKind::OomValve,
});
}
}
AdmissionOutcome::Refuse(RefuseReason::AtCapacityNoVictim)
}
fn pick_displacement_victim(
hosted: &[AdmissionIncumbent],
candidate_subscriber_count: usize,
) -> Option<ContractKey> {
hosted
.iter()
.filter(|c| c.subscriber_count < candidate_subscriber_count)
.min_by(|a, b| {
cache::victim_order(
(a.subscriber_count, a.last_get_seq, &a.key),
(b.subscriber_count, b.last_get_seq, &b.key),
)
})
.map(|c| c.key)
}
fn pick_oom_valve_victim(hosted: &[AdmissionIncumbent]) -> Option<ContractKey> {
hosted
.iter()
.min_by(|a, b| {
cache::victim_order(
(a.subscriber_count, a.last_get_seq, &a.key),
(b.subscriber_count, b.last_get_seq, &b.key),
)
})
.map(|c| c.key)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ring::hosting::cache::{AccessType, HostingCache, MemoryPressure as CachePressure};
use crate::util::time_source::SharedMockTimeSource;
use freenet_stdlib::prelude::{CodeHash, ContractInstanceId};
use std::collections::HashMap;
use std::time::Duration;
fn make_key(seed: u8) -> ContractKey {
ContractKey::from_id_and_code(
ContractInstanceId::new([seed; 32]),
CodeHash::new([seed.wrapping_add(1); 32]),
)
}
fn hosted(seed: u8, subscriber_count: usize, last_get_seq: u64) -> AdmissionIncumbent {
AdmissionIncumbent {
key: make_key(seed),
subscriber_count,
last_get_seq,
}
}
fn candidate() -> AdmissionCandidate {
AdmissionCandidate {
subscriber_count: 1,
fanout_cost: 1.0,
}
}
fn candidate_with_subs(subscriber_count: usize) -> AdmissionCandidate {
AdmissionCandidate {
subscriber_count,
fanout_cost: 1.0,
}
}
fn inputs(
candidate: AdmissionCandidate,
hosted: Vec<AdmissionIncumbent>,
pressure: MemoryPressure,
fanout_headroom: f64,
) -> AdmissionInputs {
AdmissionInputs {
candidate,
hosted,
pressure,
fanout_headroom,
}
}
#[test]
fn under_budget_admits_without_eviction() {
let got = admission_decision(&inputs(
candidate(),
vec![hosted(1, 0, 5)],
MemoryPressure::UnderBudget,
10.0,
));
assert_eq!(got, AdmissionOutcome::Admit);
}
#[test]
fn under_budget_empty_hosted_set_admits() {
let got = admission_decision(&inputs(
candidate(),
vec![],
MemoryPressure::UnderBudget,
10.0,
));
assert_eq!(got, AdmissionOutcome::Admit);
}
#[test]
fn fanout_over_headroom_refuses_even_under_budget() {
let mut c = candidate();
c.fanout_cost = 5.0;
let got = admission_decision(&inputs(c, vec![], MemoryPressure::UnderBudget, 4.0));
assert_eq!(
got,
AdmissionOutcome::Refuse(RefuseReason::FanoutExceedsHeadroom)
);
}
#[test]
fn fanout_over_headroom_refuses_at_capacity() {
let mut c = candidate();
c.fanout_cost = 5.0;
let got = admission_decision(&inputs(
c,
vec![hosted(1, 0, 5)],
MemoryPressure::AtCapacity,
4.0,
));
assert_eq!(
got,
AdmissionOutcome::Refuse(RefuseReason::FanoutExceedsHeadroom)
);
}
#[test]
fn fanout_over_headroom_refuses_under_overflow() {
let mut c = candidate();
c.fanout_cost = 5.0;
let got = admission_decision(&inputs(
c,
vec![hosted(1, 2, 5)],
MemoryPressure::Overflow,
4.0,
));
assert_eq!(
got,
AdmissionOutcome::Refuse(RefuseReason::FanoutExceedsHeadroom)
);
}
#[test]
fn fanout_exactly_at_headroom_is_affordable() {
let mut c = candidate();
c.fanout_cost = 4.0;
let got = admission_decision(&inputs(c, vec![], MemoryPressure::UnderBudget, 4.0));
assert_eq!(got, AdmissionOutcome::Admit);
}
#[test]
fn at_capacity_with_zero_sub_incumbent_displaces_for_one_sub_newcomer() {
let victim_key = make_key(7);
let got = admission_decision(&inputs(
candidate(),
vec![AdmissionIncumbent {
key: victim_key,
subscriber_count: 0,
last_get_seq: 3,
}],
MemoryPressure::AtCapacity,
10.0,
));
assert_eq!(
got,
AdmissionOutcome::EvictToAdmit(Eviction {
victim: victim_key,
kind: EvictionKind::Displacement,
})
);
}
#[test]
fn at_capacity_higher_sub_newcomer_displaces_lower_sub_incumbent() {
let victim_key = make_key(8);
let got = admission_decision(&inputs(
candidate_with_subs(3),
vec![AdmissionIncumbent {
key: victim_key,
subscriber_count: 2,
last_get_seq: 4,
}],
MemoryPressure::AtCapacity,
10.0,
));
assert_eq!(
got,
AdmissionOutcome::EvictToAdmit(Eviction {
victim: victim_key,
kind: EvictionKind::Displacement,
})
);
}
#[test]
fn at_capacity_no_incumbents_refuses() {
let got = admission_decision(&inputs(
candidate(),
vec![],
MemoryPressure::AtCapacity,
10.0,
));
assert_eq!(
got,
AdmissionOutcome::Refuse(RefuseReason::AtCapacityNoVictim)
);
}
#[test]
fn at_capacity_equal_sub_incumbent_refuses() {
let got = admission_decision(&inputs(
candidate(),
vec![hosted(1, 1, 3)],
MemoryPressure::AtCapacity,
10.0,
));
assert_eq!(
got,
AdmissionOutcome::Refuse(RefuseReason::AtCapacityNoVictim)
);
}
#[test]
fn at_capacity_higher_sub_incumbent_refuses() {
let got = admission_decision(&inputs(
candidate(),
vec![hosted(1, 2, 3)],
MemoryPressure::AtCapacity,
10.0,
));
assert_eq!(
got,
AdmissionOutcome::Refuse(RefuseReason::AtCapacityNoVictim)
);
}
#[test]
fn zero_subscriber_candidate_displaces_nothing() {
let victim = pick_displacement_victim(&[hosted(1, 0, 3)], 0);
assert_eq!(victim, None);
}
#[test]
fn victim_selection_prefers_fewest_subscribers() {
let fewest = make_key(2);
let got = admission_decision(&inputs(
candidate_with_subs(3),
vec![
hosted(1, 2, 1),
AdmissionIncumbent {
key: fewest,
subscriber_count: 1,
last_get_seq: 99,
},
],
MemoryPressure::AtCapacity,
10.0,
));
assert_eq!(
got,
AdmissionOutcome::EvictToAdmit(Eviction {
victim: fewest,
kind: EvictionKind::Displacement,
})
);
}
#[test]
fn victim_selection_prefers_least_recent_get_within_same_sub_count() {
let stale = make_key(3);
let got = admission_decision(&inputs(
candidate(),
vec![
hosted(1, 0, 50),
AdmissionIncumbent {
key: stale,
subscriber_count: 0,
last_get_seq: 5,
},
],
MemoryPressure::AtCapacity,
10.0,
));
assert_eq!(
got,
AdmissionOutcome::EvictToAdmit(Eviction {
victim: stale,
kind: EvictionKind::Displacement,
})
);
}
#[test]
fn oom_valve_sheds_fewest_sub_when_no_strict_victim() {
let fewest = make_key(4);
let got = admission_decision(&inputs(
candidate(),
vec![
hosted(1, 3, 10),
AdmissionIncumbent {
key: fewest,
subscriber_count: 1,
last_get_seq: 10,
},
],
MemoryPressure::Overflow,
10.0,
));
assert_eq!(
got,
AdmissionOutcome::EvictToAdmit(Eviction {
victim: fewest,
kind: EvictionKind::OomValve,
})
);
}
#[test]
fn overflow_prefers_displacement_over_valve() {
let zero_sub = make_key(5);
let got = admission_decision(&inputs(
candidate(),
vec![
hosted(1, 5, 2),
AdmissionIncumbent {
key: zero_sub,
subscriber_count: 0,
last_get_seq: 8,
},
],
MemoryPressure::Overflow,
10.0,
));
assert_eq!(
got,
AdmissionOutcome::EvictToAdmit(Eviction {
victim: zero_sub,
kind: EvictionKind::Displacement,
})
);
}
#[test]
fn overflow_empty_hosted_set_refuses() {
let got = admission_decision(&inputs(candidate(), vec![], MemoryPressure::Overflow, 10.0));
assert_eq!(
got,
AdmissionOutcome::Refuse(RefuseReason::AtCapacityNoVictim)
);
}
#[test]
fn oom_valve_tiebreak_by_least_recent_get_within_equal_sub_count() {
let stale = make_key(6);
let got = admission_decision(&inputs(
candidate(),
vec![
hosted(1, 2, 40),
AdmissionIncumbent {
key: stale,
subscriber_count: 2,
last_get_seq: 7,
},
hosted(2, 2, 60),
],
MemoryPressure::Overflow,
10.0,
));
assert_eq!(
got,
AdmissionOutcome::EvictToAdmit(Eviction {
victim: stale,
kind: EvictionKind::OomValve,
})
);
}
#[test]
fn victim_selection_is_deterministic_on_ties() {
let k_a = make_key(10);
let k_b = make_key(20);
let mk = |first: ContractKey, second: ContractKey| {
admission_decision(&inputs(
candidate(),
vec![
AdmissionIncumbent {
key: first,
subscriber_count: 0,
last_get_seq: 4,
},
AdmissionIncumbent {
key: second,
subscriber_count: 0,
last_get_seq: 4,
},
],
MemoryPressure::AtCapacity,
10.0,
))
};
let forward = mk(k_a, k_b);
let reversed = mk(k_b, k_a);
assert_eq!(forward, reversed, "victim choice must not depend on order");
let expected = if k_a.as_bytes() <= k_b.as_bytes() {
k_a
} else {
k_b
};
assert_eq!(
forward,
AdmissionOutcome::EvictToAdmit(Eviction {
victim: expected,
kind: EvictionKind::Displacement,
})
);
}
#[test]
fn oom_valve_selection_is_deterministic_on_ties() {
let k_a = make_key(11);
let k_b = make_key(21);
let mk = |first: ContractKey, second: ContractKey| {
admission_decision(&inputs(
candidate(),
vec![
AdmissionIncumbent {
key: first,
subscriber_count: 2,
last_get_seq: 7,
},
AdmissionIncumbent {
key: second,
subscriber_count: 2,
last_get_seq: 7,
},
],
MemoryPressure::Overflow,
10.0,
))
};
let forward = mk(k_a, k_b);
let reversed = mk(k_b, k_a);
assert_eq!(forward, reversed, "valve victim must not depend on order");
let expected = if k_a.as_bytes() <= k_b.as_bytes() {
k_a
} else {
k_b
};
assert_eq!(
forward,
AdmissionOutcome::EvictToAdmit(Eviction {
victim: expected,
kind: EvictionKind::OomValve,
})
);
}
fn cross_module_fixture(
budget: u64,
ttl: Duration,
) -> (
HostingCache<SharedMockTimeSource>,
SharedMockTimeSource,
HashMap<ContractKey, usize>,
Vec<AdmissionIncumbent>,
) {
let subs: HashMap<ContractKey, usize> = HashMap::from([
(make_key(2), 0),
(make_key(3), 0),
(make_key(4), 2),
(make_key(5), 1),
]);
let subs_of = |k: &ContractKey| subs.get(k).copied().unwrap_or(0);
let time = SharedMockTimeSource::new();
let mut cache = HostingCache::new(budget, ttl, time.clone());
for seed in [3u8, 2, 5, 4] {
let key = make_key(seed);
let r = cache.record_access(key, 100, AccessType::Get, 0, &subs_of);
assert!(r.is_new, "fixture keys must be distinct fresh inserts");
}
let hosted: Vec<AdmissionIncumbent> = subs
.keys()
.map(|k| AdmissionIncumbent {
key: *k,
subscriber_count: subs_of(k),
last_get_seq: cache.get(k).expect("inserted above").last_get_seq,
})
.collect();
(cache, time, subs, hosted)
}
#[test]
fn admission_and_cache_agree_on_full_eviction_order() {
let (mut cache, _time, subs, hosted) = cross_module_fixture(0, Duration::from_secs(3600));
let subs_of = |k: &ContractKey| subs.get(k).copied().unwrap_or(0);
let mut remaining = hosted.clone();
let mut admission_order = Vec::new();
while let Some(victim) = pick_oom_valve_victim(&remaining) {
admission_order.push(victim);
remaining.retain(|c| c.key != victim);
}
let cache_order: Vec<ContractKey> = cache
.sweep_expired(&subs_of, CachePressure::Overflow)
.into_iter()
.map(|(k, _)| k)
.collect();
assert_eq!(
admission_order, cache_order,
"admission and cache must produce the same eviction order — both go \
through cache::victim_order"
);
assert_eq!(
cache_order,
vec![make_key(3), make_key(2), make_key(5), make_key(4)],
"expected fewest-subscriber-then-least-recent-GET order"
);
}
#[test]
fn admission_and_cache_agree_on_at_capacity_victim() {
let ttl = Duration::from_secs(60);
let (mut cache, time, subs, hosted) = cross_module_fixture(350, ttl);
let subs_of = |k: &ContractKey| subs.get(k).copied().unwrap_or(0);
time.advance_time(ttl + Duration::from_secs(1));
let admission_victim =
pick_displacement_victim(&hosted, 1).expect("a zero-sub incumbent is displaceable");
let evicted = cache.sweep_expired(&subs_of, CachePressure::AtCapacity);
assert_eq!(
evicted.len(),
1,
"budget 350 sheds exactly one of four 100B entries"
);
let cache_victim = evicted[0].0;
assert_eq!(
admission_victim, cache_victim,
"admission displacement and cache AtCapacity eviction must pick the \
same victim — both go through cache::victim_order"
);
assert_eq!(
cache_victim,
make_key(3),
"the least-recent-GET zero-subscriber contract is the first victim"
);
}
}