fix(upload): stop a late retry from undoing a host takedown

Migration 026 freed the idempotency key as soon as deleted_at was set, so a
retry after a delete uploads afresh instead of 409ing forever. That rationale
only considered the GUEST deleting. deleted_at is also set by
host_delete_upload, and there the same rule reverses a moderation decision:

  1. Guest uploads; the row commits and the photo appears, but the response
     is lost on the way back — the flaky-wifi case the key exists for — so
     the phone keeps the queue item.
  2. The host takes the photo down. Epoch bumped, keepsake rebuilt without it.
  3. The phone reconnects ten minutes later and retries. The key is free, the
     INSERT succeeds, and the photo is back — in the feed and in the next
     keepsake, under a NEW uuid that matches nothing in the host's moderation
     history, with nothing logged to say a takedown was reversed.

Migration 031 keeps the key claimed for a host takedown and releases it only
for a guest's own delete, so the retry resolves to the duplicate path and is
refused. The refusal now says why ("von den Gastgebern entfernt") rather than
"already processed", which invites another try.

The index predicate and the ON CONFLICT arbiter are changed in lockstep;
these queries are not compile-checked, so a drift between them is a 500 on
exactly the retries the index exists to serve. Verified against a real
Postgres: live retry suppressed, host takedown holds the key, guest delete
releases it. The integration test's copy of the insert is updated too — it is
verbatim by design, and a stale copy would have kept passing.
This commit is contained in:
fabi
2026-08-12 09:14:51 +02:00
parent 1b3ca46f8a
commit 9b38d31f97
6 changed files with 177 additions and 18 deletions

View File

@@ -705,7 +705,9 @@ pub async fn host_delete_upload(
// invalidation didn't, the taken-down photo would stay downloadable forever and nothing would
// notice (the keepsake still looks complete, and the host can no longer find the upload to retry).
let mut tx = state.pool.begin().await?;
let deleted = Upload::soft_delete_in_event(&mut tx, upload_id, auth.event_id).await?;
// `by_host: true` — the takedown holds the uploader's idempotency key so a late retry from
// their queue cannot resurrect the photo. See migration 031.
let deleted = Upload::soft_delete_in_event(&mut tx, upload_id, auth.event_id, true).await?;
if !deleted {
return Err(AppError::NotFound("Upload nicht gefunden.".into()));
}

View File

@@ -770,11 +770,28 @@ pub async fn upload(
.map_err(AppError::from)?,
None => None,
};
// If the winning row has vanished between the conflict and this lookup (deleted in
// the intervening milliseconds), there is nothing to replay — report the conflict.
let existing = existing.ok_or_else(|| {
AppError::Conflict("Dieser Upload wurde bereits verarbeitet.".into())
})?;
// No live row behind the key. Two very different causes, and the guest deserves to be
// told which: either the winning row vanished in the intervening milliseconds, or the
// key is still held by a photo the HOST took down (migration 031), in which case the
// refusal is the whole point and re-sending will never work.
let existing = match existing {
Some(e) => e,
None => {
let taken_down = match client_upload_id {
Some(cid) => {
Upload::taken_down_by_client_upload_id(&state.pool, auth.user_id, cid)
.await
.unwrap_or(false)
}
None => false,
};
return Err(AppError::Conflict(if taken_down {
"Dieses Foto wurde von den Gastgebern entfernt.".into()
} else {
"Dieser Upload wurde bereits verarbeitet.".to_string()
}));
}
};
tracing::info!(
upload_id = %existing.id,
"concurrent duplicate upload resolved; replaying the stored row"
@@ -978,7 +995,9 @@ pub async fn delete_upload(
// Atomic with the keepsake invalidation: a guest removing their own photo must have it removed
// from the downloadable archive too, and a half-applied delete would leave it there forever.
let mut tx = state.pool.begin().await?;
Upload::soft_delete_in_event(&mut tx, upload_id, auth.event_id).await?;
// `by_host: false` — the guest deleted their own photo, so the idempotency key is released and
// a later retry of the same queue item uploads afresh rather than 409ing forever.
Upload::soft_delete_in_event(&mut tx, upload_id, auth.event_id, false).await?;
let regen = crate::services::export::invalidate_and_arm(
&mut tx,
&state.config.event_slug,