Architecture

Desenha para o webhook que chega duas vezes

A segunda cópia de um evento de pagamento não é trabalho novo. Uma chave única, uma caixa de saída e uma anulação que não edita o passado.

Design for the webhook that arrives twice

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 chegadaSegunda chegada
ConsultaA chave é novaA chave já está guardada
EscreverUma linha, um efeito lateralNenhuma linha nova
ResponderAceiteO mesmo já aceite
Se o processo morre a meioA caixa de saída ainda deve a mensagemNã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í.