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