reddb_server/storage/cache/sweeper.rs
1//! Bounded Blob Cache maintenance for L1 expiry and namespace flushing.
2//!
3//! `sweep_expired` consumes due records from per-shard L1 indexes and the
4//! store's persisted ordered L2 deadline projection, so ordinary TTL work
5//! depends on the number of deadlines due rather than the total cache size. A
6//! separate rotating scan runs only while a namespace generation flush needs
7//! physical reclamation.
8//! The runtime invokes it every 250 ms on a dedicated maintenance thread.
9//!
10//! # Operations
11//!
12//! - [`BlobCacheSweeper::sweep_expired`] — bounded sweep of expired L1/L2 entries.
13//! - [`BlobCacheSweeper::reclaim_orphans`] — bounded reclamation of L2 blob
14//! chains. This operation remains scaffolding until the L2 allocation walk
15//! is exposed; it reports zero work rather than claiming reclamation.
16//! - [`BlobCacheSweeper::flush_namespace`] — foreground-fast namespace flush.
17//! O(1): bumps the per-namespace generation counter; physical reclamation
18//! happens lazily on next access or via [`BlobCacheSweeper::sweep_expired`].
19//!
20//! # Bounding
21//!
22//! Sweep operations are bounded by [`SweepLimit`]:
23//!
24//! - `Entries(N)` — hard cap on the number of entries scanned.
25//! - `Millis(N)` — hard cap on wall-clock time. Checked at every iteration so
26//! the cap is honored within a few microseconds of overrun.
27//! - `Either { entries, millis }` — first cap to fire wins.
28//!
29//! When a sweep terminates because it hit a limit (instead of running to
30//! completion) the report's `truncated_due_to_limit` flag is set so admin
31//! callers can decide whether to schedule a follow-up sweep.
32//!
33//! # Concurrency contract
34//!
35//! Operations are safe to call while concurrent readers
36//! (`BlobCache::get`, `BlobCache::exists`) and writers
37//! (`BlobCache::put`) are in flight:
38//!
39//! - The expiry sweep takes one shard write lock at a time and releases it
40//! before advancing. Readers touching other shards continue normally.
41//! - `flush_namespace` only bumps a generation counter under a brief
42//! write-lock. Concurrent reads against the same namespace either see the
43//! old generation (returning a hit if the entry is still alive) or the new
44//! generation (treating any cached entry as stale). Either is correct.
45//! - Each invocation has entry/time bounds and never holds a global lock.
46
47use std::time::Instant;
48
49use super::blob::BlobCache;
50
51// ---------------------------------------------------------------------------
52// Public types
53// ---------------------------------------------------------------------------
54
55/// Bound for a single sweeper invocation.
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum SweepLimit {
58 /// Hard cap on deadline/generation records inspected.
59 Entries(usize),
60 /// Hard cap on wall-clock milliseconds. The sweep stops as soon as the
61 /// elapsed time crosses this threshold.
62 Millis(u32),
63 /// First-wins composite. Stops when either bound is hit.
64 Either { entries: usize, millis: u32 },
65}
66
67impl SweepLimit {
68 fn entries_cap(self) -> Option<usize> {
69 match self {
70 SweepLimit::Entries(n) => Some(n),
71 SweepLimit::Either { entries, .. } => Some(entries),
72 SweepLimit::Millis(_) => None,
73 }
74 }
75
76 fn millis_cap(self) -> Option<u32> {
77 match self {
78 SweepLimit::Millis(n) => Some(n),
79 SweepLimit::Either { millis, .. } => Some(millis),
80 SweepLimit::Entries(_) => None,
81 }
82 }
83}
84
85/// Per-namespace breakdown of a [`SweepReport`].
86#[derive(Debug, Clone, Default, PartialEq, Eq)]
87pub struct NamespaceSweepStats {
88 pub entries_scanned: usize,
89 pub entries_evicted: usize,
90 pub bytes_reclaimed: u64,
91}
92
93/// Outcome of [`BlobCacheSweeper::sweep_expired`].
94#[derive(Debug, Clone, Default, PartialEq, Eq)]
95pub struct SweepReport {
96 pub entries_scanned: usize,
97 pub entries_evicted: usize,
98 pub bytes_reclaimed: u64,
99 pub elapsed_ms: u32,
100 /// `true` iff the sweep terminated because it hit [`SweepLimit`] before
101 /// finishing the full set of candidates. Admin callers should treat this
102 /// as "schedule another sweep soon".
103 pub truncated_due_to_limit: bool,
104 pub by_namespace: Vec<(String, NamespaceSweepStats)>,
105}
106
107/// Outcome of [`BlobCacheSweeper::reclaim_orphans`].
108#[derive(Debug, Clone, Default, PartialEq, Eq)]
109pub struct OrphanReport {
110 pub blob_chains_scanned: usize,
111 pub blob_chains_reclaimed: usize,
112 pub bytes_reclaimed: u64,
113 pub elapsed_ms: u32,
114 pub truncated_due_to_limit: bool,
115}
116
117/// Outcome of [`BlobCacheSweeper::flush_namespace`].
118#[derive(Debug, Clone, PartialEq, Eq)]
119pub struct NamespaceFlushReport {
120 pub namespace: String,
121 pub generation_before: u64,
122 pub generation_after: u64,
123 /// Foreground-fast contract target: <100 µs typical.
124 pub elapsed_micros: u32,
125}
126
127// ---------------------------------------------------------------------------
128// Sweeper
129// ---------------------------------------------------------------------------
130
131/// Stateless namespace for sweeper operations against a [`BlobCache`].
132///
133/// The sweeper holds no state of its own; every method is a free-function
134/// over `&BlobCache`. This keeps the surface easy to call from admin
135/// handlers, the runtime scheduler, and tests without owning a sweeper
136/// instance.
137pub struct BlobCacheSweeper;
138
139impl BlobCacheSweeper {
140 /// Bounded sweep of expired L1 and L2 entries.
141 ///
142 /// Pops due L1 deadlines across shards, revalidates them against the
143 /// current entry, and evicts only entries whose effective expiry elapsed.
144 /// Honors `limit` precisely.
145 ///
146 /// # Concurrency
147 ///
148 /// One shard write lock is held at a time. A read on that shard may wait
149 /// briefly; readers on other shards continue unimpeded. Persistent L2
150 /// deletion is published as one bounded WAL batch after the L1 locks are
151 /// released.
152 ///
153 pub fn sweep_expired(cache: &BlobCache, limit: SweepLimit) -> SweepReport {
154 let started = Instant::now();
155 let mut outcome = cache.sweep_expired_l1(limit.entries_cap(), limit.millis_cap());
156 let entries_remaining = limit.entries_cap().map_or(usize::MAX, |cap| {
157 cap.saturating_sub(outcome.entries_scanned)
158 });
159 let millis_remaining = limit.millis_cap().map(|cap| {
160 let elapsed = u32::try_from(started.elapsed().as_millis()).unwrap_or(u32::MAX);
161 cap.saturating_sub(elapsed)
162 });
163 let l2 = cache.sweep_expired_l2(entries_remaining, millis_remaining);
164 outcome.entries_scanned = outcome.entries_scanned.saturating_add(l2.entries_scanned);
165 outcome.entries_evicted = outcome.entries_evicted.saturating_add(l2.entries_evicted);
166 outcome.expirations = outcome.expirations.saturating_add(l2.expirations);
167 outcome.bytes_reclaimed = outcome.bytes_reclaimed.saturating_add(l2.bytes_reclaimed);
168 outcome.completed &= l2.completed;
169 outcome.elapsed_ms = u32::try_from(started.elapsed().as_millis()).unwrap_or(u32::MAX);
170 for (namespace, stats) in l2.by_namespace {
171 let aggregate = outcome.by_namespace.entry(namespace).or_default();
172 aggregate.entries_scanned = aggregate
173 .entries_scanned
174 .saturating_add(stats.entries_scanned);
175 aggregate.entries_evicted = aggregate
176 .entries_evicted
177 .saturating_add(stats.entries_evicted);
178 aggregate.bytes_reclaimed = aggregate
179 .bytes_reclaimed
180 .saturating_add(stats.bytes_reclaimed);
181 }
182 SweepReport {
183 entries_scanned: outcome.entries_scanned,
184 entries_evicted: outcome.entries_evicted,
185 bytes_reclaimed: outcome.bytes_reclaimed,
186 elapsed_ms: outcome.elapsed_ms,
187 truncated_due_to_limit: !outcome.completed,
188 by_namespace: outcome
189 .by_namespace
190 .into_iter()
191 .map(|(namespace, stats)| {
192 (
193 namespace,
194 NamespaceSweepStats {
195 entries_scanned: stats.entries_scanned,
196 entries_evicted: stats.entries_evicted,
197 bytes_reclaimed: stats.bytes_reclaimed,
198 },
199 )
200 })
201 .collect(),
202 }
203 }
204
205 /// Bounded reclamation of L2 orphan blob chains.
206 ///
207 /// Orphan chains arise when a writer flushes the blob bytes to L2 pages
208 /// but is killed before the metadata B+ tree commit (see the WAL-ordering
209 /// note in `docs/perf/blob-cache-l2-spike.md`). Recovery cannot tell
210 /// these pages apart from a successful write without cross-checking the
211 /// metadata catalog, so they accumulate as wasted L2 capacity until the
212 /// sweeper reclaims them.
213 ///
214 /// The algorithm (once accessor #2 lands):
215 /// 1. Walk the L2 free-list of allocated blob chains
216 /// (`cache.l2_blob_chains()` — to-be-added).
217 /// 2. For each chain root, look up the metadata B+ tree
218 /// (`cache.for_each_l2_record`) and check whether any record points
219 /// at this root.
220 /// 3. Chains with no metadata reference are orphans — free their pages
221 /// via the L2 API and accumulate `bytes_reclaimed`.
222 ///
223 /// # Concurrency
224 ///
225 /// Same contract as [`BlobCacheSweeper::sweep_expired`].
226 pub fn reclaim_orphans(cache: &BlobCache, limit: SweepLimit) -> OrphanReport {
227 let started = Instant::now();
228 let budget = Budget::new(limit, started);
229
230 // Same "observable interaction" rationale as in `sweep_expired`.
231 let _ = cache.stats();
232
233 let mut report = OrphanReport::default();
234
235 // Bounded scaffolding: real L2 chain walk awaits flag #2.
236 budget.observe(&mut report.elapsed_ms);
237 report.truncated_due_to_limit = false;
238 report
239 }
240
241 /// Foreground-fast namespace flush.
242 ///
243 /// Delegates to [`BlobCache::invalidate_namespace`], which is O(1): it
244 /// only bumps a per-namespace generation counter. Cached entries with
245 /// the old generation become invisible immediately and are physically
246 /// removed by later cache access or by [`BlobCacheSweeper::sweep_expired`].
247 ///
248 /// # Foreground-fast contract
249 ///
250 /// Returns within ~100 µs typical. The `elapsed_micros` field on the
251 /// returned [`NamespaceFlushReport`] makes the contract observable so
252 /// admin endpoints can alert on regressions.
253 ///
254 /// # Generation reporting
255 ///
256 /// `generation_before` and `generation_after` are reported on a
257 /// best-effort basis. Because `BlobCache` does not expose a public
258 /// generation accessor, the sweeper reports them as `(0, 0)` when the
259 /// flush call signals "namespace did not exist" and `(0, 0)` when it
260 /// signals success — placeholder values until a `current_generation`
261 /// public accessor is added (see flag #2). The `namespace` and
262 /// `elapsed_micros` fields are accurate today.
263 pub fn flush_namespace(cache: &BlobCache, namespace: &str) -> NamespaceFlushReport {
264 let started = Instant::now();
265 let generation_before = cache.current_generation(namespace);
266 let _flushed = cache.invalidate_namespace(namespace);
267 let generation_after = cache.current_generation(namespace);
268 let elapsed = started.elapsed();
269 // Saturate at u32::MAX; under the foreground-fast contract this
270 // bound is never approached.
271 let elapsed_micros = u32::try_from(elapsed.as_micros()).unwrap_or(u32::MAX);
272 NamespaceFlushReport {
273 namespace: namespace.to_string(),
274 generation_before,
275 generation_after,
276 elapsed_micros,
277 }
278 }
279}
280
281// ---------------------------------------------------------------------------
282// Budget — internal bounded-work accounting
283// ---------------------------------------------------------------------------
284
285/// Internal helper: tracks elapsed time and entries-scanned against a
286/// [`SweepLimit`]. Centralises the "first-bound-wins" logic so the three
287/// public sweeper entry-points stay short.
288struct Budget {
289 started: Instant,
290 entries_cap: Option<usize>,
291 millis_cap: Option<u32>,
292 entries_seen: usize,
293}
294
295impl Budget {
296 fn new(limit: SweepLimit, started: Instant) -> Self {
297 Self {
298 started,
299 entries_cap: limit.entries_cap(),
300 millis_cap: limit.millis_cap(),
301 entries_seen: 0,
302 }
303 }
304
305 /// Returns `true` if either bound has been crossed.
306 #[allow(dead_code)] // Used by the not-yet-wired walk loops; keep for clarity.
307 fn exhausted(&self) -> bool {
308 if let Some(cap) = self.entries_cap {
309 if self.entries_seen >= cap {
310 return true;
311 }
312 }
313 if let Some(cap) = self.millis_cap {
314 if self.elapsed_ms_capped() >= cap {
315 return true;
316 }
317 }
318 false
319 }
320
321 /// Records that one more entry was scanned. Returns the post-tick
322 /// `exhausted()` result.
323 #[allow(dead_code)] // Used by the not-yet-wired walk loops; keep for clarity.
324 fn tick(&mut self) -> bool {
325 self.entries_seen = self.entries_seen.saturating_add(1);
326 self.exhausted()
327 }
328
329 fn elapsed_ms_capped(&self) -> u32 {
330 u32::try_from(self.started.elapsed().as_millis()).unwrap_or(u32::MAX)
331 }
332
333 /// Stamps the elapsed-ms field of a report at the end of a sweep.
334 fn observe(self, elapsed_ms_field: &mut u32) {
335 *elapsed_ms_field = self.elapsed_ms_capped();
336 }
337}
338
339// ---------------------------------------------------------------------------
340// Tests
341// ---------------------------------------------------------------------------
342
343#[cfg(test)]
344mod tests {
345 use std::sync::atomic::{AtomicBool, Ordering};
346 use std::sync::Arc;
347 use std::thread;
348 use std::time::{Duration, Instant};
349
350 use super::*;
351 use crate::storage::cache::blob::{BlobCache, BlobCacheConfig, BlobCachePolicy, BlobCachePut};
352
353 fn cache() -> BlobCache {
354 BlobCache::new(
355 BlobCacheConfig::default()
356 .with_l1_bytes_max(64 * 1024)
357 .with_shard_count(4)
358 .with_max_namespaces(8),
359 )
360 }
361
362 // -- SweepLimit cap-extraction ------------------------------------------
363
364 #[test]
365 fn sweep_limit_entries_only_caps_entries() {
366 let limit = SweepLimit::Entries(42);
367 assert_eq!(limit.entries_cap(), Some(42));
368 assert_eq!(limit.millis_cap(), None);
369 }
370
371 #[test]
372 fn sweep_limit_millis_only_caps_millis() {
373 let limit = SweepLimit::Millis(7);
374 assert_eq!(limit.entries_cap(), None);
375 assert_eq!(limit.millis_cap(), Some(7));
376 }
377
378 #[test]
379 fn sweep_limit_either_caps_both() {
380 let limit = SweepLimit::Either {
381 entries: 100,
382 millis: 5,
383 };
384 assert_eq!(limit.entries_cap(), Some(100));
385 assert_eq!(limit.millis_cap(), Some(5));
386 }
387
388 // -- Budget first-bound-wins --------------------------------------------
389
390 #[test]
391 fn budget_entries_bound_fires_first() {
392 let mut budget = Budget::new(SweepLimit::Entries(3), Instant::now());
393 assert!(!budget.exhausted());
394 assert!(!budget.tick());
395 assert!(!budget.tick());
396 // Third tick crosses the cap.
397 assert!(budget.tick());
398 assert!(budget.exhausted());
399 }
400
401 #[test]
402 fn budget_either_uses_first_bound_to_fire() {
403 let mut budget = Budget::new(
404 SweepLimit::Either {
405 entries: 1_000,
406 millis: 2,
407 },
408 Instant::now(),
409 );
410 // Entries cap not yet reached.
411 budget.tick();
412 // Sleep just past the millis cap.
413 thread::sleep(Duration::from_millis(5));
414 assert!(budget.exhausted(), "millis bound should fire first");
415 }
416
417 // -- sweep_expired contract ---------------------------------------------
418
419 #[test]
420 fn sweep_expired_with_entries_limit_returns_report_within_bound() {
421 let cache = cache();
422 cache
423 .put("n", "k", BlobCachePut::new(b"v".to_vec()))
424 .unwrap();
425
426 let report = BlobCacheSweeper::sweep_expired(&cache, SweepLimit::Entries(10));
427
428 assert_eq!(report.entries_scanned, 0);
429 assert_eq!(report.entries_evicted, 0);
430 assert_eq!(report.bytes_reclaimed, 0);
431 assert!(!report.truncated_due_to_limit);
432 assert!(report.by_namespace.is_empty());
433 }
434
435 #[test]
436 fn sweep_expired_with_millis_limit_honors_wall_clock_bound() {
437 let cache = cache();
438 let limit_ms = 50u32;
439 let started = Instant::now();
440 let report = BlobCacheSweeper::sweep_expired(&cache, SweepLimit::Millis(limit_ms));
441 let observed_ms = started.elapsed().as_millis() as u32;
442
443 // The sweeper itself must respect the bound. Allow a generous slop
444 // for CI scheduling jitter.
445 assert!(
446 observed_ms <= limit_ms + 50,
447 "sweep_expired should not block beyond limit; observed {observed_ms}ms vs cap {limit_ms}ms",
448 );
449 assert!(
450 report.elapsed_ms <= limit_ms + 50,
451 "report.elapsed_ms ({}) should be near or under cap ({})",
452 report.elapsed_ms,
453 limit_ms,
454 );
455 }
456
457 #[test]
458 fn sweep_expired_does_not_remove_unexpired_entries() {
459 let cache = cache();
460 cache
461 .put("alive", "k", BlobCachePut::new(b"v".to_vec()))
462 .unwrap();
463
464 let _ = BlobCacheSweeper::sweep_expired(&cache, SweepLimit::Entries(100));
465
466 // Entry must still be retrievable after a sweep when it has no TTL.
467 let hit = cache.get("alive", "k").expect("entry survives sweep");
468 assert_eq!(hit.value(), b"v");
469 }
470
471 #[test]
472 fn sweep_expired_evicts_expired_but_not_unexpired() {
473 let cache = cache();
474 let policy = BlobCachePolicy::default().expires_at_unix_ms(1);
475 cache
476 .put(
477 "n",
478 "expired",
479 BlobCachePut::new(b"x".to_vec()).with_policy(policy),
480 )
481 .unwrap();
482 cache
483 .put("n", "alive", BlobCachePut::new(b"y".to_vec()))
484 .unwrap();
485
486 let report = BlobCacheSweeper::sweep_expired(&cache, SweepLimit::Entries(10));
487
488 assert_eq!(report.entries_evicted, 1);
489 assert_eq!(report.entries_scanned, 1);
490 assert!(report.bytes_reclaimed > 1);
491 assert!(cache.get("n", "expired").is_none());
492 assert!(cache.get("n", "alive").is_some());
493 }
494
495 // -- reclaim_orphans contract -------------------------------------------
496
497 #[test]
498 fn reclaim_orphans_returns_well_formed_report() {
499 let cache = cache();
500 let report = BlobCacheSweeper::reclaim_orphans(&cache, SweepLimit::Entries(10));
501 assert_eq!(report.blob_chains_scanned, 0);
502 assert_eq!(report.blob_chains_reclaimed, 0);
503 assert_eq!(report.bytes_reclaimed, 0);
504 assert!(!report.truncated_due_to_limit);
505 }
506
507 /// Verifies that an orphan chain produced by the `fault_after_blob_write`
508 /// hook is reclaimed by the sweeper. Marked `#[ignore]` because both the
509 /// fault-injection hook and the L2-walk accessor are private to
510 /// `blob.rs`; this test is the contract this sweeper module commits to
511 /// satisfying once flag #2 lands and `inject_l2_fault_after_blob_write_once`
512 /// is exposed for cross-module use.
513 #[test]
514 #[ignore = "requires BlobCache::for_each_l2_record + cross-module fault hook (flag #2)"]
515 fn reclaim_orphans_reclaims_chain_left_by_interrupted_write() {
516 // Sketch (executable once accessor lands):
517 //
518 // let path = tempfile::NamedTempFile::new().unwrap().into_temp_path();
519 // let cache = BlobCache::new(
520 // BlobCacheConfig::default()
521 // .with_l1_bytes_max(128)
522 // .with_l2_path(&path),
523 // );
524 // cache.inject_l2_fault_after_blob_write_once();
525 // let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
526 // cache
527 // .put("n", "partial", BlobCachePut::new(b"partial".to_vec()))
528 // .unwrap();
529 // }));
530 // let report = BlobCacheSweeper::reclaim_orphans(&cache, SweepLimit::Entries(100));
531 // assert_eq!(report.blob_chains_reclaimed, 1);
532 // assert!(report.bytes_reclaimed >= b"partial".len() as u64);
533 }
534
535 // -- flush_namespace ----------------------------------------------------
536
537 #[test]
538 fn flush_namespace_returns_within_foreground_fast_bound_and_bumps_generation() {
539 let cache = cache();
540 cache
541 .put("ns", "k", BlobCachePut::new(b"v".to_vec()))
542 .unwrap();
543
544 let started = Instant::now();
545 let report = BlobCacheSweeper::flush_namespace(&cache, "ns");
546 let observed = started.elapsed();
547
548 assert_eq!(report.namespace, "ns");
549 assert_eq!(report.generation_before, 0);
550 assert_eq!(report.generation_after, 1);
551
552 // Foreground-fast contract: <100 µs typical. Allow generous slop for
553 // CI noise (debug builds + virtualised runners can be 10× slower).
554 assert!(
555 observed < Duration::from_millis(5),
556 "flush_namespace should be foreground-fast; observed {observed:?}",
557 );
558 assert!(
559 report.elapsed_micros < 5_000,
560 "report.elapsed_micros should be <5ms; observed {}µs",
561 report.elapsed_micros,
562 );
563
564 // The flush bumped the generation: previously-stored entry is gone.
565 assert!(
566 cache.get("ns", "k").is_none(),
567 "entry should be invisible after generation bump",
568 );
569 }
570
571 #[test]
572 fn flush_namespace_on_unknown_namespace_still_returns_well_formed_report() {
573 let cache = cache();
574 let report = BlobCacheSweeper::flush_namespace(&cache, "never-existed");
575 assert_eq!(report.namespace, "never-existed");
576 assert_eq!(report.generation_before, 0);
577 assert_eq!(report.generation_after, 0);
578 }
579
580 // -- Concurrency property test ------------------------------------------
581
582 /// 8 reader threads + 1 sweeper thread. Readers must complete a burst
583 /// of `cache.get` calls within a tight time budget while the sweeper
584 /// runs concurrently. Empirically verifies the
585 /// "concurrent reads never block during sweep" contract documented in
586 /// the module-level docstring.
587 ///
588 /// The test is intentionally short-running (≈250 ms total) so it stays
589 /// in the default test suite without slowing CI.
590 #[test]
591 fn concurrent_reads_never_block_during_sweep() {
592 const READER_THREADS: usize = 8;
593 const READS_PER_THREAD: usize = 5_000;
594 // Per-thread soft cap. If readers were ever serialised behind the
595 // sweeper, individual reads would queue and total time would
596 // explode well past this bound.
597 const READER_SOFT_CAP: Duration = Duration::from_millis(500);
598
599 let cache = Arc::new(cache());
600 // Pre-populate with enough entries that reads exercise multiple
601 // shards.
602 for i in 0..64 {
603 cache
604 .put("ns", &format!("k{i}"), BlobCachePut::new(vec![i as u8; 32]))
605 .unwrap();
606 }
607
608 let stop = Arc::new(AtomicBool::new(false));
609
610 // Sweeper thread: keep invoking sweep_expired with a small per-call
611 // bound, simulating the runtime scheduler's background pulse.
612 let sweeper_cache = Arc::clone(&cache);
613 let sweeper_stop = Arc::clone(&stop);
614 let sweeper = thread::spawn(move || {
615 while !sweeper_stop.load(Ordering::Relaxed) {
616 let _ = BlobCacheSweeper::sweep_expired(
617 &sweeper_cache,
618 SweepLimit::Either {
619 entries: 1_000,
620 millis: 5,
621 },
622 );
623 let _ = BlobCacheSweeper::reclaim_orphans(&sweeper_cache, SweepLimit::Millis(5));
624 }
625 });
626
627 // Reader threads: each runs a burst of gets and reports its
628 // wall-clock time so the assertion is "no reader was starved".
629 let reader_handles: Vec<_> = (0..READER_THREADS)
630 .map(|tid| {
631 let reader_cache = Arc::clone(&cache);
632 thread::spawn(move || {
633 let started = Instant::now();
634 for i in 0..READS_PER_THREAD {
635 let key = format!("k{}", (tid * 7 + i) % 64);
636 let _ = reader_cache.get("ns", &key);
637 }
638 started.elapsed()
639 })
640 })
641 .collect();
642
643 let elapsed_per_reader: Vec<Duration> = reader_handles
644 .into_iter()
645 .map(|h| h.join().expect("reader thread panicked"))
646 .collect();
647
648 stop.store(true, Ordering::Relaxed);
649 sweeper.join().expect("sweeper thread panicked");
650
651 for (tid, elapsed) in elapsed_per_reader.iter().enumerate() {
652 assert!(
653 *elapsed < READER_SOFT_CAP,
654 "reader {tid} took {elapsed:?}, exceeding soft cap {READER_SOFT_CAP:?} \
655 — sweeper appears to be blocking reads",
656 );
657 }
658 }
659
660 /// Companion property test: while a `flush_namespace` storm runs,
661 /// readers from a different namespace must remain unaffected.
662 #[test]
663 fn flush_namespace_storm_does_not_block_other_namespace_reads() {
664 let cache = Arc::new(cache());
665 cache
666 .put("readers", "k", BlobCachePut::new(b"hello".to_vec()))
667 .unwrap();
668 // Touch the flush-target namespace once so it exists.
669 cache
670 .put("flushed", "k", BlobCachePut::new(b"x".to_vec()))
671 .unwrap();
672
673 let stop = Arc::new(AtomicBool::new(false));
674 let flush_cache = Arc::clone(&cache);
675 let flush_stop = Arc::clone(&stop);
676 let flusher = thread::spawn(move || {
677 while !flush_stop.load(Ordering::Relaxed) {
678 let _ = BlobCacheSweeper::flush_namespace(&flush_cache, "flushed");
679 }
680 });
681
682 let started = Instant::now();
683 for _ in 0..10_000 {
684 let hit = cache.get("readers", "k").expect("reader namespace alive");
685 assert_eq!(hit.value(), b"hello");
686 }
687 let elapsed = started.elapsed();
688
689 stop.store(true, Ordering::Relaxed);
690 flusher.join().expect("flusher panicked");
691
692 assert!(
693 elapsed < Duration::from_millis(500),
694 "10k reads on a quiet namespace took {elapsed:?} — flush storm appears to block reads",
695 );
696 }
697}