fix(me): two hosts deleting at once can no longer leave the event with no operator

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.
This commit is contained in:
fabi
2026-08-12 21:40:29 +02:00
parent 19b59d6fee
commit 55b57fc037
2 changed files with 138 additions and 0 deletions

View File

@@ -199,6 +199,48 @@ pub async fn delete_account(
crate::services::export::Affects::Both,
)
.await?;
// The last-host guard again, now AUTHORITATIVELY — inside the transaction, holding a lock.
//
// The check above runs on the pool before this transaction opens, so two hosts deleting
// themselves at the same moment each saw the other and both proceeded, leaving the event with
// NO operator: nobody to moderate, nobody to release the gallery, and no way to appoint anyone
// because appointing requires a host. That is not recoverable from inside the app.
//
// Serialised with a transaction-scoped ADVISORY lock, not a row lock.
//
// `FOR UPDATE` on the other operators' rows looks like the obvious answer and is the wrong one:
// each deleter would lock the OTHER's row and then try to delete its own, so the two block on
// each other and Postgres resolves it by killing one with a deadlock error — the invariant
// holds, but the loser gets a 500 instead of the sentence below. Locking the `event` row
// instead would serialise cleanly, but it inverts the lock order every moderation path uses
// (upload/user rows first, event last), which is an ABBA waiting to happen.
//
// An advisory lock has neither problem: it is a separate lock space, so it cannot interact with
// the row-lock graph at all, and it is released automatically when this transaction ends.
// 4242 is an arbitrary namespace to keep this key from colliding with any future advisory use.
if matches!(user.role, UserRole::Host | UserRole::Admin) {
sqlx::query("SELECT pg_advisory_xact_lock(4242, hashtext($1::text))")
.bind(auth.event_id)
.execute(&mut *tx)
.await?;
let others: Vec<uuid::Uuid> = sqlx::query_scalar(
"SELECT id FROM \"user\"
WHERE event_id = $1 AND id != $2
AND role IN ('host', 'admin') AND is_banned = FALSE",
)
.bind(auth.event_id)
.bind(auth.user_id)
.fetch_all(&mut *tx)
.await?;
if others.is_empty() {
return Err(AppError::BadRequest(
"Du bist der letzte Gastgeber. Ernenne zuerst einen anderen Gastgeber, bevor du \
dein Konto löschst."
.into(),
));
}
}
// And the account. `session`, `like` and `pin_reset_request` cascade from here.
sqlx::query("DELETE FROM \"user\" WHERE id = $1")
.bind(auth.user_id)