Os testes estavam verdes, mas nenhuma notificação tinha sido enviada
O código que enfileirava os jobs falhava sempre, desde o primeiro dia. O erro era engolido, e os testes seguiam verdes graças aos mocks. A história de como um único caractere de dois-pontos matou três funcionalidades em silêncio.
Resumo
O BullMQ proíbe dois-pontos em jobIds customizados. Um enqueue que violava essa regra falhava 100% das vezes desde o primeiro dia, mas a falha era engolida por um catch, então ninguém sabia que a funcionalidade de notificação estava morta. Os testes seguiam verdes porque substituíam a fila por um mock puro que não executava a validação real. O mesmo defeito existia em mais duas filas, e uma delas ficou 12 dias sem nenhum sintoma. No caminho apareceu ainda uma segunda armadilha - um jobId fixo ignora o reenfileiramento sem erro algum quando o hash de um job concluído ainda está no Redis, matando em silêncio o contrato de retry.
Nesta página
Um alerta de 500 apareceu no Slack. A mensagem de erro era estranha: Error: Custom Id cannot contain :. A API que os administradores usam para registrar respostas às perguntas dos usuários estava morrendo, e o stack trace apontava para o Job.validateOptions do BullMQ.
Até aqui, um bug report comum. O ponto em que essa história deixa de ser comum vem depois de encontrar a causa. O código que lançava esse erro não tinha sido alterado no deploy daquele dia. Ele falhava toda vez, 100% das vezes, desde o dia em que a funcionalidade entrou no ar. Ou seja: as notificações push que essa fila deveria enviar nunca tinham sido enviadas, nem uma única vez. E durante todo esse tempo, os testes estavam todos verdes.
A causa: um único caractere
O código do problema era assim. É o ponto que enfileira o job de enviar um push ao usuário quando a pergunta dele recebe uma resposta.
await this.queue.add(ANSWER_NOTIFY_JOB_NAME, data, {
jobId: `answer-notify:${data.questionId}`, // ← este dois-pontos
});
A ideia era dar um jobId customizado para que o mesmo job não entrasse duas vezes para a mesma pergunta, mas o separador escolhido foi o dois-pontos. O BullMQ monta as chaves do Redis com dois-pontos, então ele proíbe dois-pontos em jobIds customizados. Na v5, a condição exata é esta: se o jobId contém dois-pontos e a divisão por dois-pontos não resulta em 3 segmentos, ele lança o erro. A exceção dos 3 segmentos é um carve-out para o formato legado de jobs repeatable, então um jobId de 2 segmentos como answer-notify:123 sempre cai em Custom Id cannot contain :. Aliás, um jobId só de números também é rejeitado com Custom Id cannot be integers, então apagar o prefixo também não é solução.
As dezenas de outras filas do codebase usavam todas hífen. Só esse arquivo usava dois-pontos.
O bug existia desde o primeiro dia. Então por que estourou hoje?
O conserto é uma linha: trocar o dois-pontos por hífen. A pergunta interessante é outra. Por que um código que sempre falhou ficou quieto esse tempo todo e virou um 500 justamente hoje?
Quando a funcionalidade foi implantada pela primeira vez, o enqueue estava embrulhado assim.
try {
await this.queue.add(/* ... */);
return true;
} catch (err) {
this.logger.error("enqueue failed", err);
return false; // ← a falha some aqui
}
Registra a falha no log e retorna false. Quem chama não confere o valor de retorno. Então a exceção que era lançada toda vez era engolida toda vez, a API retornava 200, e a notificação simplesmente evaporava em silêncio.
Até que um commit mesclado naquele mesmo dia mudou esse contrato. A intenção era: se o enqueue falhar, não engolir mais e sim lançar, para desfazer a marca de "respondida" gravada na transação e permitir a nova tentativa. Uma mudança na direção certa. Nesse instante, o enqueue que sempre falhava fez barulho pela primeira vez. O 500 não era um bug criado por aquele commit - era o primeiro grito de um bug que já estava lá.
Esse é o custo do código que engole erros. O sinal desaparece, e o bug fica lá, levando uma funcionalidade inteira junto.
Por que os testes estavam verdes
Essa fila tinha testes. Um spec no formato mais comum: chama o enqueue e verifica se queue.add foi invocado com os argumentos corretos. E o queue.add desse teste era um mock que sempre dava certo.
O add de verdade do BullMQ valida o jobId e rejeita dois-pontos. O add do mock aceita qualquer coisa. Na exata medida em que o mock é mais permissivo que o contrato real, o teste aprovava em verde entradas que jamais passariam na vida real. Quando o mock diverge do contrato real, um teste verde não atesta código vivo - atesta código morto.
Ao corrigir, tapei esse buraco primeiro. Fiz o add do mock da fila executar o Job.validateOptions real do BullMQ. É uma validação pura que roda sem Redis, então dá para plugar direto nos testes. E confirmei com um teste de mutação que a guarda era guarda de verdade: reintroduzi o dois-pontos, vi o teste ficar vermelho exatamente naquele ponto, e só então fiz o commit. Se você reverte o bug e o teste continua verde, esse teste é como se não existisse.
Não era um caso só
Parar aqui seria consertar só a metade. Se o mesmo erro está em um lugar, é bem provável que esteja em outros. Definimos a "classe" do defeito - jobIds customizados contendo dois-pontos - varremos o codebase inteiro e colocamos vários agentes de revisão para verificação cruzada. Apareceram mais dois casos.
Um era a fila que transcreve para texto os áudios enviados pelos usuários. jobId: "stt:${id}". Ali o erro ainda estava sendo engolido, então nem 500 aparecia. Fui conferir: fazia 12 dias que nenhum job de transcrição entrava na fila. Os arquivos de áudio eram salvos normalmente, a falha ficava só no log, e na tela do usuário o texto permanecia vazio para sempre. Completamente assintomático.
O outro era a fila de e-mails informativos de eventos agendados. O jobId tinha 4 segmentos separados por dois-pontos, então nem os lembretes nem os avisos de cancelamento entravam na fila.
Unifiquei os producers das três filas em um único builder compartilhado de jobId: uma função que junta com hífens, substitui qualquer dois-pontos que apareça e rejeita valores puramente numéricos. Como prevenção de recorrência, adicionei também uma regra de lint que captura exatamente o padrão de dois-pontos literal dentro de um jobId. Não migrei à força para o novo builder os cerca de 50 jobIds com hífen já existentes: se o jobId de um job repeatable muda, há risco de o cron ser registrado em duplicidade, então escolhi mirar só na classe do defeito.
A segunda armadilha: jobId fixo mata o retry em silêncio
Seria ótimo se a história terminasse aqui, mas durante a verificação da revisão surgiu um problema mais fundamental. Corrigido o dois-pontos, um jobId fixo como answer-notify-123 volta à vida, e esse jobId fixo em si contradizia o contrato de retry firmado no mesmo dia.
Se o hash de um job com o mesmo jobId ainda está no Redis, o BullMQ ignora o novo add sem erro nenhum e retorna o jobId existente. É um comportamento que o verificador confirmou lendo até os scripts Lua do BullMQ. Só que essa fila usa removeOnComplete: { count: 100 }, então o hash de um job recém-concluído também continua lá. Combinando os dois, o cenário fica assim. O job de notificação falha uma vez. Conforme o contrato, a marca de "respondida" é desfeita e o administrador tenta de novo. O novo enqueue esbarra no hash do job que ficou para trás, não faz nada e retorna como se tivesse dado certo. A notificação nunca sai, e desta vez não há erro nem DLQ.
Tiramos o catch que engolia as falhas, e o dedupe da biblioteca assume o mesmo posto. No fim, removi por completo o jobId customizado dessa fila. A prevenção de duplicidade foi para o lado do consumidor: o processor toma para si, de forma atômica, a marca de "notificação enviada" com um update condicional. Mesmo que o job entre duas vezes, só o lado que pegar a marca primeiro dispara o push.
Se no seu design um jobId customizado previne duplicidade e, ao mesmo tempo, existe reenfileiramento em caso de falha, vale conferir se os dois mecanismos não estão se matando mutuamente. Pode ser a mesma combinação que a nossa, aquela em que o retry vira um no-op silencioso.
Qual foi o prejuízo
Uma funcionalidade morta por 12 dias parece prejuízo enorme, mas a medição real disse outra coisa. As notificações de resposta: a funcionalidade ainda não estava em uso real em produção, então a perda foi zero. A fila de e-mails também só tinha eventos já passados, então não havia nada para reenviar. O alvo real de recuperação: 2 áudios não transcritos. Retranscrevi a partir dos arquivos de áudio que ficaram salvos e recuperei.
Tivemos sorte. E essa sorte não se repete. Se fosse uma funcionalidade em uso real, 12 dias de notificações e transcrições teriam se perdido de vez, e como os jobs nunca chegaram a ser criados na fila, o deploy por si só não recuperaria nada. Escrever um script de backfill era secundário. O essencial, julguei, era consertar o fato de que ninguém sabia que uma funcionalidade morta estava morta havia 12 dias. Então as falhas de transcrição agora não ficam mais só no log e sobem para um canal de alerta, e o caminho das notificações de resposta migrou para um outbox commitado junto com a transação, eliminando pela raiz o estado "no banco consta sucesso, mas o job não existe".
O que fica
Três coisas deste episódio que vão durar mais que o código.
Primeiro: um catch que só loga e retorna normalmente faz com que ninguém perceba nem mesmo um caminho que falha 100% das vezes. Se for engolir, que haja pelo menos uma métrica contando as falhas ou um alerta junto; se não houver, é melhor lançar. Erro lançado faz barulho, mas é consertado; erro engolido vive 12 dias.
Segundo: o mock precisa ser tão rigoroso quanto o contrato real. Em cima de um mock que sempre dá certo, nenhuma entrada é validada. Se a biblioteca tem uma função de validação pura, plugue-a no mock, e confirme que o teste fica vermelho ao reverter o bug - só então é uma guarda.
Terceiro: defeito se caça por classe. No mesmo codebase, o mesmo erro não acontece uma vez só. Achou um, defina a classe, varra tudo, e vá até o ponto de impedir que essa classe volte a entrar - seja com regra de lint, seja com builder compartilhado. Só assim uma descoberta paga o que vale.
Perguntas frequentes
Estou recebendo o erro 'Custom Id cannot contain :' no BullMQ. Por quê?
O BullMQ v5 proíbe dois-pontos (:) em jobIds customizados, porque o dois-pontos é o separador usado para montar as chaves do Redis. A condição exata é 'o jobId contém dois-pontos e a divisão por dois-pontos não resulta em exatamente 3 segmentos'; a exceção dos 3 segmentos existe para o formato legado de jobs repeatable. Um jobId só de números também é rejeitado com 'Custom Id cannot be integers'. Use hífen como separador.
Por que um teste com mock não pega um bug desses?
Se o mock se comporta de forma diferente do contrato da implementação real, o teste certifica como verde um código que na prática está morto. Ao substituir o add da fila por um mock que sempre dá certo, a validação de entrada da biblioteca nunca roda, então até um jobId que seria sempre rejeitado passa nos testes. Para virar uma guarda de verdade, o mock precisa ao menos executar a função de validação real da biblioteca, e é preciso confirmar que o teste fica vermelho quando o bug é reintroduzido (teste de mutação).
Artigos relacionados
- 💻 Dev
Os hooks de shutdown rodavam duas vezes - quando o SIGTERM tem dois donos
A cada deploy, um erro P2028 do Prisma caía no Sentry. Fui corrigir a ordem de shutdown e descobri que o enableShutdownHooks do NestJS e a biblioteca de graceful shutdown estavam, os dois, capturando o SIGTERM - fazendo os hooks de shutdown rodarem duas vezes a cada desligamento.
- 💻 Dev
Chegou um alerta dizendo que o banco tinha caído. O banco nunca caiu
Em um único dia acumularam-se quatro alertas CRITICAL. 504, P2028 do Prisma, 500 no admin e 'serviço DATABASE fora do ar'. Abri primeiro as métricas do RDS: durante 21 horas seguidas, a CPU não passou de 19,7%. O lento não era o banco, eram as idas e voltas atravessando o Atlântico vinte e cinco vezes - e o alerta de 'fora do ar' foi o próprio health check que o fabricou.
- 💻 Dev
Rastreei os 9 que interceptavam o TLS do nosso app - era o Google
Ligamos o certificate pinning em modo report-only e o Sentry acumulou 74 eventos de SPKI mismatch. Parecia ataque MITM, e a IA descartou de forma categórica a hipótese da revisão da loja, alegando que 'o Google não intercepta HTTPS'. Os IPs reais, tirados dos logs da API em produção, derrubaram essa suposição.