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;