Skip to main content

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}