camel_core/cache/disk_offload.rs
1//! Disk-payload offload decorator for [`CacheRepository`] backends.
2//!
3//! [`DiskOffloadRepository`] wraps any backend (the "index") and moves entry
4//! payloads to content-addressed blob files under a dedicated directory,
5//! storing only a relative file name in the index row. Index rows stay
6//! small; the payload is re-injected on `get`/`peek_stale`.
7//!
8//! # Blob lifecycle
9//!
10//! Blob names are `{blake3-128hex(key)}.{death_epoch_secs}.{blake3-128hex(
11//! bytes || content_type-discriminant)}.blob`. The death epoch —
12//! `expires_at + stale_retention + sweep_interval` — is encoded in the name
13//! so the background sweeper can reclaim dead blobs by file name alone,
14//! without consulting the index.
15//!
16//! # Failure policy
17//!
18//! - A blob write that fails falls back to storing the entry inline in the
19//! index (WARN + `inner.set` with the original entry): the decorator never
20//! converts its own file-write failure into a cache-write `Err`.
21//! - A vanished or corrupt blob row degrades to a miss (`Ok(None)` + WARN).
22//! - A blob that exists but cannot be read (e.g. `PermissionDenied`)
23//! surfaces as `Err` per ADR-0023 Contract C1.
24
25use std::path::Path;
26use std::path::PathBuf;
27use std::sync::Arc;
28use std::time::Duration;
29use std::time::SystemTime;
30use std::time::UNIX_EPOCH;
31
32use async_trait::async_trait;
33use camel_api::CamelError;
34use camel_api::cache::CacheEntry;
35use camel_api::cache::CacheRepository;
36use camel_api::cache::CacheStats;
37use camel_api::cache::ContentType;
38use parking_lot::Mutex;
39use tokio::io::AsyncWriteExt;
40use tokio_util::sync::CancellationToken;
41use tracing::{info, warn};
42
43/// Injectable wall clock for death-epoch math and deterministic tests.
44///
45/// Mirrors `ClockFn` in `camel-redis-repo::cache_repo`.
46pub type OffloadClock = Arc<dyn Fn() -> SystemTime + Send + Sync>;
47
48/// The default production clock: [`SystemTime::now`].
49pub fn default_offload_clock() -> OffloadClock {
50 Arc::new(SystemTime::now)
51}
52
53/// Max attempts to open a unique tmp file before giving up on a name.
54const TMP_NAME_ATTEMPTS: u32 = 8;
55
56/// [`CacheRepository`] decorator that offloads entry payloads to disk.
57///
58/// Wraps any index backend; see the [module docs](self) for the blob
59/// lifecycle and failure policy. `stale_retention`, `sweep_interval`, and
60/// `payload_max_ttl` must be non-zero (the payload intervals at least one
61/// second — the death epoch truncates to whole seconds) — enforced by
62/// `CacheRepoConfig` validation, not here.
63pub struct DiskOffloadRepository {
64 /// Decorated index backend (memory, redb, redis, …).
65 inner: Arc<dyn CacheRepository>,
66 /// Directory holding offloaded payload blobs.
67 dir: PathBuf,
68 /// How long an expired entry stays peekable before reclamation.
69 stale_retention: Duration,
70 /// Background sweep cadence; its length is the death-epoch grace.
71 sweep_interval: Duration,
72 /// Fabricated TTL for entries stored without an explicit one.
73 payload_max_ttl: Duration,
74 /// Wall clock for death-epoch math.
75 clock: OffloadClock,
76 /// Background payload sweeper; aborted on Drop.
77 sweep_handle: Mutex<Option<tokio::task::JoinHandle<()>>>,
78}
79
80impl DiskOffloadRepository {
81 /// Wrap `inner` with disk payload offload into `dir` (production clock).
82 ///
83 /// The `shutdown_token` stops the background payload sweeper; it is
84 /// owned by the sweeper task and never cancelled by the decorator.
85 pub fn new(
86 inner: Arc<dyn CacheRepository>,
87 dir: PathBuf,
88 stale_retention: Duration,
89 sweep_interval: Duration,
90 payload_max_ttl: Duration,
91 shutdown_token: CancellationToken,
92 ) -> Self {
93 Self::with_clock(
94 inner,
95 dir,
96 stale_retention,
97 sweep_interval,
98 payload_max_ttl,
99 shutdown_token,
100 default_offload_clock(),
101 )
102 }
103
104 /// Test seam: [`Self::new`] with an injected [`OffloadClock`].
105 ///
106 /// The injected clock drives death-epoch math only; the spawned
107 /// sweeper always sweeps on the real clock.
108 pub fn with_clock(
109 inner: Arc<dyn CacheRepository>,
110 dir: PathBuf,
111 stale_retention: Duration,
112 sweep_interval: Duration,
113 payload_max_ttl: Duration,
114 shutdown_token: CancellationToken,
115 clock: OffloadClock,
116 ) -> Self {
117 // The sweeper must observe real file ages, so it never uses the
118 // injected decorator clock. The token moves into the task: the
119 // decorator only aborts the task on Drop, never cancels the
120 // context-owned token.
121 let sweep_handle = spawn_sweeper(dir.clone(), sweep_interval, shutdown_token);
122 Self {
123 inner,
124 dir,
125 stale_retention,
126 sweep_interval,
127 payload_max_ttl,
128 clock,
129 sweep_handle: Mutex::new(Some(sweep_handle)),
130 }
131 }
132
133 /// Write `entry`'s payload to its content-addressed blob file and
134 /// return the final file name.
135 ///
136 /// Tmp-then-rename so a partially written blob is never visible under
137 /// its final name. All I/O is async `tokio::fs` (the house file-I/O
138 /// style, matching `camel-file`'s `atomic_write`).
139 async fn write_blob(
140 &self,
141 key: &str,
142 entry: &CacheEntry,
143 death_epoch: u64,
144 ) -> std::io::Result<String> {
145 tokio::fs::create_dir_all(&self.dir).await?;
146 let dest_name = blob_filename(key, death_epoch, entry);
147 let dest_path = self.dir.join(&dest_name);
148 let (mut file, tmp_path) = self.open_tmp_exclusive(key, &dest_name).await?;
149
150 // Best-effort tmp cleanup on failure: a leaked `.tmp` would never
151 // be reclaimed by the epoch sweeper.
152 if let Err(e) = file.write_all(&entry.bytes).await {
153 let _ = tokio::fs::remove_file(&tmp_path).await;
154 return Err(e);
155 }
156 if let Err(e) = file.sync_all().await {
157 let _ = tokio::fs::remove_file(&tmp_path).await;
158 return Err(e);
159 }
160 if let Err(e) = tokio::fs::rename(&tmp_path, &dest_path).await {
161 let _ = tokio::fs::remove_file(&tmp_path).await;
162 return Err(e);
163 }
164 self.fsync_dir_best_effort().await;
165 Ok(dest_name)
166 }
167
168 /// Open a unique exclusive tmp file next to `dest_name`, retrying name
169 /// collisions with a fresh nonce (bounded by [`TMP_NAME_ATTEMPTS`]).
170 ///
171 /// The nonce hashes `key || clock_nanos || attempt_counter`, so retries
172 /// still produce fresh names under a frozen test clock.
173 async fn open_tmp_exclusive(
174 &self,
175 key: &str,
176 dest_name: &str,
177 ) -> std::io::Result<(tokio::fs::File, PathBuf)> {
178 let clock_nanos = (self.clock)()
179 .duration_since(UNIX_EPOCH)
180 .map(|d| d.as_nanos())
181 .unwrap_or(0);
182 let mut last_collision: Option<std::io::Error> = None;
183 for attempt in 0..TMP_NAME_ATTEMPTS {
184 let mut hasher = blake3::Hasher::new();
185 hasher.update(key.as_bytes());
186 hasher.update(&clock_nanos.to_le_bytes());
187 hasher.update(&attempt.to_le_bytes());
188 let nonce = hasher_128hex(hasher);
189 let tmp_path = self.dir.join(format!("{dest_name}.{nonce}.tmp"));
190 match tokio::fs::OpenOptions::new()
191 .write(true)
192 .create_new(true)
193 .open(&tmp_path)
194 .await
195 {
196 Ok(file) => return Ok((file, tmp_path)),
197 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
198 last_collision = Some(e);
199 }
200 Err(e) => return Err(e),
201 }
202 }
203 Err(last_collision
204 .unwrap_or_else(|| std::io::Error::other("tmp blob name collisions exhausted")))
205 }
206
207 /// Best-effort fsync of the blob directory so the rename itself is
208 /// durable. Failures are WARNed and ignored: the blob is already
209 /// renamed, and a directory-fsync failure must not fail the write.
210 async fn fsync_dir_best_effort(&self) {
211 let result = match tokio::fs::File::open(&self.dir).await {
212 Ok(dir_file) => dir_file.sync_all().await,
213 Err(e) => Err(e),
214 };
215 if let Err(e) = result {
216 warn!(
217 dir = %self.dir.display(),
218 error = %e,
219 "cache blob directory fsync failed (best-effort, ignored)"
220 );
221 }
222 }
223
224 /// Re-inject the offloaded payload into an index row (shared by `get`
225 /// and `peek_stale`).
226 ///
227 /// Rows without `payload_path` pass through untouched (legacy/inline).
228 /// A corrupt path or a vanished blob degrades to a miss; a blob that
229 /// exists but cannot be read surfaces as `Err` (Contract C1).
230 async fn hydrate(
231 &self,
232 key: &str,
233 mut entry: CacheEntry,
234 ) -> Result<Option<CacheEntry>, CamelError> {
235 let Some(raw_path) = entry.payload_path.clone() else {
236 return Ok(Some(entry));
237 };
238 let Some(name) = sanitize_blob_name(&raw_path) else {
239 warn!(
240 key = key,
241 backend = self.inner.name(),
242 payload_path = %raw_path,
243 "corrupt cache row: payload_path must be a bare file name; treating as miss"
244 );
245 return Ok(None);
246 };
247 let blob_path = self.dir.join(name);
248 match tokio::fs::read(&blob_path).await {
249 Ok(bytes) => {
250 entry.bytes = bytes;
251 entry.payload_path = None;
252 Ok(Some(entry))
253 }
254 Err(e)
255 if matches!(
256 e.kind(),
257 std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
258 ) =>
259 {
260 warn!(
261 key = key,
262 backend = self.inner.name(),
263 blob = %blob_path.display(),
264 "cache payload blob gone; treating as miss"
265 );
266 Ok(None)
267 }
268 Err(e) => Err(CamelError::Io(format!(
269 "cache payload blob read '{}': {e}",
270 blob_path.display()
271 ))),
272 }
273 }
274
275 /// Best-effort unlink of every entry of the payload dir.
276 ///
277 /// Per-file `NotFound` is success (a concurrent sweeper or replica may
278 /// have reclaimed the blob already); any other per-file error WARNs
279 /// and iteration continues. [`Self::clear`] must never surface its
280 /// own unlink failures as `Err`.
281 async fn unlink_payload_dir_best_effort(&self) {
282 let mut read_dir = match tokio::fs::read_dir(&self.dir).await {
283 Ok(read_dir) => read_dir,
284 // No dir = nothing was ever offloaded; nothing to unlink.
285 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return,
286 Err(e) => {
287 warn!(
288 dir = %self.dir.display(),
289 error = %e,
290 "cache payload dir read failed during clear (best-effort, skipped)"
291 );
292 return;
293 }
294 };
295 loop {
296 let entry = match read_dir.next_entry().await {
297 Ok(Some(entry)) => entry,
298 Ok(None) => return,
299 Err(e) => {
300 warn!(
301 dir = %self.dir.display(),
302 error = %e,
303 "cache payload dir iteration failed during clear (best-effort, stopped)"
304 );
305 return;
306 }
307 };
308 let path = entry.path();
309 match tokio::fs::remove_file(&path).await {
310 Ok(()) => {}
311 // NotFound = a concurrent sweeper or replica won the race.
312 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
313 Err(e) => {
314 warn!(
315 dir = %self.dir.display(),
316 blob = %path.display(),
317 error = %e,
318 "cache payload blob unlink failed during clear (best-effort, skipped)"
319 );
320 }
321 }
322 }
323 }
324}
325
326#[async_trait]
327impl CacheRepository for DiskOffloadRepository {
328 fn name(&self) -> &str {
329 self.inner.name()
330 }
331
332 async fn get(&self, key: &str) -> Result<Option<CacheEntry>, CamelError> {
333 match self.inner.get(key).await? {
334 Some(entry) => self.hydrate(key, entry).await,
335 None => Ok(None),
336 }
337 }
338
339 async fn set(
340 &self,
341 key: &str,
342 mut entry: CacheEntry,
343 ttl: Option<Duration>,
344 ) -> Result<(), CamelError> {
345 let effective_ttl = ttl.unwrap_or(self.payload_max_ttl);
346 // Death epoch = expiry + retention + sweep grace, saturating in
347 // Duration space (a pre-epoch clock clamps to the Unix epoch),
348 // truncated to whole seconds for the blob filename.
349 let death_epoch = (self.clock)()
350 .duration_since(UNIX_EPOCH)
351 .unwrap_or_default()
352 .saturating_add(effective_ttl)
353 .saturating_add(self.stale_retention)
354 .saturating_add(self.sweep_interval)
355 .as_secs();
356
357 match self.write_blob(key, &entry, death_epoch).await {
358 Ok(dest_name) => {
359 entry.bytes = Vec::new();
360 entry.payload_path = Some(dest_name);
361 // The ttl MUST be Some: every inner overwrites
362 // `expires_at` from the ttl argument, so None would wipe
363 // the fabricated expiry. The inner recomputes `expires_at`
364 // from its own clock; the sub-second skew is absorbed by
365 // the death-epoch grace.
366 self.inner.set(key, entry, Some(effective_ttl)).await
367 }
368 Err(e) => {
369 warn!(
370 key = key,
371 backend = self.inner.name(),
372 dir = %self.dir.display(),
373 error = %e,
374 "cache blob write failed; storing entry inline instead"
375 );
376 // Inline fallback with the original, unstripped entry: the
377 // decorator never converts its own file-write failure into
378 // a cache-write error. The CAPPED ttl keeps the spec's
379 // no-TTL semantic (payload_max_ttl) even for degraded rows
380 // — an uncapped inline row would never be reclaimed.
381 self.inner.set(key, entry, Some(effective_ttl)).await
382 }
383 }
384 }
385
386 async fn peek_stale(&self, key: &str) -> Result<Option<CacheEntry>, CamelError> {
387 match self.inner.peek_stale(key).await? {
388 Some(entry) => self.hydrate(key, entry).await,
389 None => Ok(None),
390 }
391 }
392
393 /// Delegate-only: the index row is dropped here; the payload blob
394 /// becomes an orphan reclaimed asynchronously at its
395 /// filename-encoded death epoch.
396 async fn invalidate(&self, key: &str) -> Result<(), CamelError> {
397 self.inner.invalidate(key).await
398 }
399
400 /// Reclaim payload space now: best-effort unlink of every entry of
401 /// the payload dir, then delegate to the index. Unlink failures
402 /// never turn `clear` into `Err` — each failure WARNs and the rest
403 /// of the dir is still attempted.
404 async fn clear(&self) -> Result<(), CamelError> {
405 self.unlink_payload_dir_best_effort().await;
406 self.inner.clear().await
407 }
408
409 /// Delegate-only: the returned count is index-scoped; payload blobs
410 /// are reclaimed asynchronously at their filename-encoded death epoch.
411 async fn invalidate_prefix(&self, prefix: &str) -> Result<u64, CamelError> {
412 self.inner.invalidate_prefix(prefix).await
413 }
414
415 async fn stats(&self) -> CacheStats {
416 self.inner.stats().await
417 }
418}
419
420impl std::fmt::Debug for DiskOffloadRepository {
421 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
422 f.debug_struct("DiskOffloadRepository")
423 .field("inner", &self.inner)
424 .field("dir", &self.dir)
425 .field("stale_retention", &self.stale_retention)
426 .field("sweep_interval", &self.sweep_interval)
427 .field("payload_max_ttl", &self.payload_max_ttl)
428 .field("sweep_attached", &self.sweep_handle.lock().is_some())
429 .finish()
430 }
431}
432
433impl Drop for DiskOffloadRepository {
434 fn drop(&mut self) {
435 // Abort ONLY the sweep task. Never cancel the context-owned token —
436 // that would shut down the entire context when one repo drops.
437 if let Some(handle) = self.sweep_handle.lock().take() {
438 handle.abort();
439 }
440 }
441}
442
443// ── Filename helpers ─────────────────────────────────────────────────────────
444
445/// One-byte discriminant of the closed [`ContentType`] enum, mixed into the
446/// content fingerprint for domain separation (identical bytes under
447/// different content types produce different fingerprints). Exhaustive
448/// match — the enum is closed by contract (ADR-0049 §Exceptions).
449fn content_type_discriminant(content_type: ContentType) -> u8 {
450 match content_type {
451 ContentType::Bytes => 0,
452 ContentType::Text => 1,
453 ContentType::Json => 2,
454 ContentType::Xml => 3,
455 }
456}
457
458/// Finalize a hasher to its first 128 bits as 32 lowercase hex chars.
459fn hasher_128hex(hasher: blake3::Hasher) -> String {
460 let hex = hasher.finalize().to_hex().to_string();
461 hex[..32].to_string()
462}
463
464/// blake3-128 hex of a single byte slice.
465fn blake3_128hex(data: &[u8]) -> String {
466 let mut hasher = blake3::Hasher::new();
467 hasher.update(data);
468 hasher_128hex(hasher)
469}
470
471/// 128-bit content fingerprint: `blake3(bytes || content_type discriminant)`.
472fn content_fingerprint(entry: &CacheEntry) -> String {
473 let mut hasher = blake3::Hasher::new();
474 hasher.update(&entry.bytes);
475 hasher.update(&[content_type_discriminant(entry.content_type)]);
476 hasher_128hex(hasher)
477}
478
479/// Blob file name: `{key-hash}.{death_epoch}.{fingerprint}.blob`.
480fn blob_filename(key: &str, death_epoch: u64, entry: &CacheEntry) -> String {
481 format!(
482 "{}.{}.{}.blob",
483 blake3_128hex(key.as_bytes()),
484 death_epoch,
485 content_fingerprint(entry)
486 )
487}
488
489/// Death epoch (second dot-separated component) of a blob file name, if it
490/// parses as `u64`.
491fn parse_death_epoch(file_name: &str) -> Option<u64> {
492 file_name.split('.').nth(1)?.parse().ok()
493}
494
495/// Accept only a bare file name: non-empty, no `/`, no `\`, no `..`.
496///
497/// Absolute paths necessarily contain a separator on both Unix and Windows,
498/// so the separator checks subsume the absolute-path rejection. Everything
499/// else is treated as a corrupt row.
500fn sanitize_blob_name(path: &str) -> Option<&str> {
501 if path.is_empty() || path.contains('/') || path.contains('\\') || path.contains("..") {
502 return None;
503 }
504 Some(path)
505}
506
507// ── Payload sweeper ─────────────────────────────────────────────────────────
508
509/// Unlink one payload-dir file if it is dead: `.blob` files by their
510/// name-encoded death epoch, `.tmp` leftovers by age.
511///
512/// `Ok(true)` = unlinked here; `Ok(false)` = kept (still live, a foreign
513/// name without a parseable epoch, or vanished between listing and unlink —
514/// the ENOENT race counts as reclaimed-by-someone-else, never an error).
515/// Any other error is returned for the sweep loop to WARN over. All filesystem
516/// access is async (`tokio::fs`), keeping the sweeper off blocked
517/// runtime workers.
518async fn unlink_payload_file(
519 path: &Path,
520 now: SystemTime,
521 sweep_interval: Duration,
522) -> std::io::Result<bool> {
523 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
524 return Ok(false);
525 };
526 // Clamp a pre-epoch clock to the Unix epoch, matching `set`'s
527 // death-epoch math.
528 let now_secs = now.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
529 let dead = if name.ends_with(".blob") {
530 // Strictly-before: a blob dying exactly `now` survives this pass
531 // (the filename epoch is whole seconds; the next tick reclaims).
532 parse_death_epoch(name).is_some_and(|death| death < now_secs)
533 } else if name.ends_with(".tmp") {
534 let threshold = now.checked_sub(sweep_interval).unwrap_or(UNIX_EPOCH);
535 let mtime = match tokio::fs::metadata(path).await {
536 Ok(meta) => meta.modified()?,
537 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false),
538 Err(e) => return Err(e),
539 };
540 mtime < threshold
541 } else {
542 return Ok(false);
543 };
544 if !dead {
545 return Ok(false);
546 }
547 match tokio::fs::remove_file(path).await {
548 Ok(()) => Ok(true),
549 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
550 Err(e) => Err(e),
551 }
552}
553
554/// One sweep pass over `dir`: reclaim dead blobs by their filename
555/// death epoch and stale `.tmp` leftovers by age.
556///
557/// Per-file `NotFound` (a concurrent sweeper or replica won the race)
558/// counts as success; other per-file errors WARN and the scan
559/// continues. A missing dir is not an error — nothing was ever
560/// offloaded. Returns `(blobs_unlinked, tmps_unlinked)`.
561#[derive(Debug, Default, PartialEq, Eq, Clone, Copy)]
562struct SweepStats {
563 /// Dead blobs unlinked this pass.
564 blobs_unlinked: u64,
565 /// Bytes reclaimed with those dead blobs.
566 blob_bytes_reclaimed: u64,
567 /// Stale tmp files unlinked this pass.
568 tmps_unlinked: u64,
569 /// Blobs still on disk after the pass (live, orphan pre-epoch, or
570 /// foreign names — anything the sweep kept; a blob that vanishes
571 /// mid-pass via the ENOENT race is counted here until the next pass).
572 live_blobs: u64,
573 /// Total bytes of those surviving blobs.
574 live_blob_bytes: u64,
575}
576
577async fn sweep_payload_dir(dir: &Path, now: SystemTime, sweep_interval: Duration) -> SweepStats {
578 let mut read_dir = match tokio::fs::read_dir(dir).await {
579 Ok(read_dir) => read_dir,
580 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return SweepStats::default(),
581 Err(e) => {
582 warn!(
583 dir = %dir.display(),
584 error = %e,
585 "cache payload dir read failed during sweep (skipped)"
586 );
587 return SweepStats::default();
588 }
589 };
590 let mut stats = SweepStats::default();
591 loop {
592 let entry = match read_dir.next_entry().await {
593 Ok(Some(entry)) => entry,
594 Ok(None) => break,
595 Err(e) => {
596 warn!(
597 dir = %dir.display(),
598 error = %e,
599 "cache payload dir iteration failed during sweep (stopped)"
600 );
601 break;
602 }
603 };
604 let path = entry.path();
605 let is_tmp = path
606 .file_name()
607 .and_then(|n| n.to_str())
608 .is_some_and(|n| n.ends_with(".tmp"));
609 let size = entry.metadata().await.map(|m| m.len()).unwrap_or(0);
610 match unlink_payload_file(&path, now, sweep_interval).await {
611 Ok(true) => {
612 if is_tmp {
613 stats.tmps_unlinked += 1;
614 } else {
615 stats.blobs_unlinked += 1;
616 stats.blob_bytes_reclaimed += size;
617 }
618 }
619 Ok(false) => {
620 if !is_tmp {
621 stats.live_blobs += 1;
622 stats.live_blob_bytes += size;
623 }
624 }
625 Err(e) => warn!(
626 dir = %dir.display(),
627 file = %path.display(),
628 error = %e,
629 "cache payload file unlink failed during sweep (skipped)"
630 ),
631 }
632 }
633 stats
634}
635
636/// Spawn the background payload sweeper for `dir`.
637///
638/// Mirrors the redb sweep loop: tick every `sweep_interval`, reclaim
639/// dead blobs and stale tmp files, exit when `shutdown_token` fires.
640/// The sweep always runs on the REAL clock (`SystemTime::now`), never
641/// an injected decorator clock — it must observe actual file ages.
642fn spawn_sweeper(
643 dir: PathBuf,
644 sweep_interval: Duration,
645 shutdown_token: CancellationToken,
646) -> tokio::task::JoinHandle<()> {
647 tokio::spawn(async move {
648 let mut ticker = tokio::time::interval(sweep_interval);
649 loop {
650 tokio::select! {
651 _ = ticker.tick() => {
652 let s = sweep_payload_dir(&dir, SystemTime::now(), sweep_interval).await;
653 // Per-pass volume observability (bd rc-h3dp): live
654 // bytes are the high-water baseline operators compare
655 // against the eager-reclaim trigger; reclaimed bytes
656 // show the pass's cleanup.
657 info!(
658 dir = %dir.display(),
659 live_blobs = s.live_blobs,
660 live_blob_bytes = s.live_blob_bytes,
661 blobs_unlinked = s.blobs_unlinked,
662 blob_bytes_reclaimed = s.blob_bytes_reclaimed,
663 tmps_unlinked = s.tmps_unlinked,
664 "cache payload sweep pass"
665 );
666 }
667 _ = shutdown_token.cancelled() => break,
668 }
669 }
670 })
671}
672
673#[cfg(test)]
674#[path = "disk_offload_tests.rs"]
675mod disk_offload_tests;