Farmarcas · Pricing MVP

Relatório de melhorias de performance

08 a 17 de setembro de 2026 · simulação de preços no Radar

Este texto explica, em linguagem de produto, por que a tela de simulação “caía” no ambiente de desenvolvimento, o que o usuário via, e o que mudou no fluxo pesquisa → estratégia → simulação. Os detalhes de engenharia vêm depois de cada analogia, com trechos do código que entrou nas PRs #371 a #421.

O que a pessoa via
Tela 502
Memória do servidor
2 GB
Lista de produtos na tela
~21 MB
Simulações pesadas ao mesmo tempo
1

O problema real

O Pricing não “ficava lento”. Ele morria. Uma pessoa pedia uma simulação grande — dezenas de milhares de produtos de farmácia, preços de concorrentes, regras de margem. O servidor tentava carregar essa lista inteira na memória, várias vezes ao mesmo tempo, enquanto a tela perguntava a cada 3 segundos “já terminou?”. Cada pergunta puxava de novo a lista completa (~21 MB). O computador do servidor só tem 2 GB. Quando a conta passava disso, o processo era morto. Para quem estava na tela, aparecia um erro genérico de gateway (502 ou 504): a página branca, o overlay travado, a simulação “perdida”.

Athena
O “depósito” de dados de preço e pesquisa. A simulação pede um recorte enorme; se o recorte vier inteiro para a memória, o servidor enche.
Pod / servidor
Uma instância só do Pricing, com 2 GB de RAM. Não há cópia de reserva: se essa instância cai, ninguém da equipe usa o DEV até ela voltar.
502 / 504
A porta da frente (o balanceador) corta a ligação porque o servidor não responde a tempo, ou porque o processo já morreu. Não é uma mensagem de negócio (“simulação grande demais”); é a porta fechando na cara.

Fluxo ponta a ponta

No Radar, gerar preço não é um botão isolado. É um ciclo: a pessoa sobe pesquisas de mercado, monta a estratégia e só então pede a simulação. Cada etapa já conversa com o mesmo servidor.

O que a pessoa faz na tela

flowchart LR
  P["1. Pesquisa<br/>Importa planilhas de mercado"]
  E["2. Estratégia<br/>Regras e competitividade"]
  S["3. Simulação<br/>Clica em gerar"]
  A["4. Acompanhar<br/>A cada 3s: já terminou?"]
  C["5. Catálogo<br/>Tabela de ~21 mil produtos"]
  P --> E --> S --> A --> C
Cinco cliques, um servidor. O cálculo pesado mora no passo 3; o passo 4 não deveria carregar a tabela.

Por trás desses cliques, o pedido atravessa o navegador, a API Nuxt, o Athena e o Mongo. Tudo isso acontece no mesmo processo Node — não existe um “operário” separado só para calcular.

O caminho do pedido no servidor

flowchart LR
  N["Navegador<br/>Clique e espera"]
  API["API Nuxt<br/>Recebe, calcula e responde"]
  ATH["Athena<br/>Recorte de mercado"]
  MON["Mongo<br/>Grava produtos"]
  JSON["JSON da tela<br/>Lista da tabela"]
  N --> API
  API --> ATH
  API --> MON
  API --> JSON
Enquanto o cálculo ocupa a API, qualquer outro clique da equipe disputa o mesmo 2 GB.

O que quebrava o servidor

Pense na memória como uma mesa de 2 m². A lista de uma simulação é uma pilha de papéis de ~21 MB. O sistema antigo deixava três pilhas na mesa ao mesmo tempo: a que veio do Athena, a cópia já “enriquecida” com concorrente, e a cópia que o banco montava para gravar. Enquanto isso, a tela pedia de novo a pilha inteira a cada 3 segundos. A mesa caía.

Memória: o que ficava vivo ao mesmo tempo

flowchart LR
  subgraph antes["Antes — tudo na mesa"]
    direction TB
    a1["Athena inteiro"] --> a2["Lista enriquecida"]
    a2 --> a3["Documento no Mongo"]
    a3 --> a4["Poll 21 MB a cada 3s"]
  end
  subgraph depois["Depois — uma fatia"]
    direction TB
    d1["Athena página a página"] --> d2["Lote de 1.000"]
    d2 --> d3["Poll 115 B"]
    d3 --> d4["Catálogo só no ready"]
  end
Antes a mesa carregava três cópias mais o poll. Depois só um lote e um recado curto.

O dia em que a memória subiu 1,7 GB em 5 minutos

Em 08/09/2026 o servidor estava calmo de manhã (~90 MB). Em cinco minutos, ao materializar um recorte grande do Athena, passou de 200 MB para quase 2 GB — o teto da máquina. O coletor de lixo do Node passou mais de 99% do tempo tentando limpar. Na mesma hora: 27 respostas 504, 4 respostas 502, 12 erros de conexão. No dia anterior, o pico tinha sido 78 MB e zero reinícios. Não foi um vazamento lento; foi um único passo grande demais.

0 550 1100 1650 2200 Limite do pod (2048) 09:35 09:45 09:50 10:10 10:15 MiB
Memória do container (MiB)
Prometheus · 08/09/2026 · eixo X: horário · eixo Y: memória em MiB. A linha amarela é o teto de 2 GB.

Por isso a restrição de máquina importa: não dá para “caber mais um pouco”. Se estoura, a equipe inteira perde o DEV.

Limite da máquinaO que é, em portuguêsSe estourar
2 GB de RAMTamanho total da mesaO sistema operacional mata o Pricing. A tela vira 502.
Uma réplica, sem reservaSó existe um caixa abertoUm pedido pesado derruba o produto para todo mundo.
60 segundos na porta da frenteO balanceador não espera para sempreUma consulta lenta vira 504, sem mensagem útil.
Cálculo no mesmo processo da APICozinha e atendimento no mesmo balcãoMontar o JSON da tela compete com o cálculo.
Fonte: incidente no pod api-pricing-dev e o serviço de processamento da simulação.

Oito jeitos de a mesa cair

Não era um bug só. Era uma sequência: cada camada assumia que a anterior cabia. Abaixo, o sintoma na vida real e a causa técnica.

#O que a pessoa sentiaPor que acontecia
1O DEV caía ao buscar mercadoO Athena vinha inteiro para a memória e ainda era copiado na conversão. Sem teto de linhas.
2Abrir uma análise pronta demorava igual gerar de novoA leitura refazia as consultas caras, em vez de usar o que já estava gravado.
3Em produção o processo não tinha cerca de memóriaO limite de heap do Node existia só na etapa de build da imagem.
4A queda vinha na hora de gravar a simulaçãoLista do Athena + lista enriquecida + gravação no banco viviam juntas. A “vaga” só era liberada no fim.
5A tela “pensando” já derrubava o servidorA cada 3 s o GET devolvia os ~21 MB do catálogo, mesmo com o cálculo ainda rodando.
6Esperar na fila marcava a simulação como falhaTimeout de 2 minutos virava failed. A pessoa via um trabalho quebrado, sem o pipeline ter errado.
7Dois cliques no mesmo segundo furavam o limiteA checagem só lia “quantos estão ocupados”. A reserva vinha depois, tarde demais.
8Uma simulação sozinha ainda derrubava o podEnriquecer todos os produtos de uma vez e só então gravar. O pico era o grafo vezes três, não um lote.

Como resolvemos

Sem aumentar a máquina e sem um worker dedicado, a linha foi: não guardar o que não precisa estar vivo, recusar o que não cabe e não tratar “ocupado” como simulação quebrada. Cada PR fecha uma camada. A seguinte só faz sentido depois da anterior — como fechar torneiras de uma caixa d’água que transbordava.

Ordem das defesas

flowchart LR
  a["#371 Athena<br/>Página a página, 413 se não cabe"]
  b["#399 / #406<br/>Leitura, lotes, cerca de heap"]
  c["#417 Poll leve<br/>Já terminou? sem a tabela"]
  d["#418 Portaria<br/>Só entra quem cabe, 429"]
  e["#420 Fatias<br/>1.000 produtos por vez"]
  a --> b --> c --> d --> e
PRs #385 (cache da data) e #421 (testes alinhados ao splice) completam a série.

1. Não trazer o depósito inteiro para a sala

O Athena devolve o resultado em páginas de 1.000 linhas. O código antigo podia acumular a página crua e só depois converter — duas cópias vivas. Agora cada página é convertida na hora e a crua some. Se o recorte passar de 200 mil linhas, a API responde 413 (“isso não cabe; reduza lojas ou período”) em vez de morrer. O prazo da consulta inteira é 55 s, abaixo dos 60 s da porta da frente, para o erro chegar com mensagem, não como 504 mudo.

apps/vue/modules/shared/server/services/athena.service.ts
async function collectResults(client, queryExecutionId, mapRow, maxRows, deadline) {
  const results = [];
  do {
    const { rows } = parseResultSet(await client.send(pageCommand));
    if (results.length + rows.length > maxRows) {
      throw createError({
        statusCode: 413,
        statusMessage: `Athena: a query retornaria mais de ${maxRows} linhas`,
      });
    }
    for (const row of rows) {
      results.push(mapRow ? mapRow(row) : row);
    }
  } while (nextToken);
  return results;
}

mapRow por página, teto conferido dentro do laço. Conferir no fim seria tarde: o processo já teria morrido.

2. Separar “já terminou?” de “mande a tabela”

A tela precisa saber se o cálculo acabou. Isso não exige 21 mil produtos. O poll passou a pedir view=progress: um recado de status. O catálogo só é montado uma vez, quando o status é ready — e mesmo aí, se outro cálculo pesado está no ar, o GET da tabela espera.

apps/vue/modules/simulation/server/api/simulation/new/index.get.ts
// O poll do cliente roda a cada 3s e não precisa do catálogo.
if (view === "progress") {
  return {
    status: "ready",
    stage: clustersProducts.processing_stage,
    clusters_products_by_period_id: clustersProducts._id,
    date: responseDate,
  };
}

rejectCatalogGetIfHeavyQueryBusy();
return runWithCatalogMaterializationLock(async () => {
  return JSON.stringify({ clusters, products, margin_tree, date });
});

O poll a cada 3 s não materializa o catálogo. Foi isso que parou de empilhar 21 MB em cima do job.

apps/vue/modules/simulation/composables/useSimulationFlow.ts
const pollProgress = async () => {
  return callbacks.getSimulationData(simulationID, { view: "progress" });
};

let response = await pollProgress();
while (response.status !== "ready") {
  if (response.status === "failed") {
    throw new Error(response.message || "Falha no processamento da simulação.");
  }
  await wait(3000);
  response = await pollProgress();
}
await callbacks.getSimulationData(simulationID); // catálogo uma vez

No navegador: pergunta curta em loop; tabela só depois do ready.

3. Uma portaria na porta, não um “entra todo mundo”

O servidor só consegue um cálculo pesado com folga. Se cinco pessoas clicam juntas, as extras precisam ouvir “ocupado, tente de novo em instantes” — e a simulação delas não pode virar failed. A reserva da vaga acontece no mesmo instante do GET, antes de criar o documento. Sem isso, vários pedidos passavam na checagem “está livre?” e só depois tentavam ocupar, furando o teto.

apps/vue/modules/shared/server/utils/heavy-query-limit.ts
export const reserveHeavyQueryJob = (label, maxPending) => {
  if (pendingCount() >= maxPending) {
    throw createError({
      statusCode: 429,
      statusMessage:
        "Servidor ocupado processando consultas pesadas. Tente novamente em instantes.",
      headers: { "Retry-After": "10" },
    });
  }
  const reservation = { id: ++reservationSeq, released: false };
  openReservations.add(reservation.id);
  return reservation;
};

Reserva atômica: se não há vaga, 429 com Retry-After. Fila cheia não é falha de pipeline.

Na tela, isso vira overlay de “ocupado” e a espera continua. Dois cliques em “gerar análise” não disparam dois cálculos: o segundo informa que o trabalho já está andando.

4. Cortar o trabalho em fatias de 1.000

Mesmo com a portaria, um único recorte grande ainda empilhava o grafo inteiro na hora de enriquecer e gravar. A correção é consumir a lista com splice: tira 1.000, enriquece, grava, descarta, pega os próximos. A mesa só vê um lote. Pesquisa de mercado precisa de um cuidado extra: o preço de um grupo não pode ser cortado no meio — primeiro monta o índice dos grupos, depois pagina os itens.

apps/vue/modules/simulation/server/services/simulation-clusters-products-process.service.ts
const batchSize = persistBatchSize();

while (productsFromCluster.length) {
  const batch = productsFromCluster.splice(0, batchSize);
  const { products: enriched, research_products } =
    await enrichClusterProductsWithCompetitorMarketAndResearch(
      clusters,
      batch,
      { economicGroup, allowInPlaceMutation },
    );
  await persistClusterItemBatch(enriched, clustersProductsId, processingRunId);
  await persistResearchProductBatch(
    research_products,
    clustersProductsId,
    processingRunId,
  );
}

Pico de memória passa a ser o lote, não o catálogo × 3. Default: 1.000 itens.

Três cópias do grafo

antes

Athena devolvia a lista inteira. O enriquecimento copiava tudo. O Mongo hidratava de novo. No saving, as três pilhas continuavam vivas. Pico ≈ 21 MB × 3, mais o formato do banco.

Um lote por vez

depois

A lista original é consumida. Cada fatia é enriquecida, gravada e solta. Com pesquisa, a passagem A monta o índice de grupos; a passagem B pagina os itens para não cortar preço de grupo no escuro.

O que mudou para quem usa a tela

SituaçãoAntesAgora
Servidor ocupadoAceitava até cairAviso de ocupado, espera e tenta de novo sozinho
Simulação grande demaisA página virava 502, sem explicaçãoPedido para reduzir lojas ou clusters (413)
Fila de esperaA simulação aparecia como falhaVolta para a fila; o próximo pedido retoma
Dois cliques em gerarDois cálculos ao mesmo tempoO segundo diz que já está em andamento
Abrir análise que já existiaBuscava o mercado de novoLê o que já foi gravado

O que os testes mostraram

Rodamos o ciclo real contra o DEV: cinco importações de pesquisa em paralelo, depois 5 simulações juntas, depois 10. Critério de sucesso para o dia a dia da equipe: zero erro 5xx e uma simulação “canário” respondendo 200. O poll usava o recado curto; a tabela de 21 MB só entrava no teste smoke, depois de pronta.

Uso normal (smoke) após poll leve
passou
5 pessoas ao mesmo tempo
passou
10 ao mesmo tempo até a portaria
parcial
Testes automatizados das fatias
19/19
QuandoO que estava no arUso normal5 juntos10 juntos
16/09 17:54Poll ainda puxava a tabelaQuase: 1× 502 na análiseCaiu: 2× 502 e uma simulação morta na filaCaiu: 2× 502 e fila cheia
16/09 19:57Poll curto; fila não marca falha5 planilhas, 21,2 MB, zero 5xx4 jobs novos, zero 5xx, zero failed por filaAinda caiu: 3× 502 com vários catálogos juntos
17/09 08:28Portaria na entrada (reserva no GET)Catálogo 21,2 MB okExcedente recusado (17× ocupado), mas 2× 502 ao gravar o 4º jobInválido: o servidor já tinha morrido no teste das 5
O teste de 10 depois de um crash não mede 10 pessoas de verdade — mede um processo já morto.

Quantas vezes a porta fechou (502)

0 1 2 3 4 5 6 7 8 9 10 11 1 2 2 Antes do poll curto 3 Depois do poll curto 2 11 Depois da portaria quantidade de 502
502 no uso normal 502 com 5 juntos 502 com 10 juntos
Fonte: load-summary das três rodadas. O 11 da última coluna é o mesmo processo morto no teste das 5.

Tradução: separar o “já terminou?” do catálogo tirou o 502 do uso normal e de 5 pessoas juntas. A portaria passou a recusar o excedente em vez de aceitar até cair. O que ainda matava o servidor era o pico de um único cálculo na hora de gravar — exatamente o que as fatias (#420) atacam. Essa parte já está no código, com 19 testes do serviço passando; não havia corrida de carga nova gravada no repositório depois desse merge.

Comprovado na carga

fechado
  • “Já terminou?” passou a 115–268 bytes, não 21 MB.
  • Quem esperou 51 a 141 s na fila terminou pronto, sem virar falha.
  • No instante zero das 5 simulações, só duas vagas; o resto ouviu ocupado e entrou depois.
  • Cinco importações paralelas (21.650 linhas) passaram depois do poll curto.

Fatias no código, carga ainda antiga

em develop
  • Depois da portaria, o servidor ainda morreu ao gravar o 4º job — um cálculo empilhando o grafo, não a fila.
  • Fatias e testes (#420, #421) já estão em develop. O vitest do serviço: 19/19. O CI que quebrava era expectativa antiga, não regressão de produto.

Conclusão

As melhorias são camadas, não um interruptor. Recusar pedido extra não encolhe um cálculo; fatiar o cálculo não substitui a portaria. As duas juntas é que cabem nos 2 GB, sem máquina maior e sem um processo só para simulação.

CamadaOnde estáO que ainda pode acontecer
Athena não duplica o resultado na memóriaEm produçãoRecorte de negócio ainda pode ser enorme — aí a API recusa (413) em vez de cair
Uma vaga, poll curto, espera não é falhaEm produçãoQuem clica de novo ouve ocupado até a vaga abrir
Cálculo em fatias de 1.000Em developFalta uma corrida de carga depois do merge para fechar o 502 na gravação
Processo só de simulaçãoFora deste pacoteCálculo e tela ainda dividem o mesmo servidor
Divergência de cesta (199 vs 200)Não é memóriaQualidade do dado da pesquisa, não tamanho da pilha