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