Pular para o conteúdo principal

Performance — Relatórios de Emplacamento (foco: Por Marca)

Análise do loading lento das telas de relatório. Levantamento feito em 2026-07-28, branch feature/vona/relatorios-emplacamento-backend. Atualizado no mesmo dia após a página já ter fundido a 1ª onda (getReportData + getMonthlyComparison + opções de filtro num único Promise.all) e a inclusão do chassi-service.ts (drill-down por chassi no drawer).

Atualizado novamente no mesmo dia — itens 1, 4, 5, 6 e 7 (quick wins "só no app") foram implementados. Ver checklist na tabela priorizada abaixo. Os demais itens (2, 3, 8, 9, 10) seguem como backlog — 2 e 3 já eram cobertos pelo item 1; 8 já era coberto na prática (ver nota no item); 9 e 10 seguem pendentes de decisão/investigação. Itens que exigem o time de dados (índices, view de chassi, histórico multi-ano) continuam bloqueados, sem mudança.

Todas as queries estão contra dw_emplacamento_mart.emplacamento_kpi_dia (agregado diário) via postgres.js, exceto o drill-down de chassi que usa emplacamento_detalhe + joins no star schema cru. Cliente: singleton em por-territorio/_lib/server/supabase-dw-client.ts, reusado por todos os relatórios (por-marca, por-territorio, por-ranking, de-acessos).


Como as queries rodam hoje (cold load / cache miss)

por-marca/page.tsx:51-113 — 3 ondas, 2 delas já paralelas entre si

1. await getAvailableMonths() ← 1 query, bloqueia tudo
(decide o mês/anchor)
2. await Promise.all([ ← onda A — JÁ FUNDIDA
service.getReportData(grupoId, filters) → internamente:
getLastUpdated
+ monthDatesRows (dep. de nada externo)
+ aggRows (dep. monthDatesRows)
+ dailyRows (dep. monthDatesRows)
— aggRows/dailyRows hoje SEQUENCIAIS
service.getMonthlyComparison(grupoId, filters) → internamente:
getLastUpdated (2ª vez, redundante)
+ aggRows
+ currentMonthCountRows — SEQUENCIAIS
getFieldsAreaAbrangencia() ← array_agg no mart INTEIRO (~2,6M linhas)
getValidEmplacamentosCategories() ← bate em dw_emplacamento.dim_veiculo (não no mart)
getTypeOfSales()
])
3. await Promise.all([ ← onda B (4 KPIs, paralela entre si,
getEmplacamentosIn, getMonthDailyAverage, mas SEQUENCIAL em relação à onda A)
getMarketLeader, getTotalBrandsAndTotalEmplacamentos
])

A fusão que o doc anterior recomendava (item antigo #3, unir getReportData + opções de filtro) já foi feita. O que falta é a onda B: ela só depende do anchor calculado no passo 1, não de nada da onda A — hoje espera a onda A terminar por estar em um await separado.

Dentro de brand-service.tsgetReportData (linhas 517-665)

getLastUpdated() (linha 521, MAX(data))
→ monthDatesRows (558) — depende de ano/mes (calculados de getLastUpdated OU filters.intervalStart)
→ aggRows (590) — depende de monthDatesRows via `currentDay` (linha 580-582,
usado no corte do acum_mes_ant)
→ dailyRows (633) — depende de monthDatesRows via `days` (linha 576)

aggRows e dailyRows não dependem um do outro — só de monthDatesRows (ambos usam currentDay/days derivados dela). Hoje rodam em sequência (dois await seguidos, 590 e 633). getLastUpdatedmonthDatesRows é dependência genuína (precisa do ano/mês antes de filtrar por eles), a não ser que filters.intervalStart já esteja setado (então getLastUpdated nem precisaria rodar, mas roda sempre — ver item novo #8 abaixo).

Dentro de brand-service.tsgetMonthlyComparison (linhas 667-760, novo)

getLastUpdated() (671) — MESMA query que getReportData acabou de rodar
→ aggRows (700) — depende de ano/mes/currentDay (de getLastUpdated)
→ currentMonthCountRows (725) — depende só de ano/mes, NÃO de aggRows

aggRows e currentMonthCountRows também não dependem uma da outra — hoje sequenciais (700 → 725). Podem paralelizar.

chassi-service.ts (novo, drawer de drill-down) — sob demanda, fora do critical path

queryChassiDetails (linha 106) roda 1 query com 4 LEFT JOIN contra o star schema cru (fato_emplacamentos, dim_veiculo, dim_local, dim_concessionaria) sobre emplacamento_detalhe (~4M linhas/ano). Só dispara quando o usuário abre o drawer (não no load da página), então não afeta o TTFB do relatório — mas cada abertura de drawer paga o custo dos 4 joins em runtime. Está documentado como problema conhecido em chassi-detalhamento-view.md: a proposta é estender emplacamento_detalhe com as colunas que faltam (grupomodeloveiculo, regiões) para eliminar os joins. Já está cacheado (cacheService.wrap, tag MARCA, TTL HISTORICO) — então só o cache miss paga o custo.

Estado de cache (atualizado)

Cacheados hoje (cacheService.wrap, tag REPORT_TAGS.MARCA, TTL CACHE_TTL.HISTORICO = 3600s):

  • getReportData (brand-service.ts:480)
  • getFilterOptions (brand-service.ts:487) — não está sendo chamada pela página (page.tsx usa as funções soltas de report-utils.tsx em vez desse método do service; ver item novo #9)
  • getMonthlyComparison (brand-service.ts:493)
  • queryChassiDetails (chassi-service.ts:204)

Sem cache (batem no DW a cada navegação, mesmo warm):

  • getAvailableMonths (report-utils.tsx:294) — roda antes de qualquer Promise.all, bloqueia literalmente tudo
  • getFieldsAreaAbrangencia (42) — array_agg no mart inteiro
  • getValidEmplacamentosCategories (14) — bate em dw_emplacamento.dim_veiculo (tabela crua, não o mart — outra rota de I/O)
  • getTypeOfSales (27)
  • getEmplacamentosIn (103), getMonthDailyAverage (150), getMarketLeader (205), getTotalBrandsAndTotalEmplacamentos (256) — os 4 KPIs da onda B

Otimizações (ranqueadas)

1. Cache nas 8 funções sem cache — MAIOR GANHO, risco baixo — ✅ feito no app

report-utils.tsx: getAvailableMonths, getFieldsAreaAbrangencia, getValidEmplacamentosCategories, getTypeOfSales, getEmplacamentosIn, getMonthDailyAverage, getMarketLeader, getTotalBrandsAndTotalEmplacamentos. Envolver em cacheService.wrap com as mesmas tags/TTL do getReportData (tag REPORT_TAGS.MARCA, TTL CACHE_TTL.HISTORICO). Warm load fica quase instantâneo. Atenção: getEmplacamentosIn/getMonthDailyAverage/etc. recebem filters como argumento — a key do cache já varia por eles automaticamente (mesmo padrão de getReportData), só repetir a receita.

  • Impacto: alto. Esforço: baixo. Risco: baixo (mesmo padrão já usado 4x no arquivo).
  • ✅ Feito: cada uma das 8 funções virou um export fino 'use server' que delega para uma função interna não-exportada embrulhada em cacheService.wrap (mesmo padrão de brand-service.ts). Arquivo continua sem 'use server' no topo — os exports sync (areaAbrangenciaToTreeOptions, formatCompactNumber etc.) não foram afetados. A key do cache do unstable_cache (adapter usado por cacheService.wrap) já inclui os argumentos serializados da chamada além dos keyParts estáticos — então refDate/filters nas 4 funções de KPI já entram na key automaticamente, sem precisar de nada manual. Validado com tsc --noEmit (0 erros) e next build (chegou em "✓ Compiled successfully").

2. getFieldsAreaAbrangencia = array_agg no mart inteiro (~2,6M linhas) a cada load

Coberto pelo item 1 (cache), mas vale destacar: essa é a query mais cara das "sem cache" — array_agg(DISTINCT ...) sobre 6 colunas, mart inteiro, sem filtro de ano. Cachear com TTL longo é suficiente; as opções quase nunca mudam. Se quiser ir além: restringir a WHERE ano = <ano corrente> reduziria o scan, mas mudaria semântica (opções sumiriam se um valor só existir em ano anterior) — não recomendado sem confirmar com produto.

3. getAvailableMonths bloqueia tudo antes do primeiro Promise.all

page.tsx:57 faz await getAvailableMonths() sozinho, sequencial antes de qualquer outra coisa. É necessário porque selectedMonth/anchor dependem dele — dependência genuína, não dá pra paralelizar com a onda A. Mas hoje é o único round-trip sem cache nesse ponto crítico do caminho — cachear (item 1) já mata o problema na prática (warm: latência de rede local ao invés de round-trip pro DW).

4. Onda A e onda B são sequenciais mas independentes — ✅ feito no app

A onda B (4 KPIs, page.tsx:103-113) só precisa do anchor (calculado no passo 1), não de nada que a onda A (page.tsx:83-90) produz. Fundir num único Promise.all de 9 itens — A para de esperar B terminar. Economiza uma onda inteira de latência (≈1 round-trip a mais no caminho crítico frio).

  • Impacto: alto (mata 1 onda completa). Esforço: baixo (mover os 4 await para dentro do array já existente). Risco: baixo — nenhuma dependência de dado entre os dois grupos.
  • ✅ Feito: page.tsx agora tem um único Promise.all de 9 itens (getReportData, getMonthlyComparison, getFieldsAreaAbrangencia, getValidEmplacamentosCategories, getTypeOfSales + os 4 KPIs), todos disparados juntos assim que anchor/filters estão resolvidos.

5. aggRows + dailyRows paralelos dentro de getReportData — ✅ feito no app

brand-service.ts:590 e :633 só dependem de monthDatesRows (:558), não uma da outra. Trocar os dois await sequenciais por Promise.all — −1 round-trip no caminho de getReportData.

  • Impacto: médio. Esforço: baixo. Risco: baixo.
  • ✅ Feito: os dois await sequenciais viraram um Promise.all; quando days.length === 0 o dailyRows vira Promise.resolve([]) em vez de pular a query. Nota de trade-off: antes havia um early-return que evitava rodar dailyRows quando validAggRows dava vazio — com a paralelização as duas queries sempre rodam juntas (perde essa otimização no caso raro de resultado vazio, ganha 1 round-trip no caso comum de resultado não-vazio).

6. aggRows + currentMonthCountRows paralelos dentro de getMonthlyComparison — ✅ feito no app

Mesmo padrão do item 5, mas no método novo: brand-service.ts:700 e :725 não dependem uma da outra, ambas só de ano/mes/currentDay. Paralelizar.

  • Impacto: médio. Esforço: baixo. Risco: baixo.
  • ✅ Feito: Promise.all substituiu os dois await sequenciais.

7. MAX(data) (getLastUpdated) recalculado 6× no cold load — ✅ feito no app

getLastUpdated roda em getReportData (:521) e de novo em getMonthlyComparison (:671) — mesma query, mesmo resultado, dois round-trips (hoje em paralelo entre si por estarem na mesma onda A, então não soma latência, mas dobra carga no DW). Os 4 KPIs de report-utils.tsx também recalculam max(data) como fallback via COALESCE(refDate, max(data)) dentro de CTEs — mas como quase sempre refDate já vem preenchido (do anchor resolvido em page.tsx), o planner pode não nem precisar escanear pra esse max() (com refDate setado o COALESCE de fato só avalia o max() se o Postgres não conseguir provar a curto-circuito — depende do plano; isso é estimativa, não medido, ver seção de medição abaixo).

  • Proposta: computar a âncora uma vez em page.tsx (já é feito — anchorDate/anchor) e o lastUpdated de exibição uma vez só, passando como parâmetro pros services em vez de cada um rodar getLastUpdated(). Elimina a duplicata entre getReportData e getMonthlyComparison (que é a única redundância real e mensurável — as outras 4 já recebem refDate).
  • Impacto: baixo-médio (paralelo, não é latência do caminho crítico, mas reduz carga no DW e simplifica código). Esforço: baixo. Risco: baixo — precisa garantir que o lastUpdated retornado no payload (BrandGridData.lastUpdated / BrandMonthlyData.lastUpdated) continue correto quando passado por fora.
  • ✅ Feito, via caminho diferente do proposto: em vez de mover getLastUpdated pra fora e passar como parâmetro pelos services (o que exigiria mudar a assinatura pública de getReportData/ getMonthlyComparison, hoje já embrulhadas por cacheService.wrap), a query virou uma função de módulo memoizada com cache() do react (getLastUpdatedMemo, brand-service.ts). Os dois métodos do BrandService chamam essa mesma função memoizada em vez de terem cada um sua própria query — quando getReportData e getMonthlyComparison rodam concorrentes (mesmo Promise.all de page.tsx), a 2ª chamada reusa a promise da 1ª em vez de disparar outro round-trip. react.cache() memoiza por render/request (mesmo padrão recomendado pelo Next.js pra dedupe de fetch dentro do App Router), então não vaza estado entre requests nem quebra fronteira server/client — não muda nenhuma assinatura pública.

8. getLastUpdated roda mesmo quando filters.intervalStart já resolve refDate

getReportData:523 e getMonthlyComparison:673: const refDate = filters.intervalStart ?? lastUpdated — o lastUpdated é sempre buscado (linha anterior), mesmo quando intervalStart já está setado (usuário usou o filtro de Período) e o valor seria descartado. Adiar a chamada de getLastUpdated() pra só rodar quando filters.intervalStart for undefined evita 1 round-trip nesse caso (comum: primeira carga da página sem filtro de período custa igual, mas navegações com Período setado economizam). Combina bem com o item 7 (idealmente passar lastUpdated resolvido uma vez em page.tsx pros dois métodos).

  • Impacto: baixo. Esforço: baixo. Risco: baixo.
  • Nota (não implementado como item isolado): na prática já não dispara na maioria dos casos — page.tsx sempre popula filters.intervalStart com anchor (mês selecionado ou período explícito), então o ?? lastUpdated quase nunca é avaliado no refDate. getLastUpdated/getLastUpdatedMemo ainda roda sempre (item 7) porque o valor também é retornado no payload pra exibição ("atualizado em"), que é um propósito independente do refDate. Deduplicar via cache() (item 7) já elimina o round-trip redundante; adiar a chamada por completo exigiria decidir se o campo de exibição pode ficar undefined quando intervalStart está setado — não fizemos essa mudança de contrato sem confirmar com quem consome o campo.

9. getFilterOptions do service não é usada — dead code ou gap de cache

createBrandService() (brand-service.ts:487-491) já expõe getFilterOptions cacheado (states + grupos ABRACAF), mas page.tsx não a chama — os filtros de Estado/Grupo do BrandFilterBar aparentam vir de outro lugar (verificar brand-filter-bar.tsx/report-filter). Se os selects de Estado/Grupo estiverem sem opções reais ou refazendo query em outro componente sem cache, é um gap equivalente ao item 1. Se for efetivamente dead code, remover reduz superfície de manutenção. Vale confirmar com quem estiver mexendo em brand-filter-bar.tsx (outro subagente está tocando esse arquivo agora) antes de agir.

  • Impacto: indefinido até confirmar. Esforço: baixo (investigar).

10. KPIs combináveis

getEmplacamentosIn (total mês vs anterior) e getMonthDailyAverage (média diária vs anterior) varrem a mesma janela de 2 meses na mesma tabela, com os mesmos filtros — só mudam o agregado final (sum vs sum/dias). Dá pra combinar numa única query (1 CTE medias computando total E média). getMarketLeader e getTotalBrandsAndTotalEmplacamentos têm janelas diferentes (mês vs ano) — menos óbvio combinar sem comprometer legibilidade.

  • Proposta: fundir getEmplacamentosIn + getMonthDailyAverage em 1 round-trip. Deixar os outros 2 separados.
  • Impacto: médio (4 KPIs → 3 round-trips). Esforço: médio (query fica mais densa, precisa dos dois WHERE — mesma janela então tranquilo).
  • Risco/trade-off: perde granularidade de cache por KPI — se um dia quiser invalidar só um card, não dá mais. Com TTL fixo por tag (não por função), esse trade-off já não existe na prática hoje (a invalidação é por tag MARCA inteira via /api/revalidate/reports), então o trade-off real é baixo.

11. Amplificador: Supavisor com prepare: false

supabase-dw-client.ts:58 desliga prepared statements (obrigatório em transaction mode do Supavisor — comentário no código explica por quê: cada statement pode ir pra uma conexão física diferente). Isso significa cada query é parseada/planejada do zero a cada chamada, sem reuso de plano — soma-se ao RTT de rede pro DW remoto. Não é algo pra "corrigir" (é requisito do modo transaction), mas é o motivo pelo qual reduzir contagem de round-trips (itens 1-10) importa mais aqui do que num Postgres local: cada round-trip evitado economiza RTT + parse + plan, não só RTT.

  • Alternativa (fora do escopo de código): usar Supavisor em session mode (porta 5432 direta, não 6543) para essa conexão específica, se o time de dados permitir — reabilitaria prepared statements. Precisa avaliar limite de conexões físicas do DW antes de mudar (session mode consome 1 conexão física por client conectado, transaction mode faz pooling).

Medição — o que é estimativo vs medido

Não foi possível rodar EXPLAIN (ANALYZE, BUFFERS) nesta análise. Não há .env.local/.env.development com SUPA_DB_* configurado neste worktree (só existe o client code, sem credenciais versionadas — como esperado, dado que são segredos). Toda a análise acima é estática, baseada em leitura de código: contagem de await sequenciais, dependências de dados entre queries, e o volume de linhas citado nos comentários do próprio código-fonte (ex.: "~2,6M linhas" em getFieldsAreaAbrangencia, "~4M linhas" em emplacamento_detalhe) e em marts-emplacamento.md/ chassi-detalhamento-view.md. Números de impacto ("alto/médio/baixo") são julgamento sobre estrutura de query + contagem de round-trips, não medição de tempo real. Se alguém com acesso ao DW quiser embasar com números reais, os candidatos mais valiosos pra rodar EXPLAIN ANALYZE são: getFieldsAreaAbrangencia (item 2) e a query de chassi-service.ts (joins).


Tabela priorizada (quick wins primeiro)

#ItemImpactoEsforçoOndeStatus
1Cache nas 8 funções sem cacheAltoBaixoreport-utils.tsx✅ feito no app
4Fundir onda A + onda B num só Promise.allAltoBaixopor-marca/page.tsx✅ feito no app
5aggRows + dailyRows paralelosMédioBaixobrand-service.ts:590,633✅ feito no app
6aggRows + currentMonthCountRows paralelosMédioBaixobrand-service.ts:700,725✅ feito no app
7Deduplicar getLastUpdated entre os 2 métodosBaixo-médioBaixobrand-service.ts✅ feito no app (via react.cache())
8Pular getLastUpdated quando intervalStart setadoBaixoBaixobrand-service.tsCoberto na prática por #7 — ver nota no item
9Investigar getFilterOptions não usadaIndefinidoBaixo (investigação)brand-service.ts vs brand-filter-bar.tsxPendente
2Cache em getFieldsAreaAbrangencia (já coberto por #1)✅ feito (via #1)
10Combinar getEmplacamentosIn + getMonthDailyAverageMédioMédioreport-utils.tsxPendente
3getAvailableMonths bloqueante (resolvido por #1)por-marca/page.tsx:57✅ feito (via #1)

Ordem de execução sugerida: 1 → 4 → 5 → 6 → 7 → 8, depois avaliar 9 e 10 (10 é o único com trade-off de granularidade de cache — fazer por último e só se o ganho de #1/#4 não for suficiente).

Pendente: itens 9 e 10 seguem em aberto — 9 precisa de investigação (confirmar se brand-filter-bar.tsx já busca Estado/Grupo em outro lugar sem cache), 10 é uma reescrita de query com trade-off de granularidade de cache, melhor avaliar depois de medir o ganho real de 1+4+5+6+7 em produção.


Itens que exigem o time de dados

  • Índice em dw_emplacamento_mart.emplacamento_kpi_dia — não dá pra confirmar sem EXPLAIN (ver seção de medição), mas as queries filtram consistentemente por (ano, mes, data) e agrupam por (fabricante, grupomodeloveiculo). Se não houver índice composto cobrindo (ano, mes, data), cada query citada faz seq scan no mart inteiro. Pedir ao time de dados para confirmar índices existentes antes de sugerir criação (evitar duplicar índice já presente).
  • View/extensão de emplacamento_detalhe — já pedido em chassi-detalhamento-view.md: adicionar grupomodeloveiculo, regiao_geografica, regiao_metropolitana, area_influencia_nome, conc_regiao_abracaf (e regiao_operacional se o ETL souber derivar) diretamente no mart, eliminando os 4 LEFT JOIN em runtime de chassi-service.ts.
  • Materializar histórico multi-ano no mart agregado — já levantado em marts-emplacamento.md: hoje emplacamento_kpi_dia cobre só 2026; a fato crua tem 2025 completo. Sem isso, comparações YoY (usadas em getMonthlyComparison) ficam limitadas ao que já está materializado.

Itens que dá pra fazer só no app (sem dependência externa)

Todos os itens 1, 4, 5, 6, 7, 8, 9, 10 da lista acima — nenhum deles precisa de mudança de schema, índice ou ETL. São reorganização de await/ Promise.all, aplicação do padrão de cache já existente no arquivo, e (item 10) reescrita de uma query combinando duas existentes.


TL;DR

Gargalo principal continua sendo falta de cache nas funções de report-utils.tsx (item 1) — maior ganho, menor risco, nenhuma dependência externa. Em seguida, a onda B (4 KPIs) ainda espera a onda A mesmo sem depender dela (item 4) — fusão simples, mesmo ganho estrutural que a fusão já feita na onda A. Dentro dos services, sobra paralelismo barato não aproveitado (aggRows/dailyRows, aggRows/currentMonthCountRows — itens 5-6) e uma duplicata nova introduzida pelo getMonthlyComparison (getLastUpdated rodando 2×, item 7). Nada disso depende do time de dados; os pedidos pro time de dados (índices, view de chassi, histórico multi-ano) já estão registrados em docs separados e podem avançar em paralelo, sem bloquear os itens de app.

Ver também marts-emplacamento.md e chassi-detalhamento-view.md.


DECIDIDO — Próximo passo: lazy-loading da árvore marca → modelo

Status: aprovado, a implementar. Depois das otimizações de query já feitas (cache + paralelização, ~4,6s → ~2,9s no DW; warm ≈ cache-hit), o gargalo restante é client-side: a página serializa marca + TODOS os modelos + os dados diários de cada um num HTML de ~4,5 MB, pago em transferência e, sobretudo, em parse/hidratação no thread principal (o que trava o "tempo até interativo" em máquina/rede fraca).

Evidência medida (mês 2026-07, DW real)

  • Payload: 105 marcas vs 609 modelos; ~3.316 células diárias no grão de modelo.
  • HTML da tela: ~4,5 MB (≈ planilha de ~25 mil linhas; os dados reais são ~714 linhas / ~15 mil células — o markup infla ~20–30×).
  • A query brand-only quase não economiza no DW (agg 601→541ms, daily 580→391ms; ~250ms no total): é o mesmo scan do mart, só muda a granularidade do GROUP BY. Logo o ganho é 100% no client (payload/render/hidratação), não no servidor.

Ideia (Hugo)

Carregar só as marcas no load inicial (query agregada por fabricante), renderizar as linhas de topo, e buscar os modelos de cada marca sob demanda ao expandir (server action, como o drawer de chassi já faz) — com prefetch em background (requestIdleCallback/on-hover) das marcas visíveis, pra que ao clicar em expandir os modelos já estejam em cache. Payload inicial cai de ~4,5 MB / ~714 linhas para ~0,7 MB / 105 linhas.

Pontos de atenção (onde mora o trabalho/risco)

  1. Busca: hoje filterBrandRows filtra marca e modelo no client; com modelos lazy, buscar um modelo não carregado não acha → vira busca server-side (ou "ao digitar, carrega os modelos que batem").
  2. Exportar: hoje exporta o grid inteiro em memória; com lazy, o botão faz fetch completo na hora (aceitável, com estado "gerando…").
  3. Participação % do modelo depende do total do dia (global) — passar esses totais junto do payload de marca pro cálculo bater.
  4. Mesmo tratamento vale para a aba Comparativo Mensal (mesma árvore).

Arquivos afetados (estimativa)

brand-service.ts (query brand-only + action getModelosByMarca), components/reports/emplacamento-grid-table/* (subrows assíncronas + loading no expand), brand-grid-table.tsx / brand-monthly-table.tsx, brand-report-view.tsx (busca/export). Esforço médio; maior risco = reescrita de busca/export.