-- Export generation, unified. -- -- Before this migration, "which generation of the keepsake is current?" had no single answer. -- It was assembled at runtime from three separately-written pieces of state across two tables: -- * event.export_released_at — a release marker with NO identity (you cannot ask *which* release) -- * export_job.release_seq — a per-row counter, in a different table, bumped in a different statement -- * event.export_{zip,html}_ready — a CACHED COPY of the derivation of the other two -- Keeping those three in agreement took ~10 hand-written guards, and every guard was a repair of the -- same missing invariant. Three consecutive review rounds each found another path that slipped through. -- -- This replaces all of it with ONE authority: -- -- event.export_epoch — a monotonic counter bumped in the SAME UPDATE as any change to -- export_released_at (release and reopen are its only writers). -- export_job.epoch — a COPY of event.export_epoch taken when the job was enqueued. -- NEVER independently incremented. -- -- The single invariant, which replaces the entire proof: -- -- An export is downloadable IFF -- event.export_released_at IS NOT NULL -- AND export_job.epoch = event.export_epoch -- AND export_job.status = 'done' -- -- Readiness is therefore DERIVED, never stored — so it cannot drift, and no worker can set it. -- A worker holding a dead epoch is INERT BY CONSTRUCTION: anything it writes is invisible to every -- reader, with no guard involved. Losing a race can no longer corrupt state; it can only waste work. -- -- Crucially, epoch equality is checked on the SAME ROW the worker updates (export_job), never via a -- cross-table EXISTS. That matters: under READ COMMITTED, when an UPDATE blocks on a row lock and the -- blocker commits, Postgres re-evaluates the WHERE against the *updated target row* but answers -- subqueries on OTHER tables from the statement's ORIGINAL snapshot. The old cross-row guard -- (`EXISTS (SELECT 1 FROM event WHERE ... export_released_at IS NOT NULL)`) was unsound for exactly -- that reason — it could admit a claim against an already-reopened event while RETURNING the -- post-bump seq. A same-row `epoch = $n` predicate is re-evaluated correctly by EPQ. ALTER TABLE event ADD COLUMN export_epoch BIGINT NOT NULL DEFAULT 0; -- A currently-released event's live generation becomes epoch 1. UPDATE event SET export_epoch = 1 WHERE export_released_at IS NOT NULL; -- The per-job counter becomes a plain copy of the event epoch it was enqueued for. ALTER TABLE export_job RENAME COLUMN release_seq TO epoch; -- Carry the OLD notion of "downloadable" across exactly: a job the old model considered ready -- (released + done + its ready flag) adopts the event's live epoch. Everything else is retired to -- -1, which can never equal a non-negative event.export_epoch, so it is inert forever. UPDATE export_job j SET epoch = CASE WHEN e.export_released_at IS NOT NULL AND j.status = 'done' AND ((j.type = 'zip' AND e.export_zip_ready) OR (j.type = 'html' AND e.export_html_ready)) THEN e.export_epoch ELSE -1 END FROM event e WHERE e.id = j.event_id; -- The duplicated derivation. Gone — along with the whole class of bugs where a stale worker -- resurrected it, or where it disagreed with the job row it was supposed to summarise. ALTER TABLE event DROP COLUMN export_zip_ready; ALTER TABLE event DROP COLUMN export_html_ready; -- The ONE definition of "the current generation". Every reader goes through this, so readiness and -- the file path are resolved in a SINGLE read — closing the download-path TOCTOU where the ready -- flag was checked in one query and file_path fetched in another (a reopen between the two served a -- keepsake that should have 404'd). CREATE VIEW export_current AS SELECT j.event_id, j.type, j.status, j.progress_pct, j.file_path, j.epoch FROM export_job j JOIN event e ON e.id = j.event_id WHERE e.export_released_at IS NOT NULL -- implied by the epoch match; kept as an assertion AND j.epoch = e.export_epoch;