Vamos começar pela parte mais fácil de se perder ao ler o resto: nenhum dinheiro foi retirado. Nenhuma carteira foi debitada duas vezes, nenhum cartão foi cobrado, nenhuma cota de assinatura foi gasta em trabalho que nunca aconteceu.
O que quebrou foi um número numa tela. Em algumas contas, o detalhamento de consumo na sua própria página de conta mostrava a mais — na pior que medimos, 2,4 vezes o livro-razão que ela deveria resumir. O livro-razão esteve certo o tempo todo. Os tetos mensais também, e eles são lidos de um lugar completamente diferente. Foi um defeito de exibição, já está corrigido, e esta foi a causa.
Uma chave que procurava algo que ninguém escreveu
O trabalho que nasce de algo que você fez — atualizar um resumo diário de consumo, creditar uma indicação, distribuir uma notificação — não acontece dentro da sua requisição. A requisição escreve uma mensagem e um consumidor em segundo plano a pega um instante depois; é isso que impede um efeito colateral lento de deixar lenta uma página rápida.
O perigo desse arranjo é fazer o mesmo trabalho duas vezes, porque um broker reentrega alegremente a mesma mensagem depois de um soluço. A defesa é uma caixa de entrada: antes de processar, um consumidor reivindica a mensagem escrevendo uma linha que carrega a chave dessa mensagem, e um índice único torna impossível uma segunda reivindicação.
A nossa reivindicava sobre um par — o inquilino a que a mensagem pertencia, mais a chave da mensagem — e procurava esse par contra o identificador de inquilino carregado pelo envelope da mensagem, que vem preenchido.
As linhas de reivindicação não carregavam identificador de inquilino algum. Todas estavam vazias, e estavam assim desde o começo. A própria documentação da entidade afirmava, com essas mesmas palavras, que o framework o carimbava na inserção. Nunca carimbou. Medido em produção no dia em que encontramos: as 3.344 linhas da caixa de entrada vazias, enquanto 1.174 de 1.451 linhas do lado produtor carregavam um inquilino real — exatamente por isso ninguém desconfiou do lado que lê.
Então a busca pedia (inquilino, chave), a linha salva era (nada, chave), e elas nunca poderiam bater.
Nada falhou, e é por isso que rodou por semanas
Uma busca que nunca pode bater soa como algo que deveria dar erro na primeira tentativa. Fez o oposto.
A reivindicação decidiu que a mensagem era nova. A inserção então bateu no índice único e foi rejeitada como duplicata — corretamente, porque a linha já estava lá. Um ramo alternativo capturou essa rejeição e a leu com benevolência: outra entrega deve ter reivindicado esta, aqui não há nada a fazer. O envelope ficou no estado "ainda a processar", e como ainda estava a processar, a entrega seguinte o pegou e refez o trabalho.
Cada consumidor reprocessava a própria fila acumulada mais ou menos a cada quarenta e cinco segundos, indefinidamente. Vinte e seis faziam isso. Uma linha de produção terminou 333 horas depois de chegar — e vinha terminando repetidamente durante a maior parte desse tempo.
O que de fato quebrou
Desses vinte e seis consumidores, quatro acumulam em vez de sobrescrever. Esses quatro são as projeções de cobrança que a página de consumo lê, e foi ali que a coisa apareceu.
Eles tinham uma proteção explícita exatamente contra isso, escrita depois de um incidente em agosto em que um único evento foi aplicado nove vezes a um indicador. Essa proteção marca uma linha como bem-sucedida antes de processá-la, para que uma reentrega encontre a marca e pare. A marca era escrita corretamente todas as vezes. Aí a reivindicação seguinte a procurava com a mesma chave quebrada e não a encontrava. A garantia esteve vazia desde o dia em que foi lançada.
O que isso não tocou vale ser listado com precisão, porque "projeção de cobrança" soa alarmante e os fatos não são:
- O livro-razão estava correto. Cada chamada de IA escreve exatamente um registro de cobrança com seu próprio instantâneo de preço, e nada neste caminho os escreve.
- Nenhuma carteira se moveu. Os saldos vêm do livro-razão, nunca de um resumo.
- Nenhum teto se moveu. O guardião do orçamento lê um contador separado, e é ele a única fonte de verdade sobre o quanto você gastou neste mês. Ninguém foi cortado cedo demais por causa de um número inflado, e ninguém passou sem dever passar.
A única coisa que lê o resumo afetado é o histórico de consumo na sua própria página de conta — o seu total e o detalhamento de onde ele foi parar. Esse é todo o raio de impacto, e o que ele fez foi mostrar a mais.
A metade para a qual ninguém olhava
A mesma busca quebrada estava num segundo lugar: o caminho que roda quando uma mensagem falha. Ali ela nunca encontrava a linha que deveria atualizar, e logo abaixo havia um ramo com o comentário "inalcançável na prática" — que devolvia "esta falha é terminal" em toda falha.
Assim o contador de novas tentativas nunca avançou e nenhuma mensagem envenenada chegou à fila de mensagens mortas. Uma camada inteira de tratamento de erros estava desligada, atrás de um comentário que dizia a todo mundo que o lia que ali não havia nada a verificar.
A correção
As duas buscas agora se chaveiam pela chave da mensagem dentro do grupo de consumidores, que é o que o índice único impõe de qualquer jeito. As chaves de mensagem derivam do identificador daquilo que as produziu, então não podem colidir entre inquilinos por construção.
A documentação que afirmava o carimbo foi corrigida em vez de apagada. Aquela frase era a própria armadilha, e apagá-la não deixaria nada dizendo que alguém finalmente conferiu.
Nove testes fixam o comportamento. Seis deles falham contra o código antigo, o que confirmamos colocando o código antigo de volta.
A limpeza depois
As linhas infladas se consertam sozinhas. Um worker reconstrói os resumos diários a partir do livro-razão a cada hora, numa janela de dois dias. Ele vinha rodando o tempo todo e perdendo: uma vez por hora contra um consumidor que reaplica a cada quarenta e cinco segundos não é uma corrida justa.
Com o consumidor não rodando mais de novo, ele ganha. Os dois dias ainda dentro dessa janela foram reconstruídos a partir do livro-razão assim que a correção entrou em produção. Dois dias anteriores já tinham saído dela, e o que resta neles é uma conta nossa mais uma diferença desprezível em outro lugar — então deixamos como está, em vez de alargar uma janela em produção para correr atrás disso. Pareceu melhor do que insinuar que a varredura foi total.
O que levamos disso
Uma chave de deduplicação precisa espelhar o que o índice realmente impõe, não o que o código acredita ter sido escrito. As duas metades desta eram alimentadas por campos diferentes, e nenhuma jamais foi medida contra a outra. Agora as duas são, e o teste que prova isso fica vermelho contra a versão que estava errada.