Skip to main content

cas_kit/
gc.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Mark–sweep garbage collection for the blob store.
3//!
4//! # The reference model (read this first)
5//!
6//! cas-kit objects are **opaque blobs**: an object never references another
7//! object. There are no tree objects and no embedded pointers; the pack
8//! manifest (`.idx`) is a flat digest → offset map and expresses storage
9//! location, not reachability. Reachability therefore cannot be derived
10//! from store contents — the **host application owns the reference graph**
11//! (its manifests, message attachments, index rows, ...).
12//!
13//! Garbage collection is consequently an honest **set difference**:
14//!
15//! 1. **Mark** ([`mark`]) — the caller supplies every hash it still
16//!    considers live (the roots). There is no transitive closure to walk:
17//!    the mark phase validates the root set against what is physically
18//!    present (loose files ∪ packed objects) and reports roots that
19//!    resolve to nothing. Roots *are* the live set.
20//! 2. **Plan** ([`plan_sweep`]) — enumerate everything on disk, classify
21//!    `present − live` as garbage, and decide per pack whether it can be
22//!    removed outright or must be rewritten without its garbage (see
23//!    [Pack handling](#pack-handling)).
24//! 3. **Sweep** ([`sweep`]) — execute a plan in one of three modes:
25//!    [`SweepMode::DryRun`] (change nothing), [`SweepMode::Trash`]
26//!    (move garbage to `<root>/trash/`, recoverable) or
27//!    [`SweepMode::Delete`] (unlink permanently).
28//!
29//! If your application has internal references (e.g. a manifest blob
30//! listing chunk hashes), expand your roots to the full transitive closure
31//! *before* calling [`mark`] — the store cannot walk your graph for you.
32//!
33//! # Pack handling
34//!
35//! Packs are immutable and have no per-blob delete, so packed garbage is
36//! reclaimed at **pack granularity** using a coverage rule:
37//!
38//! - A pack whose objects are *all* garbage is **removed** (.idx first,
39//!   then .pack).
40//! - A pack with no garbage is left untouched, and its objects count as
41//!   *covered* (they will survive elsewhere no matter what).
42//! - A *partial* pack (some garbage) is **rewritten** with only the live
43//!   objects it uniquely covers — `needed = live-in-pack − covered`, where
44//!   `covered` starts as live loose objects plus all untouched packs. If
45//!   `needed` is empty (every live object in the pack is also loose or in
46//!   a healthy pack) the pack is removed without a rewrite; otherwise a
47//!   new content-named pack is created holding exactly `needed`, and the
48//!   old pair is removed. The rewrite is refcount-aware: an object that
49//!   survives in some other location is not copied into a new pack.
50//!
51//! Rewrites are ordered by pack path and the covered set grows as they
52//! proceed, so two overlapping partial packs never both copy the same
53//! object. Set `SweepOptions::rewrite_partial_packs` to `false` to leave
54//! all partial packs untouched (loose garbage is still reclaimed).
55//!
56//! # Crash safety
57//!
58//! Every destructive step is a single file operation, and phases are
59//! ordered so a crash can lose *garbage reclamation*, never *live data*:
60//!
61//! 1. **Rewrites run first and are additive**: the replacement pack is
62//!    fully written (`.pack` then `.idx`) before any old file is removed.
63//!    A crash here leaves an extra pack — harmless duplicate storage that
64//!    the next sweep reclaims (identical object sets produce identical
65//!    content-derived pack names, so re-running converges). One narrow
66//!    window exists: if the replacement pack's name already exists from
67//!    an earlier interrupted run, its `.pack` is truncated and rewritten
68//!    in place; a crash *mid-write* leaves a torn pack whose objects may
69//!    transiently fail reads with `HashMismatch`. No data is lost — the
70//!    old packs are only removed after the new pair is fully written —
71//!    and re-running the sweep repairs the torn pack.
72//! 2. **Loose deletions** unlink (or trash) one immutable file per call.
73//!    A crash mid-loop leaves the remaining garbage for the next sweep.
74//! 3. **Pack removals** delete the `.idx` before the `.pack`; a crash
75//!    between the two leaves an orphan `.pack` that no reader will ever
76//!    open (packs without an index are skipped by [`crate::PackCache`])
77//!    and that the next sweep collects via the plan's orphan list.
78//! 4. **Trash mode** moves files with `rename(2)` *within the store root*
79//!    — atomic per file on POSIX. A crash mid-sweep leaves some garbage
80//!    in `<root>/trash/` and the rest in place; either way the store is
81//!    consistent and a later sweep (or a manual restore) finishes the job.
82//!
83//! # Concurrency
84//!
85//! - **Reads of live objects are safe throughout a sweep**: sweep never
86//!   touches a file whose hash is in the live set. (POSIX: an in-flight
87//!   `read` on an unlinked file keeps its fd; Windows: the unlink may fail
88//!   with a sharing violation, surfacing as an I/O error — retry the
89//!   sweep.)
90//! - **Writers racing the sweep**: if a host re-puts a blob between
91//!   enumeration and deletion, the sweep will delete the fresh copy (the
92//!   address was classified garbage before the re-put). Quiesce writers
93//!   with the same application-level lock your writers use, or re-run
94//!   mark + sweep until the report is stable.
95//! - **Transient read errors during pack rewrites**: a reader holding a
96//!   stale in-memory pack index can hit a pack that was just removed;
97//!   live objects remain readable via their other copies once the reader
98//!   reloads its cache ([`crate::BlobStore::invalidate_pack_cache`] is
99//!   called in-process; other processes see fresh state on next open).
100//!   Retry the read, or schedule sweeps for windows where readers can
101//!   tolerate a transient `BlobNotFound`.
102//!
103//! # Recoverability (trash mode)
104//!
105//! [`SweepMode::Trash`] mirrors the removed relative paths under
106//! `<root>/trash/` (`trash/objects/<2-hex>/<62-hex>`,
107//! `trash/objects/pack/pack-<hex>.pack|.idx`). Restoring is a reverse
108//! rename of the mirrored path; a colliding trash entry (same hash
109//! trashed twice) is overwritten with the newer copy. The trash directory
110//! is outside `objects/`, so the store never reads from it — leftover
111//! trash costs disk space only. Empty it once you are confident no
112//! restore is needed.
113//!
114//! # Async
115//!
116//! The module is synchronous, matching the rest of the crate (filesystem
117//! operations dominate GC and blocking is the honest model). With the
118//! optional `tokio` feature, `mark_async` and `sweep_async` run the
119//! same code on the blocking thread pool; they take `Arc<BlobStore>` so
120//! the store can be moved into the spawned task.
121//!
122//! # Example
123//!
124//! ```no_run
125//! use std::collections::HashSet;
126//!
127//! use cas_kit::gc::{self, SweepMode, SweepOptions};
128//! use cas_kit::{BlobStore, Hash};
129//!
130//! # fn main() -> Result<(), cas_kit::CasError> {
131//! let store = BlobStore::new("/tmp/my-store")?;
132//!
133//! // The host decides what is live (here: one blob, in general the whole
134//! // transitive closure of your reference graph).
135//! let roots: HashSet<Hash> = HashSet::from([store.put_blob(b"keep me")?]);
136//! store.put_blob(b"garbage")?;
137//!
138//! let live = gc::mark(&store, &roots)?;
139//! assert!(live.missing.is_empty());
140//!
141//! let live_set: HashSet<Hash> = live.live.iter().copied().collect();
142//! let report = gc::sweep(&store, &live_set, SweepOptions {
143//!     mode: SweepMode::Trash,
144//!     ..SweepOptions::default()
145//! })?;
146//! assert_eq!(report.plan.garbage, 1);
147//! assert_eq!(report.loose_removed, 1);
148//! # Ok(())
149//! # }
150//! ```
151
152use std::collections::{BTreeSet, HashSet};
153use std::fs;
154use std::path::{Path, PathBuf};
155
156#[cfg(feature = "tokio")]
157use std::sync::Arc;
158
159use crate::error::CasError;
160use crate::hash::Hash;
161use crate::pack::{PackFile, PackIndex};
162use crate::store::BlobStore;
163
164/// Name of the trash directory created under the store root when a sweep
165/// runs in [`SweepMode::Trash`] mode. Mirrored paths under this directory
166/// restore by renaming back into `objects/`.
167pub const TRASH_DIR: &str = "trash";
168
169/// Result of the mark phase: the validated live set.
170///
171/// Objects are opaque (see the [module docs](self)), so "live" means
172/// exactly "a root the host supplied that is physically present in the
173/// store".
174#[derive(Clone, Debug, PartialEq, Eq)]
175pub struct LiveSet {
176    /// Roots present in the store — the live set to sweep against.
177    pub live: BTreeSet<Hash>,
178    /// Roots absent from the store (neither loose nor packed), sorted.
179    /// Missing roots are reported, never fatal: they may indicate a
180    /// stale host index or a blob that was already swept.
181    pub missing: Vec<Hash>,
182    /// Number of roots supplied by the host.
183    pub roots: usize,
184    /// Distinct objects physically present (loose ∪ packed).
185    pub scanned: usize,
186}
187
188/// Validate the host's live set against what the store physically holds.
189///
190/// See the [module docs](self) for why the roots *are* the live set and
191/// no reference walk happens here.
192pub fn mark(store: &BlobStore, roots: &HashSet<Hash>) -> Result<LiveSet, CasError> {
193    let mut present: HashSet<Hash> = store.list_blobs()?.into_iter().collect();
194    present.extend(store.list_blobs_packed()?);
195
196    let mut live = BTreeSet::new();
197    let mut missing = Vec::new();
198    for hash in roots {
199        if present.contains(hash) {
200            live.insert(*hash);
201        } else {
202            missing.push(*hash);
203        }
204    }
205    missing.sort();
206
207    Ok(LiveSet {
208        live,
209        missing,
210        roots: roots.len(),
211        scanned: present.len(),
212    })
213}
214
215/// What to do with garbage during [`sweep`].
216#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
217pub enum SweepMode {
218    /// Enumerate and classify only; change nothing on disk.
219    #[default]
220    DryRun,
221    /// Move garbage to `<root>/trash/` (atomic rename within the store
222    /// root), preserving relative paths for recovery.
223    Trash,
224    /// Unlink garbage permanently.
225    Delete,
226}
227
228/// Options for [`sweep`].
229#[derive(Clone, Copy, Debug)]
230pub struct SweepOptions {
231    /// What to do with garbage. Default: [`SweepMode::DryRun`].
232    pub mode: SweepMode,
233    /// Rewrite partial packs to drop their garbage (see the
234    /// [module docs](self#pack-handling)). When `false`, garbage inside
235    /// otherwise-live packs is left in place. Default: `true`.
236    pub rewrite_partial_packs: bool,
237}
238
239impl Default for SweepOptions {
240    fn default() -> Self {
241        Self {
242            mode: SweepMode::DryRun,
243            rewrite_partial_packs: true,
244        }
245    }
246}
247
248/// A pack scheduled to be rewritten without some of its objects.
249#[derive(Clone, Debug, PartialEq, Eq)]
250pub struct PackRewrite {
251    /// Path of the pack being rewritten.
252    pub pack: PathBuf,
253    /// Objects the replacement pack will contain: the pack's live
254    /// objects that are not covered by a loose copy or a healthy pack,
255    /// sorted.
256    pub keep: Vec<Hash>,
257}
258
259/// The result of enumerating the store and classifying garbage.
260///
261/// Produced by [`plan_sweep`]; embedded in [`SweepReport`]. Byte counts
262/// are on-disk sizes (compressed bytes for zstd builds).
263#[derive(Clone, Debug, Default, PartialEq, Eq)]
264pub struct SweepPlan {
265    /// Distinct objects physically present (loose ∪ packed).
266    pub scanned: usize,
267    /// Objects with a loose copy.
268    pub loose_present: usize,
269    /// Distinct objects with at least one packed copy.
270    pub packed_present: usize,
271    /// Live objects actually present (live ∩ scanned).
272    pub live: usize,
273    /// Garbage objects: `scanned − live`, the sweep target.
274    pub garbage: usize,
275    /// Garbage objects with a loose copy, sorted. Each entry is one
276    /// removable file.
277    pub loose_garbage: Vec<Hash>,
278    /// On-disk bytes of [`Self::loose_garbage`] files.
279    pub loose_garbage_bytes: u64,
280    /// Packs containing at least one garbage object (before the
281    /// coverage rule decides remove-vs-rewrite).
282    pub packs_with_garbage: usize,
283    /// Garbage objects with at least one packed copy, counted once per
284    /// pack that holds them (a hash packed twice counts twice, and a
285    /// hash with both loose and packed garbage copies is counted here
286    /// *and* in [`Self::loose_garbage`]). [`Self::garbage`] remains the
287    /// distinct total.
288    pub packed_garbage_objects: usize,
289    /// Packs removed outright (all garbage, or live contents fully
290    /// covered elsewhere), sorted.
291    pub packs_to_remove: Vec<PathBuf>,
292    /// Packs to be rewritten without their garbage.
293    pub packs_to_rewrite: Vec<PackRewrite>,
294    /// Files in the pack directory that cannot ever be read: a `.pack`
295    /// without `.idx` or an `.idx` without a `.pack`. Swept like garbage.
296    pub orphan_files: Vec<PathBuf>,
297    /// Packs whose `.idx` failed to load. Their contents are unknown, so
298    /// they are **never touched** — deleting one could destroy live data.
299    /// Investigate manually.
300    pub unreadable_packs: Vec<PathBuf>,
301    /// Exact on-disk bytes reclaimable by deleting loose garbage and
302    /// removed packs. Pack *rewrites* are excluded: their savings depend
303    /// on recompression and are only known after execution (see
304    /// [`SweepReport::bytes_reclaimed`]).
305    pub bytes_reclaimable: u64,
306}
307
308/// Outcome of an executed (or dry-run) sweep.
309#[derive(Clone, Debug, PartialEq, Eq)]
310pub struct SweepReport {
311    /// The plan that was (or would be) executed.
312    pub plan: SweepPlan,
313    /// The mode the sweep ran in.
314    pub mode: SweepMode,
315    /// Whether any filesystem change was made (`false` for dry runs).
316    pub executed: bool,
317    /// Loose garbage files removed.
318    pub loose_removed: usize,
319    /// Packs removed entirely (full-garbage, coverage-redundant, and the
320    /// old pairs of rewritten packs).
321    pub packs_removed: usize,
322    /// Packs rewritten without their garbage.
323    pub packs_rewritten: usize,
324    /// On-disk bytes no longer under `objects/`: unlinked in delete
325    /// mode, moved to trash in trash mode. For rewrites this is the old
326    /// pair size minus the new pair size.
327    pub bytes_reclaimed: u64,
328    /// Trash directory when mode was [`SweepMode::Trash`], else `None`.
329    pub trash: Option<PathBuf>,
330}
331
332/// A pack file on disk with its parsed index and classification.
333struct PackState {
334    pack_path: PathBuf,
335    hashes: BTreeSet<Hash>,
336    garbage: BTreeSet<Hash>,
337    bytes: u64,
338}
339
340/// Enumerate the store, classify garbage, and decide pack handling
341/// without touching the filesystem.
342///
343/// Packs whose index fails to load are reported in
344/// [`SweepPlan::unreadable_packs`] and never deleted; everything else
345/// proceeds. See the [module docs](self) for the coverage rule.
346pub fn plan_sweep(store: &BlobStore, live: &HashSet<Hash>) -> Result<SweepPlan, CasError> {
347    let loose = store.list_blobs()?;
348    let loose_set: BTreeSet<Hash> = loose.iter().copied().collect();
349
350    // Fresh inventory, not the store's lazy pack cache: the cache may be
351    // stale relative to disk, and a sweep must classify what is actually
352    // present.
353    let pack_dir = store.pack_dir();
354    let mut packs: Vec<PackState> = Vec::new();
355    let mut orphan_files: Vec<PathBuf> = Vec::new();
356    let mut unreadable_packs: Vec<PathBuf> = Vec::new();
357
358    for pack_path in PackFile::list_packs(&pack_dir)? {
359        let idx_path = pack_path.with_extension("idx");
360        if !idx_path.exists() {
361            orphan_files.push(pack_path);
362            continue;
363        }
364        match PackIndex::load(&idx_path) {
365            Ok(index) => {
366                let hashes: BTreeSet<Hash> = index.hashes().into_iter().collect();
367                let garbage: BTreeSet<Hash> = hashes
368                    .iter()
369                    .copied()
370                    .filter(|h| !live.contains(h))
371                    .collect();
372                let bytes = file_len(&pack_path)? + file_len(&idx_path)?;
373                packs.push(PackState {
374                    pack_path,
375                    hashes,
376                    garbage,
377                    bytes,
378                });
379            }
380            Err(_) => unreadable_packs.push(pack_path),
381        }
382    }
383    // An .idx without its .pack can never be read either.
384    let pack_names: Vec<PathBuf> = packs.iter().map(|p| p.pack_path.clone()).collect();
385    if pack_dir.exists() {
386        for entry in fs::read_dir(&pack_dir)? {
387            let entry = entry?;
388            let path = entry.path();
389            let is_idx = entry.file_name().to_string_lossy().ends_with(".idx");
390            if is_idx {
391                let pack = path.with_extension("pack");
392                if !pack_names.contains(&pack) && !pack.exists() {
393                    orphan_files.push(path);
394                }
395            }
396        }
397    }
398    orphan_files.sort();
399
400    // Distinct present set and garbage classification.
401    let packed_hashes: HashSet<Hash> = packs
402        .iter()
403        .flat_map(|p| p.hashes.iter().copied())
404        .collect();
405    let packed_present = packed_hashes.len();
406    let scanned_set: HashSet<Hash> = loose_set.iter().copied().chain(packed_hashes).collect();
407    let garbage_set: BTreeSet<Hash> = scanned_set
408        .iter()
409        .copied()
410        .filter(|h| !live.contains(h))
411        .collect();
412
413    let loose_garbage: Vec<Hash> = loose_set
414        .iter()
415        .copied()
416        .filter(|h| garbage_set.contains(h))
417        .collect();
418    let mut loose_garbage_bytes = 0u64;
419    for hash in &loose_garbage {
420        loose_garbage_bytes += file_len(&loose_path(store, hash))?;
421    }
422
423    let packed_garbage_objects = packs.iter().map(|p| p.garbage.len()).sum::<usize>();
424
425    // Pack decisions under the coverage rule (see module docs).
426    let mut covered: HashSet<Hash> = loose_set
427        .iter()
428        .copied()
429        .filter(|h| !garbage_set.contains(h))
430        .collect();
431    let mut packs_to_remove = Vec::new();
432    let mut packs_to_rewrite = Vec::new();
433    let mut remove_bytes = 0u64;
434    let mut packs_with_garbage = 0usize;
435
436    for pack in &packs {
437        if pack.garbage.is_empty() {
438            covered.extend(pack.hashes.iter().copied());
439        }
440    }
441    for pack in &packs {
442        if pack.garbage.is_empty() {
443            continue;
444        }
445        packs_with_garbage += 1;
446        let needed: Vec<Hash> = pack
447            .hashes
448            .iter()
449            .copied()
450            .filter(|h| !garbage_set.contains(h) && !covered.contains(h))
451            .collect();
452        if needed.is_empty() {
453            packs_to_remove.push(pack.pack_path.clone());
454            remove_bytes += pack.bytes;
455        } else {
456            // The replacement pack will hold exactly `needed`; those
457            // objects are covered for later packs in this run.
458            covered.extend(needed.iter().copied());
459            packs_to_rewrite.push(PackRewrite {
460                pack: pack.pack_path.clone(),
461                keep: needed,
462            });
463        }
464    }
465
466    let live_present = scanned_set.iter().filter(|h| live.contains(h)).count();
467
468    Ok(SweepPlan {
469        scanned: scanned_set.len(),
470        loose_present: loose_set.len(),
471        packed_present,
472        live: live_present,
473        garbage: garbage_set.len(),
474        loose_garbage,
475        loose_garbage_bytes,
476        packs_with_garbage,
477        packed_garbage_objects,
478        packs_to_remove,
479        packs_to_rewrite,
480        orphan_files,
481        unreadable_packs,
482        bytes_reclaimable: loose_garbage_bytes + remove_bytes,
483    })
484}
485
486/// Sweep the store: remove everything not in `live`, according to
487/// [`SweepOptions::mode`].
488///
489/// A dry run is exactly [`plan_sweep`] plus a zeroed report. Execution
490/// order is crash-safe (see the [module docs](self)): pack rewrites
491/// first (additive), then loose deletions, then orphan and pack removals.
492/// The store's pack cache is invalidated afterwards so in-process reads
493/// observe the new pack layout.
494pub fn sweep(
495    store: &BlobStore,
496    live: &HashSet<Hash>,
497    options: SweepOptions,
498) -> Result<SweepReport, CasError> {
499    let plan = plan_sweep(store, live)?;
500
501    if options.mode == SweepMode::DryRun {
502        return Ok(SweepReport {
503            plan,
504            mode: options.mode,
505            executed: false,
506            loose_removed: 0,
507            packs_removed: 0,
508            packs_rewritten: 0,
509            bytes_reclaimed: 0,
510            trash: None,
511        });
512    }
513
514    let trash_root = store.root().join(TRASH_DIR);
515    let mut loose_removed = 0usize;
516    let mut packs_removed = 0usize;
517    let mut packs_rewritten = 0usize;
518    let mut bytes_reclaimed = 0u64;
519
520    // Phase 1: pack rewrites — purely additive. New packs are fully
521    // written before any old file is removed (phase 4), so a crash here
522    // only leaves duplicate storage that the next sweep reclaims.
523    if options.rewrite_partial_packs {
524        for rewrite in &plan.packs_to_rewrite {
525            let idx_path = rewrite.pack.with_extension("idx");
526            let index = PackIndex::load(&idx_path)?;
527            let mut objects = Vec::with_capacity(rewrite.keep.len());
528            for hash in &rewrite.keep {
529                let data = PackFile::read_blob(&rewrite.pack, &index, hash)?;
530                objects.push((*hash, data));
531            }
532            // Deterministic content-derived name: identical keep-sets
533            // converge to the same pack across sweeps.
534            let (new_pack, new_idx) = PackFile::create(&store.pack_dir(), &objects)?;
535            let old_bytes = file_len(&rewrite.pack)? + file_len(&idx_path)?;
536            let new_bytes = file_len(&new_pack)? + file_len(&new_idx)?;
537            bytes_reclaimed += old_bytes.saturating_sub(new_bytes);
538            packs_rewritten += 1;
539        }
540    }
541
542    // Phase 2: loose garbage — one immutable file per call.
543    for hash in &plan.loose_garbage {
544        bytes_reclaimed +=
545            remove_or_trash(store, options.mode, &trash_root, &loose_path(store, hash))?;
546        loose_removed += 1;
547    }
548
549    // Phase 3: orphaned pack-directory files.
550    for path in &plan.orphan_files {
551        bytes_reclaimed += remove_or_trash(store, options.mode, &trash_root, path)?;
552    }
553
554    // Phase 4: pack removals — full-garbage packs, coverage-redundant
555    // packs, and the old pairs of rewritten packs. The .idx goes first:
556    // a crash between the two leaves an unreadable orphan .pack that the
557    // next sweep collects.
558    let mut removals: Vec<PathBuf> = plan.packs_to_remove.clone();
559    if options.rewrite_partial_packs {
560        removals.extend(plan.packs_to_rewrite.iter().map(|r| r.pack.clone()));
561    }
562    for pack_path in &removals {
563        let idx_path = pack_path.with_extension("idx");
564        for path in [idx_path, pack_path.clone()] {
565            if path.exists() {
566                bytes_reclaimed += remove_or_trash(store, options.mode, &trash_root, &path)?;
567            }
568        }
569        packs_removed += 1;
570    }
571
572    // In-process readers must observe the new pack layout.
573    store.invalidate_pack_cache();
574
575    Ok(SweepReport {
576        plan,
577        mode: options.mode,
578        executed: true,
579        loose_removed,
580        packs_removed,
581        packs_rewritten,
582        bytes_reclaimed,
583        trash: match options.mode {
584            SweepMode::Trash => Some(trash_root),
585            _ => None,
586        },
587    })
588}
589
590/// [`mark`] on the blocking thread pool (requires the `tokio` feature).
591///
592/// The store is moved into the spawned task, hence the `Arc`.
593#[cfg(feature = "tokio")]
594pub async fn mark_async(store: Arc<BlobStore>, roots: HashSet<Hash>) -> Result<LiveSet, CasError> {
595    tokio::task::spawn_blocking(move || mark(&store, &roots))
596        .await
597        .map_err(|e| CasError::TaskJoin(e.to_string()))?
598}
599
600/// [`sweep`] on the blocking thread pool (requires the `tokio` feature).
601///
602/// The store is moved into the spawned task, hence the `Arc`.
603#[cfg(feature = "tokio")]
604pub async fn sweep_async(
605    store: Arc<BlobStore>,
606    live: HashSet<Hash>,
607    options: SweepOptions,
608) -> Result<SweepReport, CasError> {
609    tokio::task::spawn_blocking(move || sweep(&store, &live, options))
610        .await
611        .map_err(|e| CasError::TaskJoin(e.to_string()))?
612}
613
614/// Path of the loose file backing `hash` (mirrors `BlobStore::blob_path`).
615fn loose_path(store: &BlobStore, hash: &Hash) -> PathBuf {
616    let hex = hash.to_hex();
617    store.objects_dir().join(&hex[..2]).join(&hex[2..])
618}
619
620/// On-disk length of a regular file.
621fn file_len(path: &Path) -> Result<u64, CasError> {
622    Ok(fs::metadata(path)?.len())
623}
624
625/// Delete `path`, or move it into the trash mirror. Returns the on-disk
626/// bytes no longer under `objects/`.
627///
628/// Never called in dry-run mode (sweep returns early).
629fn remove_or_trash(
630    store: &BlobStore,
631    mode: SweepMode,
632    trash_root: &Path,
633    path: &Path,
634) -> Result<u64, CasError> {
635    let len = file_len(path)?;
636    match mode {
637        SweepMode::Delete => fs::remove_file(path)?,
638        SweepMode::Trash => {
639            let relative = path
640                .strip_prefix(store.root())
641                .map_err(|_| CasError::GcPathEscape(path.display().to_string()))?;
642            let destination = trash_root.join(relative);
643            if let Some(parent) = destination.parent() {
644                fs::create_dir_all(parent)?;
645            }
646            // A previous sweep may already have trashed this address;
647            // the newer copy wins (both were garbage).
648            if destination.exists() {
649                let _ = fs::remove_file(&destination);
650            }
651            fs::rename(path, &destination)?;
652        }
653        SweepMode::DryRun => unreachable!("dry-run sweeps never reach deletion"),
654    }
655    Ok(len)
656}