A Metade do Cutover Que Ninguém Agenda
Vinte e um tipos de notificação estavam sendo entregues direto ao Firebase, com o SNS completamente fora do caminho. Os números de entrega estavam bons. E o Subscribe do SNS continuava sendo throttled a cerca de 44 recusas por minuto, que é o número que toda a migração existia para remover.
O cutover havia parado de publicar. Nunca havia tocado no provisionamento. Por cerca de 24 horas o serviço construiu fielmente o parque completo de tópicos SNS para notificações que ele não tinha intenção de publicar ali.
Dois pontos, ambos invisíveis do código de entrega
O caminho de publicação checa um predicado, deliversDirectly, e desvia. O provisionamento acontecia em dois lugares que nunca o consultavam.
EventProcessor.prepareEvent resolvia um ARN de tópico antes de o desvio acontecer em deliverEvent. Então o ato de preparar um evento entregue diretamente criava o tópico SNS no qual ele então não publicaria.
E GenerateMatchTopics rodava a cada cinco minutos, pré-criando um tópico para cada tipo de notificação vezes cada idioma, para cada partida de duas horas antes do apito até três horas depois. Depois GeneratePendingSubscriptions vinha e inscrevia dispositivos em tudo aquilo, contra o teto de 20 chamadas por segundo que a AWS havia se recusado a levantar.
Nenhum dos dois é bug do cutover. Os dois são o design original funcionando exatamente como escrito. O cutover moveu a entrega e deixou rodando a plena carga a maquinaria que existe para tornar a entrega possível, o que em retrospecto é o modo de falha óbvio de uma migração em estágios e não foi nada óbvio para mim enquanto eu a fazia.
A supressão é derivada, nunca configurada
A implementação tentadora é uma variável de ambiente nova: uma lista de tipos que não devem ser provisionados. Dois dias de trabalho viram vinte minutos.
Não fiz isso, e essa é a decisão de design da mudança que eu defenderia com mais força. A supressão é derivada do mesmo predicado deliversDirectly que governa a entrega. Um parse de uma variável alimenta quatro consumidores: o processador de eventos, o scheduler, o registro de dispositivos e a inscrição em partidas.
Um interruptor separado é algo que pode ser configurado errado. Especificamente, pode ser configurado em produção, que naquele momento não tinha nenhum tipo configurado para entrega direta e nunca havia enviado uma notificação direta. Um operador de produção habilitando "não provisione GOAL" sem habilitar "entregue GOAL diretamente" produz um serviço que não provisiona nada e não entrega nada, e o sintoma é silêncio, que é a assinatura de falha com que este projeto inteiro começou.
Derivar significa que produção mantém o provisionamento inalterado sem nenhuma configuração, e os dois fatos não podem discordar.
Existe uma segunda proteção embaixo dessa. O conjunto parseado é nil até que um deliverer de FCM tenha sido construído com sucesso. Então uma credencial ruim deixa tudo no SNS em vez de deixar um tipo suprimido sem nada capaz de entregá-lo.
O risco era o deduplicador
Apagar a criação de tópicos parece a parte perigosa. Não era. A parte perigosa era uma chave de cache.
A deduplicação tinha escopo por ARN de tópico: dedup:<uniqueKey>:<topicARN>. É uma chave sensata enquanto todo evento resolve para um tópico. Ela deixa de estar disponível no momento em que eventos param de resolver para tópicos.
O novo escopo é o nome canônico do tópico, {stage}_{matchID}_{kind}_{language}, que é a identidade que o ARN carregava menos o prefixo arn:aws:sns:{region}:{account}:. Mesmo poder de discriminação, sem depender de um recurso existir.
As duas formas de errar isso são invisíveis em produção, que é o que fez disso a coisa a ser cuidadosa. Muito solto, e duas partidas do mesmo tipo colapsam numa chave: um gol real é classificado como duplicata e nunca enviado, e a linha de log diz duplicata em vez de erro. Instável, e todo evento dentro da janela de seis horas de deduplicação é reentregue, então usuários recebem tudo duas vezes e nada parece quebrado do nosso lado.
A chave agora é calculada a partir da configuração e não de o tópico por acaso ainda existir. Essa distinção importa exatamente durante a transição que esta mudança executa: uma linha de tópico deixada em cache moveria a chave no meio do voo, que é uma tempestade de duplicatas sem nenhuma mudança de código para culpar.
A alternativa que rejeitei foi consultar um tópico existente sem criá-lo. Ela mantém o acoplamento que a mudança existe para remover, e adia a mesma mudança de chave para o estágio seguinte. O custo de fazer certo agora é uma janela estreita: no instante em que um tipo faz cutover, um evento cuja chave única já foi entregue dentro da janela de seis horas não é reconhecido como duplicata. Isso exige um reenvio duplicado upstream nos minutos ao redor do deploy, e só o ambiente de staging tinha tipos em cutover quando entrou.
O resultado foi um penhasco, não um declínio
Eu havia escrito no ADR que o throttling declinaria devagar conforme o parque existente envelhecesse. Estava errado, da melhor forma disponível.
| janela | failed to subscribe to SNS |
failed to create subscription |
taxa |
|---|---|---|---|
| 15:50-17:20, antes | 3.816 | 177 | ~44/min |
| 17:20-19:09, depois | 7 | 1 | ~0,07/min |
Uma redução de cerca de 500 vezes, e aconteceu às 17:19:42 UTC, que é o minuto em que a task do scheduler carregando a flag efetivamente subiu. Não o horário do merge, que é meia hora antes e enganoso.
Duas coisas que isso corrige. O throttling era dirigido quase inteiramente por provisionamento novo e não pelo milhão de inscrições já de pé. Parar a criação foi suficiente, e as remoções que eu havia agendado como estágio seguinte são arrumação e não a correção.
E a minha medição vinha errada por uma ordem de magnitude esse tempo todo. Eu contava linhas de log contendo o texto literal Rate exceeded, que teve pico de 278 num bucket de 30 minutos. A maior parte do dano estava aparecendo como context deadline exceeded na chamada de Subscribe, porque uma chamada throttled que o SDK retenta três vezes esgota o contexto antes de esgotar as tentativas. Contar failed to subscribe to SNS dá o número real. Os sete residuais são os dois tipos que ainda provisionam por design.
O parque drena sem ferramenta nova
Com nada os substituindo, os tópicos existentes começaram a desaparecer por conta própria. PruneAll roda de hora em hora e já apaga tópicos de partida com mais de 24 horas, e as inscrições caíram de 1.032.981 para 1.016.095 numa única hora.
A razão pela qual isso é viável é um detalhe em deleteTopic que eu não havia apreciado: ele remove as linhas de inscrição do banco, depois o tópico no SNS, depois a própria linha, e nunca chama Unsubscribe. Apagar um tópico remove as inscrições dele implicitamente. Então drenar um milhão de inscrições são cerca de 23.000 remoções de tópico em vez de um milhão de chamadas de API com rate limit, o que levaria duas semanas na taxa que nos era permitida.
O que não vai drenar sozinho são os tópicos de time do coração. Podá-los exige tanto que não tenham sido atualizados em 30 dias quanto que não tenham inscrições restantes, e um time com torcedores mantém as inscrições para sempre. Como a única coisa que publicava neles foi movida para uma query com escopo de time que lê a tabela de usuários direto, aqueles agora são peso morto permanente: um por time, então arrumação e não pressão.
Uma razão para não ter pressa com a remoção
Cada remoção torna reverter mais caro, e eu não teria pensado nisso nessa ordem.
Enquanto o parque está de pé, reverter a migração inteira é um flip de flag. Uma vez drenado, reverter significa reconstruir 1.016.095 inscrições pelo backfill a um teto de 20 por segundo, que são cerca de 14 horas. Uma flag se torna um dia de trabalho.
O que sugere deixar o parque de pé pelo tempo em que custar nada mantê-lo, e não custa nada: ele não gera mais chamadas de Subscribe, apenas ocupa estado do lado da AWS que não lemos mais.
A parte desconfortável é que o número de produção é desconhecido. O diagnóstico que reporta a contagem de inscrições consultava uma coluna que não existe, então nunca emitiu um valor e ninguém havia notado a ausência. Vale ter esse número antes de a mesma mudança chegar em produção.
Dois desperdícios pré-existentes que ela expôs
Nenhum foi introduzido pelo cutover e os dois rodavam há muito tempo.
Os tópicos de time do coração descritos acima, cujo único publicador já havia se mudado.
E três tipos de resultado de votação sem nenhum ponto de publicação em lugar nenhum do código. Todo tópico e toda inscrição de dispositivo criados para eles foram desperdício puro, desde o dia em que foram adicionados. Eles eram provisionados a cada cinco minutos, inscritos contra uma API com rate limit, e podados de hora em hora, para eventos que nenhuma linha de código emite.
Parar o provisionamento é o que os trouxe à superfície, porque o filtro precisou enumerar quais tipos o SNS ainda carrega, e enumerar forçou alguém a perguntar o que publica cada um.
A armadilha no filtro óbvio
O lugar óbvio para filtrar é models.MatchTopicKinds(), a função que lista quais tipos ganham tópicos de partida. Remova os tipos entregues diretamente ali e todo ponto de provisionamento herda a mudança de graça.
Aquela função é também o que internal/rest/notification.go usa para montar o conjunto de preferências de notificação que a API REST aceita. Remover um tipo dela começaria a rejeitar clientes mobile escrevendo as próprias preferências, para tipos que aqueles clientes corretamente ainda podem definir. Uma mudança com escopo de provisionamento na AWS teria quebrado a tela de configurações do app.
Todo filtro é aplicado nos pontos de chamada de provisionamento em vez disso, o que é mais código e menos elegante, e correto.
A segunda rota para o SNS
Mais uma coisa que esta mudança encontrou, latente há meses.
deliversDirectly tinha exatamente um ponto de chamada, dentro de deliverEvent. Mas processWorkerBatch alcançava o SNS por uma segunda rota que nunca o consultava: qualquer grupo de dois ou mais eventos compartilhando um ARN de tópico ia para a API de publicação em lote do SNS. Então um tipo em cutover podia silenciosamente voltar ao SNS puramente porque dois dos seus eventos chegaram perto o suficiente para serem agrupados.
Nunca disparou uma única vez. Zero publicações em lote em staging em 12 horas contra 1.174 entregas diretas, e zero em produção em sete dias e 1.892.754 registros de log, porque a drenagem do worker é não bloqueante e os 0,04 a 0,07 eventos por segundo medidos nunca enfileiram nada. Ela dispara nos 5.000 eventos por segundo para os quais o sistema foi desenhado.
Eventos preparados agora são particionados pelo desvio antes de serem agrupados por ARN, então o agrupamento é estruturalmente incapaz de carregar um tipo em cutover. Prefiro isso a adicionar a verificação no segundo ponto, porque um predicado com dois pontos de chamada eventualmente terá três.
Dois tipos ficam no SNS deliberadamente
Uma campanha de usuário publica num tópico por audiência, então uma chamada de API alcança cerca de 500.000 dispositivos. Fazer isso como fan-out direto são 500.000 requisições HTTP para substituir uma, para uma notificação sem nenhum requisito de latência. Resultados de aposta são por usuário, e o direito a eles hoje é implícito em se a inscrição existe, que é uma questão de produto e não de transporte.
Os dois estão fixados em código em vez de deixados para configuração, e configurar qualquer um deles para entrega direta agora emite um aviso de startup nomeando o tipo e o mantém no SNS. Um cutover que silenciosamente se recusa a acontecer é pior que um que recusa em voz alta.
O que eu levei disso
Quando você move uma carga de trabalho, liste tudo que existe para dar suporte àquela carga e verifique cada coisa separadamente. A entrega mudou e o provisionamento ficou, e os dois só estão acoplados na minha cabeça.
Derive um interruptor de um predicado existente em vez de adicionar um paralelo, sempre que os dois precisarem concordar. O custo é uma assinatura de função um pouco desajeitada. O benefício é que um operador de produção não consegue criar um estado que o seu ambiente de desenvolvimento nunca viu.
Antes de remover um recurso, procure o que está chaveado nele. Uma chave de cache nomeando algo a ponto de desaparecer é a linha de maior risco da mudança, e não parece um risco.
E quando uma métrica se recusa a mover depois de uma mudança que deveria movê-la, desconfie da métrica. A minha vinha contando a string errada por uma semana.
Parte de Removendo um Gargalo, sobre o incidente de throttling no SNS Subscribe e a migração para entrega direta via FCM.