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