Skip to main content

moq_net/model/
cache.rs

1//! A shared byte budget for cached groups, reclaimed with LRU eviction.
2//!
3//! Every group registers its cached bytes in a [`Pool`]. When the pool exceeds its
4//! capacity, the least-recently-read groups are aborted with
5//! [`Error::Evicted`](crate::Error::Evicted), freeing their frames immediately. The
6//! latest group of each track is pinned and never evicted, so the live edge always
7//! survives memory pressure.
8//!
9//! A pool is inert by default ([`Pool::unbounded`]): publishers and subscribers that
10//! never set a capacity pay only a couple of atomic counters. A relay creates one
11//! bounded pool and shares it across every origin so the whole process caches into a
12//! single budget.
13
14use std::cmp::Reverse;
15use std::collections::{BinaryHeap, HashMap};
16use std::sync::Arc;
17use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
18
19use web_async::Lock;
20
21/// Fixed bookkeeping charged per cached group on top of its frame payload bytes.
22///
23/// Covers the group/track slot allocations so a track producing many tiny groups
24/// (e.g. one frame per group) is billed roughly for its real footprint instead of
25/// just its payload bytes.
26const ENTRY_OVERHEAD: u64 = 256;
27
28/// A shared byte budget that caches charge into; cloning shares the same budget.
29///
30/// The pool tracks how many payload bytes are cached across every registered group
31/// and evicts the least-recently-read groups once `used` exceeds `capacity`.
32/// Eviction aborts the victim group with [`Error::Evicted`](crate::Error::Evicted),
33/// which frees its frames immediately and wakes any parked readers. Pinned (latest)
34/// groups are never evicted but still count against the budget.
35///
36/// Reads and writes only touch atomics; the internal lock is taken when a group
37/// registers/unregisters and when the pool is actually over budget.
38#[derive(Clone, Default)]
39pub struct Pool {
40	inner: Arc<Inner>,
41}
42
43struct Inner {
44	// Total bytes currently charged, including pinned entries and per-entry overhead.
45	used: AtomicU64,
46	// u64::MAX means unbounded.
47	capacity: AtomicU64,
48	// Reference point for the coarse `last_access` clock.
49	epoch: web_async::time::Instant,
50	lru: Lock<Lru>,
51}
52
53impl Default for Inner {
54	fn default() -> Self {
55		Self {
56			used: AtomicU64::new(0),
57			capacity: AtomicU64::new(u64::MAX),
58			epoch: web_async::time::Instant::now(),
59			lru: Lock::default(),
60		}
61	}
62}
63
64#[derive(Default)]
65struct Lru {
66	// Min-heap of (last_access snapshot, id). Snapshots go stale when an entry is
67	// touched; a popped entry whose current last_access differs is re-pushed with
68	// the fresh key instead of being evicted (lazy re-keying).
69	heap: BinaryHeap<Reverse<(u64, u64)>>,
70	// Live entries by id. An id popped from the heap that's no longer here was
71	// already evicted or dropped.
72	entries: HashMap<u64, Arc<Entry>>,
73	next_id: u64,
74}
75
76/// A single cached group's registration in the pool.
77///
78/// Holds only atomics plus the eviction hook, so touching recency or flipping the
79/// pin never takes a lock. The hook holds a weak handle to the group, so an entry
80/// never keeps its group alive.
81pub(crate) struct Entry {
82	id: u64,
83	// Payload bytes plus ENTRY_OVERHEAD currently charged by this entry.
84	bytes: AtomicU64,
85	// Coarse milliseconds since the pool epoch of the last read (or write).
86	last_access: AtomicU64,
87	// Pinned entries (the track's latest group) are skipped by eviction.
88	pinned: AtomicBool,
89	// Aborts the group with Error::Evicted. Called without any pool lock held.
90	evict: Box<dyn Fn() + Send + Sync>,
91	epoch: web_async::time::Instant,
92}
93
94impl Entry {
95	fn now(&self) -> u64 {
96		self.epoch.elapsed().as_millis() as u64
97	}
98
99	/// Record a read so eviction considers this group recently used.
100	pub(crate) fn touch(&self) {
101		self.last_access.store(self.now(), Ordering::Relaxed);
102	}
103
104	/// Pin or unpin this entry. Pinned entries are immune to eviction.
105	pub(crate) fn set_pinned(&self, pinned: bool) {
106		self.pinned.store(pinned, Ordering::Relaxed);
107	}
108}
109
110impl Pool {
111	/// Create a pool that evicts once `capacity` bytes are cached.
112	///
113	/// The budget counts frame payload bytes (plus a small fixed overhead per
114	/// group), not process RSS; leave headroom when sizing it from real memory.
115	pub fn new(capacity: u64) -> Self {
116		let pool = Self::default();
117		pool.inner.capacity.store(capacity, Ordering::Relaxed);
118		pool
119	}
120
121	/// Create a pool that never evicts. This is the [`Default`].
122	pub fn unbounded() -> Self {
123		Self::default()
124	}
125
126	/// The configured capacity in bytes, or `None` when unbounded.
127	pub fn capacity(&self) -> Option<u64> {
128		match self.inner.capacity.load(Ordering::Relaxed) {
129			u64::MAX => None,
130			capacity => Some(capacity),
131		}
132	}
133
134	/// Bytes currently cached across every registered group.
135	pub fn used(&self) -> u64 {
136		self.inner.used.load(Ordering::Relaxed)
137	}
138
139	/// Change the capacity, evicting immediately if the new capacity is exceeded.
140	/// `None` makes the pool unbounded.
141	pub fn resize(&self, capacity: impl Into<Option<u64>>) {
142		let capacity = capacity.into().unwrap_or(u64::MAX);
143		self.inner.capacity.store(capacity, Ordering::Relaxed);
144		self.evict();
145	}
146
147	/// Returns true if both handles share the same underlying pool.
148	pub fn same_pool(&self, other: &Self) -> bool {
149		Arc::ptr_eq(&self.inner, &other.inner)
150	}
151
152	/// Test-only snapshot of the live entries: (id, last_access ms, bytes, pinned),
153	/// plus the pool's current clock reading. For diagnosing eviction order.
154	#[cfg(test)]
155	pub(crate) fn debug_entries(&self) -> (u64, Vec<(u64, u64, u64, bool)>) {
156		let now = self.inner.epoch.elapsed().as_millis() as u64;
157		let lru = self.inner.lru.lock();
158		let mut entries: Vec<_> = lru
159			.entries
160			.values()
161			.map(|e| {
162				(
163					e.id,
164					e.last_access.load(Ordering::Relaxed),
165					e.bytes.load(Ordering::Relaxed),
166					e.pinned.load(Ordering::Relaxed),
167				)
168			})
169			.collect();
170		entries.sort_unstable();
171		(now, entries)
172	}
173
174	/// Register a group, returning the [`Charge`] that tracks its bytes.
175	///
176	/// `evict` must abort the group (releasing its charge); it is invoked without
177	/// any pool lock held, and never after the returned charge is dropped.
178	pub(crate) fn register(&self, evict: Box<dyn Fn() + Send + Sync>) -> Charge {
179		let inner = self.inner.clone();
180		let entry = {
181			let mut lru = inner.lru.lock();
182			let id = lru.next_id;
183			lru.next_id += 1;
184
185			let entry = Arc::new(Entry {
186				id,
187				bytes: AtomicU64::new(ENTRY_OVERHEAD),
188				last_access: AtomicU64::new(0),
189				pinned: AtomicBool::new(false),
190				evict,
191				epoch: inner.epoch,
192			});
193			entry.touch();
194
195			lru.entries.insert(id, entry.clone());
196			lru.heap.push(Reverse((entry.last_access.load(Ordering::Relaxed), id)));
197			entry
198		};
199
200		inner.used.fetch_add(ENTRY_OVERHEAD, Ordering::Relaxed);
201		Charge {
202			inner: Some((inner, entry)),
203		}
204	}
205
206	/// Evict least-recently-read groups until the pool is back under capacity.
207	///
208	/// Victims are collected under the lock but aborted after it's released, so an
209	/// eviction hook can freely take its group's lock.
210	pub(crate) fn evict(&self) {
211		let inner = &self.inner;
212		if inner.used.load(Ordering::Relaxed) <= inner.capacity.load(Ordering::Relaxed) {
213			return;
214		}
215
216		let mut victims = Vec::new();
217		{
218			let mut lru = inner.lru.lock();
219			// Bytes the collected victims will release once aborted below.
220			let mut freed = 0u64;
221			// Pinned entries popped this pass; re-pushing them immediately would just
222			// pop them again (same key), so they're held aside until the pass ends.
223			let mut pinned = Vec::new();
224			// Bounding the pops guarantees termination: an entry needs at most two
225			// (one re-key of its stale snapshot, one settle). A concurrent touch can
226			// steal a slot, ending the pass early; the next write retries.
227			let mut budget = 2 * lru.heap.len();
228
229			while budget > 0
230				&& inner.used.load(Ordering::Relaxed).saturating_sub(freed) > inner.capacity.load(Ordering::Relaxed)
231			{
232				budget -= 1;
233				let Some(Reverse((snapshot, id))) = lru.heap.pop() else {
234					break;
235				};
236				let Some(entry) = lru.entries.get(&id) else {
237					// Already evicted or dropped; discard the stale heap slot.
238					continue;
239				};
240				let access = entry.last_access.load(Ordering::Relaxed);
241				if access != snapshot {
242					// Touched since this snapshot; re-key instead of evicting.
243					lru.heap.push(Reverse((access, id)));
244					continue;
245				}
246				if entry.pinned.load(Ordering::Relaxed) {
247					pinned.push(Reverse((access, id)));
248					continue;
249				}
250
251				let entry = lru.entries.remove(&id).unwrap();
252				freed += entry.bytes.load(Ordering::Relaxed);
253				victims.push(entry);
254			}
255
256			for slot in pinned {
257				lru.heap.push(slot);
258			}
259		}
260
261		for victim in victims {
262			(victim.evict)();
263		}
264	}
265}
266
267impl std::fmt::Debug for Pool {
268	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
269		f.debug_struct("Pool")
270			.field("used", &self.used())
271			.field("capacity", &self.capacity())
272			.finish()
273	}
274}
275
276/// The RAII side of a group's pool registration, owned by the group's state.
277///
278/// `add`/`sub` mirror the group's cached payload bytes into the pool with plain
279/// atomics; no lock is taken until the group unregisters. Dropping (or
280/// [`clear`](Self::clear)ing) the charge releases everything it holds. The default
281/// charge is detached: it belongs to no pool and every operation is a no-op.
282#[derive(Default)]
283pub(crate) struct Charge {
284	inner: Option<(Arc<Inner>, Arc<Entry>)>,
285}
286
287impl Charge {
288	/// Charge `n` more payload bytes and mark the group recently used.
289	///
290	/// Doesn't evict; callers trigger [`Pool::evict`] after releasing their own locks.
291	pub(crate) fn add(&self, n: u64) {
292		if let Some((inner, entry)) = &self.inner {
293			entry.bytes.fetch_add(n, Ordering::Relaxed);
294			inner.used.fetch_add(n, Ordering::Relaxed);
295			entry.touch();
296		}
297	}
298
299	/// Release `n` payload bytes (a frame evicted by the group's own cap).
300	pub(crate) fn sub(&self, n: u64) {
301		if let Some((inner, entry)) = &self.inner {
302			entry.bytes.fetch_sub(n, Ordering::Relaxed);
303			inner.used.fetch_sub(n, Ordering::Relaxed);
304		}
305	}
306
307	/// Release everything this charge holds (bytes and overhead). Idempotent;
308	/// used when the group aborts and clears its frames.
309	pub(crate) fn clear(&self) {
310		if let Some((inner, entry)) = &self.inner {
311			let bytes = entry.bytes.swap(0, Ordering::Relaxed);
312			inner.used.fetch_sub(bytes, Ordering::Relaxed);
313		}
314	}
315
316	/// Mark the group recently used (a consumer read a frame).
317	pub(crate) fn touch(&self) {
318		if let Some((_, entry)) = &self.inner {
319			entry.touch();
320		}
321	}
322
323	/// The registration entry, for pinning. `None` when detached.
324	pub(crate) fn entry(&self) -> Option<Arc<Entry>> {
325		self.inner.as_ref().map(|(_, entry)| entry.clone())
326	}
327}
328
329impl Drop for Charge {
330	fn drop(&mut self) {
331		let Some((inner, entry)) = self.inner.take() else {
332			return;
333		};
334		let bytes = entry.bytes.swap(0, Ordering::Relaxed);
335		inner.used.fetch_sub(bytes, Ordering::Relaxed);
336		// The heap slot (if any) goes stale and is discarded on its next pop.
337		inner.lru.lock().entries.remove(&entry.id);
338	}
339}
340
341#[cfg(test)]
342mod test {
343	use super::*;
344	use std::time::Duration;
345
346	fn flag() -> (Arc<AtomicBool>, Box<dyn Fn() + Send + Sync>) {
347		let evicted = Arc::new(AtomicBool::new(false));
348		let hook = evicted.clone();
349		(
350			evicted,
351			Box::new(move || {
352				hook.store(true, Ordering::Relaxed);
353			}),
354		)
355	}
356
357	#[test]
358	fn unbounded_never_evicts() {
359		let pool = Pool::unbounded();
360		let (evicted, hook) = flag();
361		let charge = pool.register(hook);
362		charge.add(1 << 40);
363		pool.evict();
364		assert!(!evicted.load(Ordering::Relaxed));
365		assert_eq!(pool.used(), (1 << 40) + ENTRY_OVERHEAD);
366		drop(charge);
367		assert_eq!(pool.used(), 0);
368	}
369
370	#[test]
371	fn detached_charge_is_noop() {
372		let charge = Charge::default();
373		charge.add(123);
374		charge.sub(23);
375		charge.clear();
376		charge.touch();
377		assert!(charge.entry().is_none());
378	}
379
380	#[tokio::test]
381	async fn evicts_least_recently_used() {
382		tokio::time::pause();
383
384		let pool = Pool::new(3 * ENTRY_OVERHEAD + 2500);
385		let (evicted_a, hook_a) = flag();
386		let (evicted_b, hook_b) = flag();
387		let (evicted_c, hook_c) = flag();
388
389		let a = pool.register(hook_a);
390		a.add(1000);
391		tokio::time::advance(Duration::from_millis(10)).await;
392		let b = pool.register(hook_b);
393		b.add(1000);
394		tokio::time::advance(Duration::from_millis(10)).await;
395
396		// Read A so B becomes the least recently used.
397		a.touch();
398		tokio::time::advance(Duration::from_millis(10)).await;
399
400		let c = pool.register(hook_c);
401		c.add(1000);
402		// Over budget by 500: evicting B (stalest) is enough once its charge clears.
403		pool.evict();
404
405		assert!(!evicted_a.load(Ordering::Relaxed));
406		assert!(evicted_b.load(Ordering::Relaxed));
407		assert!(!evicted_c.load(Ordering::Relaxed));
408
409		// The hook is responsible for releasing the charge (a real group aborts).
410		b.clear();
411		assert_eq!(pool.used(), 2 * (1000 + ENTRY_OVERHEAD));
412	}
413
414	#[tokio::test]
415	async fn pinned_entries_survive() {
416		tokio::time::pause();
417
418		let pool = Pool::new(ENTRY_OVERHEAD);
419		let (evicted_a, hook_a) = flag();
420		let (evicted_b, hook_b) = flag();
421
422		let a = pool.register(hook_a);
423		a.entry().unwrap().set_pinned(true);
424		a.add(1000);
425		tokio::time::advance(Duration::from_millis(10)).await;
426
427		let b = pool.register(hook_b);
428		b.add(1000);
429
430		pool.evict();
431		// A is older but pinned; B is the only eligible victim.
432		assert!(!evicted_a.load(Ordering::Relaxed));
433		assert!(evicted_b.load(Ordering::Relaxed));
434
435		// With B gone, everything left is pinned: eviction terminates without
436		// touching A even though the pool stays over budget.
437		b.clear();
438		drop(b);
439		pool.evict();
440		assert!(!evicted_a.load(Ordering::Relaxed));
441	}
442
443	#[tokio::test]
444	async fn resize_evicts() {
445		tokio::time::pause();
446
447		let pool = Pool::unbounded();
448		let (evicted_a, hook_a) = flag();
449		let a = pool.register(hook_a);
450		a.add(1000);
451
452		pool.evict();
453		assert!(!evicted_a.load(Ordering::Relaxed));
454
455		pool.resize(100);
456		assert!(evicted_a.load(Ordering::Relaxed));
457
458		// Back to unbounded: nothing more to do, and capacity() reports None.
459		pool.resize(None);
460		assert_eq!(pool.capacity(), None);
461	}
462
463	#[tokio::test]
464	async fn touched_entry_is_rekeyed_not_evicted() {
465		tokio::time::pause();
466
467		let pool = Pool::new(2 * ENTRY_OVERHEAD + 1500);
468		let (evicted_a, hook_a) = flag();
469		let (evicted_b, hook_b) = flag();
470
471		let a = pool.register(hook_a);
472		a.add(1000);
473		tokio::time::advance(Duration::from_millis(10)).await;
474		let b = pool.register(hook_b);
475		b.add(1000);
476		tokio::time::advance(Duration::from_millis(10)).await;
477
478		// A's heap snapshot is stale after this touch, so eviction re-keys A and
479		// picks B instead.
480		a.touch();
481		tokio::time::advance(Duration::from_millis(10)).await;
482		pool.evict();
483
484		assert!(!evicted_a.load(Ordering::Relaxed));
485		assert!(evicted_b.load(Ordering::Relaxed));
486	}
487
488	#[test]
489	fn dropped_charge_leaves_stale_heap_slot() {
490		let pool = Pool::new(0);
491		let (evicted_a, hook_a) = flag();
492		let a = pool.register(hook_a);
493		drop(a);
494
495		// The heap still has A's slot, but the entry is gone: eviction discards it
496		// without calling the hook.
497		let (evicted_b, hook_b) = flag();
498		let b = pool.register(hook_b);
499		b.add(1);
500		pool.evict();
501		assert!(!evicted_a.load(Ordering::Relaxed));
502		assert!(evicted_b.load(Ordering::Relaxed));
503	}
504}