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 arrivo | Secondo arrivo | |
|---|---|---|
| Ricerca | La chiave è nuova | La chiave è già salvata |
| Scrivere | Una riga, un effetto collaterale | Nessuna riga nuova |
| Rispondere | Accettato | Lo stesso già accettato |
| Se il processo muore a metà | L’outbox deve ancora il messaggio | Non 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ì.
