fix(rereview): close residual export finalize↔flip double-race at reopen

Adversarial re-review of df275bb found the two-guard ready-flip fix still left a
narrow double-race open, plus test-hygiene drift from the banUser param removal.

MED — residual stale-keepsake race the `export_released_at` guard alone missed
  The flip relied on two guards defeating two clearers: the `release_seq` EXISTS
  check (vs a re-release bump) and `export_released_at IS NOT NULL` (vs open_event's
  clear). But release_gallery re-arms `export_released_at = NOW()` and bumps
  `release_seq` as SEPARATE statements, so a stale worker's flip landing in the gap
  between them satisfies BOTH guards (released re-armed, seq not yet bumped) and
  resurrects a pre-reopen keepsake — the next re-release then skips regeneration and
  serves an archive missing the reopen-window uploads.

  Root-cause fix: `open_event` now bumps every `export_job.release_seq`, making a
  reopen a supersession point symmetric with a re-release. A worker that captured the
  pre-reopen seq can never match its `release_seq`-guarded finalize/flip again,
  regardless of when the re-release re-arms `export_released_at`. The released-anchor
  guard stays as defense-in-depth. Added a deterministic e2e asserting the reopen
  bumps the seq (the invariant that closes the race without depending on
  sub-millisecond worker timing).

LOW
  - Gate the `export-progress: 100` SSE + `prune_stale_export_files` on the ready-flip
    actually flipping (rows_affected > 0). A superseded worker no longer advertises a
    misleading 100% or prunes on a fresh generation's behalf.
  - moderation.spec.ts: the two ban tests had become byte-identical after the
    hide_uploads param removal; drop the one whose "hide_uploads=true" title no longer
    matched what it exercised, keep the accurate "always hides" test.
  - host-dashboard page-object: drop the dead `banUser(hideUploads)` param + its
    checkbox branch (the UI no longer renders that checkbox — a latent hang trap).
  - sse-eviction.spec.ts: rename the test whose title referenced the removed flag.

Verified: backend 40 tests, e2e 156 passed / 1 skipped on chromium-desktop
(incl. the new reopen-supersession regression).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
fabi
2026-07-13 22:05:06 +02:00
parent 99f79e2898
commit d643256f36
6 changed files with 99 additions and 42 deletions

View File

@@ -528,6 +528,24 @@ pub async fn open_event(
.await?;
if result.rows_affected() > 0 {
// A reopen invalidates any released keepsake, so make it a *supersession point* for the
// export pipeline: bump every export_job generation for this event. An in-flight worker
// — or one that has already `finalize_job`'d but not yet flipped its ready flag — captured
// the pre-reopen `release_seq`, so its guarded finalize/ready-flip now match nothing and
// become no-ops. Without this, the `export_released_at IS NOT NULL` flip guard alone leaves
// a sub-window open: a re-release re-arms `export_released_at` *before* it bumps the seq, and
// a stale worker's flip landing in that gap satisfies both guards and resurrects a
// pre-reopen keepsake (silent data loss). Bumping here closes it — the old seq is retired
// for good and the next re-release regenerates from a current snapshot.
sqlx::query(
"UPDATE export_job SET release_seq = release_seq + 1
FROM event e
WHERE e.id = export_job.event_id AND e.slug = $1",
)
.bind(&state.config.event_slug)
.execute(&state.pool)
.await?;
let _ = state.sse_tx.send(SseEvent::new("event-opened", "{}"));
}

View File

@@ -303,16 +303,16 @@ async fn run_zip_export_inner(
return Ok(());
}
// Flip the ready flag only while still current AND still released. The `release_seq`
// EXISTS check alone is NOT enough: a reopen (`open_event`) landing in the window between
// our `finalize_job` above and this UPDATE clears `export_released_at` + both ready flags
// but leaves our `export_job` row `done` at this same seq — so EXISTS would still match and
// we'd resurrect `export_zip_ready = TRUE` on a keepsake that predates the reopen. The next
// re-release would then read that stale TRUE, skip regeneration (`if ready { continue }`),
// and serve a snapshot missing every upload added during the reopen window — the exact
// stale-keepsake data loss migration 012 exists to prevent. Anchoring on
// `export_released_at IS NOT NULL` makes the flip a no-op once a reopen has landed.
sqlx::query(
// Flip the ready flag only while our generation is still current AND the event is still
// released. Two layers guard this against a reopen resurrecting a pre-reopen keepsake:
// 1. `release_seq = $2` — a reopen bumps every export_job's seq (see `open_event`), so a
// stale worker holding the old seq matches nothing here.
// 2. `export_released_at IS NOT NULL` — belt-and-suspenders in case any path clears the
// release without bumping the seq.
// If this flip is a no-op (0 rows), a reopen/supersession has landed: the keepsake isn't
// downloadable and a fresh generation owns cleanup + the completion signal, so we must NOT
// advertise 100% or prune on this superseded generation's behalf.
let flipped = sqlx::query(
"UPDATE event SET export_zip_ready = TRUE
WHERE id = $1
AND export_released_at IS NOT NULL
@@ -325,6 +325,11 @@ async fn run_zip_export_inner(
.execute(pool)
.await?;
if flipped.rows_affected() == 0 {
tracing::info!("ZIP export for event {event_id} superseded before ready-flip; not advertising");
return Ok(());
}
prune_stale_export_files(&exports_dir, "Gallery", event_id, seq).await;
let _ = sse_tx.send(SseEvent {
@@ -652,9 +657,10 @@ async fn run_html_export_inner(
return Ok(());
}
// Same released-anchored guard as the ZIP flip (see run_zip_export): a reopen between our
// finalize and here must not resurrect `export_html_ready` on a pre-reopen snapshot.
sqlx::query(
// Same two-layer guard as the ZIP flip (see run_zip_export): the reopen seq-bump plus the
// `export_released_at IS NOT NULL` anchor keep a superseded worker from resurrecting
// `export_html_ready` on a pre-reopen snapshot. A no-op flip → superseded → don't advertise/prune.
let flipped = sqlx::query(
"UPDATE event SET export_html_ready = TRUE
WHERE id = $1
AND export_released_at IS NOT NULL
@@ -667,6 +673,11 @@ async fn run_html_export_inner(
.execute(pool)
.await?;
if flipped.rows_affected() == 0 {
tracing::info!("HTML export for event {event_id} superseded before ready-flip; not advertising");
return Ok(());
}
prune_stale_export_files(&exports_dir, "Memories", event_id, seq).await;
let _ = sse_tx.send(SseEvent {