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:
@@ -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", "{}"));
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user