Architecture

Progetta per il webhook che arriva due volte

La seconda copia di un evento di pagamento non è lavoro nuovo. Una chiave unica, un’outbox e uno storno che non modifica il passato.

Design for the webhook that arrives twice

La richiesta pericolosa non è la prima. È la stessa richiesta che arriva di nuovo, perché chi invia non ha sentito la prima risposta, o l’ha sentita e ha riprovato lo stesso. I fornitori di pagamento fanno questo. Le code fanno questo. Un browser fa questo quando una persona preme il pulsante due volte. Se il sistema tratta la seconda copia come lavoro nuovo, addebiti due volte, spedisci due volte o scrivi due righe di libro per una sola puntata.

Parti dal duplicato

Progetto il gestore così che la seconda consegna sia un successo che non fa nulla. La prima fa il lavoro e memorizza una chiave: l’id evento del fornitore, o una chiave inviata dal client, unica nel database. La seconda trova la chiave e restituisce lo stesso risultato. Nessun secondo addebito. Nessuna seconda mail. Nessuna seconda riga.

Il vincolo di unicità è la parte di cui mi fido. Un controllo nel codice dell’applicazione, un attimo prima dell’insert, perde la gara quando due worker si svegliano insieme. Il database non perde quella gara. Chi perde riceve un conflitto e lo trasforma in “già fatto”.

Primo arrivoSecondo arrivo
RicercaLa chiave è nuovaLa chiave è già salvata
ScrivereUna riga, un effetto collateraleNessuna riga nuova
RispondereAccettatoLo stesso già accettato
Se il processo muore a metàL’outbox deve ancora il messaggioNon ne deve un secondo

Il messaggio esce con la scrittura, oppure no

Mandare la mail, o il messaggio di coda, dentro la richiesta e prima del commit è il modo in cui ottieni un messaggio per una transazione che è stata annullata. Scrivo il messaggio in uscita nella stessa transazione della riga di business, e un worker separato lo pubblica. Se il processo muore, la riga e il messaggio ci sono entrambi o non c’è nessuno dei due. Il worker può ritentare la pubblicazione. Il gestore non deve ritentare l’addebito.

Uno storno è una scrittura nuova, non una modifica

Quando qualcosa va disfatto, non sovrascrivo la riga originale. Una scrittura di compensazione dice che cosa è cambiato e lascia la prima riga leggibile. Una settimana dopo puoi ancora dire che cosa si credeva alle 14:02. Modificare la riga risponde solo a che cosa si crede adesso, e senza un testimone.

Se un nuovo tentativo può far accadere l’operazione due volte, l’operazione non è finita.

Dai un nome alla chiave prima di scrivere l’handler

La chiave non è “un hash di tutta la richiesta” a meno che tu non abbia controllato che cosa cambia chi invia tra un tentativo e l’altro. Un fornitore di pagamenti rinvierà lo stesso id evento con un nuovo timestamp di consegna. Se il timestamp è dentro la chiave, la seconda consegna sembra nuova, e hai costruito il bug che volevi evitare. Uso l’id che chi invia promette stabile. Se non ne promette uno, esigo che chi chiama ne mandi uno, e rifiuto la chiamata che arriva senza.

Poi scrivo il fallimento nello stesso posto del successo. Un conflitto non è una pagina di errore per chi invia. È la risposta attesa a un nuovo tentativo. Chi invia deve poter fermarsi. Se rispondo 500, proveranno una terza volta, e una quarta, e l’incidente diventa una coda di lavoro identico.

Non lo considero fatto perché il percorso felice ha restituito 200. Il test invia di nuovo lo stesso payload e afferma un solo effetto. Poi uccide il worker tra il commit e la pubblicazione e afferma che il messaggio esce ancora, una volta. Quei due test sono più corti dell’incidente che sostituiscono. Comincio da lì.