fix(export): pin export workers to a live release epoch; make reopen atomic
Adversarial re-review ofc197b2cfound the previous fix both incomplete AND that it introduced a new stuck-state regression. Two independent reviewers flagged the latter. HIGH (data loss, still open after the last fix) — a worker could claim a LIVE seq after a reopen. `claim_job` took any `pending` row. If a reopen landed between `release_gallery`'s spawn and the worker's claim, the row was left pending at the *bumped* seq, so the worker claimed a CURRENT generation and spent minutes exporting a snapshot taken while the event was open and guests were still uploading. On the next re-release (which re-arms `export_released_at` BEFORE it bumps the seq) that worker's finalize and ready-flip both passed their guards, flipped ready on its stale snapshot, and made `enqueue_and_spawn_exports` skip regeneration — silently serving a keepsake missing every upload from the reopen window. The reopen seq-bump did not help: the worker's seq was not stale. Fix: `claim_job` now refuses to claim unless the event is still released. A worker's generation is therefore always born AND finalized inside a single live release epoch. The unclaimed pending row is harmless — the next release resets and re-spawns it. HIGH (regression I introduced inc197b2c) — `open_event`'s event-UPDATE and its export_job seq-bump were two autocommit statements. The first is exactly what re-enables `release_gallery`, so a re-release could slip between them: it spawns a FRESH worker at seq N+1, then our second statement bumps that live generation to N+2, orphaning the worker. Its seq-guarded finalize/fail/flip then all match nothing, the row is stranded `running` with no owner, and the host cannot retry ("bereits freigegeben") — only a process restart recovers it. Fix: both statements now run in one transaction, so the row lock on `event` serializes `release_gallery` behind the commit. LOW - The flip-lost path left its stale archive on disk (asymmetric with the finalize-lost path, which deletes it). If a host reopened and never re-released, no future generation would ever prune it. Now discards `out_path` and still sweeps older gens. - A reopen clears the ready flags server-side, but nothing told the clients: the nav and /export page kept advertising a keepsake that now 404s. Both now refresh on `event-opened` (a superseded worker deliberately emits no `export-progress`). Tests - New: a release broadcasts `export-progress` 100 for both types + one `export-available`. The export SSE had ZERO e2e coverage, and this round made the emit conditional — `/export/status` polling reads the job row, not the SSE, so a regression here would have left every other test green while the live UI silently stopped updating. - New: open ‖ release churn always converges to a consistent, downloadable keepsake. Honestly labelled a STRESS test, not a regression guard: verified it still passes against a deliberately non-transactional build even with ~90 concurrent pairs, so it does NOT cover the atomicity fix (that window is not reproducible out-of-process). - Verified the reopen-supersession test is non-vacuous by empirically reverting the seq-bump — it fails, as intended. Verified: backend 40 tests, svelte-check 0 errors, e2e typecheck clean, e2e 158 passed / 1 skipped on chromium-desktop. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -43,6 +43,12 @@ export function initExportStatus(): void {
|
||||
void refreshExportStatus();
|
||||
sseUnsubs.push(onSseEvent('export-progress', () => { void refreshExportStatus(); }));
|
||||
sseUnsubs.push(onSseEvent('export-available', () => { void refreshExportStatus(); }));
|
||||
// A reopen INVALIDATES the released keepsake server-side (it clears `export_released_at` and
|
||||
// both ready flags). Without refreshing here, the nav would keep advertising a download that
|
||||
// now 404s — the host reopens to collect more photos and every guest still sees "keepsake
|
||||
// ready" until a manual reload. `event-opened` is the only signal for this: a superseded
|
||||
// worker deliberately emits no `export-progress`.
|
||||
sseUnsubs.push(onSseEvent('event-opened', () => { void refreshExportStatus(); }));
|
||||
}
|
||||
|
||||
export function teardownExportStatus(): void {
|
||||
|
||||
@@ -42,6 +42,11 @@
|
||||
}),
|
||||
onSseEvent('export-available', async () => {
|
||||
await loadStatus();
|
||||
}),
|
||||
// A reopen invalidates the release (ready flags cleared server-side) — refresh so this
|
||||
// page stops offering a download that would now 404.
|
||||
onSseEvent('event-opened', async () => {
|
||||
await loadStatus();
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user