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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user