Pular para o conteúdo principal

Validação por Marca — OOM em produção e limite de tamanho de arquivo

Documento de contexto para discussão com o time. Branch: emplacamento/hotfix/validacao-por-marca-out-of-range Data: 2026-08-14


1. Resumo executivo

A geração de arquivo da Validação por Marca (POST /api/emplacamento/validacao-marca/gerar) quebra em produção com grandes volumes de dados. São dois problemas distintos, descobertos em sequência:

  1. Estouro de memória (OOM) — a função na Vercel era morta por falta de RAM (instance was killed because it ran out of available memory, 2048 MB). → Já corrigido nesta branch (streaming via cursor).

  2. Arquivo grande demais para o Storage — depois de corrigir a memória, o arquivo gerado ultrapassa o limite de tamanho do bucket do Supabase Storage (The object exceeded the maximum allowed size, limite de 100 MB no bucket / 50 MiB global). → Em aberto — precisa de decisão do time (seção 5).


2. Como o fluxo funciona hoje

Usuário (tela Validação da Marca)
│ seleciona período, montadoras, área, tipo (padrão/detalhado), formato (CSV/XLSX)

POST /api/emplacamento/validacao-marca/gerar
│ 1. valida permissão
│ 2. cria registro em brand_validations (status = processing)
│ 3. consulta o DW (dw_emplacamento_mart.emplacamento_detalhe + joins)
│ 4. monta o arquivo (CSV ou XLSX) em memória
│ 5. sobe o arquivo no bucket "validacao-marca" (Supabase Storage)
│ 6. marca status = completed e devolve um signedUrl

Navegador baixa o arquivo direto pelo signedUrl do Storage

Pontos relevantes de arquitetura:

  • A geração é síncrona dentro da própria route handler (não há fila/worker; é o equivalente moderno do antigo EventBridge/Lambda do sistema legado).
  • O DW é um Postgres separado acessado via postgres.js (não é o Supabase do app). Passa por Supavisor em transaction mode (por isso prepare: false).
  • O download é feito direto pelo navegador contra o Storage, usando um signedUrl de 1h. A aplicação não fica no meio do download.

3. Problema 1 — Estouro de memória (OOM) — CORRIGIDO

Causa raiz

O código carregava todo o resultado da query em memória e ainda o duplicava várias vezes:

  1. queryDetalhe(...) → array completo de objetos JS (milhões de linhas — o item mais pesado, por causa do overhead de objeto/string por campo);
  2. rows.map(buildRow)segundo array completo;
  3. [header, ...bodyRows].map().join() → um array de strings de linha e uma string única gigante concatenada;
  4. Buffer.from(csvContent)mais uma cópia.

Pico de memória ≈ 4 a 5× o tamanho do dataset. Com o volume de produção, estourava os 2048 MB da função e a Vercel matava a instância.

Correção aplicada (nesta branch)

Streaming via cursor do Postgres — nunca segura todas as linhas de uma vez.

  • validacao-marca-service.ts: novo método streamDetalhe(), um async generator que usa query.cursor(2000) (entrega no máximo 2000 linhas por vez).
  • gerar/route.ts:
    • CSV: montado em chunks de Buffer — cada lote do cursor vira bytes e é descartado em seguida. Sem teto de linhas.
    • XLSX: monta a planilha linha a linha a partir do cursor (elimina a duplicação rows + bodyRows). Como o SheetJS materializa a planilha inteira em memória (não tem escrita em streaming na versão community), foi imposto um teto de 200.000 linhas com mensagem pedindo para usar CSV ou reduzir período/filtros.
    • export const maxDuration = 300 para exportações longas usarem o teto de 5 min da função.

Detalhe técnico importante (para o time)

O cursor é seguro sob o Supavisor em transaction mode: o .cursor() do postgres.js roda dentro de uma transação, e em transaction pooling a transação inteira fica "presa" (pinned) a uma única conexão física — então todos os FETCH sucessivos batem no mesmo backend. O idle_timeout não fecha a conexão durante a iteração (ela está em uso, não idle).

Status: ✅ implementado, com typecheck + lint + format limpos.


4. Problema 2 — Arquivo maior que o limite do Storage — EM ABERTO

Depois de resolver a memória, o arquivo é gerado com sucesso, mas no passo de upload o Supabase Storage rejeita:

The object exceeded the maximum allowed size

Causa

O arquivo bruto de milhões de linhas passa dos limites de tamanho:

  • Bucket validacao-marca: file_size_limit = 104857600 (100 MB) — definido na migration 20260801000001_brand_validations.sql.
  • Limite global do Storage: 50 MiB em desenvolvimento (config.toml); em produção é a configuração do dashboard da Supabase.

O limite efetivo é o menor dos dois. Um CSV detalhado de período longo com todas as montadoras pode facilmente passar de 100 MB (estimativa: milhões de linhas × ~150 bytes/linha → centenas de MB).

Restrição do fluxo atual

Como o download é feito direto pelo navegador contra o Storage (signedUrl), o arquivo precisa, hoje, existir descompactado no bucket. Isso amarra as opções.


5. Soluções propostas (para decidir com o time)

Opção A — Gzip + rota de download própria ⭐ (recomendada)

Comprime o arquivo antes de subir e serve o download através de uma rota nossa.

  • No gerar: buffer → gzip → upload como .csv.gz / .xlsx.gz.
  • No download: em vez de redirecionar pro signedUrl, uma rota nossa faz stream do objeto do Storage de volta ao navegador com os headers:
    • Content-Encoding: gzip → o navegador descompacta sozinho;
    • Content-Disposition: attachment; filename=... → salva como .csv/.xlsx.
  • O usuário baixa um arquivo normal, sem perceber a compressão.

Prós

  • CSV comprime ~85–90% (ex.: 400 MB → ~40 MB). Fica bem abaixo de qualquer limite.
  • Não depende de mudar nada no dashboard da Supabase.
  • Funciona para praticamente qualquer tamanho.
  • Downloads ficam muito mais rápidos (menos banda).
  • A rota de download faz stream (não segura o arquivo em memória).

Contras

  • Mexe em mais lugares: gerar (gzip), rota de download (proxy com headers) e no client (passar a baixar pela rota em vez do signedUrl direto).
  • Precisa validar o comportamento de download com Content-Encoding: gzip nos navegadores usados.

Opção B — Aumentar os limites do Storage

Sobe o file_size_limit do bucket e o limite global.

  • Migration: update storage.buckets set file_size_limit = ... (ex.: 1 GB).
  • config.toml: file_size_limit = "1GiB" (dev).
  • Dashboard de produção: aumentar o Upload file size limit na Supabase (manual — precisa ser feito por alguém com acesso ao projeto).

Prós

  • Mínimo de código.
  • Mantém o fluxo atual (download direto por signedUrl).

Contras

  • Depende de mudança manual no dashboard de produção (não dá pra fazer só no repositório).
  • Uploads e downloads de centenas de MB via upload padrão são lentos e podem falhar — para arquivos realmente grandes o recomendado é resumable upload (TUS), que é mais complexo.
  • O usuário baixa arquivos enormes (banda/tempo).
  • Não resolve o problema de fundo, só empurra o teto.

Opção C — Limitar o tamanho da exportação

Impor um teto de linhas também no CSV (como já existe no XLSX) e barrar com mensagem pedindo para reduzir período/filtros.

Prós

  • Simples e previsível; evita o erro por completo.

Contras

  • Não entrega os dados grandes que o usuário precisa — só impede o erro.
  • Ruim se as exportações grandes forem um caso de uso legítimo (aparentemente são).

Opção D — Geração assíncrona (evolução futura, fora do hotfix)

Tirar a geração de dentro da request e mandar para uma fila/worker (o sistema legado usava EventBridge/Lambda). A tela já escuta status via Realtime, então a UX de "processando → pronto" já está pronta para isso.

Prós

  • Elimina de vez os limites de tempo/memória da request.
  • Caminho natural para volumes muito grandes.

Contras

  • Bem mais trabalho; não é um hotfix. Fica como recomendação de médio prazo.

6. Recomendação

  • Hotfix imediato: manter a correção de memória (Problema 1, já feita) e adotar a Opção A (gzip + rota de download) para o Problema 2 — é a única que resolve de verdade sem depender de configuração manual em produção e sem degradar a experiência de download.
  • Médio prazo: avaliar a Opção D (geração assíncrona) se o volume continuar crescendo.
  • Combinar com um teto de sanidade (uma variação leve da Opção C) só para proteger contra exportações absurdas continua sendo saudável.

7. Perguntas para o time

  1. Qual o tamanho real esperado dessas exportações (ordem de grandeza)?
  2. Há restrição para mudar o Upload file size limit no dashboard de produção?
  3. Tem apetite para a geração assíncrona (Opção D) agora ou deixamos como evolução?
  4. Existe um teto de negócio aceitável para o número de linhas por exportação?

8. Arquivos envolvidos

ArquivoPapel
apps/web/app/api/emplacamento/validacao-marca/gerar/route.tsGera o arquivo e sobe no Storage
apps/web/app/api/emplacamento/validacao-marca/[id]/download/route.tsDevolve signedUrl para re-download
apps/web/app/[locale]/emplacamento/home/[account]/validacao-da-marca/_lib/server/validacao-marca-service.tsQuery no DW + upload/URL do Storage
apps/web/app/[locale]/emplacamento/home/[account]/validacao-da-marca/_components/validacao-marca-view.tsxUI + download no client
apps/web/supabase/migrations/20260801000001_brand_validations.sqlCria bucket (limite 100 MB) e tabela
apps/web/supabase/config.tomlLimite global do Storage (50 MiB em dev)