O bot começou a disparar a mesma resposta duas ou três vezes sempre que um usuário clicava repetidamente no botão de enviar. A duplicação só aparecia para pessoas que digitavam rápido o suficiente para enviar várias mensagens antes que a IA começasse a processar, e permaneceu oculta em produção por muito tempo porque o padrão era raro. Um bloqueio de banco de dados prematuro – liberado poucos milissegundos após ser adquirido – deixou a conversa desprotegida, permitindo que múltiplos processos respondessem ao mesmo prompt.

Por que o bloqueio falhou

O código adquiria um bloqueio em uma única chamada de banco de dados e, em seguida, devolvia imediatamente o controle ao manipulador de requisições. O tempo de vida do bloqueio era medido em milissegundos, muito menor do que o tempo que o modelo de IA precisa para gerar uma resposta. No momento em que o modelo começava o trabalho, o bloqueio já havia desaparecido, então nada impedia que uma segunda requisição capturasse o mesmo registro de conversa e emitisse outra resposta.

Dois sintomas surgiram:

  • Respostas idênticas eram enviadas consecutivamente.
  • Respostas levemente reformuladas apareciam para a mesma pergunta, já que cada processo construía seu próprio prompt a partir da mesma entrada do usuário.

Como a maioria dos usuários faz uma pausa entre as mensagens, o bug passou despercebido. Apenas digitadores rápidos disparavam a condição de corrida, e os casos eram raros.

A solução paliativa que não foi suficiente

A primeira resposta foi adicionar um pequeno atraso após o recebimento de uma mensagem, na esperança de fazer o "debounce" de entradas rápidas. Isso ajudou quando duas mensagens chegavam em sucessão rápida, mas a solução falhou se uma terceira mensagem aparecesse enquanto a IA ainda estava gerando o texto.

Um segundo problema surgiu quando os timers e os dados da conversa residiam no mesmo bucket de armazenamento. Quando o bot terminava de processar uma requisição, ele sobrescrevia o registro do timer, efetivamente deletando seu próprio contador. O sistema perdia o controle de quais mensagens já haviam sido respondidas, abrindo as portas para novas duplicações.

Construindo uma proteção confiável: contadores de versão, timers isolados e um lease

A equipe redesenhou o fluxo em torno de três pilares:

  • Contador de versão – cada mensagem recebida incrementa um contador armazenado com a conversa. O contador informa ao sistema quantas mensagens chegaram desde a última resposta, facilitando a detecção de novas entradas enquanto uma resposta está sendo gerada.
  • Janela de debounce dedicada – os timers agora residem em uma área de armazenamento separada, isolada dos payloads da conversa. Um limite rígido na duração do debounce impede que um usuário trave o bot indefinidamente.
  • Lease de sessão – o bloqueio original foi substituído por um lease que carrega um timestamp de expiração explícito. O lease é reivindicado usando uma operação compare-and-swap (CAS): o processo lê o valor atual do lease, escreve um novo apenas se o valor antigo coincidir e, assim, obtém direitos exclusivos sobre a conversa. Se o processo falhar, o lease expira automaticamente, liberando a conversa para o próximo manipulador.

Como o novo pipeline funciona

  1. Chegada da mensagem – o sistema incrementa o contador de versão e (re)define o timer de debounce. Ele retorna ao cliente imediatamente, sem iniciar a IA.
  2. Expiração do timer – o manipulador do timer tenta adquirir o lease. Se o CAS for bem-sucedido, o manipulador prossegue; caso contrário, ele recua, sabendo que outro processo já possui a conversa.
  3. Verificação de nova entrada – o manipulador compara o contador de versão atual com o valor registrado quando o timer começou. Se o contador tiver avançado, ele agrega as mensagens pendentes em um único prompt.
  4. Gerar uma resposta – o modelo de IA é executado uma única vez, produzindo uma única resposta que cobre todas as entradas recentes do usuário.
  5. Verificação final de integridade – pouco antes da resposta ser enviada, o manipulador lê o contador de versão novamente. Se uma mensagem mais recente chegou durante a geração, a resposta é descartada e o processo reinicia o timer, garantindo que nenhuma resposta obsoleta chegue ao usuário.

Essa abordagem elimina respostas duplicadas, limita o tempo que uma conversa pode ficar travada e se recupera automaticamente de falhas de processo, pois o lease expira por conta própria.

Lição aprendida

Um bloqueio que desaparece antes do início da seção crítica não oferece proteção alguma. Ao substituir um bloqueio de banco de dados efêmero por um lease explícito e com expiração, e ao isolar os timers dos dados da conversa, o bot agora garante uma única resposta atualizada, mesmo quando os usuários digitam em velocidade recorde. O episódio reforça uma lição atemporal: as salvaguardas de concorrência devem durar mais do que o trabalho que elas protegem, ou se tornarão barreiras invisíveis que permitem a passagem de bugs.