O Teto Que Você Não Pode Levantar

Em dezembro escrevi um post sobre construir este serviço de notificações: eventos entrando por gRPC, deduplicados, publicados num tópico SNS por partida, distribuídos aos dispositivos inscritos naquele tópico. Ainda acho que o raciocínio dele estava correto. Em 29 de agosto ele parou de entregar notificações push por cerca de 48 horas, e nenhum ajuste de configuração teria evitado isso, porque o que ele encontrou foi um número que a AWS não muda.

O serviço não estava fora do ar. Eventos chegaram, foram deduplicados e foram publicados nos tópicos certos durante todo o incidente. Toda linha de log sobre publicação dizia o que sempre dizia — um serviço saudável, entregando nada em tópicos que não tinham inscrito nenhum dispositivo.

Duas cotas, quatro ordens de magnitude de distância

Criamos um tópico SNS por (match_id, kind, language) e inscrevemos os endpoints dos dispositivos nele. Uma partida começando significa tópicos criados e milhares de inscrições; uma partida terminando significa o inverso.

Isso coloca duas APIs do SNS no caminho crítico, e elas não são comparáveis:

API Cota da conta Para que usávamos
Publish 30.000 / seg as notificações de fato
Subscribe 100 / seg, não ajustável todo o crescimento e toda a rotatividade

Publicar é o que parece ser a carga. Roda a cerca de 1,2 eventos por segundo no pico, quatro centésimos de um por cento da cota. Inscrever é o que ninguém pensa como carga, porque parece configuração inicial. É onde toda a pressão de escala realmente foi, e o teto dela é 300 vezes menor.

A palavra que importa é não ajustável. O TPS de Subscribe não é um limite flexível para o qual você abre um ticket. Eu verifiquei, porque o primeiro instinto num incidente assim é pedir mais espaço. Não existe mais espaço.

A espiral

Em 29 de agosto uma importação em massa upstream criou cerca de 32.000 tópicos de partida em nove horas. Cada um deles queria por volta de 2.400 inscrições de dispositivo.

O backfill do scheduler foi para cima disso com 50 goroutines paralelas e nenhum rate limiting, o que produziu cerca de 123 chamadas de Subscribe por segundo contra um teto de 100. Então praticamente toda chamada voltava com Throttling: Rate exceeded.

Aqui está a parte que transforma uma tarde ruim num incidente de dois dias. Uma chamada throttled não escreve nenhuma linha em r10_subscription. A execução seguinte consulta as inscrições que deveriam existir e não existem, encontra o mesmo par (tópico, dispositivo) ainda faltando, e tenta de novo. A falha alimenta a fila de trabalho que a causou.

É uma espiral sem nenhum termo de amortecimento. Ela não se recupera quando o pico passa, porque o pico já não é o que está dirigindo o processo. No momento em que comecei a ler os logs, tópicos de partidas ao vivo tinham zero inscritos enquanto eventos eram publicados neles com sucesso, e a entrega para dispositivos reais havia caído de cerca de 66.000 por hora para cerca de 400 por hora no pico das partidas.

A AWS acabou aplicando um limite protetivo à conta, o que levou a taxa aceita de Subscribe para algo entre 7 e 9 por segundo. A posição deles, razoável, era que o padrão estava causando contenção de recursos além do nosso próprio serviço. Então o teto efetivo durante a recuperação não era 100. Era de um dígito, e foi imposto em vez de escolhido.

Por que ninguém percebeu por dois dias

Esta é a parte mais difícil de escrever, e a mais útil.

A task de backfill terminava cada execução registrando o resultado. Durante todo o incidente ela registrou isto:

{"msg":"task_completed","status":"success"}

Enquanto cerca de 50.000 inscrições falhavam por janela de 20 minutos.

Falhas por dispositivo nunca incrementavam TaskResult.Errors e nunca limpavam TaskResult.Success. O contador tratava a execução como uma unidade: a execução terminou, portanto a execução teve sucesso. Nada nessa frase é falso, e ela é completamente inútil.

Não havia alarme para disparar, porque não havia nada em que alarmar. Um dashboard construído a partir dessas linhas de log mostrou um serviço saudável por 48 horas. O incidente foi descoberto por uma pessoa notando que as notificações tinham parado de chegar no próprio celular.

Falha parcial é o caso normal de qualquer coisa que faz fan-out — um contador que reduz duas mil requisições independentes a um booleano vai eventualmente reportar sucesso enquanto dispositivos falham em silêncio. Eu mesmo escrevi aquele contador e ele pareceu bom na revisão, porque na época em que escrevi o fan-out nunca falhava parcialmente.

Três defeitos, um sintoma

Lendo o caminho da task depois, o incidente precisou de três erros separados, todos no mesmo arquivo, e cada um deles era individualmente defensável quando foi escrito.

O primeiro foi o rate limit ausente. Existe um comentário em internal/tasks/subscriptions.go afirmando que 50 workers concorrentes deixam folga sob a cota de 100 TPS. Isso confunde concorrência com throughput, e está errado por mais de uma ordem de magnitude. Merece o próprio post, que é o próximo.

O segundo foi o orçamento. Cada execução tinha um teto MaxSubscriptions, que é exatamente o instinto certo. Ele contava sucessos. Quando toda chamada falha, o orçamento nunca é consumido, então a execução continua: até 500 tópicos vezes 1.000 dispositivos de tentativas. Um limite que só conta o trabalho que deu certo deixa de ser um limite exatamente na condição para a qual você o escreveu.

O terceiro foi a ordenação. Tópicos pendentes voltavam com ORDER BY match_start_time ASC, que serve a partida mais antiga primeiro. Sob backlog isso significa que o orçamento de tentativas vai para partidas que terminaram horas atrás antes de partidas começando agora. Os usuários assistindo a um jogo naquele instante eram os últimos da fila.

Nenhum desses é exótico. Cada um é o tipo de coisa que passa na revisão, porque revisar exige segurar um número de cota e uma estimativa de latência na cabeça ao mesmo tempo.

As proporções

Depois que os incêndios imediatos foram apagados fui olhar o tamanho do que estávamos operando, e este é o número que decidiu o que aconteceu em seguida:

Medida Valor
Dispositivos habilitados 4.976
Tópicos SNS em produção 25.297
Dispositivos num tópico popular até ~2.400
Taxa de eventos no pico ~1,2 / seg

Estávamos mantendo 25.297 peças de estado de roteamento do lado da AWS, cerca de cinco por dispositivo, para 4.976 dispositivos e aproximadamente um evento por segundo.

E os dados de inscrição já estavam no nosso próprio Postgres. A tabela r10_subscription é escrita pelo mesmo código que chama Subscribe, é consultada constantemente, e é a fonte que o backfill lê para decidir o que está faltando. A inscrição no SNS era uma segunda cópia de dados que já possuíamos, mantida num sistema onde escrever uma linha custava uma chamada de API com rate limit.

Esse enquadramento é o que tornou a correção óbvia em retrospecto e invisível de antemão. O rate limiting, a contabilidade honesta e a ordenação por urgência todos tinham que ser entregues, e foram, em sete releases em cerca de um dia. Mas todos eles são formas de viver dentro de um teto. Nenhum questiona por que o teto está no caminho crítico.

O que eu levei disso

A carga que te derruba não é sempre a carga que você mede. Observávamos throughput de publicação porque publicar é o que o serviço faz. A API que caiu foi a que tratávamos como configuração.

Uma cota sem caminho de ticket é uma restrição de arquitetura, não de operação. Se o único movimento disponível é sentar mais abaixo dela, então o design tem uma capacidade rígida que escala com rotatividade e não com tráfego. O dobro de dispositivos é o dobro de carga de inscrição, sem folga para comprar.

Uma falha que recria a própria entrada não vai se recuperar sozinha. Vale verificar, para qualquer coisa que faz retry: uma tentativa falha deixa o sistema num estado em que a passagem seguinte vê o mesmo trabalho? Se sim, o retry não é um retry. É um laço.

E uma flag de sucesso no nível da execução, num fan-out, é uma mentira esperando as condições certas. O próximo post desta série é sobre o quanto o ritmo estava errado. O seguinte é sobre decidir remover o teto em vez de respeitá-lo.


Parte de Removendo um Gargalo, sobre o incidente de throttling no SNS Subscribe e a migração para entrega direta via FCM.