fix(export): deleting a comment mid-build no longer strands the ZIP forever

Found while auditing the test suite: the ViewerOnly carry-forward (added by me in
9666d74) permanently breaks the ZIP download if a comment is deleted while the ZIP is
still being built.

A comment-only change doesn't alter the ZIP's media, so `Affects::ViewerOnly` carries the
finished archive into the new epoch rather than rebuilding it. But "finished" is a
PRECONDITION, and between `release_gallery` and the ZIP worker completing it is false —
for a real multi-GB gallery, for MINUTES. Deleting a comment in that window is an utterly
ordinary thing to do.

The carry-forward UPDATE is guarded on `status = 'done'`, so in that window it matched
nothing — and ViewerOnly re-armed only the viewer. The ZIP row was left at the retired
epoch; the in-flight worker finished and wrote `done` at an epoch `export_current` no
longer matches. `GET /export/zip` then 404s FOREVER: recovery only runs at boot, and
`release_gallery` refuses an already-released event. The guests' keepsake is simply gone,
and the only escape is the rebuild button added one commit ago.

Fix: the carry-forward's own `rows_affected()` decides. It matched => there is a current,
finished ZIP and only the viewer needs rebuilding. It didn't => there is nothing to
preserve, so rebuild the ZIP too, like any other invalidation. Never assume the
precondition; ask the UPDATE.

Proven non-vacuous: with the fix reverted the new test fails with the ZIP stranded at the
retired epoch (job epoch 1, event epoch 2, export_current holding only "html", download
404). Backend 40 tests, e2e 163 passed / 1 skipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
fabi
2026-07-14 22:50:17 +02:00
parent db88230221
commit 2bef6e19ef
2 changed files with 78 additions and 12 deletions

View File

@@ -183,12 +183,21 @@ pub async fn invalidate_and_arm(
return Ok(None);
};
if affects == Affects::ViewerOnly {
// Carry the finished ZIP into the new epoch instead of rebuilding it: same file, same
// `file_path`, now stamped current so `export_current` keeps serving it. Only the viewer is
// re-armed below. (`prune_stale_export_files` is told to protect any file a current-epoch
// row still points at, so the carried archive isn't swept for having an older epoch in its
// name.)
// A comment-only change doesn't alter the ZIP's contents (the ZIP holds media, not comments), so
// we'd rather carry the finished archive into the new epoch than spend minutes rebuilding it.
//
// But "finished" is the whole precondition, and it is NOT guaranteed: between `release_gallery`
// and the ZIP worker completing, the row sits at `pending`/`running` — MINUTES, for a real
// multi-GB gallery — and deleting a comment right after release is an utterly ordinary thing to
// do. If we blindly re-armed only the viewer, the carry-forward would match nothing, the ZIP row
// would be left stranded at the retired epoch, and NOTHING would ever re-arm it: the in-flight
// worker finishes and writes `done` at an epoch `export_current` no longer matches, so
// `GET /export/zip` 404s forever (short of a boot or the host finding the rebuild button).
//
// So the carry-forward's OWN result decides. It matched ⇒ there is a current, finished ZIP and
// only the viewer needs rebuilding. It didn't ⇒ there is no ZIP to preserve, and the ZIP must be
// rebuilt at the new epoch like any other invalidation. Never assume; ask the UPDATE.
let carried = if affects == Affects::ViewerOnly {
sqlx::query(
"UPDATE export_job SET epoch = $2
WHERE event_id = $1 AND type = 'zip'::export_type
@@ -197,13 +206,16 @@ pub async fn invalidate_and_arm(
.bind(event_id)
.bind(epoch)
.execute(&mut *conn)
.await?;
}
let types: &[&str] = match affects {
Affects::Both => &["zip", "html"],
Affects::ViewerOnly => &["html"],
.await?
.rows_affected()
== 1
} else {
false
};
// (`prune_stale_export_files` protects any file a current-epoch row still points at, so a
// carried archive isn't swept for having an older epoch in its name.)
let types: &[&str] = if carried { &["html"] } else { &["zip", "html"] };
enqueue_types_at_epoch(&mut *conn, event_id, epoch, types).await?;
Ok(Some(PendingRegen { event_id, event_name, epoch }))