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 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
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
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 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.
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áquina | O que é, em português | Se estourar |
|---|---|---|
| 2 GB de RAM | Tamanho total da mesa | O sistema operacional mata o Pricing. A tela vira 502. |
| Uma réplica, sem reserva | Só existe um caixa aberto | Um pedido pesado derruba o produto para todo mundo. |
| 60 segundos na porta da frente | O balanceador não espera para sempre | Uma consulta lenta vira 504, sem mensagem útil. |
| Cálculo no mesmo processo da API | Cozinha e atendimento no mesmo balcão | Montar o JSON da tela compete com o cálculo. |
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 sentia | Por que acontecia |
|---|---|---|
| 1 | O DEV caía ao buscar mercado | O Athena vinha inteiro para a memória e ainda era copiado na conversão. Sem teto de linhas. |
| 2 | Abrir uma análise pronta demorava igual gerar de novo | A leitura refazia as consultas caras, em vez de usar o que já estava gravado. |
| 3 | Em produção o processo não tinha cerca de memória | O limite de heap do Node existia só na etapa de build da imagem. |
| 4 | A queda vinha na hora de gravar a simulação | Lista do Athena + lista enriquecida + gravação no banco viviam juntas. A “vaga” só era liberada no fim. |
| 5 | A tela “pensando” já derrubava o servidor | A cada 3 s o GET devolvia os ~21 MB do catálogo, mesmo com o cálculo ainda rodando. |
| 6 | Esperar na fila marcava a simulação como falha | Timeout de 2 minutos virava failed. A pessoa via um trabalho quebrado, sem o pipeline ter errado. |
| 7 | Dois cliques no mesmo segundo furavam o limite | A checagem só lia “quantos estão ocupados”. A reserva vinha depois, tarde demais. |
| 8 | Uma simulação sozinha ainda derrubava o pod | Enriquecer 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
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.
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.
// 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.
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.
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.
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
antesAthena 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
depoisA 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ção | Antes | Agora |
|---|---|---|
| Servidor ocupado | Aceitava até cair | Aviso de ocupado, espera e tenta de novo sozinho |
| Simulação grande demais | A página virava 502, sem explicação | Pedido para reduzir lojas ou clusters (413) |
| Fila de espera | A simulação aparecia como falha | Volta para a fila; o próximo pedido retoma |
| Dois cliques em gerar | Dois cálculos ao mesmo tempo | O segundo diz que já está em andamento |
| Abrir análise que já existia | Buscava o mercado de novo | Lê 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.
| Quando | O que estava no ar | Uso normal | 5 juntos | 10 juntos |
|---|---|---|---|---|
| 16/09 17:54 | Poll ainda puxava a tabela | Quase: 1× 502 na análise | Caiu: 2× 502 e uma simulação morta na fila | Caiu: 2× 502 e fila cheia |
| 16/09 19:57 | Poll curto; fila não marca falha | 5 planilhas, 21,2 MB, zero 5xx | 4 jobs novos, zero 5xx, zero failed por fila | Ainda caiu: 3× 502 com vários catálogos juntos |
| 17/09 08:28 | Portaria na entrada (reserva no GET) | Catálogo 21,2 MB ok | Excedente recusado (17× ocupado), mas 2× 502 ao gravar o 4º job | Inválido: o servidor já tinha morrido no teste das 5 |
Quantas vezes a porta fechou (502)
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.
| Camada | Onde está | O que ainda pode acontecer |
|---|---|---|
| Athena não duplica o resultado na memória | Em produção | Recorte de negócio ainda pode ser enorme — aí a API recusa (413) em vez de cair |
| Uma vaga, poll curto, espera não é falha | Em produção | Quem clica de novo ouve ocupado até a vaga abrir |
| Cálculo em fatias de 1.000 | Em develop | Falta uma corrida de carga depois do merge para fechar o 502 na gravação |
| Processo só de simulação | Fora deste pacote | Cálculo e tela ainda dividem o mesmo servidor |
| Divergência de cesta (199 vs 200) | Não é memória | Qualidade do dado da pesquisa, não tamanho da pilha |