O Que uma Sombra Mede

Antes de enviar uma única notificação pelo caminho novo, rodei ele em sombra: a cada publicação, seguir pelo SNS como sempre, e também calcular para quem o nosso próprio fan-out enviaria, registrando a comparação. Barato, óbvio, e o movimento padrão antes de um cutover.

Levou três tentativas para acertar a comparação, e cada erro foi mais interessante que o código.

Os dois lados não medem a mesma coisa

Minha primeira versão comparava o nosso conjunto de destinatários contra o que o SNS carregava, e tratava qualquer diferença como algo a levar a zero. É para isso que uma sombra normalmente serve: o caminho antigo é a referência, o novo deve concordar com ele, divergência é bug na coisa nova.

Depois olhei de onde vem cada lado.

lado tabela de origem quando é escrita
o que o usuário pediu r10_user_subscribed_matches sincronamente, antes de qualquer chamada ao SNS
o que o SNS carrega r10_subscription apenas após um Subscribe bem-sucedido

A intenção do usuário é registrada imediatamente, na requisição. A inscrição no SNS é registrada depois de uma chamada externa, com rate limit, que vinha falhando por dois dias.

Então os dois lados não são duas implementações de uma pergunta. Um guarda o que as pessoas pediram. O outro guarda o que conseguimos registrar na AWS. A lacuna entre eles não é ruído a ser minimizado: é a contagem de usuários que pediram uma notificação e não estão recebendo, um número que esta plataforma nunca teve.

Esse reenquadramento matou um plano que eu havia escrito no ADR, que era esperar a AWS retirar o limite protetivo antes de rodar a comparação, sob o argumento de que o limite distorce quais inscrições existem. Ao contrário. A distorção era a coisa mais informativa disponível, e esperar teria descartado a janela em que ela era mensurável.

Um veredito assimétrico

Uma vez que as duas direções significam coisas diferentes, elas não podem compartilhar um limiar. Então a comparação tem dois contadores que nunca são somados.

Um destinatário que o SNS alcança e que o nosso fan-out perderia é fatal. Isso é um bug na query, e é o único resultado inaceitável, porque significa que fazer o cutover pararia silenciosamente de notificar alguém.

Um destinatário que o nosso fan-out adiciona e que o SNS não tem é contado separadamente, e esperado. Esses são os usuários cujas inscrições falharam durante o incidente.

Somar os dois teria produzido um único número de "divergência" que sobe quando as coisas melhoram. Pior, o sinal seria dominado pela segunda categoria, então um bug real na primeira ficaria invisível dentro dele.

Um detalhe de implementação carrega a maior parte da segurança aqui. O veredito limpo é derivado do conjunto fatal em cada chamada, em vez de armazenado como flag quando a comparação é escrita. Uma comparação que contém um dispositivo perdido não consegue reportar sucesso, porque sucesso não é um campo que alguém define.

Um instrumento que nunca roda não valida nada

O primeiro tipo de notificação que escolhi sombrear foi HIGHLIGHT_VIDEO. O raciocínio era decente: ele resolve destinatários pelo caminho com escopo de partida, que é a forma que os estágios seguintes precisam, e publica de um tick do scheduler a cada cinco minutos, então a sombra rodaria numa cadência previsível longe de qualquer caminho quente.

Ele dispara no máximo uma vez por partida, só para partidas que têm vídeo de melhores momentos, e só dentro de 72 horas. Na prática o instrumento ficaria parado por dias.

Eu havia otimizado para um lugar confortável de colocar a medição em vez de otimizar para obter medições. Mudei para GOAL e CORNER, que acontecem muitas vezes por partida, então um defeito na query aparece dentro de uma rodada em vez de uma semana.

O custo disso é real e vale declarar. Esses dois publicam de EventProcessor.deliverEvent, que é o caminho quente do gRPC, não um tick de scheduler. Então a sombra roda em fire-and-forget numa goroutine rastreada pelo próprio wait group que o shutdown gracioso drena, espelhando o que o fan-out de Live Activity na mesma função já faz. A entrega retorna sem esperar por ela, e existe um teste que falha se alguém tornar a chamada síncrona.

Deliberadamente não adicionei amostragem nem throttle. Uma partida tem cerca de dez escanteios e três gols em 90 minutos, então mesmo 100 partidas simultâneas são cerca de 1.300 eventos sombreados em uma hora e meia, algo como um quarto por segundo, duas queries cada. Um throttle estaria guardando uma carga que não existe. Se o volume real contradisser isso, a correção é um throttle naquele momento, com a medição em mão.

O ponto de chamada de HIGHLIGHT_VIDEO ficou, inerte a menos que configurado, porque é a única cobertura do caminho do scheduler.

O que a sombra não podia provar

Vale ser explícito, porque é a razão de sombrear em vez de uma nota de pé.

Nenhum teste exercitava aquele SQL contra um banco real. Este repositório não tem teste de repositório com banco e a CI não provisiona Postgres, então o uso de tabelas da query é afirmado contra o texto dela e a semântica contra um fake. Um join invertido passaria em toda a suíte.

Essa é precisamente a lacuna que produção fecha. Sombrear não é confiança extra em cima de uma query testada. É o teste, para a parte que não pode ser testada em nenhum outro lugar.

Depois ela encontrou ausências, e teve que explicá-las

Quatro horas de dados de produção com a sombra ligada em GOAL e CORNER:

tipo comparações ausentes adicionados maior fan-out maior SNS
CORNER 47 2 27 218 206
GOAL 28 4 51 260 251

Duas coisas nessa tabela. Fan-outs de 218 e 260 destinatários, onde 17 horas no ambiente de staging nunca passaram de um, e 81% das comparações de staging tinham os dois lados vazios. Staging provou que o encanamento funcionava. Só produção exercitou o caminho de múltiplos dispositivos.

E ela falhou no critério fatal. Seis ausências, que eram dois dispositivos distintos vistos repetidamente. Um deles ainda estava ausente doze minutos após a primeira observação, o que descarta uma corrida entre a publicação e a query assíncrona da sombra. Era uma diferença persistente de estado.

Ler o código eliminou duas explicações. Não era divergência de semântica de is_enabled, porque o caminho ativo aplica a regra idêntica. Não era um tópico de time do coração entrando, porque o lado do SNS filtra por id de partida. A hipótese principal era uma inscrição obsoleta no SNS: a intenção do usuário apagada sem que a inscrição fosse limpa, o que significaria que o nosso fan-out está correto e o SNS vinha entregando demais para pessoas que desligaram a notificação.

Não codifiquei essa hipótese. O banco de produção não é alcançável do meu ambiente de desenvolvimento, e adivinhar era exatamente o que este estágio existia para impedir.

O que entrou no lugar foi atribuição. Cada ausência agora reporta o próprio motivo: sem linha de partida inscrita, tipo desabilitado, nenhuma linha de preferência, ou inexplicada. Quatro motivos, não os cinco que eu havia esboçado, porque um dispositivo cuja linha se foi não pode aparecer no lado do SNS: aquela query faz inner join na tabela de dispositivos.

O veredito ficou mais difícil, não mais fácil

Esta é a parte de que eu desconfiaria se lesse no post de outra pessoa.

Antes da atribuição, qualquer ausência era fatal. Depois, o veredito limpo significa nenhuma ausência inexplicada. À primeira vista é uma definição sendo relaxada para deixar passar algo que falha, que é o movimento mais antigo do manual.

É estritamente mais difícil de satisfazer, e a razão é que exige que a atribuição tenha retornado um motivo conhecido para cada ausência. Uma comparação que pulou a atribuição não é limpa. Uma cuja atribuição deu erro não é limpa. Antes, uma ausência sem explicação e uma ausência com boa explicação eram a mesma coisa; agora a segunda é permitida e a primeira continua fatal, e as duas precisam ser positivamente estabelecidas.

A evidência de que a definição se moveu na direção segura é que os testes do critério original continuam passando sem alteração.

Depois disso, 499 comparações atribuídas com miss_unexplained = 0. Toda ausência era uma inscrição obsoleta ou uma preferência desligada, dois estados que o fan-out está certo em excluir. O que fez do cutover uma melhoria em vez de um risco: ele para de notificar usuários que explicitamente pediram para não ser, e alcança cerca de 136 por hora que pediram e não estavam sendo alcançados.

O que eu levei disso

Verifique se os dois lados de uma comparação são escritos no mesmo momento pelo mesmo caminho de código. Se não são, a divergência carrega informação e reduzi-la a um número destrói isso.

Conte as duas direções de uma diferença separadamente quando significam coisas diferentes, e derive o veredito do conjunto fatal em vez de armazená-lo, para que uma comparação ruim não possa se declarar boa.

Escolha o instrumento pela frequência com que ele produzirá dados, não por onde é conveniente colocá-lo. Um sinal limpo de algo que dispara duas vezes por semana ainda não é um sinal.

E uma sombra valida a lógica, não a carga. Essa distinção me custou depois, e ganhou o próprio post.


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