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 lock, registry, or background 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//! A pool is inert by default ([`Pool::unbounded`]): publishers and subscribers that
22//! never set a capacity pay only a couple of atomic counters. A relay creates one
23//! bounded pool and shares it across every origin so the whole process caches into a
24//! single budget.
25
26use std::sync::Arc;
27use std::sync::atomic::{AtomicU64, Ordering};
28use std::time::Duration;
29
30use super::track::TrackState;
31
32/// Fixed bookkeeping charged per cached group on top of its frame payload bytes.
33///
34/// Covers the group/track slot allocations so a track producing many tiny groups
35/// (e.g. one frame per group) is billed roughly for its real footprint instead of
36/// just its payload bytes. Also bounds the live group count (`used / 256`), which
37/// keeps the access-time sum below u64 (see [`TICK_MS`]).
38const ENTRY_OVERHEAD: u64 = 256;
39
40/// Sub-tick boosts applied to the last-access stamp, breaking ties within one
41/// coarse tick: a frame write outranks merely-inserted content, and a read (a
42/// delivered or fetched group, a frame read, a backfill's birth) outranks both.
43const WRITE_BOOST: u64 = 1;
44const READ_BOOST: u64 = 2;
45
46/// Milliseconds per tick of the coarse clock behind access timestamps.
47///
48/// Coarse ticks keep the count-weighted timestamp sum far from u64 overflow: the
49/// sum is bounded by `elapsed_ticks * live_groups`, live groups are bounded by
50/// `used / ENTRY_OVERHEAD`, and twenty years of ticks (6.3e9) times a 64 GiB
51/// target's worst-case ~270M groups is ~1.7e18, a tenth of `u64::MAX`. A
52/// byte-weighted mean would overflow u64 even at whole-second ticks, which is why
53/// the mean is count-weighted.
54const TICK_MS: u64 = 100;
55
56/// A shared byte budget that caches charge into; cloning shares the same budget.
57///
58/// The pool tracks how many payload bytes are cached across every registered group,
59/// plus the mean last-access time of the evictable ones. It never evicts on its own:
60/// tracks accrue eviction debt as they write and evict their own oldest groups to
61/// pay it, so every operation here is a few atomics with no lock. The capacity is
62/// therefore a target usage converges toward, not a hard limit: carried debt, capped
63/// payments, and the always-protected live edge all let usage transiently exceed it.
64#[derive(Clone, Default)]
65pub struct Pool {
66 inner: Arc<Inner>,
67}
68
69struct Inner {
70 // Total bytes currently charged, including per-entry overhead.
71 used: AtomicU64,
72 // u64::MAX means unbounded.
73 capacity: AtomicU64,
74 // Reference point for the coarse tick clock.
75 epoch: web_async::time::Instant,
76 // Sum and count of last-access ticks across the evictable population, giving a
77 // count-weighted mean. Tracks add a group when it becomes evictable (demoted
78 // from the live edge, or inserted behind it) and remove it when it leaves.
79 access_sum: AtomicU64,
80 access_count: AtomicU64,
81}
82
83impl Default for Inner {
84 fn default() -> Self {
85 Self {
86 used: AtomicU64::new(0),
87 capacity: AtomicU64::new(u64::MAX),
88 epoch: web_async::time::Instant::now(),
89 access_sum: AtomicU64::new(0),
90 access_count: AtomicU64::new(0),
91 }
92 }
93}
94
95impl Pool {
96 /// Create a pool with a byte target that tracks evict toward as they write.
97 ///
98 /// The budget counts frame payload bytes (plus a small fixed overhead per
99 /// group), not process RSS, and is a convergence target rather than a hard
100 /// limit; leave headroom when sizing it from real memory.
101 pub fn new(capacity: u64) -> Self {
102 let pool = Self::default();
103 pool.inner.capacity.store(capacity, Ordering::Relaxed);
104 pool
105 }
106
107 /// Create a pool that never evicts. This is the [`Default`].
108 pub fn unbounded() -> Self {
109 Self::default()
110 }
111
112 /// The configured byte target, or `None` when unbounded.
113 pub fn capacity(&self) -> Option<u64> {
114 match self.inner.capacity.load(Ordering::Relaxed) {
115 u64::MAX => None,
116 capacity => Some(capacity),
117 }
118 }
119
120 /// Bytes currently cached across every registered group.
121 pub fn used(&self) -> u64 {
122 self.inner.used.load(Ordering::Relaxed)
123 }
124
125 /// Change the capacity. `None` makes the pool unbounded.
126 ///
127 /// Takes effect as tracks write: a shrink leaves the pool over budget, which every
128 /// subsequent write pays down proportionally. Nothing is reclaimed synchronously.
129 pub fn resize(&self, capacity: impl Into<Option<u64>>) {
130 let capacity = capacity.into().unwrap_or(u64::MAX);
131 self.inner.capacity.store(capacity, Ordering::Relaxed);
132 }
133
134 /// Returns true if both handles share the same underlying pool.
135 pub fn same_pool(&self, other: &Self) -> bool {
136 Arc::ptr_eq(&self.inner, &other.inner)
137 }
138
139 /// Charge `n` more cached bytes.
140 pub(crate) fn add(&self, n: u64) {
141 self.inner.used.fetch_add(n, Ordering::Relaxed);
142 }
143
144 /// Release `n` cached bytes.
145 pub(crate) fn sub(&self, n: u64) {
146 self.inner.used.fetch_sub(n, Ordering::Relaxed);
147 }
148
149 /// Coarse ticks since the pool was created: the clock access timestamps use.
150 pub(crate) fn now(&self) -> u64 {
151 self.inner.epoch.elapsed().as_millis() as u64 / TICK_MS
152 }
153
154 /// Convert a duration into coarse ticks, saturating.
155 pub(crate) fn ticks(duration: Duration) -> u64 {
156 u64::try_from(duration.as_millis() / TICK_MS as u128).unwrap_or(u64::MAX)
157 }
158
159 /// Mean last-access tick across the evictable population, or `None` when it is
160 /// empty. The sum and count are read separately, so the mean is approximate
161 /// under concurrent updates; eviction only needs a rough frontier.
162 pub(crate) fn average(&self) -> Option<u64> {
163 let count = self.inner.access_count.load(Ordering::Relaxed);
164 if count == 0 {
165 return None;
166 }
167 Some(self.inner.access_sum.load(Ordering::Relaxed) / count)
168 }
169
170 /// A group with last-access tick `ts` joined the evictable population.
171 pub(crate) fn access_insert(&self, ts: u64) {
172 self.inner.access_sum.fetch_add(ts, Ordering::Relaxed);
173 self.inner.access_count.fetch_add(1, Ordering::Relaxed);
174 }
175
176 /// A group with last-access tick `ts` left the evictable population.
177 pub(crate) fn access_remove(&self, ts: u64) {
178 self.inner.access_sum.fetch_sub(ts, Ordering::Relaxed);
179 self.inner.access_count.fetch_sub(1, Ordering::Relaxed);
180 }
181
182 /// An evictable group's last-access tick moved from `old` to `new` (a FETCH hit).
183 pub(crate) fn access_refresh(&self, old: u64, new: u64) {
184 // A single wrapping add keeps the sum exact even under racing refreshes.
185 self.inner
186 .access_sum
187 .fetch_add(new.wrapping_sub(old), Ordering::Relaxed);
188 }
189
190 /// The eviction debt a track takes on by writing `written` bytes, or `None` while
191 /// the pool is under capacity (the caller should forget any outstanding debt).
192 ///
193 /// The debt is `written * used / capacity`, so paying it evicts slightly more
194 /// than was written and the overshoot decays toward the capacity. Tracks double
195 /// it when their oldest content is staler than [`Self::average`]. Saturates: a
196 /// tiny capacity must not wrap a huge debt into a small one.
197 pub(crate) fn accrue(&self, written: u64) -> Option<u64> {
198 let used = self.inner.used.load(Ordering::Relaxed);
199 let capacity = self.inner.capacity.load(Ordering::Relaxed);
200 if used <= capacity {
201 return None;
202 }
203 let debt = written as u128 * used as u128 / capacity.max(1) as u128;
204 Some(u64::try_from(debt).unwrap_or(u64::MAX))
205 }
206}
207
208impl std::fmt::Debug for Pool {
209 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
210 f.debug_struct("Pool")
211 .field("used", &self.used())
212 .field("capacity", &self.capacity())
213 .finish()
214 }
215}
216
217/// Gross bytes a track writes before a frame write settles its eviction debt itself,
218/// so a track appending frames to open groups (never inserting another group) still
219/// pays. Coarse: the cost is one track-state lock per threshold crossing.
220const WRITE_CHARGE_THRESHOLD: u64 = 256 * 1024;
221
222/// One track's account against the [`Pool`], shared with every group it creates.
223///
224/// Groups charge their bytes here (through a [`Charge`]) rather than straight into the
225/// pool, so the track can drain what its own groups wrote into eviction debt and pay it
226/// off by evicting them. The link back to the track is a [`kio::Weak`] because the track
227/// owns its cached groups and each of those owns this account: anything stronger would
228/// make a track's cache immortal.
229///
230/// The default account is detached: an unbounded pool and no track, so every operation
231/// is a no-op.
232#[derive(Default)]
233pub(crate) struct Track {
234 pool: Pool,
235
236 // Gross bytes charged by this track's groups (payload plus overhead), never
237 // decremented here: the track swaps it out as it accrues debt.
238 written: AtomicU64,
239
240 // The track that pays this account off, holding the groups being charged.
241 state: kio::Weak<TrackState>,
242}
243
244impl Track {
245 /// Open an account against `pool` for the track behind `state`.
246 pub(crate) fn new(pool: Pool, state: kio::Weak<TrackState>) -> Arc<Self> {
247 Arc::new(Self {
248 pool,
249 written: AtomicU64::new(0),
250 state,
251 })
252 }
253
254 /// The pool this track caches into.
255 pub(crate) fn pool(&self) -> &Pool {
256 &self.pool
257 }
258
259 /// Charge a new group's fixed overhead, returning its [`Charge`].
260 pub(crate) fn charge(self: &Arc<Self>) -> Charge {
261 self.pool.add(ENTRY_OVERHEAD);
262 self.written.fetch_add(ENTRY_OVERHEAD, Ordering::Relaxed);
263 let last = self.pool.now();
264 Charge {
265 track: Some(self.clone()),
266 bytes: ENTRY_OVERHEAD,
267 last: AtomicU64::new(last),
268 counted: false,
269 }
270 }
271
272 /// Take everything written since the last call, to be turned into eviction debt.
273 pub(crate) fn take_written(&self) -> u64 {
274 self.written.swap(0, Ordering::Relaxed)
275 }
276
277 /// Settle eviction debt from a frame write, once enough bytes accumulate.
278 ///
279 /// Called with no group lock held (locks are ordered track then group). Cheap
280 /// until the threshold crosses: one relaxed load. This is what makes a track
281 /// that only appends frames to open groups, never inserting another group,
282 /// still pay its debt (and age its content out).
283 pub(crate) fn settle(&self) {
284 if self.written.load(Ordering::Relaxed) < WRITE_CHARGE_THRESHOLD {
285 return;
286 }
287 // Counts as a producer while it lives, which is why `track::Producer` gates
288 // its teardown on its own clone count rather than the state's.
289 let Some(state) = self.state.upgrade() else { return };
290 if let Ok(mut state) = state.write() {
291 state.charge_debt();
292 }
293 }
294}
295
296/// The RAII byte accounting for one cached group, owned by the group's state.
297///
298/// `add`/`sub` mirror the group's cached payload bytes into the pool with plain
299/// atomics, and every charged byte (overhead included) is also accumulated into the
300/// track's account, which the track drains into eviction debt on its next write. The
301/// charge also owns the group's sample in the pool's access mean, so the sample lives
302/// exactly as long as the cached bytes do: aborting or dropping the group removes both,
303/// no matter who does it or when. The default charge is detached: it belongs to no
304/// account and every operation is a no-op.
305#[derive(Default)]
306pub(crate) struct Charge {
307 track: Option<Arc<Track>>,
308 // Bytes currently charged, including ENTRY_OVERHEAD, released on drop.
309 bytes: u64,
310 // Tick of the last cache access: creation, every write, and every read (group
311 // delivery, frame reads, FETCH hits, a fetched backfill's birth). Eviction
312 // protection and age expiry key off this. Atomic so the read paths can stamp
313 // it through a shared guard: a kio write guard's release notifies every parked
314 // consumer, which a mere access must not do. Accesses are still serialized by
315 // the owning state's lock, so `counted` can pair with it as a plain bool.
316 last: AtomicU64,
317 // Whether `last` is currently a sample in the pool's access mean, i.e. the
318 // group is in the evictable population.
319 counted: bool,
320}
321
322impl Charge {
323 /// Charge `n` more payload bytes, counting them as written.
324 ///
325 /// A write is also an access: it restarts the retention clock and keeps an
326 /// actively-growing group (a straggler or backfill still being filled) from
327 /// being evicted or expired mid-write, even within the same coarse tick as
328 /// content that was merely inserted.
329 pub(crate) fn add(&mut self, n: u64) {
330 if let Some(track) = &self.track {
331 track.pool.add(n);
332 track.written.fetch_add(n, Ordering::Relaxed);
333 self.bytes += n;
334 }
335 self.touch(WRITE_BOOST);
336 }
337
338 /// Release `n` payload bytes (a frame evicted by the group's own cap).
339 pub(crate) fn sub(&mut self, n: u64) {
340 if let Some(track) = &self.track {
341 track.pool.sub(n);
342 self.bytes = self.bytes.saturating_sub(n);
343 }
344 }
345
346 /// The group's full cached footprint: payload bytes plus overhead.
347 pub(crate) fn size(&self) -> u64 {
348 self.bytes
349 }
350
351 /// Tick of the group's last cache access.
352 pub(crate) fn accessed(&self) -> u64 {
353 self.last.load(Ordering::Relaxed)
354 }
355
356 /// Enter the group into the evictable population (demoted from the live edge,
357 /// or inserted behind it), sampling its access time into the pool's mean.
358 /// Idempotent.
359 pub(crate) fn demote(&mut self) {
360 if let Some(track) = &self.track
361 && !self.counted
362 {
363 track.pool.access_insert(self.accessed());
364 self.counted = true;
365 }
366 }
367
368 /// Record a cache read: a delivered or fetched group, a frame read, or a
369 /// fetched backfill's birth. `&self` so the read paths can stamp through a
370 /// shared guard without waking parked consumers.
371 pub(crate) fn refresh(&self) {
372 self.touch(READ_BOOST);
373 }
374
375 /// Record a write that charges no new bytes (a chunk written into an
376 /// already-charged in-flight frame): restarts the retention clock like any
377 /// other write. `&mut self` deliberately: reaching it through a kio write
378 /// guard marks the guard modified, so its release wakes parked readers.
379 pub(crate) fn record_write(&mut self) {
380 self.touch(WRITE_BOOST);
381 }
382
383 /// Advance the last-access tick to `boost` ticks past the coarse clock.
384 ///
385 /// The boost breaks ties within one coarse tick: written content outranks
386 /// merely-inserted content, and explicitly read content outranks both, so a
387 /// same-tick access still reads as strictly newer than the population mean of
388 /// weaker accesses. Idempotent within a tick (monotone, never regressing), so
389 /// repeated accesses can't run ahead of the clock by more than the boost.
390 fn touch(&self, boost: u64) {
391 let Some(track) = &self.track else { return };
392 let target = track.pool.now().saturating_add(boost);
393 // `fetch_max` keeps the stamp monotone, and its prior value makes the
394 // paired mean update exact even for back-to-back accesses.
395 let prev = self.last.fetch_max(target, Ordering::Relaxed);
396 if target <= prev {
397 return;
398 }
399 if self.counted {
400 track.pool.access_refresh(prev, target);
401 }
402 }
403
404 /// Release everything this charge holds: bytes, overhead, and the access
405 /// sample. Idempotent; used when the group aborts and clears its frames.
406 pub(crate) fn clear(&mut self) {
407 if let Some(track) = &self.track {
408 track.pool.sub(self.bytes);
409 self.bytes = 0;
410 if self.counted {
411 track.pool.access_remove(self.accessed());
412 self.counted = false;
413 }
414 }
415 }
416}
417
418impl Drop for Charge {
419 fn drop(&mut self) {
420 self.clear();
421 }
422}
423
424#[cfg(test)]
425mod test {
426 use super::*;
427
428 fn charge(pool: &Pool) -> Charge {
429 // No track behind the account: nothing here settles debt, it just accounts.
430 Track::new(pool.clone(), kio::Weak::new()).charge()
431 }
432
433 #[test]
434 fn unbounded_never_accrues() {
435 let pool = Pool::unbounded();
436 let mut charge = charge(&pool);
437 charge.add(1 << 40);
438 assert_eq!(pool.accrue(1 << 30), None);
439 assert_eq!(pool.used(), (1 << 40) + ENTRY_OVERHEAD);
440 drop(charge);
441 assert_eq!(pool.used(), 0);
442 }
443
444 #[test]
445 fn accrue_none_under_capacity() {
446 let pool = Pool::new(1000);
447 let mut charge = charge(&pool);
448 charge.add(500);
449 assert_eq!(pool.accrue(100), None);
450 }
451
452 #[test]
453 fn accrue_proportional_over_capacity() {
454 let pool = Pool::new(1000);
455 let mut charge = charge(&pool);
456 charge.add(2000 - ENTRY_OVERHEAD); // used = 2000, twice the capacity
457
458 // Debt exceeds what was written by the overshoot ratio, so the pool drains.
459 assert_eq!(pool.accrue(100), Some(200));
460 // Zero written accrues zero: an idle track takes on no debt.
461 assert_eq!(pool.accrue(0), Some(0));
462 }
463
464 #[test]
465 fn average_tracks_evictable_population() {
466 let pool = Pool::new(1000);
467 assert_eq!(pool.average(), None);
468
469 pool.access_insert(10);
470 pool.access_insert(20);
471 assert_eq!(pool.average(), Some(15));
472
473 // A refresh moves one member's contribution, exactly.
474 pool.access_refresh(10, 40);
475 assert_eq!(pool.average(), Some(30));
476
477 pool.access_remove(40);
478 assert_eq!(pool.average(), Some(20));
479 pool.access_remove(20);
480 assert_eq!(pool.average(), None);
481 }
482
483 #[test]
484 fn charge_raii() {
485 let pool = Pool::new(1000);
486 let mut charge = charge(&pool);
487 assert_eq!(pool.used(), ENTRY_OVERHEAD);
488
489 charge.add(100);
490 assert_eq!(pool.used(), ENTRY_OVERHEAD + 100);
491 charge.sub(40);
492 assert_eq!(pool.used(), ENTRY_OVERHEAD + 60);
493
494 charge.clear();
495 assert_eq!(pool.used(), 0);
496 // Idempotent: a second clear (and the eventual drop) releases nothing more.
497 charge.clear();
498 drop(charge);
499 assert_eq!(pool.used(), 0);
500 }
501
502 #[test]
503 fn detached_charge_is_noop() {
504 let mut charge = Charge::default();
505 charge.add(123);
506 charge.sub(23);
507 charge.clear();
508 }
509
510 #[test]
511 fn accrue_saturates() {
512 // A huge overshoot against a tiny capacity must saturate, not wrap.
513 let pool = Pool::new(1);
514 let mut c = charge(&pool);
515 c.add(1 << 40);
516 assert_eq!(pool.accrue(1 << 40), Some(u64::MAX));
517 }
518
519 #[test]
520 fn charge_counts_gross_writes() {
521 let track = Track::new(Pool::new(1000), kio::Weak::new());
522 let mut c = track.charge();
523 c.add(100);
524 c.sub(40); // releases don't refund the gross counter
525 assert_eq!(track.take_written(), ENTRY_OVERHEAD + 100);
526 assert_eq!(track.take_written(), 0, "taking it drains the counter");
527 }
528
529 #[test]
530 fn charge_owns_access_sample() {
531 let pool = Pool::new(1000);
532 let mut c = charge(&pool);
533 assert_eq!(pool.average(), None, "not evictable until demoted");
534
535 c.demote();
536 c.demote(); // idempotent
537 assert!(pool.average().is_some());
538
539 // Clearing (an abort, from anyone) removes the sample with the bytes.
540 c.clear();
541 assert_eq!(pool.average(), None, "aborted groups leave no ghost sample");
542 drop(c);
543 assert_eq!(pool.average(), None);
544 }
545
546 #[test]
547 fn refresh_updates_a_counted_sample() {
548 let pool = Pool::new(1000);
549 let mut c = charge(&pool);
550 c.demote();
551 c.refresh();
552 // The sample in the pool mean moved with the stamp, so releasing the charge
553 // removes exactly what was inserted and leaves no residue.
554 assert_eq!(pool.average(), Some(c.accessed()));
555 c.clear();
556 assert_eq!(pool.average(), None);
557 }
558
559 #[test]
560 fn refresh_protects_within_a_tick() {
561 let pool = Pool::new(1000);
562 let mut c = charge(&pool);
563 c.demote();
564 let average = pool.average().unwrap();
565 // A refresh in the same coarse tick still lifts the group above the mean.
566 c.refresh();
567 assert!(c.accessed() > average);
568 // Repeated same-tick refreshes are idempotent, not runaway.
569 let stamped = c.accessed();
570 c.refresh();
571 assert_eq!(c.accessed(), stamped);
572 }
573
574 #[test]
575 fn resize() {
576 let pool = Pool::unbounded();
577 assert_eq!(pool.capacity(), None);
578
579 let mut charge = charge(&pool);
580 charge.add(1000);
581
582 // Shrinking doesn't reclaim anything synchronously; writers accrue debt instead.
583 pool.resize(100);
584 assert_eq!(pool.capacity(), Some(100));
585 assert!(pool.used() > 100);
586 assert!(pool.accrue(50).unwrap() > 50);
587
588 pool.resize(None);
589 assert_eq!(pool.capacity(), None);
590 assert_eq!(pool.accrue(50), None);
591 }
592}