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