moq_net/model/cache.rs
1//! A shared byte budget for cached groups, repaid by write-time eviction.
2//!
3//! Every group charges its cached bytes into a [`Pool`] through a crate-internal
4//! `Charge`, billed to its track's `Track` account. The pool itself never evicts: it is
5//! a handful of atomic counters. While the pool is over capacity, each track accrues
6//! eviction debt as it writes (`accrue`),
7//! sized proportionally to what it wrote, and pays that debt by aborting its own oldest
8//! groups with [`Error::Evicted`](crate::Error::Evicted). Reclamation is therefore
9//! distributed across every writing track and converges on the capacity without any
10//! global eviction task.
11//!
12//! Cross-track ordering comes from one statistic: the mean last-access time of the
13//! evictable population (every cached group except each live track's protected latest).
14//! A group accessed more recently than that mean is never evicted, so freshly read
15//! or fetched content in one track can't die while another track holds staler
16//! content, and a track
17//! whose oldest group is staler than the mean accrues debt at double rate. Evicting
18//! old entries and inserting new ones both advance the mean, so the eviction
19//! frontier moves with cache turnover on its own.
20//!
21//! The pool also owns the wall-clock LRU window ([`Pool::expiry`]): a group that
22//! nobody has read or written for that long is reclaimed, no matter what retention
23//! its track advertises. Only a live track's latest group is exempt: once a track
24//! ends, a stale consumer can't pin any of it. Track retention
25//! ([`max_age`](crate::track::Info::max_age)) is measured in media timestamps, so a
26//! congestion stall can't age content out; the pool's expiry is the orthogonal
27//! wall-clock bound that keeps unwatched content from pinning RAM.
28//!
29//! Expiry is driven by [`Pool::gc`], also called by each origin driver.
30//! Reads and writes clear the expiration timestamp without reading a clock.
31//! The next cleanup pass dates that activity at its supplied instant. Delayed
32//! cleanup extends retention; standalone pools must call `gc` too.
33//! Byte-pressure eviction still runs inline on writes.
34//!
35//! A bare pool is inert by default ([`Pool::unbounded`]): publishers and subscribers
36//! that never set a capacity or expiry pay only a couple of atomic counters, and
37//! register nothing. A standalone [`origin`](crate::origin::Config) enables
38//! [`DEFAULT_EXPIRY`], while a relay creates one configured pool and shares it across
39//! every origin so the whole process caches into a single policy.
40
41use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
42use std::sync::{Arc, Mutex, OnceLock, Weak};
43use std::time::Duration;
44
45use super::group;
46use super::track::{self, TrackState};
47
48/// Fixed bookkeeping charged per cached group on top of its frame payload bytes.
49///
50/// A group that holds one small frame is almost entirely bookkeeping: the kio channel
51/// carrying its state, the containers the track indexes it by, and the frame slots
52/// themselves dwarf a chat-sized payload. Billing payload alone lets such a track cache
53/// millions of groups while the pool believes it is inside budget, so the process is
54/// killed before anything is evicted.
55///
56/// Derived from `size_of` rather than pasted from a measured process, so it follows the
57/// structs instead of rotting: each half lives beside the types it sizes, in
58/// [`group::CACHE_OVERHEAD`] and [`track::CACHE_OVERHEAD`]. It excludes what the
59/// allocator rounds up and what a group with many frames grows into, both of which only
60/// matter for shapes payload already dominates.
61///
62/// Also bounds the live group count (`used / ENTRY_OVERHEAD`), which keeps the
63/// access-time sum below u64 (see [`TICK_MS`]).
64pub(crate) const ENTRY_OVERHEAD: u64 = group::CACHE_OVERHEAD + track::CACHE_OVERHEAD;
65
66/// Sub-tick boosts applied to the last-access stamp, breaking ties within one
67/// coarse tick: a frame write outranks merely-inserted content, and a read (a
68/// delivered or fetched group, a frame read, a backfill's birth) outranks both.
69const WRITE_BOOST: u64 = 1;
70const READ_BOOST: u64 = 2;
71const ACCESS_SHIFT: u32 = 2;
72
73/// Milliseconds per tick of the coarse clock behind access timestamps.
74///
75/// Coarse ticks keep the count-weighted timestamp sum far from u64 overflow: the
76/// sum is bounded by `elapsed_ticks * live_groups`, plus two low tie-breaking
77/// bits. Live groups are bounded by `used / ENTRY_OVERHEAD`, and twenty years of
78/// ticks (6.3e9) times a 64 GiB target's worst case of ~70M groups is ~1.8e18 after
79/// that encoding, a tenth of `u64::MAX`. A byte-weighted mean would overflow u64
80/// even at whole-second ticks, which is why the mean is count-weighted.
81const TICK_MS: u64 = 100;
82
83/// Default idle window for standalone origins and relays.
84pub const DEFAULT_EXPIRY: Duration = Duration::from_secs(30);
85
86/// The initial policy for a [`Pool`].
87///
88/// The default is inert: no byte target and no idle expiry. Use
89/// [`Self::with_capacity`] and [`Self::with_expiry`] before creating the pool.
90#[derive(Clone, Debug, Default)]
91pub struct Config {
92 capacity: Option<u64>,
93 expiry: Option<Duration>,
94}
95
96impl Config {
97 /// Set the initial byte target. `None` leaves it unbounded.
98 pub fn with_capacity(mut self, capacity: impl Into<Option<u64>>) -> Self {
99 self.capacity = capacity.into();
100 self
101 }
102
103 /// Set the wall-clock LRU window. `None` disables idle reclamation.
104 ///
105 /// A cached group (other than a live track's latest) that nobody reads or writes
106 /// for this long is reclaimed, surfacing to any remaining reader as
107 /// [`Error::Old`](crate::Error::Old). This is independent of track retention:
108 /// [`max_age`](crate::track::Info::max_age) uses media timestamps, while this
109 /// window keeps idle content from pinning memory. Reclaiming without a write behind
110 /// it needs [`Pool::gc`] called periodically (origins do this automatically).
111 /// The value is fixed when the
112 /// pool is created. Values are rounded up to the pool's 100 ms clock tick, with
113 /// 100 ms as the minimum effective window.
114 pub fn with_expiry(mut self, expiry: impl Into<Option<Duration>>) -> Self {
115 self.expiry = expiry.into();
116 self
117 }
118}
119
120/// A shared cache policy and byte budget; cloning shares both.
121///
122/// The pool tracks how many payload bytes are cached across every registered group,
123/// plus the mean last-access time of the evictable ones. It never evicts on its own:
124/// tracks accrue eviction debt as they write and evict their own oldest groups to
125/// pay it. Idle expiration runs during [`Self::gc`]. The capacity is
126/// therefore a target usage converges toward, not a hard limit: carried debt, capped
127/// payments, and the always-protected live edge all let usage transiently exceed it.
128#[derive(Clone)]
129pub struct Pool {
130 inner: Arc<Inner>,
131}
132
133impl Default for Pool {
134 fn default() -> Self {
135 Self::unbounded()
136 }
137}
138
139struct Inner {
140 // Total bytes currently charged, including per-entry overhead.
141 used: AtomicU64,
142 // u64::MAX means unbounded.
143 capacity: AtomicU64,
144 // Wall-clock LRU window in milliseconds; u64::MAX means never expire by idleness.
145 expiry: u64,
146 // Reference point for the coarse tick clock.
147 clock: Mutex<Option<Clock>>,
148 tick: AtomicU64,
149 // Sum and count of last-access ticks across the evictable population, giving a
150 // count-weighted mean. Tracks add a group when it becomes evictable (demoted
151 // from the live edge, or inserted behind it) and remove it when it leaves.
152 access_sum: AtomicU64,
153 access_count: AtomicU64,
154 // Live track accounts, so [`Pool::sweep`] can expire idle groups in a track that
155 // has stopped writing. Empty and never touched when expiry is disabled: the byte
156 // budget needs no registry, since a track that never writes never grows the pool.
157 // Weak, because a track owns its account and the account must not outlive it.
158 tracks: kio::Lock<slab::Slab<Weak<Track>>>,
159}
160
161struct Clock {
162 epoch: crate::time::Instant,
163 now: crate::time::Instant,
164 sweep: Option<crate::time::Instant>,
165}
166
167impl Pool {
168 /// Create a pool from an initial policy.
169 ///
170 /// The budget counts frame payload bytes plus a fixed cost per cached group, which
171 /// is most of what a group carrying one small frame occupies. It is not process
172 /// RSS, and it is a convergence target rather than a hard limit; leave headroom
173 /// when sizing it from real memory. The expiry is fixed, while the capacity can
174 /// later be changed with [`Self::resize`].
175 pub fn new(config: Config) -> Self {
176 let expiry = config.expiry.map_or(u64::MAX, |expiry| {
177 let ms = u64::try_from(expiry.as_millis()).unwrap_or(u64::MAX);
178 if ms == u64::MAX {
179 return u64::MAX;
180 }
181 ms.max(1).div_ceil(TICK_MS).saturating_mul(TICK_MS)
182 });
183 let pool = Self {
184 inner: Arc::new(Inner {
185 used: AtomicU64::new(0),
186 capacity: AtomicU64::new(config.capacity.unwrap_or(u64::MAX)),
187 expiry,
188 clock: Mutex::new(None),
189 tick: AtomicU64::new(0),
190 access_sum: AtomicU64::new(0),
191 access_count: AtomicU64::new(0),
192 tracks: kio::Lock::new(slab::Slab::new()),
193 }),
194 };
195 #[cfg(test)]
196 crate::model::clock::register(&pool);
197 pool
198 }
199
200 /// Create a pool that never evicts. This is the [`Default`].
201 pub fn unbounded() -> Self {
202 Self::new(Config::default())
203 }
204
205 /// The configured byte target, or `None` when unbounded.
206 pub fn capacity(&self) -> Option<u64> {
207 match self.inner.capacity.load(Ordering::Relaxed) {
208 u64::MAX => None,
209 capacity => Some(capacity),
210 }
211 }
212
213 /// Bytes currently cached across every registered group.
214 pub fn used(&self) -> u64 {
215 self.inner.used.load(Ordering::Relaxed)
216 }
217
218 /// Change the capacity. `None` makes the pool unbounded.
219 ///
220 /// Takes effect as tracks write: a shrink leaves the pool over budget, which every
221 /// subsequent write pays down proportionally. Nothing is reclaimed synchronously.
222 pub fn resize(&self, capacity: impl Into<Option<u64>>) {
223 let capacity = capacity.into().unwrap_or(u64::MAX);
224 self.inner.capacity.store(capacity, Ordering::Relaxed);
225 }
226
227 /// The wall-clock LRU window, or `None` when idle content is never reclaimed.
228 pub fn expiry(&self) -> Option<Duration> {
229 match self.inner.expiry {
230 u64::MAX => None,
231 ms => Some(Duration::from_millis(ms)),
232 }
233 }
234
235 /// The LRU window in coarse ticks; effectively infinite when disabled.
236 pub(crate) fn expiry_ticks(&self) -> u64 {
237 match self.inner.expiry {
238 u64::MAX => u64::MAX,
239 ms => ms / TICK_MS,
240 }
241 }
242
243 /// Sample recency periodically while either cache policy is enabled.
244 ///
245 /// Expiry is approximate: activity is dated on the following cleanup pass,
246 /// and passes run at half the idle window.
247 pub(crate) fn sweep_interval(&self) -> Option<Duration> {
248 self.expiry()
249 .or_else(|| self.capacity().map(|_| DEFAULT_EXPIRY))
250 .map(|window| window / 2)
251 }
252
253 /// Expire idle groups across the registered tracks.
254 pub(crate) fn sweep(&self) {
255 // Upgrade outside each track's lock: dropping its last account unregisters it.
256 let tracks: Vec<_> = self
257 .inner
258 .tracks
259 .lock()
260 .iter()
261 .filter_map(|(_, track)| track.upgrade())
262 .collect();
263 for track in tracks {
264 track.sweep();
265 }
266 }
267
268 /// Collect idle cache entries and return the next cleanup time.
269 ///
270 /// Call after polling and at the returned deadline, including when idle.
271 /// Calls before that deadline only advance the pool's sampled clock. A due
272 /// pass visits every cached group, dating accesses since the last pass and
273 /// reclaiming idle groups except each live track's latest. Delayed calls extend
274 /// retention. Shared pools use the latest supplied instant.
275 ///
276 /// `None` means both cache policies are disabled. After enabling a capacity
277 /// with [`Self::resize`], call this again to resume periodic clock sampling.
278 pub fn gc(&self, now: crate::time::Instant) -> Option<crate::time::Instant> {
279 self.advance(now, true)
280 }
281
282 fn advance(&self, now: crate::time::Instant, sweep: bool) -> Option<crate::time::Instant> {
283 // Shared origins must not run overlapping collection passes.
284 let mut clock = self.inner.clock.lock().unwrap();
285 let clock = clock.get_or_insert(Clock {
286 epoch: now,
287 now,
288 sweep: None,
289 });
290 let now = now.max(clock.now);
291 let tick = u64::try_from(now.duration_since(clock.epoch).as_millis() / u128::from(TICK_MS))
292 .expect("cache clock overflow");
293 self.inner.tick.store(tick, Ordering::Relaxed);
294 clock.now = now;
295 if self.sweep_interval().is_none() {
296 clock.sweep = None;
297 } else if sweep && clock.sweep.is_none_or(|at| at <= now) {
298 self.sweep();
299 clock.sweep = self.sweep_interval().and_then(|interval| now.checked_add(interval));
300 }
301 clock.sweep
302 }
303
304 #[cfg(test)]
305 pub(crate) fn advance_test(&self, now: crate::time::Instant) {
306 self.advance(now, false);
307 let tracks: Vec<_> = self
308 .inner
309 .tracks
310 .lock()
311 .iter()
312 .filter_map(|(_, track)| track.upgrade())
313 .collect();
314 for track in tracks {
315 if let Some(state) = track.state.upgrade() {
316 state.read().date_cache_accesses(self.now());
317 }
318 }
319 }
320
321 /// Enter a track account into the sweep registry, returning its key. `None` when
322 /// idle reclamation is off, which is what keeps a bare pool free of bookkeeping.
323 fn register(&self, track: &Arc<Track>) -> Option<usize> {
324 self.expiry()?;
325 Some(self.inner.tracks.lock().insert(Arc::downgrade(track)))
326 }
327
328 /// Drop a track account from the sweep registry.
329 fn unregister(&self, key: usize) {
330 self.inner.tracks.lock().remove(key);
331 }
332
333 /// Returns true if both handles share the same underlying pool.
334 #[cfg(test)]
335 pub(crate) fn same_pool(&self, other: &Self) -> bool {
336 Arc::ptr_eq(&self.inner, &other.inner)
337 }
338
339 /// A handle that reaches this budget without keeping it alive.
340 pub fn downgrade(&self) -> PoolWeak {
341 PoolWeak {
342 inner: Arc::downgrade(&self.inner),
343 }
344 }
345
346 /// Charge `n` more cached bytes.
347 pub(crate) fn add(&self, n: u64) {
348 self.inner.used.fetch_add(n, Ordering::Relaxed);
349 }
350
351 /// Release `n` cached bytes.
352 pub(crate) fn sub(&self, n: u64) {
353 self.inner.used.fetch_sub(n, Ordering::Relaxed);
354 }
355
356 /// Coarse ticks since the first cleanup call.
357 pub(crate) fn now(&self) -> u64 {
358 self.inner.tick.load(Ordering::Relaxed)
359 }
360
361 /// Encode the current clock tick and an access-priority tie breaker.
362 fn stamp(&self, boost: u64) -> u64 {
363 self.now().saturating_mul(1 << ACCESS_SHIFT).saturating_add(boost)
364 }
365
366 /// Mean last-access tick across the evictable population, or `None` when it is
367 /// empty. The sum and count are read separately, so the mean is approximate
368 /// under concurrent updates; eviction only needs a rough frontier.
369 pub(crate) fn average(&self) -> Option<u64> {
370 let count = self.inner.access_count.load(Ordering::Relaxed);
371 if count == 0 {
372 return None;
373 }
374 Some(self.inner.access_sum.load(Ordering::Relaxed) / count)
375 }
376
377 /// A group with last-access tick `ts` joined the evictable population.
378 pub(crate) fn access_insert(&self, ts: u64) {
379 self.inner.access_sum.fetch_add(ts, Ordering::Relaxed);
380 self.inner.access_count.fetch_add(1, Ordering::Relaxed);
381 }
382
383 /// A group with last-access tick `ts` left the evictable population.
384 pub(crate) fn access_remove(&self, ts: u64) {
385 self.inner.access_sum.fetch_sub(ts, Ordering::Relaxed);
386 self.inner.access_count.fetch_sub(1, Ordering::Relaxed);
387 }
388
389 /// An evictable group's last-access tick moved from `old` to `new` (a FETCH hit).
390 pub(crate) fn access_refresh(&self, old: u64, new: u64) {
391 // A single wrapping add keeps the sum exact even under racing refreshes.
392 self.inner
393 .access_sum
394 .fetch_add(new.wrapping_sub(old), Ordering::Relaxed);
395 }
396
397 /// The eviction debt a track takes on by writing `written` bytes, or `None` while
398 /// the pool is under capacity (the caller should forget any outstanding debt).
399 ///
400 /// The debt is `written * used / capacity`, so paying it evicts slightly more
401 /// than was written and the overshoot decays toward the capacity. Tracks double
402 /// it when their oldest content is staler than [`Self::average`]. Saturates: a
403 /// tiny capacity must not wrap a huge debt into a small one.
404 pub(crate) fn accrue(&self, written: u64) -> Option<u64> {
405 let used = self.inner.used.load(Ordering::Relaxed);
406 let capacity = self.inner.capacity.load(Ordering::Relaxed);
407 if used <= capacity {
408 return None;
409 }
410 let debt = written as u128 * used as u128 / capacity.max(1) as u128;
411 Some(u64::try_from(debt).unwrap_or(u64::MAX))
412 }
413}
414
415impl std::fmt::Debug for Pool {
416 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
417 f.debug_struct("Pool")
418 .field("used", &self.used())
419 .field("capacity", &self.capacity())
420 .field("expiry", &self.expiry())
421 .finish()
422 }
423}
424
425/// A handle to a [`Pool`] that does not keep the budget alive.
426///
427/// [`upgrade`](Self::upgrade) stops returning a [`Pool`] once every strong handle has
428/// dropped, which is how a background resizer learns the budget it manages is gone and
429/// nothing can cache into it any more.
430#[derive(Clone)]
431pub struct PoolWeak {
432 inner: std::sync::Weak<Inner>,
433}
434
435impl PoolWeak {
436 /// Recover a [`Pool`], or `None` once every strong handle has dropped.
437 pub fn upgrade(&self) -> Option<Pool> {
438 self.inner.upgrade().map(|inner| Pool { inner })
439 }
440}
441
442impl std::fmt::Debug for PoolWeak {
443 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
444 match self.upgrade() {
445 Some(pool) => pool.fmt(f),
446 None => f.debug_struct("PoolWeak").finish_non_exhaustive(),
447 }
448 }
449}
450
451/// Gross bytes a track writes before a frame write settles its eviction debt itself,
452/// so a track appending frames to open groups (never inserting another group) still
453/// pays. Coarse: the cost is one track-state lock per threshold crossing.
454const WRITE_CHARGE_THRESHOLD: u64 = 256 * 1024;
455
456/// Maximum cadence for write-driven expiry scans, in the pool's coarse ticks.
457///
458/// Byte debt settles only after enough data accumulates, but expiry is a time
459/// policy and must also run for low-bitrate tracks. Limiting that extra track lock
460/// to once per second keeps the write hot path cheap while a bounded scan drains
461/// stale backlogs steadily.
462const EXPIRY_SCAN_TICKS: u64 = 1000 / TICK_MS;
463
464/// One track's account against the [`Pool`], shared with every group it creates.
465///
466/// Groups charge their bytes here (through a [`Charge`]) rather than straight into the
467/// pool, so the track can drain what its own groups wrote into eviction debt and pay it
468/// off by evicting them. The link back to the track is a [`kio::Weak`] because the track
469/// owns its cached groups and each of those owns this account: anything stronger would
470/// make a track's cache immortal.
471///
472/// The default account is detached: an unbounded pool and no track, so every operation
473/// is a no-op.
474#[derive(Default)]
475pub(crate) struct Track {
476 pool: Pool,
477
478 // Gross bytes charged by this track's groups (payload plus overhead), never
479 // decremented here: the track swaps it out as it accrues debt.
480 written: AtomicU64,
481
482 // Earliest coarse tick when a frame write may run another expiry scan.
483 next_expiry: AtomicU64,
484
485 // Rotating position of the expiry scan over the track's eviction order.
486 expiry_cursor: AtomicUsize,
487
488 // The track that pays this account off, holding the groups being charged.
489 state: kio::Weak<TrackState>,
490
491 // This account's slot in the pool's sweep registry, absent when the pool has no
492 // expiry window (nothing is registered) or for the detached default account.
493 sweep: OnceLock<usize>,
494}
495
496impl Track {
497 /// Open an account against `pool` for the track behind `state`.
498 pub(crate) fn new(pool: Pool, state: kio::Weak<TrackState>) -> Arc<Self> {
499 let track = Arc::new(Self {
500 pool,
501 written: AtomicU64::new(0),
502 next_expiry: AtomicU64::new(0),
503 expiry_cursor: AtomicUsize::new(0),
504 state,
505 sweep: OnceLock::new(),
506 });
507 if let Some(key) = track.pool.register(&track) {
508 let _ = track.sweep.set(key);
509 }
510 track
511 }
512
513 /// The pool this track caches into.
514 pub(crate) fn pool(&self) -> &Pool {
515 &self.pool
516 }
517
518 /// Charge a new group's fixed overhead, returning its [`Charge`].
519 pub(crate) fn charge(self: &Arc<Self>) -> Charge {
520 self.pool.add(ENTRY_OVERHEAD);
521 self.written.fetch_add(ENTRY_OVERHEAD, Ordering::Relaxed);
522 let access = Arc::new(Access::new(self.pool.stamp(0)));
523 Charge {
524 track: Some(self.clone()),
525 bytes: ENTRY_OVERHEAD,
526 access,
527 counted: false,
528 }
529 }
530
531 /// Take everything written since the last call, to be turned into eviction debt.
532 pub(crate) fn take_written(&self) -> u64 {
533 self.written.swap(0, Ordering::Relaxed)
534 }
535
536 /// Settle eviction debt and expire idle groups from a frame write.
537 ///
538 /// Called with no group lock held (locks are ordered track then group). Cheap
539 /// until the byte or time gate crosses: relaxed atomics plus a coarse clock read
540 /// when expiry is enabled. This is what makes a track that only appends frames to
541 /// open groups, never inserting another group, still pay its debt and age its
542 /// idle content out.
543 ///
544 /// `now` is the coarse tick a cache access on this path just sampled (see
545 /// [`Charge::add`]), reused so a frame write reads the clock once instead of
546 /// twice. `None` leaves the gate to sample it, and only if it needs it.
547 pub(crate) fn settle(&self, now: Option<u64>) {
548 self.settle_inner(now, false);
549 }
550
551 /// Settle from [`Pool::sweep`], dating activity and expiring every idle candidate.
552 pub(crate) fn sweep(&self) {
553 self.settle_inner(None, true);
554 }
555
556 fn settle_inner(&self, now: Option<u64>, full: bool) {
557 let settle_debt = self.written.load(Ordering::Relaxed) >= WRITE_CHARGE_THRESHOLD;
558 let scan_expiry = if full {
559 self.pool.expiry().is_some()
560 } else {
561 self.expiry_due(now)
562 };
563 if !settle_debt && !scan_expiry {
564 return;
565 }
566 // Counts as a producer while it lives, which is why `track::Producer` gates
567 // its teardown on its own clone count rather than the state's.
568 let Some(state) = self.state.upgrade() else {
569 // An ended track's channel is closed, but a stale consumer can still hold its
570 // groups: the sweep expires them in place, or they would never go.
571 if full && scan_expiry {
572 self.state.read(|state| state.expire_closed(state.expiry_scan_drain()));
573 }
574 return;
575 };
576 let expiry = if scan_expiry {
577 let state = state.read();
578 let scan = if full {
579 state.expiry_scan_drain()
580 } else {
581 state.expiry_scan()
582 };
583 state.expiry_mutation_due(scan).then_some(scan)
584 } else {
585 None
586 };
587 if !settle_debt && expiry.is_none() {
588 return;
589 }
590 if let Ok(mut state) = state.write() {
591 if settle_debt {
592 state.charge_debt();
593 }
594 if let Some(scan) = expiry {
595 state.evict_expired_scan(scan);
596 }
597 }
598 }
599
600 /// Claim the next rotating window in the track's eviction order.
601 pub(crate) fn next_expiry_scan(&self, width: usize) -> usize {
602 self.expiry_cursor.fetch_add(width, Ordering::Relaxed)
603 }
604
605 /// Claim the next write-driven expiry scan when its time gate is due.
606 fn expiry_due(&self, now: Option<u64>) -> bool {
607 let expiry = self.pool.expiry_ticks();
608 if expiry == u64::MAX {
609 return false;
610 }
611
612 // Sampled here, below the gate: a pool with no expiry window returns above
613 // without ever reading the clock, whether or not a caller had a tick.
614 let now = now.unwrap_or_else(|| self.pool.now());
615 let interval = expiry.clamp(1, EXPIRY_SCAN_TICKS);
616 let deadline = now.saturating_add(interval);
617 let next = self.next_expiry.load(Ordering::Relaxed);
618 if now < next {
619 return false;
620 }
621
622 self.next_expiry
623 .compare_exchange(next, deadline, Ordering::Relaxed, Ordering::Relaxed)
624 .is_ok()
625 }
626}
627
628impl Drop for Track {
629 fn drop(&mut self) {
630 if let Some(key) = self.sweep.get() {
631 self.pool.unregister(*key);
632 }
633 }
634}
635
636/// The RAII byte accounting for one cached group, owned by the group's state.
637///
638/// `add`/`sub` mirror the group's cached payload bytes into the pool with plain
639/// atomics, and every charged byte (overhead included) is also accumulated into the
640/// track's account, which the track drains into eviction debt on its next write. The
641/// charge also owns the group's sample in the pool's access mean, so the sample lives
642/// exactly as long as the cached bytes do: aborting or dropping the group removes both,
643/// no matter who does it or when. The default charge is detached: it belongs to no
644/// account and every operation is a no-op.
645#[derive(Default)]
646pub(crate) struct Charge {
647 track: Option<Arc<Track>>,
648 // Bytes currently charged, including ENTRY_OVERHEAD, released on drop.
649 bytes: u64,
650 // The group's last-access stamp, shared with the handles that read it during a
651 // track scan. Only ever written here, under the group's state lock.
652 access: Arc<Access>,
653 // Whether `access` is currently a sample in the pool's access mean, i.e. the
654 // group is in the evictable population. A plain bool: it is only read while
655 // updating the mean, which the owning state's lock already serializes.
656 counted: bool,
657}
658
659/// The tick of one cached group's last cache access: its creation, every write, and
660/// every read (group delivery, frame reads, FETCH hits, a fetched backfill's birth).
661/// Eviction protection and age expiry both key off it.
662///
663/// Shared, so a track scan can read it without entering the group's state. The
664/// eviction and expiry walks run under the track lock and weigh every candidate
665/// against [`Pool::average`], so reaching this through the group lock would nest one
666/// lock inside the other once per candidate.
667///
668/// Atomic for a second reason on the write side: a kio write guard's release notifies
669/// every parked consumer, and a mere cache access must not wake anyone, so [`Charge`]
670/// stamps this through a shared guard.
671#[derive(Default)]
672pub(crate) struct Access {
673 stamp: AtomicU64,
674 expires: AtomicU64,
675 // Live tracks holding the group as their latest; see [`Protection`].
676 protected: AtomicUsize,
677}
678
679impl Access {
680 fn new(stamp: u64) -> Self {
681 Self {
682 stamp: AtomicU64::new(stamp),
683 expires: AtomicU64::new(u64::MAX),
684 protected: AtomicUsize::new(0),
685 }
686 }
687
688 /// Whether a live track holds the group as its latest.
689 pub(crate) fn is_protected(&self) -> bool {
690 self.protected.load(Ordering::Relaxed) > 0
691 }
692
693 /// The stamp, tie-breaking bits included.
694 pub(crate) fn get(&self) -> u64 {
695 self.stamp.load(Ordering::Relaxed)
696 }
697
698 /// Clear the expiration timestamp until a cleanup pass observes this access.
699 pub(crate) fn touch(&self) {
700 self.expires.store(u64::MAX, Ordering::Relaxed);
701 }
702
703 /// The last access tick, assigning undated activity only during cleanup.
704 pub(crate) fn tick(&self, now: Option<u64>) -> Option<u64> {
705 let tick = match now {
706 Some(now) => match self
707 .expires
708 .compare_exchange(u64::MAX, now, Ordering::Relaxed, Ordering::Relaxed)
709 {
710 Ok(_) => now,
711 Err(tick) => tick,
712 },
713 None => self.expires.load(Ordering::Relaxed),
714 };
715 (tick != u64::MAX).then_some(tick)
716 }
717
718 /// Advance to `target` if it is newer, returning the previous stamp.
719 fn bump(&self, target: u64) -> u64 {
720 // `fetch_max` keeps the stamp monotone, and its prior value makes the
721 // paired mean update exact even for back-to-back accesses.
722 self.stamp.fetch_max(target, Ordering::Relaxed)
723 }
724}
725
726/// A live track holding a group as its latest, for as long as this lives.
727///
728/// Tracks can share a group (a warm copy adopts the relay copy's), so the group itself
729/// counts these: an ended track sharing it must not expire another track's live edge.
730pub(crate) struct Protection(Arc<Access>);
731
732impl Protection {
733 pub(crate) fn new(access: &Arc<Access>) -> Self {
734 access.protected.fetch_add(1, Ordering::Relaxed);
735 Self(access.clone())
736 }
737}
738
739impl Drop for Protection {
740 fn drop(&mut self) {
741 self.0.protected.fetch_sub(1, Ordering::Relaxed);
742 }
743}
744
745impl Charge {
746 /// Charge `n` more payload bytes, counting them as written.
747 ///
748 /// A write is also an access: it restarts the retention clock and keeps an
749 /// actively-growing group (a straggler or backfill still being filled) from
750 /// being evicted or expired mid-write, even within the same coarse tick as
751 /// content that was merely inserted.
752 ///
753 /// Returns the coarse tick it stamped, which the caller hands to
754 /// [`Track::settle`] so the write path reads the clock once rather than twice.
755 /// `None` when the charge is detached and stamped nothing.
756 pub(crate) fn add(&mut self, n: u64) -> Option<u64> {
757 if let Some(track) = &self.track {
758 track.pool.add(n);
759 track.written.fetch_add(n, Ordering::Relaxed);
760 self.bytes += n;
761 }
762 self.touch(WRITE_BOOST)
763 }
764
765 /// The group's full cached footprint: payload bytes plus overhead.
766 pub(crate) fn size(&self) -> u64 {
767 self.bytes
768 }
769
770 /// The shared handle to this group's last-access stamp, so the group can read it
771 /// without taking the state lock this charge lives behind.
772 pub(crate) fn access(&self) -> Arc<Access> {
773 self.access.clone()
774 }
775
776 /// Tick of the group's last cache access.
777 pub(crate) fn accessed(&self) -> u64 {
778 self.access.get()
779 }
780
781 /// Enter the group into the evictable population (demoted from the live edge,
782 /// or inserted behind it), sampling its access time into the pool's mean.
783 /// Idempotent.
784 pub(crate) fn demote(&mut self) {
785 if let Some(track) = &self.track
786 && !self.counted
787 {
788 track.pool.access_insert(self.accessed());
789 self.counted = true;
790 }
791 }
792
793 /// Record a cache read: a delivered or fetched group, a frame read, or a
794 /// fetched backfill's birth. `&self` so the read paths can stamp through a
795 /// shared guard without waking parked consumers.
796 pub(crate) fn refresh(&self) {
797 self.touch(READ_BOOST);
798 }
799
800 /// Record a write that charges no new bytes (a chunk written into an
801 /// already-charged in-flight frame): restarts the retention clock like any
802 /// other write. `&mut self` deliberately: reaching it through a kio write
803 /// guard marks the guard modified, so its release wakes parked readers. Returns
804 /// the stamped tick like [`Self::add`].
805 pub(crate) fn record_write(&mut self) -> Option<u64> {
806 self.touch(WRITE_BOOST)
807 }
808
809 /// Advance the last-access stamp to the current clock tick with `boost` priority.
810 ///
811 /// The boost breaks ties within one coarse tick: written content outranks
812 /// merely-inserted content, and explicitly read content outranks both, so a
813 /// same-tick access still reads as strictly newer than the population mean of
814 /// weaker accesses. Idempotent within a tick (monotone, never regressing), so
815 /// repeated accesses remain idempotent without advancing the expiry clock.
816 /// Returns the tick it read, or `None` when the charge is detached.
817 fn touch(&self, boost: u64) -> Option<u64> {
818 let track = self.track.as_ref()?;
819 // Cleanup assigns the next supplied timestamp to this access.
820 self.access.touch();
821 let target = track.pool.stamp(boost);
822 let prev = self.access.bump(target);
823 if target > prev && self.counted {
824 track.pool.access_refresh(prev, target);
825 }
826 Some(target >> ACCESS_SHIFT)
827 }
828
829 /// Release everything this charge holds: bytes, overhead, and the access
830 /// sample. Idempotent; used when the group aborts and clears its frames.
831 pub(crate) fn clear(&mut self) {
832 if let Some(track) = &self.track {
833 track.pool.sub(self.bytes);
834 self.bytes = 0;
835 if self.counted {
836 track.pool.access_remove(self.accessed());
837 self.counted = false;
838 }
839 }
840 }
841}
842
843impl Drop for Charge {
844 fn drop(&mut self) {
845 self.clear();
846 }
847}
848
849#[cfg(test)]
850mod test {
851 use super::*;
852
853 fn charge(pool: &Pool) -> Charge {
854 // No track behind the account: nothing here settles debt, it just accounts.
855 Track::new(pool.clone(), kio::Weak::new()).charge()
856 }
857
858 fn bounded(capacity: u64) -> Pool {
859 let config = Config::default().with_capacity(capacity).with_expiry(DEFAULT_EXPIRY);
860 Pool::new(config)
861 }
862
863 #[test]
864 fn unbounded_never_accrues() {
865 let pool = Pool::unbounded();
866 let mut charge = charge(&pool);
867 charge.add(1 << 40);
868 assert_eq!(pool.accrue(1 << 30), None);
869 assert_eq!(pool.used(), (1 << 40) + ENTRY_OVERHEAD);
870 drop(charge);
871 assert_eq!(pool.used(), 0);
872 }
873
874 #[test]
875 fn config_applies_capacity_and_expiry() {
876 let pool = bounded(1000);
877 assert_eq!(pool.capacity(), Some(1000));
878 assert_eq!(pool.expiry(), Some(DEFAULT_EXPIRY));
879 }
880
881 #[test]
882 fn weak_follows_the_last_strong_handle() {
883 let pool = bounded(1000);
884 let clone = pool.clone();
885 let weak = pool.downgrade();
886
887 drop(pool);
888 let upgraded = weak.upgrade().expect("a strong handle remains");
889 assert!(upgraded.same_pool(&clone));
890
891 drop(upgraded);
892 drop(clone);
893 assert!(weak.upgrade().is_none());
894 }
895
896 #[test]
897 fn accrue_none_under_capacity() {
898 let pool = bounded(ENTRY_OVERHEAD + 1000);
899 let mut charge = charge(&pool);
900 charge.add(500);
901 assert_eq!(pool.accrue(100), None);
902 }
903
904 #[test]
905 fn accrue_proportional_over_capacity() {
906 let pool = bounded(1000);
907 let mut charge = charge(&pool);
908 charge.add(2000 - ENTRY_OVERHEAD); // used = 2000, twice the capacity
909
910 // Debt exceeds what was written by the overshoot ratio, so the pool drains.
911 assert_eq!(pool.accrue(100), Some(200));
912 // Zero written accrues zero: an idle track takes on no debt.
913 assert_eq!(pool.accrue(0), Some(0));
914 }
915
916 #[test]
917 fn average_tracks_evictable_population() {
918 let pool = bounded(1000);
919 assert_eq!(pool.average(), None);
920
921 pool.access_insert(10);
922 pool.access_insert(20);
923 assert_eq!(pool.average(), Some(15));
924
925 // A refresh moves one member's contribution, exactly.
926 pool.access_refresh(10, 40);
927 assert_eq!(pool.average(), Some(30));
928
929 pool.access_remove(40);
930 assert_eq!(pool.average(), Some(20));
931 pool.access_remove(20);
932 assert_eq!(pool.average(), None);
933 }
934
935 #[test]
936 fn charge_raii() {
937 let pool = bounded(1000);
938 let mut charge = charge(&pool);
939 assert_eq!(pool.used(), ENTRY_OVERHEAD);
940
941 charge.add(100);
942 assert_eq!(pool.used(), ENTRY_OVERHEAD + 100);
943
944 charge.clear();
945 assert_eq!(pool.used(), 0);
946 // Idempotent: a second clear (and the eventual drop) releases nothing more.
947 charge.clear();
948 drop(charge);
949 assert_eq!(pool.used(), 0);
950 }
951
952 #[test]
953 fn detached_charge_is_noop() {
954 let mut charge = Charge::default();
955 charge.add(123);
956 charge.clear();
957 }
958
959 #[test]
960 fn accrue_saturates() {
961 // A huge overshoot against a tiny capacity must saturate, not wrap.
962 let pool = bounded(1);
963 let mut c = charge(&pool);
964 c.add(1 << 40);
965 assert_eq!(pool.accrue(1 << 40), Some(u64::MAX));
966 }
967
968 #[test]
969 fn charge_counts_gross_writes() {
970 let track = Track::new(bounded(1000), kio::Weak::new());
971 let mut c = track.charge();
972 c.add(100);
973 assert_eq!(track.take_written(), ENTRY_OVERHEAD + 100);
974 assert_eq!(track.take_written(), 0, "taking it drains the counter");
975 }
976
977 #[test]
978 fn charge_owns_access_sample() {
979 let pool = bounded(1000);
980 let mut c = charge(&pool);
981 assert_eq!(pool.average(), None, "not evictable until demoted");
982
983 c.demote();
984 c.demote(); // idempotent
985 assert!(pool.average().is_some());
986
987 // Clearing (an abort, from anyone) removes the sample with the bytes.
988 c.clear();
989 assert_eq!(pool.average(), None, "aborted groups leave no ghost sample");
990 drop(c);
991 assert_eq!(pool.average(), None);
992 }
993
994 #[test]
995 fn refresh_updates_a_counted_sample() {
996 let pool = bounded(1000);
997 let mut c = charge(&pool);
998 c.demote();
999 c.refresh();
1000 // The sample in the pool mean moved with the stamp, so releasing the charge
1001 // removes exactly what was inserted and leaves no residue.
1002 assert_eq!(pool.average(), Some(c.accessed()));
1003 c.clear();
1004 assert_eq!(pool.average(), None);
1005 }
1006
1007 #[test]
1008 fn refresh_protects_within_a_tick() {
1009 let pool = bounded(1000);
1010 let mut c = charge(&pool);
1011 c.demote();
1012 let average = pool.average().unwrap();
1013 // A refresh in the same coarse tick still lifts the group above the mean.
1014 c.refresh();
1015 assert!(c.accessed() > average);
1016 assert_eq!(c.access().tick(None), None, "undated access is protected until cleanup");
1017 assert_eq!(
1018 c.access().tick(Some(pool.now())),
1019 Some(pool.now()),
1020 "cleanup dates the access"
1021 );
1022 // Repeated same-tick refreshes are idempotent, not runaway.
1023 let stamped = c.accessed();
1024 c.refresh();
1025 assert_eq!(c.accessed(), stamped);
1026 }
1027
1028 #[test]
1029 fn expiry_config() {
1030 // A bare pool preserves the unbounded contract in both dimensions.
1031 let pool = Pool::unbounded();
1032 assert_eq!(pool.expiry(), None);
1033
1034 let pool = Pool::new(Config::default().with_expiry(Duration::from_secs(1)));
1035 assert_eq!(pool.expiry(), Some(Duration::from_secs(1)));
1036 assert_eq!(pool.expiry_ticks(), 10);
1037
1038 let pool = Pool::new(Config::default().with_expiry(Duration::from_millis(1)));
1039 assert_eq!(pool.expiry(), Some(Duration::from_millis(TICK_MS)));
1040 assert_eq!(pool.expiry_ticks(), 1);
1041
1042 // Disabled: never reclaimed by idleness, but readers still re-stamp on a
1043 // bounded cadence for byte-eviction protection.
1044 let pool = Pool::new(Config::default());
1045 assert_eq!(pool.expiry(), None);
1046 assert_eq!(pool.expiry_ticks(), u64::MAX);
1047 }
1048
1049 #[test]
1050 fn expiry_gate_reuses_a_supplied_tick() {
1051 let pool = Pool::new(Config::default().with_expiry(Duration::from_secs(1)));
1052 let track = Track::new(pool, kio::Weak::new());
1053 // A supplied tick drives the gate on its own: claimed immediately, closed
1054 // until the interval elapses, claimable again on the tick it reopens.
1055 assert!(track.expiry_due(Some(0)));
1056 assert!(!track.expiry_due(Some(9)));
1057 assert!(track.expiry_due(Some(10)));
1058 // Without one the gate samples the pool clock, frozen here at tick 0, so it
1059 // stays closed rather than inheriting the caller's tick 10.
1060 assert!(!track.expiry_due(None));
1061 }
1062
1063 #[test]
1064 fn sweep_interval_is_half_the_window() {
1065 assert_eq!(Pool::unbounded().sweep_interval(), None);
1066 let pool = Pool::new(Config::default().with_expiry(Duration::from_secs(4)));
1067 assert_eq!(pool.sweep_interval(), Some(Duration::from_secs(2)));
1068 }
1069
1070 #[test]
1071 fn the_sweep_registry_follows_account_lifetime() {
1072 let pool = bounded(1000);
1073 assert!(pool.inner.tracks.lock().is_empty());
1074
1075 let track = Track::new(pool.clone(), kio::Weak::new());
1076 assert_eq!(pool.inner.tracks.lock().len(), 1);
1077
1078 // A registered account whose track is already gone is swept harmlessly.
1079 pool.sweep();
1080
1081 drop(track);
1082 assert!(pool.inner.tracks.lock().is_empty(), "a dropped account leaves no entry");
1083 }
1084
1085 #[test]
1086 fn an_inert_pool_registers_nothing() {
1087 // No window means no sweep, so a bare pool pays no registry cost.
1088 let pool = Pool::unbounded();
1089 let track = Track::new(pool.clone(), kio::Weak::new());
1090 assert!(pool.inner.tracks.lock().is_empty());
1091 pool.sweep();
1092 drop(track);
1093 }
1094
1095 #[test]
1096 fn expiry_gate_stays_closed_without_a_window() {
1097 // No window means no time gate, and no clock read to reach it.
1098 let track = Track::new(Pool::unbounded(), kio::Weak::new());
1099 assert!(!track.expiry_due(None));
1100 assert!(!track.expiry_due(Some(u64::MAX)));
1101 }
1102
1103 #[test]
1104 fn standalone_origin_enables_default_expiry() {
1105 assert_eq!(crate::origin::Config::default().pool.expiry(), Some(DEFAULT_EXPIRY));
1106 }
1107
1108 #[test]
1109 fn collecting_before_the_deadline_does_not_postpone_it() {
1110 let pool = Pool::new(Config::default().with_expiry(Duration::from_secs(2)));
1111 let now = crate::model::clock::now();
1112 let deadline = pool.gc(now);
1113 assert_eq!(pool.gc(now + Duration::from_millis(500)), deadline);
1114 }
1115
1116 #[test]
1117 fn bounded_pools_sample_recency_without_expiration() {
1118 let pool = Pool::unbounded();
1119 let now = crate::model::clock::now();
1120 assert_eq!(pool.gc(now), None);
1121 pool.resize(1024);
1122 assert_eq!(pool.gc(now), Some(now + DEFAULT_EXPIRY / 2));
1123 pool.gc(now + DEFAULT_EXPIRY);
1124 assert!(pool.now() > 0);
1125 pool.resize(None);
1126 assert_eq!(pool.gc(now + DEFAULT_EXPIRY), None);
1127 }
1128
1129 #[test]
1130 fn resize() {
1131 let pool = Pool::unbounded();
1132 assert_eq!(pool.capacity(), None);
1133
1134 let mut charge = charge(&pool);
1135 charge.add(1000);
1136
1137 // Shrinking doesn't reclaim anything synchronously; writers accrue debt instead.
1138 pool.resize(100);
1139 assert_eq!(pool.capacity(), Some(100));
1140 assert!(pool.used() > 100);
1141 assert!(pool.accrue(50).unwrap() > 50);
1142
1143 pool.resize(None);
1144 assert_eq!(pool.capacity(), None);
1145 assert_eq!(pool.accrue(50), None);
1146 }
1147}