fix(rereview): close export-flip race + queue-dedup reload regression

Adversarial re-review of the persona-audit + audit-followup rounds (6411747..)
found one HIGH and one MED regression plus LOW gaps. All fixed with coverage.

HIGH — export stale-keepsake resurrected by an open_event race
  A reopen landing in the window between a *current* export worker's finalize_job
  and its ready-flag flip cleared export_released_at + the ready flags but left the
  export_job row `done` at the same release_seq. The seq-guarded flip then still
  matched and re-set export_{zip,html}_ready=TRUE on a pre-reopen snapshot; the next
  re-release read that stale TRUE and skipped regeneration (`if ready { continue }`),
  serving a keepsake missing every upload from the reopen window — the exact data
  loss migration 012 exists to prevent. Both ready-flip UPDATEs are now additionally
  anchored on `export_released_at IS NOT NULL`, so a landed reopen makes the flip a
  no-op and the re-release regenerates cleanly.

MED — queue dedup broke for reloaded items
  loadQueue rebuilt QueueItems from IndexedDB without copying lastModified, which the
  new addToQueue dedup keys on. A file re-selected after a page reload / PWA relaunch
  missed the duplicate check and uploaded twice. Rehydration now carries lastModified
  (extracted to a pure, tested entryToQueueItem helper).

LOW
  - diashow: clear the upload-processed debounce timer in onDestroy (no stray
    post-unmount /feed fetch).
  - USER_JOURNEYS §9.5: document the reconnect-delta ban replay (hidden_user_ids /
    uploads_hidden_at, migration 013), not just the live user-hidden SSE.
  - e2e api-client: drop the misleading hide_uploads param from banUser — the backend
    takes no body and always hides; strip the dead boolean at all call sites.

Tests
  - Extract isReversibleLock (the terminal-403 KEEP-vs-PURGE-blob discriminator) into a
    pure exported helper + unit tests, so the data-loss-critical branch is covered
    without an XHR harness.
  - entryToQueueItem unit tests lock the lastModified-carry regression.
  - Document the export flip-race guard in the reopen/re-release spec (the sub-ms
    finalize↔flip interleave isn't deterministically forceable with fast fixtures;
    covered by the SQL guard + the end-to-end completeness test).

Verified: backend 40 tests, frontend 44 unit tests, svelte-check 0 errors,
e2e 156 passed / 1 skipped on chromium-desktop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
fabi
2026-07-13 21:47:44 +02:00
parent 768e712a26
commit df275bbefa
10 changed files with 166 additions and 33 deletions

View File

@@ -303,11 +303,19 @@ async fn run_zip_export_inner(
return Ok(());
}
// Flip the ready flag only while still current (a re-release between finalize and here
// would have cleared it; don't resurrect it for a superseded generation).
// 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(
"UPDATE event SET export_zip_ready = TRUE
WHERE id = $1
AND export_released_at IS NOT NULL
AND EXISTS (SELECT 1 FROM export_job
WHERE event_id = $1 AND type = 'zip'::export_type
AND release_seq = $2 AND status = 'done')",
@@ -644,9 +652,12 @@ 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(
"UPDATE event SET export_html_ready = TRUE
WHERE id = $1
AND export_released_at IS NOT NULL
AND EXISTS (SELECT 1 FROM export_job
WHERE event_id = $1 AND type = 'html'::export_type
AND release_seq = $2 AND status = 'done')",