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    /// Best-effort eager unlink of a key's predecessor blob after a
326    /// successful overwrite (ADR-0065, amendment "bd rc-uteoa").
327    ///
328    /// Row-guided, no directory scan: only the name the pre-swap index
329    /// row carried is eligible, and only when it passes
330    /// [`sanitize_blob_name`], carries a parseable death epoch, and starts
331    /// with the current key's blake3-128 filename prefix — a corrupt row
332    /// naming another key's blob (or a foreign file) is never unlinked.
333    /// `keep_name` is the fresh blob's name on the successful-blob path:
334    /// a same-second identical rewrite reuses the name, and only the
335    /// fresh file owns it, so an equal name skips the reclaim. On the
336    /// inline-fallback path no fresh file owns any name; callers pass
337    /// `None` to disable the equal-name guard. `NotFound` counts as
338    /// reclaimed by someone else; any other unlink failure WARNs once and
339    /// leaves the blob to the sweeper at its death epoch. The function
340    /// never returns `Err`: the reclaim adds no failure mode to `set`.
341    async fn reclaim_predecessor(
342        &self,
343        key: &str,
344        old_name: Option<&str>,
345        keep_name: Option<&str>,
346    ) {
347        let Some(old_name) = old_name else {
348            return;
349        };
350        if keep_name == Some(old_name) {
351            return;
352        }
353        let key_prefix = format!("{}.", blake3_128hex(key.as_bytes()));
354        let eligible = sanitize_blob_name(old_name).is_some()
355            && parse_death_epoch(old_name).is_some()
356            && old_name.starts_with(&key_prefix);
357        if !eligible {
358            return;
359        }
360        match tokio::fs::remove_file(self.dir.join(old_name)).await {
361            Ok(()) => {}
362            // NotFound = a concurrent sweeper or replica won the race.
363            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
364            Err(e) => {
365                warn!(
366                    key = key,
367                    backend = self.inner.name(),
368                    dir = %self.dir.display(),
369                    error = %e,
370                    "eager reclaim of predecessor blob failed; sweeper reclaims it at its death epoch"
371                );
372            }
373        }
374    }
375}
376
377#[async_trait]
378impl CacheRepository for DiskOffloadRepository {
379    fn name(&self) -> &str {
380        self.inner.name()
381    }
382
383    async fn get(&self, key: &str) -> Result<Option<CacheEntry>, CamelError> {
384        match self.inner.get(key).await? {
385            Some(entry) => self.hydrate(key, entry).await,
386            None => Ok(None),
387        }
388    }
389
390    async fn set(
391        &self,
392        key: &str,
393        mut entry: CacheEntry,
394        ttl: Option<Duration>,
395    ) -> Result<(), CamelError> {
396        let effective_ttl = ttl.unwrap_or(self.payload_max_ttl);
397        // Capture the predecessor's blob name before the index swap so a
398        // successful overwrite can reclaim it eagerly (ADR-0065 amendment,
399        // "bd rc-uteoa"). A failed read only skips the reclaim — the write
400        // proceeds unchanged in every case.
401        let old_name = match self.inner.get(key).await {
402            Ok(Some(row)) => row.payload_path,
403            Ok(None) => None,
404            Err(e) => {
405                warn!(
406                    key = key,
407                    backend = self.inner.name(),
408                    error = %e,
409                    "pre-swap row read failed; skipping eager reclaim"
410                );
411                None
412            }
413        };
414        // Death epoch = expiry + retention + sweep grace, saturating in
415        // Duration space (a pre-epoch clock clamps to the Unix epoch),
416        // truncated to whole seconds for the blob filename.
417        let death_epoch = (self.clock)()
418            .duration_since(UNIX_EPOCH)
419            .unwrap_or_default()
420            .saturating_add(effective_ttl)
421            .saturating_add(self.stale_retention)
422            .saturating_add(self.sweep_interval)
423            .as_secs();
424
425        match self.write_blob(key, &entry, death_epoch).await {
426            Ok(dest_name) => {
427                entry.bytes = Vec::new();
428                // Clone: `dest_name` is still needed for the equal-name
429                // guard after `entry` (carrying the same name) moves into
430                // the inner set.
431                entry.payload_path = Some(dest_name.clone());
432                // The ttl MUST be Some: every inner overwrites
433                // `expires_at` from the ttl argument, so None would wipe
434                // the fabricated expiry. The inner recomputes `expires_at`
435                // from its own clock; the sub-second skew is absorbed by
436                // the death-epoch grace.
437                let result = self.inner.set(key, entry, Some(effective_ttl)).await;
438                // Reclaim only after the inner accepted the swap: on an
439                // error the surviving row may still reference the
440                // predecessor blob.
441                if result.is_ok() {
442                    self.reclaim_predecessor(key, old_name.as_deref(), Some(&dest_name))
443                        .await;
444                }
445                result
446            }
447            Err(e) => {
448                warn!(
449                    key = key,
450                    backend = self.inner.name(),
451                    dir = %self.dir.display(),
452                    error = %e,
453                    "cache blob write failed; storing entry inline instead"
454                );
455                // Inline fallback with the original, unstripped entry: the
456                // decorator never converts its own file-write failure into
457                // a cache-write error. The CAPPED ttl keeps the spec's
458                // no-TTL semantic (payload_max_ttl) even for degraded rows
459                // — an uncapped inline row would never be reclaimed. The
460                // new row no longer references the predecessor, so the
461                // reclaim runs with the equal-name guard disabled (the
462                // failed write left no fresh file owning that name).
463                let result = self.inner.set(key, entry, Some(effective_ttl)).await;
464                if result.is_ok() {
465                    self.reclaim_predecessor(key, old_name.as_deref(), None)
466                        .await;
467                }
468                result
469            }
470        }
471    }
472
473    async fn peek_stale(&self, key: &str) -> Result<Option<CacheEntry>, CamelError> {
474        match self.inner.peek_stale(key).await? {
475            Some(entry) => self.hydrate(key, entry).await,
476            None => Ok(None),
477        }
478    }
479
480    /// Delegate-only: the index row is dropped here; the payload blob
481    /// becomes an orphan reclaimed asynchronously at its
482    /// filename-encoded death epoch.
483    async fn invalidate(&self, key: &str) -> Result<(), CamelError> {
484        self.inner.invalidate(key).await
485    }
486
487    /// Reclaim payload space now: best-effort unlink of every entry of
488    /// the payload dir, then delegate to the index. Unlink failures
489    /// never turn `clear` into `Err` — each failure WARNs and the rest
490    /// of the dir is still attempted.
491    async fn clear(&self) -> Result<(), CamelError> {
492        self.unlink_payload_dir_best_effort().await;
493        self.inner.clear().await
494    }
495
496    /// Delegate-only: the returned count is index-scoped; payload blobs
497    /// are reclaimed asynchronously at their filename-encoded death epoch.
498    async fn invalidate_prefix(&self, prefix: &str) -> Result<u64, CamelError> {
499        self.inner.invalidate_prefix(prefix).await
500    }
501
502    async fn stats(&self) -> CacheStats {
503        self.inner.stats().await
504    }
505}
506
507impl std::fmt::Debug for DiskOffloadRepository {
508    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
509        f.debug_struct("DiskOffloadRepository")
510            .field("inner", &self.inner)
511            .field("dir", &self.dir)
512            .field("stale_retention", &self.stale_retention)
513            .field("sweep_interval", &self.sweep_interval)
514            .field("payload_max_ttl", &self.payload_max_ttl)
515            .field("sweep_attached", &self.sweep_handle.lock().is_some())
516            .finish()
517    }
518}
519
520impl Drop for DiskOffloadRepository {
521    fn drop(&mut self) {
522        // Abort ONLY the sweep task. Never cancel the context-owned token —
523        // that would shut down the entire context when one repo drops.
524        if let Some(handle) = self.sweep_handle.lock().take() {
525            handle.abort();
526        }
527    }
528}
529
530// ── Filename helpers ─────────────────────────────────────────────────────────
531
532/// One-byte discriminant of the closed [`ContentType`] enum, mixed into the
533/// content fingerprint for domain separation (identical bytes under
534/// different content types produce different fingerprints). Exhaustive
535/// match — the enum is closed by contract (ADR-0049 §Exceptions).
536fn content_type_discriminant(content_type: ContentType) -> u8 {
537    match content_type {
538        ContentType::Bytes => 0,
539        ContentType::Text => 1,
540        ContentType::Json => 2,
541        ContentType::Xml => 3,
542    }
543}
544
545/// Finalize a hasher to its first 128 bits as 32 lowercase hex chars.
546fn hasher_128hex(hasher: blake3::Hasher) -> String {
547    let hex = hasher.finalize().to_hex().to_string();
548    hex[..32].to_string()
549}
550
551/// blake3-128 hex of a single byte slice.
552fn blake3_128hex(data: &[u8]) -> String {
553    let mut hasher = blake3::Hasher::new();
554    hasher.update(data);
555    hasher_128hex(hasher)
556}
557
558/// 128-bit content fingerprint: `blake3(bytes || content_type discriminant)`.
559fn content_fingerprint(entry: &CacheEntry) -> String {
560    let mut hasher = blake3::Hasher::new();
561    hasher.update(&entry.bytes);
562    hasher.update(&[content_type_discriminant(entry.content_type)]);
563    hasher_128hex(hasher)
564}
565
566/// Blob file name: `{key-hash}.{death_epoch}.{fingerprint}.blob`.
567fn blob_filename(key: &str, death_epoch: u64, entry: &CacheEntry) -> String {
568    format!(
569        "{}.{}.{}.blob",
570        blake3_128hex(key.as_bytes()),
571        death_epoch,
572        content_fingerprint(entry)
573    )
574}
575
576/// Death epoch (second dot-separated component) of a blob file name, if it
577/// parses as `u64`.
578fn parse_death_epoch(file_name: &str) -> Option<u64> {
579    file_name.split('.').nth(1)?.parse().ok()
580}
581
582/// Accept only a bare file name: non-empty, no `/`, no `\`, no `..`.
583///
584/// Absolute paths necessarily contain a separator on both Unix and Windows,
585/// so the separator checks subsume the absolute-path rejection. Everything
586/// else is treated as a corrupt row.
587fn sanitize_blob_name(path: &str) -> Option<&str> {
588    if path.is_empty() || path.contains('/') || path.contains('\\') || path.contains("..") {
589        return None;
590    }
591    Some(path)
592}
593
594// ── Payload sweeper ─────────────────────────────────────────────────────────
595
596/// Unlink one payload-dir file if it is dead: `.blob` files by their
597/// name-encoded death epoch, `.tmp` leftovers by age.
598///
599/// `Ok(true)` = unlinked here; `Ok(false)` = kept (still live, a foreign
600/// name without a parseable epoch, or vanished between listing and unlink —
601/// the ENOENT race counts as reclaimed-by-someone-else, never an error).
602/// Any other error is returned for the sweep loop to WARN over. All filesystem
603/// access is async (`tokio::fs`), keeping the sweeper off blocked
604/// runtime workers.
605async fn unlink_payload_file(
606    path: &Path,
607    now: SystemTime,
608    sweep_interval: Duration,
609) -> std::io::Result<bool> {
610    let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
611        return Ok(false);
612    };
613    // Clamp a pre-epoch clock to the Unix epoch, matching `set`'s
614    // death-epoch math.
615    let now_secs = now.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
616    let dead = if name.ends_with(".blob") {
617        // Strictly-before: a blob dying exactly `now` survives this pass
618        // (the filename epoch is whole seconds; the next tick reclaims).
619        parse_death_epoch(name).is_some_and(|death| death < now_secs)
620    } else if name.ends_with(".tmp") {
621        let threshold = now.checked_sub(sweep_interval).unwrap_or(UNIX_EPOCH);
622        let mtime = match tokio::fs::metadata(path).await {
623            Ok(meta) => meta.modified()?,
624            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false),
625            Err(e) => return Err(e),
626        };
627        mtime < threshold
628    } else {
629        return Ok(false);
630    };
631    if !dead {
632        return Ok(false);
633    }
634    match tokio::fs::remove_file(path).await {
635        Ok(()) => Ok(true),
636        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
637        Err(e) => Err(e),
638    }
639}
640
641/// One sweep pass over `dir`: reclaim dead blobs by their filename
642/// death epoch and stale `.tmp` leftovers by age.
643///
644/// Per-file `NotFound` (a concurrent sweeper or replica won the race)
645/// counts as success; other per-file errors WARN and the scan
646/// continues. A missing dir is not an error — nothing was ever
647/// offloaded. Returns `(blobs_unlinked, tmps_unlinked)`.
648#[derive(Debug, Default, PartialEq, Eq, Clone, Copy)]
649struct SweepStats {
650    /// Dead blobs unlinked this pass.
651    blobs_unlinked: u64,
652    /// Bytes reclaimed with those dead blobs.
653    blob_bytes_reclaimed: u64,
654    /// Stale tmp files unlinked this pass.
655    tmps_unlinked: u64,
656    /// Blobs still on disk after the pass (live, orphan pre-epoch, or
657    /// foreign names — anything the sweep kept; a blob that vanishes
658    /// mid-pass via the ENOENT race is counted here until the next pass).
659    live_blobs: u64,
660    /// Total bytes of those surviving blobs.
661    live_blob_bytes: u64,
662}
663
664async fn sweep_payload_dir(dir: &Path, now: SystemTime, sweep_interval: Duration) -> SweepStats {
665    let mut read_dir = match tokio::fs::read_dir(dir).await {
666        Ok(read_dir) => read_dir,
667        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return SweepStats::default(),
668        Err(e) => {
669            warn!(
670                dir = %dir.display(),
671                error = %e,
672                "cache payload dir read failed during sweep (skipped)"
673            );
674            return SweepStats::default();
675        }
676    };
677    let mut stats = SweepStats::default();
678    loop {
679        let entry = match read_dir.next_entry().await {
680            Ok(Some(entry)) => entry,
681            Ok(None) => break,
682            Err(e) => {
683                warn!(
684                    dir = %dir.display(),
685                    error = %e,
686                    "cache payload dir iteration failed during sweep (stopped)"
687                );
688                break;
689            }
690        };
691        let path = entry.path();
692        let is_tmp = path
693            .file_name()
694            .and_then(|n| n.to_str())
695            .is_some_and(|n| n.ends_with(".tmp"));
696        let size = entry.metadata().await.map(|m| m.len()).unwrap_or(0);
697        match unlink_payload_file(&path, now, sweep_interval).await {
698            Ok(true) => {
699                if is_tmp {
700                    stats.tmps_unlinked += 1;
701                } else {
702                    stats.blobs_unlinked += 1;
703                    stats.blob_bytes_reclaimed += size;
704                }
705            }
706            Ok(false) => {
707                if !is_tmp {
708                    stats.live_blobs += 1;
709                    stats.live_blob_bytes += size;
710                }
711            }
712            Err(e) => warn!(
713                dir = %dir.display(),
714                file = %path.display(),
715                error = %e,
716                "cache payload file unlink failed during sweep (skipped)"
717            ),
718        }
719    }
720    stats
721}
722
723/// Spawn the background payload sweeper for `dir`.
724///
725/// Mirrors the redb sweep loop: tick every `sweep_interval`, reclaim
726/// dead blobs and stale tmp files, exit when `shutdown_token` fires.
727/// The sweep always runs on the REAL clock (`SystemTime::now`), never
728/// an injected decorator clock — it must observe actual file ages.
729fn spawn_sweeper(
730    dir: PathBuf,
731    sweep_interval: Duration,
732    shutdown_token: CancellationToken,
733) -> tokio::task::JoinHandle<()> {
734    tokio::spawn(async move {
735        let mut ticker = tokio::time::interval(sweep_interval);
736        loop {
737            tokio::select! {
738                _ = ticker.tick() => {
739                    let s = sweep_payload_dir(&dir, SystemTime::now(), sweep_interval).await;
740                    // Per-pass volume observability (bd rc-h3dp): live
741                    // bytes are the high-water baseline operators compare
742                    // against the eager-reclaim trigger; reclaimed bytes
743                    // show the pass's cleanup.
744                    info!(
745                        dir = %dir.display(),
746                        live_blobs = s.live_blobs,
747                        live_blob_bytes = s.live_blob_bytes,
748                        blobs_unlinked = s.blobs_unlinked,
749                        blob_bytes_reclaimed = s.blob_bytes_reclaimed,
750                        tmps_unlinked = s.tmps_unlinked,
751                        "cache payload sweep pass"
752                    );
753                }
754                _ = shutdown_token.cancelled() => break,
755            }
756        }
757    })
758}
759
760#[cfg(test)]
761#[path = "disk_offload_tests.rs"]
762mod disk_offload_tests;
763
764#[cfg(test)]
765#[path = "disk_offload_reclaim_tests.rs"]
766mod disk_offload_reclaim_tests;