Diogo Kammers

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.

Quero aplicar isso na minha operação

Esse artigo faz parte do hub Engenharia & Método.

FAQ

Perguntas frequentes

Por que um laço em PLpgSQL pode ser lento mesmo processando poucas linhas de teste?

Porque concatenar repetidamente a uma variável de array ou string copia a estrutura acumulada inteira a cada iteração. Com poucas linhas o custo é imperceptível, mas cresce de forma quadrática, e se torna proibitivo com centenas ou milhares de linhas.

Um DO block inteiro do Postgres conta como um comando só?

Sim. Se o timeout for atingido em qualquer ponto dentro dele, o bloco inteiro reverte, mesmo que partes anteriores já tivessem, hipoteticamente, terminado de processar.

Como corrigir uma transformação de dado que estourou timeout num laço?

Reescrever como uma consulta declarativa, usando funções de janela e agregação em vez de iteração linha a linha. O motor de banco de dados é otimizado para esse tipo de operação em conjunto de um jeito que um laço processual não consegue igualar.