The last-operator guard ran on the pool, before the transaction opened. Two hosts deleting themselves at the same moment each saw the other, both passed, and the event was left with nobody who can moderate, nobody who can release the gallery, and no way to appoint anyone — because appointing requires a host. Not recoverable from inside the app. The fix is a transaction-scoped ADVISORY lock, and the two obvious alternatives are both worse: * `FOR UPDATE` on the other operators' rows DEADLOCKS. Each deleter locks the other's row and then tries to delete its own, so Postgres resolves it by killing one. The invariant survives; the loser gets a 500 instead of the sentence explaining what to do. My first attempt did exactly this, and the test caught it. * Locking the `event` row serialises cleanly but inverts the lock order every moderation path takes (upload/user rows first, event last). That is an ABBA against a path that runs constantly during the event, traded for one that runs approximately never. An advisory lock is a separate lock space, so it cannot interact with the row-lock graph at all, and it is released when the transaction ends. The loser waits, counts zero once the winner's row is gone, and is refused with the sentence it should have got. The test is a genuine concurrency test — it spawns the second deleter and asserts it unblocks to see no remaining operator. It fails against the pre-check-outside-the-transaction version and against the FOR UPDATE version.
16 KiB
16 KiB