Engenharia & Método · 6 min
Escrevi um laço para processar os dados de uma pesquisa. O banco recusou.
Uma migração de banco escrita como um laço linha a linha passou em todos os testes automatizados, com quatro linhas de exemplo. Contra os dados reais, o Postgres recusou o comando inteiro por estourar o tempo limite. A causa era um erro clássico de complexidade que o próprio teste não conseguia revelar.
Por Diogo Kammers · 17/09/2026
O erro apareceu direto do banco de dados
Estávamos aplicando uma migração que processava respostas de uma pesquisa de satisfação, reorganizando os dados em um formato novo. A migração tinha autoteste, quatro linhas de exemplo, e passava sem problema. Contra os dados reais, centenas de respostas, o retorno foi direto do Postgres:
ERRO: canceling statement due to statement timeout
O arquivo inteiro reverteu. Confirmamos dos dois lados: a migração continuou listada como pendente, e a análise que ela deveria transformar continuou exatamente como estava antes, sem nenhuma linha gravada. Esse é o comportamento correto do Postgres diante de um timeout, mas só por sorte de o bloco inteiro contar como um único comando.
A causa era elementar, e por isso passou despercebida
A função que processava os dados montava o resultado dentro de um laço, linha por linha, concatenando cada nova entrada a um array que já vinha crescendo: saida := saida || novo_item. Em PLpgSQL, cada concatenação desse tipo copia o array acumulado inteiro de novo. Com 4 linhas de teste, isso é imperceptível. Com centenas ou milhares de linhas, o custo cresce de forma quadrática, e o tempo de execução passa longe do que a rota de aplicação de migrações tolera.
O autoteste nunca exercitou a função no tamanho real dos dados. É o mesmo tipo de erro que qualquer verificação por amostra pequena corre o risco de esconder: passar no teste não significa funcionar na escala em que o sistema realmente opera.
Reescrever como consulta, não como laço
A correção trocou o laço por uma consulta única, em três passos: agrupar linhas consecutivas que pertencem à mesma resposta (usando função de janela para detectar onde uma sequência começa e termina), identificar a posição de cada resposta dentro do seu grupo, e calcular o deslocamento de cada grupo em relação ao anterior. Nenhum laço explícito, tudo resolvido pelo próprio motor de consulta do banco, que é otimizado para esse tipo de operação em conjunto.
Um detalhe da correção importava mais do que parecia à primeira vista: a pesquisa em questão é anônima, sem nome nem comentário livre em boa parte das respostas. Isso significa que dezenas de respostas sem identificação formam, à primeira vista, um único bloco indistinguível. O que separa uma pessoa da outra dentro desse bloco é a ordem de ocorrência de cada tipo de métrica (cada pessoa responde a cada métrica uma única vez). Uma comparação simplificada, olhando só a linha anterior, colapsaria dezenas de pessoas distintas em uma só, o pior erro possível numa migração que existe justamente para preservar a integridade da pesquisa. Esse detalhe virou autoteste tanto na consulta SQL quanto na camada de aplicação, exatamente para não voltar a acontecer de forma silenciosa.
Como a correção provou que estava certa antes de ir ao ar
A migração corrigida não tinha como ser testada contra o volume real sem aplicá-la de verdade, e aplicá-la de verdade era o próprio risco que se queria evitar. A saída foi um marcador simples: o simulador de aplicação de migrações imprime o número de linhas do arquivo que seria executado. A versão que tinha falhado tinha 306 linhas; a versão corrigida, reescrita como consulta, tem 362. Antes de rodar --aplicar, conferir esse número contra o arquivo local é uma checagem de segundos que evita aplicar a versão errada, ou aplicar duas vezes a mesma.
Um detalhe de regra que ficou junto: editar uma migração que já foi mergeada, mas que nunca chegou a ser aplicada em produção, é seguro. Não existe registro dela em applied_migrations, então não há nada para contradizer. Se ela já tivesse sido aplicada, a correção precisaria virar um arquivo novo, nunca uma edição do que já rodou.
O que ficou como prática
Daqui em diante, qualquer transformação de dado em volume relevante dentro de uma migração precisa ser escrita como consulta, e não como laço processual. E qualquer autoteste de migração que processa dado em lote precisa incluir um caso com volume próximo do real, não só o mínimo necessário para o teste passar. O gargalo aqui não era falta de teste. Era um teste que testava a lógica, mas não testava a escala.
Veja as outras peças desta série: o cache de borda que derrubou a produção duas vezes, a calculadora que recusava toda execução em silêncio, e o guardrail contra o git add -A que misturou o trabalho de duas sessões.
Esse artigo faz parte do hub Engenharia & Método.