Trocando um join em tempo real por uma coluna materializada, depois encontrando os 79% que minha própria migração deixou para trás

Um join do warehouse por requisição estava escaneando 658,8 MB contra um teto rígido de 953,7 MiB por job, em uma tabela que cresce a cada novo cliente. Medi, movi o join para o pipeline que já o executava, deletei o cache que escondia o problema, depois auditei minha própria migração e encontrei 79% das linhas de produção nunca preenchidas retroativamente, mais 9.299 alertas que nunca tinham aparecido no produto.

impacto658,8 MB por cache miss removidos; o precipício de quota removido juntoLatência a frio de 3,4s para ~0,8s (~77%)Jobs de warehouse por requisição a frio de 3 para 270.502 linhas de produção reparadas, de forma idempotente9.299 alertas invisíveis (10,4%) recuperados para o produto
01

Contexto

Esta é minha evidência mais clara de dois comportamentos sêniores em um único arco. Primeiro, decidir com base em medições, não em instinto: a correção não foi "essa query parece lenta", foi "essa query está em 69% de um teto rígido em uma tabela que cresce a cada cliente que assinamos, e aqui estão os bytes". Segundo, tratar minha própria mudança já entregue como algo a ser auditado. Ninguém reportou nenhum dos dois bugs. Eu fui atrás, descobri que minha migração tinha deixado a maior parte da produção desatualizada, documentei a causa raiz e o risco residual aceito em tickets públicos com meu nome neles, e corrigi. A capacidade que eu apontaria não é o SQL. É estar disposta a ser a pessoa que encontra o próprio resíduo.

02

Problema

Um serviço de relatórios serve uma view de gestão de anomalias: para cada alerta de inspeção pendente, o ativo e a cadeia de pastas organizacionais acima dele, para que um operador possa localizar o equipamento em uma planta.

Essa cadeia era montada por requisição. O serviço emitia uma query extra no warehouse que unia linhas de alerta a uma tabela de referência de nós organizacionais por prefixo de caminho. A tabela de referência é clusterizada pelo seu identificador, não pelo caminho, então um join por prefixo não tem nenhuma poda disponível e lê a tabela do início ao fim. Medido em produção, essa única query escaneava 658,8 MB por cache miss, enquanto a query principal do mesmo endpoint escaneava 53 MB. Os bytes praticamente não mudavam entre uma requisição de um caminho e um lote de vinte caminhos, porque o custo era o escaneamento completo, não o lote.

A plataforma limita cada job do warehouse a 953,7 MiB. Então uma única query de suporte estava consumindo 69% do teto, em uma tabela de referência já com 962 MB e 2,67M linhas que cresce toda vez que um cliente cria um workspace. Com aproximadamente 45% mais crescimento o endpoint para de ser lento e começa a retornar um erro rígido de quota. O que estava segurando a barra era um cache LRU em memória de 15 minutos que eu tinha adicionado antes no mesmo épico: uma mitigação, não uma correção, já que todo miss ainda pagava os 658,8 MB.

03

Restrições

A parte difícil não era a query. Era enxergar o modo de falha corretamente e sequenciar a correção.

O sintoma mentia. A latência parecia um problema de cache. O risco real era um *precipício de quota*: não uma lentidão gradual, mas uma falha rígida em um limiar definido por uma tabela que cresce com a assinatura de clientes, então chega mais rápido exatamente quando o negócio está indo bem.
Sete escritores, um schema. A coluna precisava ser adicionada ao schema da tabela, ao tipo de retorno declarado da table function, à carga incremental, ao merge diário, ao backfill, à checagem de consistência e a um script de migração. Perder um faz linhas chegarem incompletas, silenciosamente.
A ordem era estrutural. Se o serviço começasse a ler a nova coluna antes do backfill ter rodado em produção, todo breadcrumb no produto ficaria em branco. Essa restrição é o que transformou isso em uma migração faseada em vez de um pull request.
A rede de segurança não conseguia ver a falha. A checagem de consistência do pipeline compara o evento bruto com a linha curada, mas lê o caminho do *mesmo campo do evento* que o pipeline lê. Quando esse campo estava ausente, os dois lados eram NULL e a checagem reportava concordância perfeita. Um verificador de divergência estruturalmente não consegue detectar ausência.
04

Decisão

Medi antes de propor qualquer coisa. Execuções reais de query com pruning de cluster tanto em staging quanto em produção: bytes da query de suporte, bytes da query principal, tamanho e contagem de linhas da tabela de referência, número de jobs de warehouse por requisição a frio, e latência a frio. Esses números são o que sustentou o argumento, e são o que me disse qual alavanca importava.

Perguntei onde o trabalho já estava sendo feito. A carga incremental do pipeline já unia as mesmas linhas de alerta contra a mesma tabela de referência para computar quatro colunas fixas de profundidade. Então materializar a cadeia completa não era trabalho novo; era mais uma agregação dentro de um join que já rodava. Esse reenquadramento é o que tornou a mudança barata em vez de uma troca de custo de requisição por custo de pipeline.

Escrevi a migração como quatro etapas ordenadas com seus próprios critérios de aceite: schema e pipeline em staging, depois produção, depois o serviço, depois uma limpeza opcional, com a dependência declarada explicitamente: o serviço não deve ler a coluna até que as linhas de produção estejam populadas. Também sinalizei, por escrito, que as colunas de profundidade existentes não filtravam nada por tipo de nó enquanto a nova cadeia precisava excluir nós de nível de máquina, então era uma nova agregação e não um reaproveitamento da antiga.

Depois auditei minha própria mudança. Enquanto modelava a próxima funcionalidade, verifiquei se algum alerta ficaria fora do escopo de um workspace, e encontrei 70.684 de 88.995 alertas pendentes (79%) com uma cadeia de localização vazia. O script de backfill existia, tratava a nova coluna, e simplesmente nunca tinha sido executado em produção.

Provei que era uma lacuna temporal, não um defeito de dados, antes de tocar em qualquer coisa: 61.385 linhas tinham a coluna de profundidade antiga populada e a nova cadeia vazia. Ambas vêm do mesmo join, na mesma expressão. Se o join tivesse falhado, ambas estariam vazias. Essas linhas passaram pelo join em um momento em que a nova coluna ainda não existia. Um segundo sinal concordava: 733 caminhos tinham linhas em *ambos* os estados, as vazias parando no dia da migração e as populadas continuando.

Reparei com uma instrução idempotente e direcionada em vez de uma recarga. O movimento óbvio, truncar e rodar o backfill de novo sobre uma janela de dez anos, era mais raio de impacto do que o problema justificava. Em vez disso escrevi um `UPDATE` restrito a linhas com cadeia vazia, e reaproveitei a própria lógica do pipeline com uma mudança: resolver ancestrais enumerando prefixos de caminho e unindo por igualdade, em vez do join por correspondência de prefixo que o pipeline usa, que levava mais de quatro minutos aqui. Validei que as duas formas produziam saída idêntica em 500 caminhos amostrados (500/500) antes de rodar. O predicado de cadeia vazia aparece tanto no alvo quanto no filtro deliberadamente, então a instrução é idempotente e não pode sobrescrever um valor bom. 61.203 linhas reparadas.

O resíduo foi a descoberta real. 9.299 linhas não se moveram, e todas tinham um caminho NULL, de um período antes do produtor de eventos começar a enviar o campo. A table function filtra escopo com `path = workspace OR STARTS_WITH(path, workspace || '/')`. Com um caminho NULL, ambas as comparações avaliam para NULL, `NULL OR NULL` é NULL, e `WHERE NULL` descarta a linha. Esses alertas não estavam aparecendo sem localização. Não estavam aparecendo de jeito nenhum: 10,4% dos alertas pendentes, incluindo 5.469 na faixa de maior severidade, em 1.588 checklists, 91% deles dentro de um único tenant. Staging estava proporcionalmente pior, em 31,9%. Nenhum cliente tinha reportado, porque a resposta do produto para "quantos alertas pendentes eu tenho?" estava simples e silenciosamente errada.

Recuperei esses caminhos de uma fonte materializada indexada por checklist, e validei antes de executar em vez de depois: uma checagem de join 1:1 (2.167.356 linhas para 2.167.356 ids distintos), zero caminhos NULL na fonte, formatação byte-idêntica em amostras, 99,57% de concordância (32.846 de 32.988) no subconjunto onde ambos os valores existiam, e um ensaio completo em staging que corrigiu 139 de 139 com zero resíduo.

05

Trade-offs

Materializar a cadeia no pipeline em vez de elevar o teto de bytes por job. Elevar o teto converte uma falha rígida e visível em um custo silencioso e crescente. Documentei isso como um paliativo aceitável se o trabalho atrasasse, explicitamente não como a solução.
Materializar em vez de manter o cache em tempo de requisição e ajustá-lo. O cache era mitigação real e eu mesma o tinha construído, mas todo miss ainda pagava o escaneamento completo. Mantê-lo significava manter a lógica de TTL, LRU e cache negativo que existia *só* por causa dessa única query. Remover a causa me permitiu deletar tudo isso.
Um `UPDATE` idempotente e direcionado em vez de truncar-e-recarregar a partir do script de backfill. A recarga era o caminho sancionado e teria sido mais simples de justificar. Escolhi a instrução mais estreita porque toca só linhas quebradas, é segura para rodar de novo, e não pode sobrescrever um valor correto; o custo é que tive que provar que minha resolução reescrita de ancestrais era equivalente à do pipeline.
Enumeração de prefixo com join por igualdade em vez do join por correspondência de prefixo do pipeline. Mais rápido por ordens de magnitude na escala do reparo, ao preço de uma segunda implementação da mesma regra. Paguei esse preço com uma checagem de equivalência de 500 amostras em vez de um argumento.
Recuperar caminhos da fonte materializada atual em vez de deixar 9.299 alertas invisíveis. A fonte guarda a localização *atual* de um checklist, enquanto o relatório guardava sua localização *no momento da resposta*. Na base comparável, 143 linhas (0,43%) discordavam, 14 delas sob uma raiz diferente: checklists que tinham sido movidos. Extrapolado para o conjunto recuperado, isso é aproximadamente 7 movidos internamente e possivelmente 1 entre raízes, e como essas linhas não tinham caminho algum, não há como identificar quais. Escolhi visível-e-possivelmente-desatualizado em vez de invisível, e documentei o risco residual no ticket para que a próxima pessoa a ser perguntada "por que esse alerta antigo está arquivado ali?" tenha a resposta.
Sem snapshot antes do reparo em vez de uma cópia defensiva. Uma escolha consciente, não um descuido. As instruções eram idempotentes, estreitas, ensaiadas em staging, e o time-travel de 7 dias do warehouse era o fallback. Registrei que a decisão foi tomada e por quê, porque os números pré-reparo no ticket agora são o único registro do estado anterior.
06

Impacto

Removi 658,8 MB de bytes escaneados por cache miss e, com eles, o precipício de quota. O endpoint não fica mais em 69% de um teto rígido em uma tabela que cresce com a contagem de clientes.
Latência a frio de 3,4s → ~0,8s (~77%), cortando jobs de warehouse por requisição a frio de 3 para 2.
Deletei o cache de localização por completo, junto com sua lógica de TTL, LRU e cache negativo, já que o código existia só para compensar a query que não roda mais. O breadcrumb também parou de ficar limitado a quatro níveis: as cadeias agora vão de 1 a 10 de profundidade.
Reparei 70.502 linhas de produção: 61.203 com uma cadeia vazia desatualizada, 9.299 que estavam sendo descartadas do produto por completo. Estado final verificado: 89.067 alertas pendentes, zero sem um caminho, zero com uma cadeia vazia.
Recuperei 9.299 alertas (10,4% dos alertas pendentes) para o produto, incluindo 5.469 na faixa de maior severidade, em um relatório que clientes usam para priorizar manutenção. Contadores e listagens estavam subestimando o backlog real.
Desbloqueei a funcionalidade que encontrou o bug. Uma view de alertas acumulados escopada por workspace teria visto apenas 20% dos dados.
Registrei o trabalho de prevenção honestamente como *a fazer*, não como feito: um fallback no pipeline quando o campo de evento está ausente, uma asserção de completude na checagem de consistência, e uma decisão sobre se a table function deveria excluir caminhos NULL explicitamente e contá-los em vez de deixá-los desaparecer.
07

Lições Aprendidas

Uma quota é um precipício, não uma rampa. Limites de cobrança por byte e similares não degradam, eles falham de forma abrupta em um limiar. Quando o motor desse limiar é uma tabela que cresce com o sucesso do negócio, "está tudo bem hoje" não é uma medição, é uma contagem regressiva.
Meça a alavanca, não a suspeita. Duas otimizações plausíveis estavam na mesa. A medição disse que uma estava em 69% do teto e a outra em 18% de uma query que não era o problema. Escrever "não é a alavanca, revisitar se a base crescer 10x" na seção fora de escopo é parte da entrega.
Um verificador de divergência não consegue detectar ausência. Se sua validação lê a mesma fonte da coisa que ela valida, NULL concorda com NULL e a checagem passa. Checagens de integridade precisam de ao menos um invariante de *completude*, verificado de forma independente.
Filtros que descartam NULL falham de forma fechada e silenciosa. `WHERE prefix_match(NULL, x)` não retorna uma linha sem localização, retorna nenhuma linha. Um registro que desaparece é muito pior do que um que renderiza incompleto, e nada no sistema vai te avisar.
Adicionar uma coluna derivada a um pipeline é uma migração, não uma edição. Linhas históricas não se reconstroem sozinhas. "Rodar o backfill" pertence aos critérios de aceite do PR que adiciona a coluna, não à memória de alguém.
Prefira a escrita idempotente e estreita à ampla sancionada quando você conseguir provar equivalência. Repetir a guarda tanto no alvo quanto no filtro é o que torna um reparo seguro para rodar de novo às 3 da manhã.
Publicar o risco residual é parte de entregar a correção. Eu não conseguia identificar quais alertas recuperados poderiam mostrar uma localização desatualizada. Dizer isso, com a taxa medida, converte um passivo desconhecido em um documentado.
08

Evidência

Fui dona de ponta a ponta e sozinha: medição, a decisão escrita com suas alternativas rejeitadas, um rollout faseado em quatro etapas em dois ambientes com critérios de aceite por etapa, a mudança do lado do serviço, e os dois reparos forenses.
Medições tomadas como execuções reais com pruning de cluster tanto em staging quanto em produção, registradas no ticket junto com o teto contra o qual foram comparadas, incluindo a contra-medição que eliminou a otimização alternativa.
Ambos os defeitos foram encontrados por mim durante a modelagem da próxima funcionalidade, não reportados por um cliente ou por monitoramento; ambos foram documentados como tickets públicos de causa raiz com queries de reprodução, a correção aplicada, o risco residual aceito, e itens de prevenção.
Todo reparo ensaiado em staging antes de produção, com a tabela de validação pré-voo (cardinalidade de join, completude da fonte, igualdade de formato, taxa de concordância) registrada antes da execução em vez de depois.
Fonte (privada): épico e tickets de causa raiz do Jira no domínio de inspeção (2026-08), mais os pull requests correspondentes de infrastructure-as-code e de serviço.