fix(export): stop the keepsake guards from destroying the keepsake

Two guards added to protect the archive each had a failure mode worse than the one they
prevented, and both were unrecoverable — which is what makes them worth reverting rather
than tuning.

The completeness gate refused to publish once skips passed max(2, 10% of expected). That
refusal is DETERMINISTIC ACROSS RETRIES: the unreadable files are still unreadable when the
host taps "Neu erzeugen", and the gallery is already released so the uploads cannot be
collected again. On a 30-photo event, four bad files meant nobody ever got the other 26.
That is precisely the "one-photo gap becomes total loss" outcome MAX_SKIPPED_FRACTION's own
comment says it exists to avoid. Anything short of an empty archive now publishes and logs
the counts at error level. `written == 0` stays fatal — a wrong MEDIA_PATH is a
misconfiguration the host CAN fix and retry, and it once shipped a few-hundred-byte ZIP
containing zero photos that passed every automated check.

The space reclaim refused to prune unless it freed the entire shortfall, to protect an
archive that no handler can serve: a download resolves through `export_current`, which
requires `job.epoch = event.export_epoch`, and the epoch only increments. Meanwhile
`reclaimable` is scoped to the caller's own prefix — one old archive — while `deficit` is
sized for both halves plus the reserve. So on a tight disk each worker measured its own
share as insufficient and neither pruned, though the two shares were jointly sufficient.
Every "Neu erzeugen" reran the identical arithmetic and refused identically: permanently
stuck, with dead archives nothing would reclaim and nothing could serve. It now prunes what
it can and lets the re-check decide, so the sibling's prune lets the host's retry converge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
fabi
2026-08-11 22:44:10 +02:00
parent 7154b3a810
commit c9a4d4a9c0
3 changed files with 646 additions and 30 deletions

View File

@@ -448,6 +448,12 @@ async fn sweep_orphan_originals(pool: &PgPool, media_path: &std::path::Path) {
}
// Too young to judge: an upload committing RIGHT NOW is indistinguishable from an
// orphan, because the rename precedes the commit.
//
// This sweep is also the backstop for the one case the upload handler's drop guard
// deliberately leaks: a client disconnect while `tx.commit()` is in flight disarms
// the guard first (so a COMMIT that Postgres applied anyway keeps its file), which
// means a COMMIT that did NOT apply leaves a final-named file with no row. The
// `NOT EXISTS` check below is what reclaims it. See `upload.rs`, the disarm site.
let recent = meta
.modified()
.ok()