feat(upload): make uploads idempotent so a lost response cannot duplicate a photo
The ordinary mobile failure, not an exotic one: the server receives the body, validates it, commits the row — and the response is lost on the way back because the guest walked out of range or the AP dropped the connection. The client sees a network error with the blob still in hand and re-sends it, both when the guest taps "Erneut" and automatically when the queue requeues on reconnect. Every attempt minted a fresh `Uuid::new_v4()` server-side, so the same photo landed in the gallery two or three times and was charged against the guest's storage quota each time. The client already has a stable per-queue-item UUID, so it costs nothing to send. Migration 022 adds `client_upload_id` with a partial unique index — partial so the NULLs of every pre-022 upload, and of any caller that doesn't send one, keep working untouched. Two paths, because there are two races: - Sequential retry: a lookup before the transaction finds the stored row, deletes the re-sent bytes and replays the original response as 200. The body has necessarily already been streamed, since the key arrives as a multipart field — re-sending is the client's cost and is already paid by the time we see it. What must be prevented is a second ROW. - Concurrent retry: two attempts in flight at once. `ON CONFLICT DO NOTHING` returns no row to the loser, which abandons its transaction (quota increment included) and replays the winner. Letting the unique index raise instead would only surface after the transaction had aborted, as an opaque error the caller would have to string-match. The replay reads live state rather than assuming a fresh row: a reconnect can be minutes later, by which time the derivatives may exist and the photo may have been liked. Every read there fails soft — the upload is already safely stored, so a sparser response is fine and failing the request is not. Verified live: the same photo sent three times returns 201, 200, 200 with one id, one row, and the quota charged exactly once. Also in this file: the two image-header probes at admission now run on `spawn_blocking`. Both open the file and run the codec's header parse synchronously, and `#[tokio::main]` gives two worker threads on a 2-vCPU box — so every upload stalled half the runtime's request-serving capacity. Everything else that blocks here (image encode, bcrypt) was already offloaded; this was the one that wasn't. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -53,6 +53,9 @@ pub struct VisibleMedia {
|
||||
impl Upload {
|
||||
/// Takes any executor so the caller can run it inside a transaction (atomic
|
||||
/// quota + insert) or standalone against the pool.
|
||||
// Eight arguments, one per column the INSERT writes, with exactly one call site. A params
|
||||
// struct here would restate the column list a second time and buy nothing.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn create<'e, E>(
|
||||
executor: E,
|
||||
event_id: Uuid,
|
||||
@@ -61,13 +64,23 @@ impl Upload {
|
||||
mime_type: &str,
|
||||
original_size_bytes: i64,
|
||||
caption: Option<&str>,
|
||||
) -> Result<Self, sqlx::Error>
|
||||
client_upload_id: Option<Uuid>,
|
||||
) -> Result<Option<Self>, sqlx::Error>
|
||||
where
|
||||
E: sqlx::PgExecutor<'e>,
|
||||
{
|
||||
// `Ok(None)` means this exact `client_upload_id` is already stored — the caller's request
|
||||
// is a retry of one that already succeeded, and it must replay the original row rather
|
||||
// than create a second. Letting the unique index raise instead would work, but only after
|
||||
// the whole transaction had aborted, and it would arrive as an opaque database error the
|
||||
// caller would have to string-match to recognise.
|
||||
//
|
||||
// The conflict target repeats the index's `WHERE` clause because it is a partial index;
|
||||
// without it Postgres cannot prove which index to use and rejects the statement.
|
||||
sqlx::query_as::<_, Self>(
|
||||
"INSERT INTO upload (event_id, user_id, original_path, mime_type, original_size_bytes, caption)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
"INSERT INTO upload (event_id, user_id, original_path, mime_type, original_size_bytes, caption, client_upload_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (client_upload_id) WHERE client_upload_id IS NOT NULL DO NOTHING
|
||||
RETURNING *",
|
||||
)
|
||||
.bind(event_id)
|
||||
@@ -76,7 +89,29 @@ impl Upload {
|
||||
.bind(mime_type)
|
||||
.bind(original_size_bytes)
|
||||
.bind(caption)
|
||||
.fetch_one(executor)
|
||||
.bind(client_upload_id)
|
||||
.fetch_optional(executor)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Look up a live upload by the idempotency key its client sent.
|
||||
///
|
||||
/// Scoped to the user as well as the key: the key alone is unique, but a lookup that ignored
|
||||
/// ownership would let one guest's retry return another guest's row if a key ever repeated.
|
||||
/// Soft-deleted rows are excluded on purpose — if the guest deleted the photo and their queue
|
||||
/// later retries, they should get a fresh upload rather than a resurrection of a deleted one.
|
||||
pub async fn find_by_client_upload_id(
|
||||
pool: &sqlx::PgPool,
|
||||
user_id: Uuid,
|
||||
client_upload_id: Uuid,
|
||||
) -> Result<Option<Self>, sqlx::Error> {
|
||||
sqlx::query_as::<_, Self>(
|
||||
"SELECT * FROM upload
|
||||
WHERE client_upload_id = $1 AND user_id = $2 AND deleted_at IS NULL",
|
||||
)
|
||||
.bind(client_upload_id)
|
||||
.bind(user_id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user