dig_store_cache/policy.rs
1//! The eviction policy trait + the default LRU implementation.
2//!
3//! Eviction is a PLUGGABLE decision, deliberately factored behind a trait so future rules
4//! (min-free-disk, TTL, access-heat, publisher-owned pinning) drop in as new [`EvictionPolicy`]
5//! implementations reading new [`EvictionContext`] fields — additively, with no change to the cache's
6//! public API. In v0.1.0 the only implementation is [`LruPolicy`] (least-recently-used).
7//!
8//! The one INVARIANT every policy must uphold: it MUST NEVER select a pinned entry. Pins are the
9//! caller's explicit "keep this" override (an owned/republished capsule, content the node is a primary
10//! host for); the cache relies on the policy respecting them and does not re-filter the returned set.
11
12use dig_store::CapsuleIdentity;
13
14/// A single cache entry as the policy sees it — enough to rank it for eviction, nothing more.
15#[derive(Debug, Clone, Copy)]
16pub struct EvictionEntry {
17 /// The capsule this entry holds.
18 pub id: CapsuleIdentity,
19 /// Its on-disk size in bytes.
20 pub size: u64,
21 /// A monotonically increasing recency stamp — larger means more recently accessed/admitted.
22 pub last_access: u64,
23 /// Whether the caller has pinned this entry. A policy MUST NOT return a pinned entry.
24 pub pinned: bool,
25}
26
27/// The read-only view a policy reasons over when choosing what to evict.
28pub struct EvictionContext<'a> {
29 /// Every current cache entry EXCEPT the one being admitted.
30 pub entries: &'a [EvictionEntry],
31 /// Bytes currently held by `entries` (excludes the incoming capsule).
32 pub current_bytes: u64,
33 /// The cache capacity in bytes.
34 pub capacity: u64,
35 /// The size of the capsule being admitted (0 for a reconcile/reconfigure sweep).
36 pub incoming_size: u64,
37}
38
39impl EvictionContext<'_> {
40 /// How many bytes must be freed so `current_bytes + incoming_size <= capacity`; 0 if it fits.
41 pub fn bytes_to_free(&self) -> u64 {
42 (self.current_bytes.saturating_add(self.incoming_size)).saturating_sub(self.capacity)
43 }
44}
45
46/// Chooses which capsules to evict to make room. See the module docs for the pinned invariant.
47pub trait EvictionPolicy: Send + Sync {
48 /// Return the capsules to evict (in eviction order) to satisfy [`EvictionContext::bytes_to_free`].
49 /// MUST NOT include a pinned entry. Returning too few is allowed — the cache then admits over
50 /// capacity (only pins can force that), which is the caller's explicit choice.
51 fn select_evictions(&self, ctx: &EvictionContext<'_>) -> Vec<CapsuleIdentity>;
52}
53
54/// Least-recently-used eviction: evict the coldest unpinned entries first until enough room is freed.
55#[derive(Debug, Default, Clone, Copy)]
56pub struct LruPolicy;
57
58impl EvictionPolicy for LruPolicy {
59 fn select_evictions(&self, ctx: &EvictionContext<'_>) -> Vec<CapsuleIdentity> {
60 let need = ctx.bytes_to_free();
61 if need == 0 {
62 return Vec::new();
63 }
64
65 let mut coldest_first: Vec<&EvictionEntry> =
66 ctx.entries.iter().filter(|entry| !entry.pinned).collect();
67 coldest_first.sort_by_key(|entry| entry.last_access);
68
69 let mut freed = 0u64;
70 let mut victims = Vec::new();
71 for entry in coldest_first {
72 if freed >= need {
73 break;
74 }
75 freed = freed.saturating_add(entry.size);
76 victims.push(entry.id);
77 }
78 victims
79 }
80}