fix(dedup): bulk manifest reap orphaned every derived row

Real leak, found by storage_cleanup_check.sh: three blobs surviving a
full teardown, all `derived=1`, all naming one `src` whose manifest,
blob row and files were already gone. The source had been reaped without
its derived rows being purged.

`reap_blob` purges correctly for the single-blob path. The BULK manifest
reap did not — it iterated the deleted batch only to invalidate the
manifest cache, so every manifest reaped that way left its
content_derived_blobs rows behind.

The predicate is not at fault. It protects a manifest that IS a derived
artifact (content_derived_blobs.blob_hash) and deliberately not one that
is the SOURCE of them, because counting source_hash as a reference would
pin every original for as long as a thumbnail existed. The source is
therefore reaped correctly and the purge simply has to follow it.

The consequence is permanent, not cosmetic: the orphaned row holds
chunk_manifests.ref_count at 1 on the thumbnail's own blob, so GC is
thereafter CORRECT to refuse it — which is why three passes with
force=true reclaimed nothing. Every deleted image left three behind, one
per size, growing forever.

Fixed at the reap rather than in any deletion path, which is where all
of them converge: folder cascade, drive deletion, user deletion and
single-file delete all reach it through the decrement trigger, so one
call covers every route.
This commit is contained in:
Edouard Vanbelle
2026-08-28 08:58:11 +02:00
parent 0119110345
commit ea5d3003e0
@@ -2971,6 +2971,28 @@ impl DedupService {
// and accounting remain below and run only after refcounts succeed.
for (file_hash, _, _) in &batch {
self.manifest_cache.invalidate(file_hash).await;
// Drop everything derived FROM this Blob, exactly as
// `reap_blob` does for the single-blob path.
//
// Without this, bulk manifest reaping orphans the rows: the
// reap predicate protects a manifest that IS a derived
// artifact (`content_derived_blobs.blob_hash`), but
// deliberately not one that is the SOURCE of them — counting
// `source_hash` as a reference would pin every original for
// as long as a thumbnail existed. So the source is reaped
// correctly, and the purge has to follow it.
//
// It did not, and the leak is permanent rather than cosmetic:
// the orphaned row holds `chunk_manifests.ref_count` at 1 on
// the thumbnail's own blob, so GC is thereafter *correct* to
// refuse it and those bytes are never reclaimed. Every
// deleted image left three of them behind — one per size.
//
// Found by storage_cleanup_check.sh: three leftover blobs,
// all `derived=1`, all naming one `src` whose manifest, blob
// row and files were already gone.
self.purge_derived_blobs(file_hash).await;
}
if batch.len() == 1 {