Alertas que disparam uma vez, e não uma vez por servidor
Um dos nossos alertas operacionais, o de “créditos baixos” para o app publicado de um criador, deveria chegar no máximo uma vez por dia. Numa tarde ele chegou cinco vezes. A causa: cada cópia da nossa API lembrava, na própria memória, quais alertas já tinha enviado; então dois servidores enviavam o alerta duas vezes, e cada deploy apagava essa memória e permitia que enviassem de novo. O mesmo padrão fazia um job em segundo plano executar seu trabalho em dobro. Agora os dois se coordenam por meio de um armazenamento compartilhado, e ambos falham do lado seguro se esse armazenamento estiver indisponível.
O sintoma
A Mythex envia alertas para a equipe quando algo precisa de atenção: um serviço caiu, um job travou, o saldo de créditos de um criador está baixo enquanto o app dele está publicado. Cada alerta tem uma janela de deduplicação, um período durante o qual o mesmo alerta é enviado só uma vez. Para o alerta de créditos baixos, essa janela é de 24 horas.
Cinco cópias do mesmo alerta em uma tarde significavam que a janela não estava funcionando. Pior: elas vinham em pares, com cerca de 11 minutos de intervalo.
Por que aconteceu
O pacote de alertas mantinha a deduplicação em memória: uma tabela de “chave do alerta → quando foi enviado pela última vez”, dentro do processo em execução. Isso funciona quando existe um único processo que nunca reinicia. A produção não tem nenhum dos dois:
- Duas réplicas da API. A produção roda duas cópias da API. Cada uma tinha sua própria tabela, então cada uma enviava o alerta uma vez. Daí os pares.
- Deploys. Cada deploy substitui as duas réplicas por processos novos com tabelas vazias. Depois de um deploy, o alerta saía de novo, como se nunca tivesse sido enviado.
Os cerca de 11 minutos entre os dois alertas de cada par vinham do job que dispara esse alerta. Ele roda nas duas réplicas e pega um lock, então as réplicas se revezam, e cada uma disparava o alerta na sua vez.
Havia um segundo bug, mais silencioso, no mesmo código. Para evitar que a tabela crescesse para sempre, ela se limpava ao chegar a 500 chaves, apagando toda entrada com mais de 15 minutos, independentemente da janela dela. Sob carga, uma deduplicação de 24 horas podia durar um quarto de hora.
A correção
Um registro compartilhado por alerta
Antes de a API enviar um alerta, ela agora reserva a chave de deduplicação do alerta no Redis, um armazenamento em memória compartilhado que as duas réplicas acessam e que não reinicia quando a API reinicia. A reserva é uma única operação de “defina esta chave somente se ela não existir, e expire-a após a janela”. A réplica que reservar primeiro envia o alerta; a outra encontra a chave ocupada e fica em silêncio. Um processo novo depois de um deploy encontra a chave ainda lá.
A chave e a janela vêm da mesma regra que o pacote de alertas usa, agora compartilhada entre as duas camadas, então elas não podem divergir sobre o que conta como “o mesmo alerta”.
Falhar aberto, não fechado
A decisão mais importante foi o que fazer quando o Redis não pode ser alcançado. Escolhemos enviar o alerta mesmo assim. Se o Redis estiver fora do ar, ou não responder em 1,5 segundo, o alerta é enviado. Um alerta duplicado é ruído. Um alerta perdido é alguém que não foi avisado.
Os alertas mais graves, P0, nunca são retidos pela deduplicação.
Cada entrada mantém a própria janela
Na deduplicação em memória do próprio pacote de alertas, cada entrada agora guarda quando a sua janela termina, e a limpeza remove apenas as entradas cuja janela já acabou. Uma chave de 24 horas permanece por 24 horas, não importa quantos outros alertas cheguem.
O mesmo bug, em outro lugar
O mesmo padrão também tinha duplicado um trabalho em segundo plano: um job de reconciliação que mantém nosso registro do status de cada workspace alinhado com o do nosso provedor de sandbox. Cada rodada lista todos os sandboxes da conta, milhares na produção, cerca de cinco segundos, além de todos os projetos em execução ou pausados. As duas réplicas executavam isso a cada dois minutos.
As escritas dele são idempotentes, ou seja, executá-las duas vezes dá o mesmo resultado que executá-las uma vez, então nada quebrava. Era apenas o dobro de trabalho. Agora cada janela de dois minutos é reservada no Redis do mesmo jeito que nossos medidores de uso já reservavam as deles, e só a réplica que reserva a janela executa a rodada. Se o Redis não puder ser alcançado, a rodada executa mesmo assim: fazer o trabalho duas vezes é inofensivo, deixar de fazê-lo não é.
Como testamos
Os testes reproduzem a configuração real. Carregam o módulo de alertas duas vezes, como duas “réplicas” separadas, apontam ambas para um único armazenamento compartilhado, disparam o mesmo alerta em cada uma e depois carregam uma terceira cópia nova para representar “depois de um deploy” e disparam de novo. Exatamente um alerta é enviado. Outros testes verificam que o alerta de outro criador ainda é enviado, que um P0 é enviado a partir das duas réplicas e que o alerta ainda é enviado quando o armazenamento está fora do ar ou ultrapassa o tempo limite. A correção da limpeza tem teste próprio: uma chave de 24 horas sobrevive a 600 outros alertas que chegam 20 minutos depois.
Para o job de reconciliação, duas réplicas disparando ao mesmo tempo, mais uma terceira rodada na mesma janela, produzem uma única listagem.
O que tiramos disso
- Estado em memória pertence a cada processo. Qualquer coisa que precise ser verdade entre servidores, “enviado uma vez” ou “executado uma vez”, precisa estar em um lugar que eles compartilhem.
- Deploys são reinicializações. Estado que sobrevive apenas até a próxima reinicialização não sobrevive a um dia de trabalho normal.
- Decida para qual lado falhar. No caso de alertas, um armazenamento compartilhado fora do ar deve significar mais alertas, não menos.
- Procure o padrão, não só a instância. O alerta foi o caso barulhento; o job de reconciliação duplicado era o mesmo bug, custando trabalho em silêncio.
Nada disso aparece de dentro da Mythex. Para saber mais sobre como mantemos a plataforma estável, veja como zeramos os erros 502 nos deploys e o arquivo de swap que faltava. Se a ideia de rodar mais de uma cópia de um serviço é nova para você, nosso guia sobre o que é um servidor cobre o básico.