Skip to main content

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    // -- flush_namespace ----------------------------------------------------
508
509    #[test]
510    fn flush_namespace_returns_within_foreground_fast_bound_and_bumps_generation() {
511        let cache = cache();
512        cache
513            .put("ns", "k", BlobCachePut::new(b"v".to_vec()))
514            .unwrap();
515
516        let started = Instant::now();
517        let report = BlobCacheSweeper::flush_namespace(&cache, "ns");
518        let observed = started.elapsed();
519
520        assert_eq!(report.namespace, "ns");
521        assert_eq!(report.generation_before, 0);
522        assert_eq!(report.generation_after, 1);
523
524        // Foreground-fast contract: <100 µs typical. Allow generous slop for
525        // CI noise (debug builds + virtualised runners can be 10× slower).
526        assert!(
527            observed < Duration::from_millis(5),
528            "flush_namespace should be foreground-fast; observed {observed:?}",
529        );
530        assert!(
531            report.elapsed_micros < 5_000,
532            "report.elapsed_micros should be <5ms; observed {}µs",
533            report.elapsed_micros,
534        );
535
536        // The flush bumped the generation: previously-stored entry is gone.
537        assert!(
538            cache.get("ns", "k").is_none(),
539            "entry should be invisible after generation bump",
540        );
541    }
542
543    #[test]
544    fn flush_namespace_on_unknown_namespace_still_returns_well_formed_report() {
545        let cache = cache();
546        let report = BlobCacheSweeper::flush_namespace(&cache, "never-existed");
547        assert_eq!(report.namespace, "never-existed");
548        assert_eq!(report.generation_before, 0);
549        assert_eq!(report.generation_after, 0);
550    }
551
552    // -- Concurrency property test ------------------------------------------
553
554    /// 8 reader threads + 1 sweeper thread. Readers must complete a burst
555    /// of `cache.get` calls within a tight time budget while the sweeper
556    /// runs concurrently. Empirically verifies the
557    /// "concurrent reads never block during sweep" contract documented in
558    /// the module-level docstring.
559    ///
560    /// The test is intentionally short-running (≈250 ms total) so it stays
561    /// in the default test suite without slowing CI.
562    #[test]
563    fn concurrent_reads_never_block_during_sweep() {
564        const READER_THREADS: usize = 8;
565        const READS_PER_THREAD: usize = 5_000;
566        // Per-thread soft cap. If readers were ever serialised behind the
567        // sweeper, individual reads would queue and total time would
568        // explode well past this bound.
569        const READER_SOFT_CAP: Duration = Duration::from_millis(500);
570
571        let cache = Arc::new(cache());
572        // Pre-populate with enough entries that reads exercise multiple
573        // shards.
574        for i in 0..64 {
575            cache
576                .put("ns", &format!("k{i}"), BlobCachePut::new(vec![i as u8; 32]))
577                .unwrap();
578        }
579
580        let stop = Arc::new(AtomicBool::new(false));
581
582        // Sweeper thread: keep invoking sweep_expired with a small per-call
583        // bound, simulating the runtime scheduler's background pulse.
584        let sweeper_cache = Arc::clone(&cache);
585        let sweeper_stop = Arc::clone(&stop);
586        let sweeper = thread::spawn(move || {
587            while !sweeper_stop.load(Ordering::Relaxed) {
588                let _ = BlobCacheSweeper::sweep_expired(
589                    &sweeper_cache,
590                    SweepLimit::Either {
591                        entries: 1_000,
592                        millis: 5,
593                    },
594                );
595                let _ = BlobCacheSweeper::reclaim_orphans(&sweeper_cache, SweepLimit::Millis(5));
596            }
597        });
598
599        // Reader threads: each runs a burst of gets and reports its
600        // wall-clock time so the assertion is "no reader was starved".
601        let reader_handles: Vec<_> = (0..READER_THREADS)
602            .map(|tid| {
603                let reader_cache = Arc::clone(&cache);
604                thread::spawn(move || {
605                    let started = Instant::now();
606                    for i in 0..READS_PER_THREAD {
607                        let key = format!("k{}", (tid * 7 + i) % 64);
608                        let _ = reader_cache.get("ns", &key);
609                    }
610                    started.elapsed()
611                })
612            })
613            .collect();
614
615        let elapsed_per_reader: Vec<Duration> = reader_handles
616            .into_iter()
617            .map(|h| h.join().expect("reader thread panicked"))
618            .collect();
619
620        stop.store(true, Ordering::Relaxed);
621        sweeper.join().expect("sweeper thread panicked");
622
623        for (tid, elapsed) in elapsed_per_reader.iter().enumerate() {
624            assert!(
625                *elapsed < READER_SOFT_CAP,
626                "reader {tid} took {elapsed:?}, exceeding soft cap {READER_SOFT_CAP:?} \
627                 — sweeper appears to be blocking reads",
628            );
629        }
630    }
631
632    /// Companion property test: while a `flush_namespace` storm runs,
633    /// readers from a different namespace must remain unaffected.
634    #[test]
635    fn flush_namespace_storm_does_not_block_other_namespace_reads() {
636        let cache = Arc::new(cache());
637        cache
638            .put("readers", "k", BlobCachePut::new(b"hello".to_vec()))
639            .unwrap();
640        // Touch the flush-target namespace once so it exists.
641        cache
642            .put("flushed", "k", BlobCachePut::new(b"x".to_vec()))
643            .unwrap();
644
645        let stop = Arc::new(AtomicBool::new(false));
646        let flush_cache = Arc::clone(&cache);
647        let flush_stop = Arc::clone(&stop);
648        let flusher = thread::spawn(move || {
649            while !flush_stop.load(Ordering::Relaxed) {
650                let _ = BlobCacheSweeper::flush_namespace(&flush_cache, "flushed");
651            }
652        });
653
654        let started = Instant::now();
655        for _ in 0..10_000 {
656            let hit = cache.get("readers", "k").expect("reader namespace alive");
657            assert_eq!(hit.value(), b"hello");
658        }
659        let elapsed = started.elapsed();
660
661        stop.store(true, Ordering::Relaxed);
662        flusher.join().expect("flusher panicked");
663
664        assert!(
665            elapsed < Duration::from_millis(500),
666            "10k reads on a quiet namespace took {elapsed:?} — flush storm appears to block reads",
667        );
668    }
669}