fix(compression): reclaim failed originals instead of leaking them
Round 1 stopped the compression worker deleting an upload's original on failure — a transient ENOSPC or a codec panic must never destroy the only copy of a photo a guest cannot retake. But it left `Upload::soft_delete`'s quota refund in place, so the bytes stayed on disk while the uploader was charged nothing for them. That is worse than it first looks. The row is soft-deleted, so the file is invisible and unowned; a guest hitting a reproducible codec failure can accumulate orphans indefinitely at zero personal cost. And `active_uploaders` counts only users with non-deleted uploads, so dropping out of that count RAISES everyone's per-user ceiling — the leak loosens the very quota meant to contain it. Keep the refund: the uploader didn't cause the failure and shouldn't silently lose quota to it. Bound the leak instead, with an hourly sweep alongside the existing session cleanup in `spawn_periodic_tasks`, reclaiming failed originals older than 14 days — comfortably longer than any single event, so an operator investigating a failed upload still has the file. The selection predicate is the entire safety argument, so it is deliberately narrow: `compression_status = 'failed'` AND soft-deleted AND past the window AND `original_path <> ''`. That is exactly the state the give-up path leaves behind, and it cannot reach a live upload, an owner-deleted one, or a failure still inside its recovery window. `original_path` is cleared after a successful reclaim, which makes the sweep idempotent — otherwise a row whose file is already gone is re-selected on every tick forever. The row itself is kept as the audit trail. Tests reproduce the selection verbatim (same pattern as upload_concurrency) and assert it against five near-misses that must survive, both sides of the retention boundary, and the idempotence property. Also fixes two comments in export.rs still claiming "the compression worker hard-deletes an original when its transcode fails" — no longer true, and the defensive handling they justify is now justified by this sweep and by ordinary deletes instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -720,10 +720,11 @@ async fn run_html_export_inner(
|
||||
let src = media_path.join(&row.original_path);
|
||||
// Stat ONCE, up front, and skip this upload if the source is gone. The old code probed with
|
||||
// `exists()` here and then did `metadata(&src).await?` further down — a TOCTOU whose `?`
|
||||
// aborted the ENTIRE keepsake if the file vanished in between. It genuinely can: the
|
||||
// compression worker hard-deletes an original when its transcode fails, and it can still be
|
||||
// running when the gallery is released. A missing source must degrade one entry, never the
|
||||
// whole archive (which, once released, the host cannot rebuild without reopening uploads).
|
||||
// aborted the ENTIRE keepsake if the file vanished in between. It can still happen: the
|
||||
// compression worker no longer deletes originals on failure, but the hourly sweep reclaims
|
||||
// them once past the retention window, and an owner or host delete can land mid-export. A
|
||||
// missing source must degrade one entry, never the whole archive (which, once released, the
|
||||
// host cannot rebuild without reopening uploads).
|
||||
let src_meta = match tokio::fs::metadata(&src).await {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
@@ -953,9 +954,9 @@ async fn run_html_export_inner(
|
||||
|
||||
for (name, source) in &media_manifest {
|
||||
let path = source.path();
|
||||
// Open-first: a source that disappeared between the manifest being built and now (the
|
||||
// compression worker deletes originals on transcode failure) must skip this entry, not
|
||||
// fail the whole viewer. Opening collapses the check and the use into one operation.
|
||||
// Open-first: a source that disappeared between the manifest being built and now (a
|
||||
// delete, or the hourly sweep reclaiming a long-failed original) must skip this entry,
|
||||
// not fail the whole viewer. Opening collapses the check and the use into one operation.
|
||||
let src_file = match tokio::fs::File::open(path).await {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
|
||||
Reference in New Issue
Block a user