O pedido perigoso não é o primeiro. É o mesmo pedido a chegar outra vez, porque quem envia não ouviu a primeira resposta, ou ouviu-a e tentou na mesma. Os fornecedores de pagamento fazem isto. As filas fazem isto. Um navegador faz isto quando uma pessoa carrega no botão duas vezes. Se o sistema tratar a segunda cópia como trabalho novo, cobras duas vezes, envias duas vezes ou escreves duas linhas de livro por uma só aposta.
Assume o duplicado
Desenho o tratador para que a segunda entrega seja um sucesso que não faz nada. A primeira faz o trabalho e guarda uma chave: o id do evento do fornecedor, ou uma chave que o cliente enviou, única na base de dados. A segunda encontra a chave e devolve o mesmo resultado. Sem segundo débito. Sem segundo correio. Sem segunda linha.
A restrição de unicidade é a parte em que confio. Uma verificação no código da aplicação, um instante antes do insert, perde a corrida quando dois workers acordam juntos. A base de dados não perde essa corrida. O perdedor recebe um conflito e transforma-o em “já está feito”.
| Primeira chegada | Segunda chegada | |
|---|---|---|
| Consulta | A chave é nova | A chave já está guardada |
| Escrever | Uma linha, um efeito lateral | Nenhuma linha nova |
| Responder | Aceite | O mesmo já aceite |
| Se o processo morre a meio | A caixa de saída ainda deve a mensagem | Não deve um segundo |
A mensagem sai com a escrita, ou não sai
Enviar o correio, ou a mensagem da fila, dentro do pedido e antes do commit é como se obtém uma mensagem para uma transacção que foi revertida. Escrevo a mensagem de saída na mesma transacção que a linha de negócio, e um worker à parte publica-a. Se o processo morrer, a linha e a mensagem estão ambas ou nenhuma. O worker pode repetir a publicação. O tratador não deve repetir o débito.
Uma anulação é um lançamento novo, não uma edição
Quando algo tem de ser desfeito, não escrevo por cima da linha original. Um lançamento de compensação diz o que mudou e deixa a primeira linha legível. Uma semana depois ainda consegues responder o que se acreditava às 14:02. Editar a linha só responde o que se acredita agora, e responde sem testemunha.
Se uma nova tentativa pode fazer a operação acontecer duas vezes, a operação não está terminada.
Nomeia a chave antes de escrever o handler
A chave não é “um hash de todo o pedido” a menos que tenhas verificado o que quem envia muda entre tentativas. Um fornecedor de pagamentos reenviará o mesmo id de evento com uma marca temporal de entrega nova. Se a marca estiver dentro da chave, a segunda entrega parece nova, e construíste o erro que querias evitar. Uso o id que quem envia promete estável. Se não prometer um, exijo que quem chama envie um, e rejeito a chamada que chega sem ele.
Depois escrevo a falha no mesmo sítio que o sucesso. Um conflito não é uma página de erro para quem envia. É a resposta esperada a uma nova tentativa. Quem envia deve conseguir parar. Se eu responder 500, tentam uma terceira vez, e uma quarta, e o incidente torna-se uma fila de trabalho idêntico.
Não considero isto feito porque o caminho feliz devolveu 200. O teste volta a enviar a mesma carga e afirma um só efeito. Depois mata o worker entre o commit e a publicação e afirma que a mensagem continua a sair, uma vez. Esses dois testes são mais curtos do que o incidente que substituem. Começo por aí.
