brunodiedri.ch
blog

275 consultas para um pool de 5 conexões

Uma nota fiscal com 275 itens salvava, mas a tela de validação devolvia centenas de respostas com código HTTP 500. O dado estava certo: o problema era quantas perguntas o frontend fazia ao mesmo tempo.

·9 min de leitura#angular#rxjs#quarkus#postgres#concorrência

Na aba Network do navegador, centenas de linhas vermelhas, quase todas iguais: GET /figura-fiscal?... com código 500.

O corpo da resposta trazia só um identificador de erro e um stack vazio. E a nota, uma nota fiscal de entrada importada de um XML com 275 itens, tinha sido salva normalmente.

Reparei que só alguns produtos davam problema. Eram 103 produtos diferentes voltando código 500. Enquanto outros, da mesma nota, voltavam com código 200. Fui atrás do que aqueles 103 tinham de errado. E não tinham nada.

O fluxo

O lançamento de uma nota de entrada por XML atravessa alguns microsserviços. O fiscal é o dono da nota: recebe o XML, compara a tributação dele com a do cadastro e grava tudo quando o usuário salva.

O cadastro guarda produtos, fornecedores, NCM e as figuras fiscais, que dizem como cada produto é tributado. O frontend é em Angular 16 com RxJS 7, os backends (microserviços) são Quarkus, cada um com o seu Postgres separado.

Entre importar e salvar existe uma etapa de divergências de tributação. Para cada item da nota, o frontend pergunta ao serviço de cadastro qual é a figura fiscal daquele produto: uma chamada GET /figura-fiscal por item. Era essa chamada que falhava.

Procurando o defeito no dado

Com um grupo de produtos que falha e outro que funciona, o reflexo é procurar a diferença entre eles. Separei três produtos que voltavam com código HTTP 500 e um que voltava código 200, para ver se não era algum problema de configuração nos produtos.

A primeira suspeita, e a que eu mais levei a sério, foi uma migration que não tinha sido executada no cadastro, de uma branch que mexia em replicação de serviços. Li o diff: ela não tocava a consulta de figura fiscal.

Depois, código novo na release. O histórico do arquivo dizia que a última mudança era de novembro de 2025.

Descartada a hipótese da migration, pensei em informação suja/incompleta ou em algum NPE escondido atrás daquele código 500 sem stack. Fui para o banco com esses quatro produtos e comparei as figuras fiscais de ICMS, PIS/COFINS e ISS, no produto e no NCM. Tudo nulo, nos quatro, inclusive no que funcionava.

Procurei dado sujo, linha sem empresa, cenário sem tributação do participante: zero. CST de IPI com tipo de tributo nulo: nenhum dos produtos tinha CST de IPI.

A última tentativa foi comparar a linha inteira de cad_produto. Ler um select * de uma tabela larga procurando a coluna diferente é tedioso e fácil de errar, então transformei cada linha em JSON e deixei o Postgres mostrar só as colunas em que os valores não batem:

só as colunas que diferem entre os produtos
select t.key,
       max(t.value::text) filter (where t.id_produto = 18525) as ok,
       max(t.value::text) filter (where t.id_produto = 18403) as falha_a,
       max(t.value::text) filter (where t.id_produto = 18398) as falha_b
from (
  select p.id_produto, j.key, j.value
  from cad_produto p, jsonb_each(to_jsonb(p)) j
  where p.id_produto in (18525, 18403, 18398)
) t
group by t.key
having count(distinct t.value::text) > 1
order by t.key;

O to_jsonb transforma a linha num objeto, o jsonb_each quebra o objeto em pares de chave e valor, e o having descarta as colunas em que os produtos têm o mesmo valor. Sobraram código, descrição, código de barras e data. Identificação, nada que mudasse tributação.

Foi aqui que a pergunta mudou. Eu tinha dois produtos com o mesmo NCM, todas as figuras nulas, um respondendo com código HTTP 200 e outro com código 500. Se o dado é igual e o resultado é diferente, o dado não é a causa.

Eu já sabia que não era problema de operação nem de cadastro dos produtos, e só alguns itens falhavam. Isso me fez desconfiar de algum timeout estourando, ou de algum problema envolvendo o banco de dados.

Quatro itens

Se fosse timeout, a quantidade importava. Montei um XML só com aqueles quatro produtos, os três que falhavam e o que funcionava, e refiz o fluxo. Todos voltaram com código 200.

Mesmos produtos, mesmos dados, mesma tela. A única diferença era a quantidade de chamadas, quatro em vez de centenas. Abri os logs dos pods do cadastro pelo Freelens e lá estava:

Unable to acquire JDBC Connection

Problema de conexão com o banco. E nada disso aparecia no Grafana: o painel do serviço não tinha as top queries do cadastro (via pg_stat_statements) nem um widget com a quantidade de conexões no banco do serviço. Com esses dois painéis, eu teria olhado para conexão antes de olhar para o dado.

A causa

No frontend, a etapa de divergências montava o formulário de cada item num forEach e, no meio do laço, chamava uma função assíncrona sem await:

divergencias-tributacao.component.ts (antes, resumido)
this.divergenciasList.forEach(i => {
  // ... monta o form do item
  this.validateFiguraFiscal(i, form); // async, ninguém espera
  this.formGroupList.push(form);
});

Cada volta do laço disparava o seu GET /figura-fiscal na hora. Com 275 itens, eram centenas de requisições saindo juntas do navegador.

Do outro lado, o pool de conexões do cadastro tem max-size: 5 no arquivo de configuração base, os perfis de dev, qa e prod não mudam esse valor, e é com 5 que o pod roda.

Cada consulta de figura fiscal precisa de uma conexão. Cinco conseguem, o resto espera. Quem não consegue uma conexão dentro do timeout de aquisição gera uma exceção que ninguém trata, e o serviço devolve com um código HTTP 500 genérico, sem mensagem e sem stack trace de erro.

275 consultas, 5 conexões
NavegadorCadastroPostgres1. GET /figura-fiscal (as primeiras)2. 5 consultas, 5 conexões3. GET /figura-fiscal (o resto)4. resultado5. código HTTP 2006. código HTTP 500
passo 1/6
GET /figura-fiscal (as primeiras) · cada consulta pega uma das 5 conexões do pool

Isso explica os 103 produtos "defeituosos". O defeito não estava no produto. Falhava quem chegava tarde, e a ordem de chegada muda a cada execução.

Lendo o código ainda apareceu um efeito colateral mais quieto. A função assíncrona era esta:

validateFiguraFiscal (antes)
async validateFiguraFiscal(item: any, form: UntypedFormGroup) {
  const controlFiguraFiscal = form.get('figuraFiscal');
 
  const figuraFiscal = await this.getFigurasFiscais(item);
 
  if (
    isNullOrUndefined(figuraFiscal.figuraIcms) ||
    isNullOrUndefined(figuraFiscal.figuraPisCofins)
  ) {
    controlFiguraFiscal.setErrors({ notFound: true });
  } else {
    controlFiguraFiscal.setValue(figuraFiscal);
  }
}

Quando o await rejeitava, nenhum dos dois ramos rodava. O controle ficava com null e sem erro, ou seja, válido e sem figura fiscal.

A opção "aplicar tributação do cadastro" olhava para os itens válidos, então podia ser aplicada a um item cuja figura nunca tinha sido carregada. O console vermelho pelo menos chamava atenção. Esse não chamava.

A correção

A mudança ficou no frontend e foi pequena. O forEach virou um map que devolve o item e o seu form, e a validação saiu do laço para um método que passa essa lista por um mergeMap com limite de concorrência:

divergencias-tributacao.component.ts (depois, resumido)
private static readonly MAX_CONSULTAS_FIGURA_FISCAL_SIMULTANEAS = 3;
 
private validateFigurasFiscais(validacoes: { item: unknown; form: UntypedFormGroup }[]) {
  from(validacoes)
    .pipe(
      mergeMap(
        ({ item, form }) => this.validateFiguraFiscal(item, form),
        DivergenciasTributacaoComponent.MAX_CONSULTAS_FIGURA_FISCAL_SIMULTANEAS
      ),
      takeUntil(this.unsubscribe)
    )
    .subscribe();
}

O mergeMap aceita a promise que a função async devolve, e o segundo parâmetro é o número máximo de promises em andamento. Com 3, a quarta consulta só sai quando uma das três primeiras termina.

O 3 fica abaixo do pool de 5 para sobrar conexão para o resto do sistema. O takeUntil corta a fila quando a tela é destruída: o que já saiu termina, o que ainda não saiu não sai mais.

A outra metade foi um try/catch em volta do await. Se a consulta falha, o item recebe o mesmo erro de "figura fiscal não encontrada" e continua com a tributação da própria nota, em vez de ficar válido e vazio:

validateFiguraFiscal (depois, só o catch)
} catch (error) {
  console.error('Falha ao consultar a figura fiscal do item', item?.nrItem, error);
  controlFiguraFiscal.setErrors({ notFound: true });
}

Com a correção, refiz o teste com a mesma nota de 275 itens. Os 275 deram certo.

Pesei outras saídas. O cadastro já tem um endpoint em lote para figuras fiscais, e trocar centenas de chamadas por uma seria o natural. Mas o código dele lança exceção para a nota inteira se um único produto estiver inativo, o que trocaria 103 falhas por uma falha de 275.

Aumentar o pool resolveria o sintoma, só que ele é compartilhado com todas as outras telas, e uma importação grande continuaria disputando conexão com elas.

A terceira ideia era reaproveitar a figura fiscal que o fiscal já calcula em lote durante a importação, em vez de perguntar de novo. Essa eu não cheguei a avaliar.

Fiquei com a correção no cliente. Quem sabe quantas chamadas faz é o frontend, então é ele que deveria dizer quantas vão ao mesmo tempo.

Eu mudaria de ideia em dois casos: se outros consumidores, outra tela ou uma integração, passarem a chamar o mesmo endpoint do mesmo jeito, porque aí o limite num cliente só não protege o pool; ou se notas maiores deixarem a validação lenta a ponto de atrapalhar o usuário.

A objeção que eu espero ouvir é essa segunda: com 3 por vez, fica mais lento. Fica. No teste com a nota de 275 itens, a diferença não incomodou. Não medi o tempo, então não tenho número para mostrar.

Mesmo dado, resultado diferente

O sintoma parecia ser sobre dados: alguns produtos falham, outros não. Por isso comecei comparando produtos, e cada comparação voltava igual.

A suspeita de timeout e o teste com quatro itens trocaram a pergunta "o que esses produtos têm?" por "o que muda quando são muitos?". Quando o mesmo dado dá resultados diferentes, vale olhar para o que ele divide com os outros. Aqui era um pool de cinco conexões.

A segunda coisa é mais mecânica. Um laço que dispara uma requisição por item não tem limite nenhum, a não ser o do servidor do outro lado, e esse limite aparece como erro.

O que muda daqui pra frente

O que ficou decidido é o Grafana. Depois dessa ocorrência, as top queries e a quantidade de conexões do banco do serviço vão entrar no painel. Da próxima vez, a pergunta "o que muda quando são muitos?" deve aparecer num gráfico antes de eu abrir o primeiro SQL.