1use std::time::Instant;
48
49use super::blob::BlobCache;
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum SweepLimit {
58 Entries(usize),
60 Millis(u32),
63 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#[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#[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 pub truncated_due_to_limit: bool,
104 pub by_namespace: Vec<(String, NamespaceSweepStats)>,
105}
106
107#[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#[derive(Debug, Clone, PartialEq, Eq)]
119pub struct NamespaceFlushReport {
120 pub namespace: String,
121 pub generation_before: u64,
122 pub generation_after: u64,
123 pub elapsed_micros: u32,
125}
126
127pub struct BlobCacheSweeper;
138
139impl BlobCacheSweeper {
140 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 pub fn reclaim_orphans(cache: &BlobCache, limit: SweepLimit) -> OrphanReport {
227 let started = Instant::now();
228 let budget = Budget::new(limit, started);
229
230 let _ = cache.stats();
232
233 let mut report = OrphanReport::default();
234
235 budget.observe(&mut report.elapsed_ms);
237 report.truncated_due_to_limit = false;
238 report
239 }
240
241 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 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
281struct 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 #[allow(dead_code)] 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 #[allow(dead_code)] 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 fn observe(self, elapsed_ms_field: &mut u32) {
335 *elapsed_ms_field = self.elapsed_ms_capped();
336 }
337}
338
339#[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 #[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 #[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 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 budget.tick();
412 thread::sleep(Duration::from_millis(5));
414 assert!(budget.exhausted(), "millis bound should fire first");
415 }
416
417 #[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 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 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 #[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 #[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 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 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 #[test]
563 fn concurrent_reads_never_block_during_sweep() {
564 const READER_THREADS: usize = 8;
565 const READS_PER_THREAD: usize = 5_000;
566 const READER_SOFT_CAP: Duration = Duration::from_millis(500);
570
571 let cache = Arc::new(cache());
572 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 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 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 #[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 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}