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}