Skip to main content

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;