Skip to main content

sim_lib_stream_host/
store_evict.rs

1//! Size-bounded content store with modeled retention eviction.
2
3use std::collections::{BTreeMap, VecDeque};
4
5use sim_kernel::{Error, Expr, Result, Symbol};
6
7/// Stable key for a content-store entry.
8#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
9pub struct StoreKey(Symbol);
10
11impl StoreKey {
12    /// Builds a store key from a symbol.
13    pub fn new(symbol: Symbol) -> Self {
14        Self(symbol)
15    }
16
17    /// Builds a stream-host content key.
18    pub fn named(name: impl Into<String>) -> Self {
19        Self(Symbol::qualified("stream/content", name.into()))
20    }
21
22    /// Returns the backing symbol.
23    pub fn as_symbol(&self) -> &Symbol {
24        &self.0
25    }
26}
27
28/// One sample-by-reference frame stored under bounded host policy.
29#[derive(Clone, Debug, PartialEq)]
30pub struct ContentFrame {
31    key: StoreKey,
32    session: Symbol,
33    receipt_seq: u64,
34    inserted_tick: u64,
35    size_bytes: usize,
36    value: Expr,
37}
38
39impl ContentFrame {
40    /// Builds a stored content frame.
41    pub fn new(
42        key: StoreKey,
43        session: Symbol,
44        receipt_seq: u64,
45        inserted_tick: u64,
46        size_bytes: usize,
47        value: Expr,
48    ) -> Self {
49        Self {
50            key,
51            session,
52            receipt_seq,
53            inserted_tick,
54            size_bytes,
55            value,
56        }
57    }
58
59    /// Returns this frame's key.
60    pub fn key(&self) -> &StoreKey {
61        &self.key
62    }
63
64    /// Returns this frame's owning session.
65    pub fn session(&self) -> &Symbol {
66        &self.session
67    }
68
69    /// Returns the governing consent receipt sequence.
70    pub fn receipt_seq(&self) -> u64 {
71        self.receipt_seq
72    }
73
74    /// Returns the modeled insertion tick.
75    pub fn inserted_tick(&self) -> u64 {
76        self.inserted_tick
77    }
78
79    /// Returns the frame size counted against the store bound.
80    pub fn size_bytes(&self) -> usize {
81        self.size_bytes
82    }
83
84    /// Returns the stored expression value.
85    pub fn value(&self) -> &Expr {
86        &self.value
87    }
88}
89
90/// Retention window assigned by one visible consent receipt.
91#[derive(Clone, Debug, PartialEq, Eq)]
92pub struct RetentionWindow {
93    /// Session that owns the consent receipt.
94    pub session: Symbol,
95    /// Receipt sequence number.
96    pub receipt_seq: u64,
97    /// Retention window in modeled milliseconds.
98    pub retain_ms: u64,
99}
100
101impl RetentionWindow {
102    /// Builds a retention window.
103    pub fn new(session: Symbol, receipt_seq: u64, retain_ms: u64) -> Self {
104        Self {
105            session,
106            receipt_seq,
107            retain_ms,
108        }
109    }
110}
111
112/// One removed content-store entry.
113#[derive(Clone, Debug, PartialEq, Eq)]
114pub struct StoreEvicted {
115    /// Removed key.
116    pub key: StoreKey,
117    /// Eviction reason.
118    pub reason: Symbol,
119}
120
121/// Size-bounded store for sample-by-reference frames.
122#[derive(Clone, Debug, PartialEq)]
123pub struct BoundedContentStore {
124    max_bytes: usize,
125    used_bytes: usize,
126    entries: BTreeMap<StoreKey, ContentFrame>,
127    order: VecDeque<StoreKey>,
128}
129
130impl BoundedContentStore {
131    /// Builds an empty store with a byte limit.
132    pub fn new(max_bytes: usize) -> Result<Self> {
133        if max_bytes == 0 {
134            return Err(Error::Eval(
135                "content store bound must be greater than zero".to_owned(),
136            ));
137        }
138        Ok(Self {
139            max_bytes,
140            used_bytes: 0,
141            entries: BTreeMap::new(),
142            order: VecDeque::new(),
143        })
144    }
145
146    /// Inserts a frame, evicting oldest frames until the size bound holds.
147    pub fn insert(&mut self, frame: ContentFrame) -> Result<Vec<StoreEvicted>> {
148        if frame.size_bytes > self.max_bytes {
149            return Err(Error::Eval(format!(
150                "content frame {} bytes exceeds store bound {}",
151                frame.size_bytes, self.max_bytes
152            )));
153        }
154        let mut evicted = Vec::new();
155        if self.remove_existing(&frame.key).is_some() {
156            evicted.push(StoreEvicted {
157                key: frame.key.clone(),
158                reason: replaced_reason(),
159            });
160        }
161        while self.used_bytes.saturating_add(frame.size_bytes) > self.max_bytes {
162            if let Some(item) = self.evict_oldest(size_bound_reason()) {
163                evicted.push(item);
164            } else {
165                break;
166            }
167        }
168        self.used_bytes = self.used_bytes.saturating_add(frame.size_bytes);
169        self.order.push_back(frame.key.clone());
170        self.entries.insert(frame.key.clone(), frame);
171        Ok(evicted)
172    }
173
174    /// Sweeps frames older than their modeled retention window.
175    pub fn sweep_retention(
176        &mut self,
177        now_tick: u64,
178        adapt_hz: u16,
179        windows: &[RetentionWindow],
180    ) -> Vec<StoreEvicted> {
181        let expired: Vec<StoreKey> = self
182            .entries
183            .values()
184            .filter(|frame| {
185                windows
186                    .iter()
187                    .find(|window| {
188                        window.session == frame.session && window.receipt_seq == frame.receipt_seq
189                    })
190                    .is_none_or(|window| {
191                        elapsed_ms(now_tick, frame.inserted_tick, adapt_hz) > window.retain_ms
192                    })
193            })
194            .map(|frame| frame.key.clone())
195            .collect();
196        expired
197            .into_iter()
198            .filter_map(|key| {
199                self.remove_existing(&key).map(|_| StoreEvicted {
200                    key,
201                    reason: retention_reason(),
202                })
203            })
204            .collect()
205    }
206
207    /// Returns true when the store contains `key`.
208    pub fn contains(&self, key: &StoreKey) -> bool {
209        self.entries.contains_key(key)
210    }
211
212    /// Returns the current byte use.
213    pub fn used_bytes(&self) -> usize {
214        self.used_bytes
215    }
216
217    /// Returns the maximum byte bound.
218    pub fn max_bytes(&self) -> usize {
219        self.max_bytes
220    }
221
222    /// Returns the number of stored frames.
223    pub fn len(&self) -> usize {
224        self.entries.len()
225    }
226
227    /// Returns whether no frames are stored.
228    pub fn is_empty(&self) -> bool {
229        self.entries.is_empty()
230    }
231
232    fn evict_oldest(&mut self, reason: Symbol) -> Option<StoreEvicted> {
233        while let Some(key) = self.order.pop_front() {
234            if self.remove_existing(&key).is_some() {
235                return Some(StoreEvicted { key, reason });
236            }
237        }
238        None
239    }
240
241    fn remove_existing(&mut self, key: &StoreKey) -> Option<ContentFrame> {
242        let frame = self.entries.remove(key)?;
243        self.used_bytes = self.used_bytes.saturating_sub(frame.size_bytes);
244        self.order.retain(|existing| existing != key);
245        Some(frame)
246    }
247}
248
249/// Returns the stable reason for size-bound eviction.
250pub fn size_bound_reason() -> Symbol {
251    Symbol::qualified("stream/content-evict", "size-bound")
252}
253
254/// Returns the stable reason for retention eviction.
255pub fn retention_reason() -> Symbol {
256    Symbol::qualified("stream/content-evict", "retention")
257}
258
259fn replaced_reason() -> Symbol {
260    Symbol::qualified("stream/content-evict", "replaced")
261}
262
263fn elapsed_ms(now_tick: u64, then_tick: u64, adapt_hz: u16) -> u64 {
264    let elapsed_ticks = now_tick.saturating_sub(then_tick);
265    elapsed_ticks.saturating_mul(1000) / u64::from(adapt_hz.max(1))
266}