Blog

Transação longa que ninguém fecha: idle in transaction que segura vacuum, lock e conexão

Um SaaS de logística com 40 conexões no pool da API e um worker de faturamento sofreu três incidentes no mesmo mês que pareciam não ter relação: a API travando por alguns minutos em horários aleatórios, uma migração de 2 segundos que ficou 35 minutos presa e a tabela de pedidos crescendo 3 GB por semana sem ganhar linhas. A causa era a mesma: uma transação aberta por um worker que chamava o ERP com um lock na mão, e algumas sessões de BI que abriam BEGIN e iam almoçar. Este artigo mostra o que uma transação parada segura mesmo sem executar nada, como encontrar a sessão e o que ela está travando, de onde essas transações nascem no código Node.js e como garantir que toda transação termine, pelo código e pelo servidor.

2026-11-08 / Arquitetura / 15 min

01

Três incidentes sem relação aparente e uma única causa

O primeiro chamado veio do suporte: a tela de pedidos ficava sem responder por dois ou três minutos, algumas vezes por dia, e voltava sozinha. O segundo veio do time de plataforma: uma migração que adicionava uma coluna anulável em pedidos, operação que no Postgres é só uma mudança de catálogo, ficou 35 minutos esperando e foi cancelada à mão. O terceiro veio do custo: a tabela de pedidos tinha 4,1 milhões de linhas em setembro e 4,3 milhões em outubro, mas passou de 19 GB para 31 GB, e o autovacuum rodava 140 vezes por semana sem recuperar nada.

Os três foram tratados separadamente por semanas. O travamento da API ganhou um aumento de pool de 40 para 60 conexões, que piorou o problema, porque mais conexões entraram na mesma fila. A migração foi reagendada para a madrugada e travou de novo. O inchaço ganhou um ajuste de autovacuum por tabela, que não teve efeito, porque o vacuum não estava atrasado: estava impedido de remover.

A conexão entre eles apareceu quando alguém olhou pg_stat_activity durante um travamento e viu uma sessão com state igual a "idle in transaction" havia 41 minutos, pid 48213, application_name vazio, último comando um SELECT FOR UPDATE em pedidos. Atrás dela, uma migração esperando ACCESS EXCLUSIVE; atrás da migração, 40 sessões da API esperando ACCESS SHARE. E a coluna backend_xmin daquela sessão era a mais antiga do servidor, o que impedia o vacuum de limpar qualquer tupla morta criada desde que ela começou.

t=0        worker abre BEGIN, faz SELECT ... FOR UPDATE em pedidos (pid 48213)
t=0,2 s    worker chama o ERP e fica esperando (state = idle in transaction)
t=0,2 s    locks em pedidos e xmin = 9.412.807 ficam presos; 1 conexao do pool ocupada
t=3 min    autovacuum em pedidos termina sem remover nada: tuplas mortas > xmin retido
t=12 min   migracao roda ALTER TABLE pedidos ADD COLUMN: pede ACCESS EXCLUSIVE, entra na fila
t=12 min   toda consulta nova em pedidos entra na fila atras do ALTER: a API trava
t=14 min   pool da API chega a 40/40 conexoes esperando lock; p99 passa de 30 s
t=47 min   ERP responde; worker faz UPDATE e COMMIT; fila inteira anda em 400 ms

Uma transação parada é pior que uma consulta longa. A consulta longa pelo menos está fazendo algo e vai terminar. A transação parada não consome CPU, não aparece em consultas lentas, não dispara o statement_timeout, porque não há statement rodando, e segura tudo o que adquiriu pelo tempo que o cliente quiser. O resto do artigo é sobre esse "tudo".

02

O que uma transação aberta segura mesmo sem fazer nada

No Postgres, três recursos ficam presos à transação, não ao comando, e são liberados apenas no COMMIT ou ROLLBACK. O primeiro são os locks: todo lock de tabela ou de linha adquirido por qualquer comando dentro da transação dura até o fim dela. Um SELECT comum em pedidos deixa um ACCESS SHARE pendurado na tabela, que por si só não atrapalha ninguém, até que uma migração peça ACCESS EXCLUSIVE. Nesse momento, a migração entra na fila atrás do ACCESS SHARE, e tudo o que vier depois dela entra na fila atrás da migração, porque a fila de locks do Postgres respeita a ordem de chegada para evitar que o pedido mais forte morra de fome.

O segundo é o horizonte de visibilidade. Toda transação com snapshot ativo publica em backend_xmin o id da transação mais antiga que ela ainda pode precisar enxergar. O vacuum calcula o horizonte global como o menor xmin entre todas as sessões, slots de replicação e transações preparadas, e só remove tuplas mortas anteriores a ele. Uma única sessão parada com um snapshot de 47 minutos atrás impede a remoção de toda tupla morta criada nesses 47 minutos, em todas as tabelas do banco, não só na que ela consultou. O autovacuum roda, conta as tuplas, informa no log que "N dead row versions cannot be removed yet" e sai sem recuperar espaço.

O terceiro é a conexão. Uma sessão em transação ocupa um backend do servidor e uma conexão do pool da aplicação, e nenhum pool pode entregá-la a outra requisição enquanto a transação não terminar. Com um pool de 40 e dez transações penduradas, a aplicação opera com 30 conexões, e ninguém vê isso nos gráficos de CPU ou de consultas lentas.

Recurso retidoAté quandoQuem pagaComo aparece
Locks de tabela e de linhaCOMMIT ou ROLLBACKMigrações, UPDATE e DELETE nas mesmas linhas, depois toda consulta atrás de uma migração enfileiradaFila em pg_locks com granted = false, pg_blocking_pids apontando para a sessão parada
Horizonte de xminFim da transação (ou da sessão, em REPEATABLE READ)Vacuum de todas as tabelas do banco, índices que crescem, HOT que deixa de acontecerLog do autovacuum com "dead row versions cannot be removed yet", bloat crescendo com linhas estáveis
Conexão do pool e backend do servidorFim da transação e release do clienteRequisições que esperam o pool, max_connections do servidorTempo de espera do pool subindo com CPU do banco baixa
Arquivos temporários e cursores WITH HOLDFim da transaçãoDisco do servidortemp_files e temp_bytes em pg_stat_database

A consequência prática é que a transação não é uma unidade de código, é uma unidade de posse de recursos compartilhados. Cada milissegundo que ela fica aberta além do necessário é cobrado de outro cliente, e o pior cenário é o em que ela fica aberta esperando algo que o banco não controla.

03

Encontrar a sessão e o que ela está segurando

pg_stat_activity tem tudo o que é preciso, desde que se olhe as colunas certas. xact_start diz quando a transação começou. state_change diz quando a sessão mudou de estado pela última vez, então em uma sessão "idle in transaction" é o instante em que o último comando terminou e a transação ficou parada. backend_xmin diz qual horizonte ela está segurando, e age dessa coluna diz quantas transações já aconteceram desde então. A coluna query guarda o último comando executado, que costuma ser a pista de qual trecho de código abriu a transação, mesmo que application_name esteja vazio.

-- Sessoes com transacao aberta ha mais de 30 s, da mais antiga para a mais nova.
-- "idle in transaction" e a pior combinacao: segura tudo e nao executa nada.
SELECT pid,
       usename,
       application_name,
       client_addr,
       state,
       now() - xact_start                        AS idade_transacao,
       now() - state_change                      AS parada_ha,
       age(backend_xmin)                         AS xids_retidos,   -- quanto do vacuum esta preso
       wait_event_type || ':' || wait_event      AS esperando,
       left(query, 80)                           AS ultimo_comando
  FROM pg_stat_activity
 WHERE backend_type = 'client backend'
   AND xact_start IS NOT NULL
   AND now() - xact_start > interval '30 seconds'
 ORDER BY xact_start;

-- O que a sessao esta segurando: tabelas e modos de lock.
SELECT l.pid,
       l.locktype,
       l.relation::regclass AS tabela,
       l.mode,
       l.granted
  FROM pg_locks l
 WHERE l.pid = 48213            -- pid da sessao suspeita
   AND l.locktype = 'relation'
 ORDER BY tabela;

-- Quem esta preso atras dela, direta ou indiretamente.
SELECT pid,
       state,
       now() - xact_start AS esperando_ha,
       pg_blocking_pids(pid) AS bloqueado_por,
       left(query, 80) AS query
  FROM pg_stat_activity
 WHERE cardinality(pg_blocking_pids(pid)) > 0
 ORDER BY xact_start;

-- O horizonte que o vacuum consegue enxergar neste momento.
SELECT min(age(backend_xmin)) FILTER (WHERE backend_xmin IS NOT NULL) AS mais_antiga_sessao,
       (SELECT max(age(xmin)) FROM pg_replication_slots)             AS slot_mais_antigo,
       (SELECT max(age(transaction)) FROM pg_prepared_xacts)         AS prepared_mais_antiga
  FROM pg_stat_activity;

A primeira consulta lista as transações antigas. A segunda mostra os locks da sessão suspeita: no incidente, o pid 48213 segurava ROW SHARE em pedidos, do SELECT FOR UPDATE, e ACCESS SHARE em três outras tabelas que o worker tinha consultado antes. A terceira usa pg_blocking_pids, que resolve a fila inteira: a migração estava bloqueada pelo 48213, e cada sessão da API estava bloqueada pela migração. Só a quarta consulta explica o inchaço, porque mostra que o horizonte do vacuum estava a 1,9 milhão de transações do presente, e que era uma sessão, não um slot abandonado nem uma transação preparada, que o segurava.

Com a sessão identificada, o encerramento imediato é pg_terminate_backend(pid), que derruba a conexão e faz ROLLBACK de tudo. pg_cancel_backend não ajuda em uma sessão parada, porque não há comando para cancelar. Terminar a sessão resolve o travamento em segundos, e é o que o time fez às 14h47 daquele dia, mas é remédio para o sintoma: o mesmo worker abriu outra transação igual na execução seguinte.

  • Preencha application_name em todas as conexões, com nome do serviço e do processo. Uma sessão parada com nome "faturamento-worker" leva ao código em minutos; sem nome, leva horas de correlação por client_addr e horário.
  • Guarde snapshots de pg_stat_activity a cada 30 segundos em uma tabela ou no sistema de métricas. Transações paradas de 5 minutos, que não chegam a derrubar nada, só aparecem em série histórica.
  • Ao analisar bloat, separe "vacuum atrasado" de "vacuum impedido". O primeiro se resolve com ajuste de autovacuum; o segundo só se resolve achando quem segura o horizonte, e qualquer ajuste de vacuum é tempo perdido.

04

De onde vêm as transações que ninguém fecha

Quase nenhuma transação parada é aberta de propósito. Elas nascem de padrões de código que parecem corretos no caminho feliz e deixam a transação aberta em algum caminho alternativo, ou que fazem dentro da transação algo cuja duração o banco não controla. O worker de faturamento tinha os três problemas mais comuns no mesmo arquivo.

// faturamento.js (antes): tres formas de deixar a transacao aberta sem perceber.
export async function emitirFatura(pool, pedidoId) {
  const client = await pool.connect();
  await client.query('BEGIN');

  const { rows } = await client.query(
    'SELECT * FROM pedidos WHERE id = $1 FOR UPDATE',
    [pedidoId],
  );
  const pedido = rows[0];

  // 1. Retorno antecipado: sai da funcao com BEGIN aberto e sem release.
  if (pedido.status === 'faturado') return pedido;

  // 2. Chamada externa dentro da transacao: o lock em pedidos e o xmin ficam
  //    presos pelo tempo que o ERP levar para responder. Sem timeout, para sempre.
  const nota = await fetch(process.env.ERP_URL + '/notas', {
    method: 'POST',
    body: JSON.stringify({ pedidoId, total: pedido.total }),
  }).then((r) => r.json());

  await client.query(
    'UPDATE pedidos SET status = $2, nota_fiscal = $3 WHERE id = $1',
    [pedidoId, 'faturado', nota.numero],
  );

  // 3. Se qualquer await acima lancar, nao ha ROLLBACK nem release: a conexao
  //    volta ao pool (ou nem isso) com a transacao ainda aberta.
  await client.query('COMMIT');
  client.release();
  return { ...pedido, nota };
}

O retorno antecipado sai da função com BEGIN aberto e sem release. Dependendo do pool, essa conexão fica perdida para sempre, ou volta ao pool com a transação aberta e é entregue à próxima requisição, que vai executar seus comandos dentro de uma transação que não abriu, com um lock em pedidos que não pediu. A chamada ao ERP dentro da transação é o que travou a API: o lock e o xmin ficam presos pelo tempo que o ERP levar, e um fetch sem signal de timeout espera indefinidamente em um socket que o outro lado esqueceu. E a ausência de try/finally faz qualquer exceção, de rede ou de banco, pular o COMMIT e o release de uma vez.

Outras origens frequentes, encontradas em auditorias de bases diferentes:

  • ORMs configurados para abrir uma transação por requisição em um middleware, que fica aberta enquanto o handler faz chamadas HTTP, envia e-mail ou espera upload. A transação dura o tempo da requisição inteira, não o do acesso ao banco.
  • Clientes com autocommit desligado por padrão, como o psycopg2 e alguns drivers JDBC, em que o primeiro SELECT abre uma transação que ninguém fecha porque a leitura "não precisava de commit".
  • Sessões interativas de analistas em ferramentas de BI ou psql que executam BEGIN, ou que usam um modo "manual commit", e ficam abertas durante o almoço. Essas são as que mais seguram o horizonte, porque duram horas.
  • Pool que devolve conexões sem verificar o estado. Uma conexão liberada com transação aberta por um bug de um endpoint contamina o endpoint seguinte, e a sessão parada aparece com um último comando que não tem nada a ver com a origem.
  • Conexões de jobs que morrem no meio sem fechar o socket, por kill -9 ou OOM do contêiner. O servidor só percebe quando tcp_keepalives vence, que no padrão do kernel pode ser mais de duas horas.

05

Fechar pelo código: todo caminho termina e o mundo externo fica fora

A correção tem duas partes. A primeira é estrutural: nenhum código da aplicação chama BEGIN diretamente. Toda transação passa por um helper que garante COMMIT no sucesso, ROLLBACK em qualquer exceção, release em todos os casos e descarte da conexão quando o estado dela é incerto. A segunda é de desenho: dentro do helper só entra código que fala com o banco. Chamada HTTP, fila, e-mail e espera por qualquer coisa que não seja o Postgres ficam fora, e a consistência entre as duas fases é garantida por um registro de efeito pendente, não por um lock.

// db/transacao.js: todo caminho de saida termina a transacao e devolve a conexao.
import { setTimeout as sleep } from 'node:timers/promises';

export async function emTransacao(pool, trabalho, { nome, limiteMs = 5000 } = {}) {
  const client = await pool.connect();
  let terminou = false;
  try {
    await client.query('BEGIN');
    // Nomeia a transacao para pg_stat_activity e limita a duracao no servidor:
    // uma transacao que estoura o limite cai por timeout, nao fica pendurada.
    await client.query(`SET LOCAL application_name = '${nome ?? 'app'}'`);
    await client.query(`SET LOCAL idle_in_transaction_session_timeout = '${limiteMs}ms'`);
    await client.query(`SET LOCAL lock_timeout = '2s'`);

    const resultado = await trabalho(client);

    await client.query('COMMIT');
    terminou = true;
    return resultado;
  } catch (erro) {
    try {
      await client.query('ROLLBACK');
      terminou = true;
    } catch {
      // Conexao quebrada: nao pode voltar ao pool.
    }
    throw erro;
  } finally {
    // Uma conexao cujo estado e incerto e descartada, nunca reutilizada.
    client.release(terminou ? undefined : new Error('transacao em estado desconhecido'));
  }
}

// faturamento.js (depois): o banco decide dentro da transacao, o mundo externo fica fora.
export async function emitirFatura(pool, pedidoId) {
  // Fase 1: curta, so banco. Marca a intencao e registra o efeito pendente (outbox).
  const reservado = await emTransacao(
    pool,
    async (client) => {
      const { rows } = await client.query(
        `UPDATE pedidos
            SET status = 'faturando'
          WHERE id = $1 AND status = 'pago'
      RETURNING id, total`,
        [pedidoId],
      );
      if (!rows[0]) return null; // ja faturado ou em andamento: nada a fazer
      await client.query(
        `INSERT INTO efeitos_pendentes (tipo, chave, payload)
         VALUES ('emitir_nota', $1, $2)
         ON CONFLICT (tipo, chave) DO NOTHING`,
        [`pedido:${pedidoId}`, JSON.stringify(rows[0])],
      );
      return rows[0];
    },
    { nome: 'faturamento:reservar' },
  );
  if (!reservado) return;

  // Fase 2: chamada externa sem nenhuma transacao aberta, com timeout proprio.
  const nota = await emitirNoErp(reservado, { timeoutMs: 15000 });

  // Fase 3: curta, so banco. Idempotente: se o worker reprocessar, nao duplica.
  await emTransacao(
    pool,
    async (client) => {
      await client.query(
        `UPDATE pedidos SET status = 'faturado', nota_fiscal = $2
          WHERE id = $1 AND status = 'faturando'`,
        [pedidoId, nota.numero],
      );
      await client.query(
        `DELETE FROM efeitos_pendentes WHERE tipo = 'emitir_nota' AND chave = $1`,
        [`pedido:${pedidoId}`],
      );
    },
    { nome: 'faturamento:confirmar' },
  );
}

async function emitirNoErp(pedido, { timeoutMs }) {
  for (let tentativa = 1; ; tentativa += 1) {
    try {
      const resposta = await fetch(process.env.ERP_URL + '/notas', {
        method: 'POST',
        headers: { 'content-type': 'application/json', 'idempotency-key': `pedido:${pedido.id}` },
        body: JSON.stringify({ pedidoId: pedido.id, total: pedido.total }),
        signal: AbortSignal.timeout(timeoutMs),
      });
      if (!resposta.ok) throw new Error(`ERP respondeu ${resposta.status}`);
      return resposta.json();
    } catch (erro) {
      if (tentativa >= 3) throw erro;
      await sleep(500 * 2 ** tentativa);
    }
  }
}

O helper faz três coisas que não são óbvias. SET LOCAL application_name dá nome à transação em pg_stat_activity, então uma sessão parada diz de qual fluxo veio. SET LOCAL idle_in_transaction_session_timeout faz o servidor derrubar a sessão se ela ficar parada além do limite, mesmo que o código tenha um bug novo que o helper não previu: o helper é a rede e o timeout é a rede da rede. E o release com um erro como argumento, no node-postgres, destrói a conexão em vez de devolvê-la ao pool, o que impede que uma conexão em estado desconhecido contamine a requisição seguinte.

O fluxo de faturamento virou três fases. A primeira reserva o pedido com um UPDATE condicional e grava o efeito pendente, em uma transação que dura alguns milissegundos. A segunda chama o ERP sem nenhuma conexão ao banco na mão, com timeout, retentativa e chave de idempotência, então o ERP pode demorar 47 minutos e o único prejuízo é desse pedido. A terceira confirma o resultado e apaga o efeito pendente. Se o processo morrer entre a segunda e a terceira fase, um worker de reconciliação lê efeitos_pendentes, repete a chamada ao ERP com a mesma chave de idempotência e completa o fluxo, e o status "faturando" impede que duas instâncias disputem o mesmo pedido.

Esse desenho troca uma garantia por outra. A versão original prometia atomicidade entre banco e ERP, que nunca existiu de fato, porque um COMMIT que falhasse depois da nota emitida já deixaria o sistema inconsistente. A nova versão promete convergência: o pedido pode ficar em "faturando" por alguns minutos, mas sempre chega a "faturado" ou volta a "pago" pela reconciliação, e nenhuma dessas esperas segura um lock.

06

Fechar pelo servidor: limites por papel e um alerta que chega antes

O código corrige o que o time controla, mas o banco recebe conexões de ORMs, ferramentas de BI, scripts de migração e pessoas, e nem todos passam pelo helper. O servidor precisa de limites próprios, e o lugar certo para eles é o papel de banco, não o postgresql.conf: a API, o worker, o BI e a migração têm necessidades diferentes, e um limite único ou é frouxo demais para a API ou quebra o BI.

-- Limites por papel: cada tipo de cliente do banco tem um teto proprio.
-- A aplicacao nao tem motivo para ficar parada dentro de uma transacao.
ALTER ROLE app_api      SET idle_in_transaction_session_timeout = '10s';
ALTER ROLE app_api      SET statement_timeout = '15s';
ALTER ROLE app_api      SET lock_timeout = '3s';

-- Workers fazem lotes maiores, mas tambem nao param no meio.
ALTER ROLE app_worker   SET idle_in_transaction_session_timeout = '30s';
ALTER ROLE app_worker   SET statement_timeout = '5min';

-- BI e pessoas: consultas longas sao legitimas, transacao parada nao e.
ALTER ROLE bi_leitura   SET idle_in_transaction_session_timeout = '60s';
ALTER ROLE bi_leitura   SET statement_timeout = '30min';
ALTER ROLE bi_leitura   SET default_transaction_read_only = on;

-- Postgres 17 ou superior: teto para a transacao inteira, ativa ou parada.
ALTER ROLE app_api      SET transaction_timeout = '30s';

-- Migracoes: desistem do lock rapido em vez de formar fila atras de alguem.
ALTER ROLE app_migracao SET lock_timeout = '5s';

-- Alerta: idade da transacao mais antiga por estado, para o coletor de metricas.
SELECT state,
       count(*)                                             AS sessoes,
       coalesce(extract(epoch FROM max(now() - xact_start)), 0)::int AS mais_antiga_s
  FROM pg_stat_activity
 WHERE backend_type = 'client backend'
   AND xact_start IS NOT NULL
 GROUP BY state;
ParâmetroO que limitaValor típico por papelCuidado
idle_in_transaction_session_timeoutTempo parado dentro de uma transação; ao estourar, a sessão é encerrada com ROLLBACKAPI 10 s, worker 30 s, BI 60 sEncerra a sessão inteira, não só a transação; o pool precisa tratar a conexão perdida
statement_timeoutDuração de um único comandoAPI 15 s, worker 5 min, BI 30 minNão ajuda com transação parada: não há comando rodando
lock_timeoutTempo esperando por um lockAPI 3 s, migração 5 sFaz a migração desistir em vez de formar fila; precisa de retentativa no script
transaction_timeout (Postgres 17 ou superior)Duração total da transação, ativa ou paradaAPI 30 sCobre o caso de muitos comandos curtos em sequência que nenhum outro limite pega
idle_session_timeout (Postgres 14 ou superior)Sessão parada fora de transaçãoBI 2 hLibera conexões esquecidas, mas não protege locks nem xmin, porque fora de transação não há o que proteger

O limite por papel precisa de uma contrapartida na aplicação: quando o servidor encerra uma sessão por timeout, o pool recebe um erro de conexão fechada na próxima consulta e precisa descartar a conexão e abrir outra, em vez de repetir o erro para o usuário. O node-postgres faz isso quando o erro chega pelo evento error do cliente, mas vale testar derrubando uma sessão com pg_terminate_backend em homologação e observando o que a aplicação faz na requisição seguinte.

O alerta que importa não é "houve uma transação parada", porque isso acontece o tempo todo por frações de segundo. É a idade da transação mais antiga por estado, coletada a cada 15 segundos pela última consulta do bloco acima e exportada como métrica. A regra adotada foi: "idle in transaction" com mais de 30 segundos gera aviso com pid, application_name e último comando; qualquer transação com mais de 5 minutos gera alerta para quem está de plantão; e o horizonte de xmin acima de 20 milhões de transações gera alerta de risco de wraparound. Os três chegam antes de a API travar.

IndicadorAntesDepois
Transação "idle in transaction" mais longa no mês2 h 50 min (sessão de BI)58 s (BI, encerrada por timeout)
Travamentos da API por fila de lock14 em 30 dias0 em 60 dias
Migração ADD COLUMN em pedidos35 min, cancelada1,8 s, com uma retentativa por lock_timeout
Tamanho de pedidos com 4,3 milhões de linhas31 GB21 GB após pg_repack, estável
Tuplas mortas que o autovacuum não pôde remover (média por execução)1,2 milhão0
Conexões do pool da API efetivamente disponíveis em pico29 de 4040 de 40

FAQ

Perguntas frequentes

Por que não usar um idle_in_transaction_session_timeout global curto, como 5 segundos, e resolver tudo de uma vez?

Porque o limite certo depende de quem está do outro lado. Cinco segundos é generoso para uma API, mas derruba um worker que faz um lote legítimo de 10 segundos entre dois comandos e quebra qualquer analista que use transação manual. Um limite global curto tende a ser afrouxado na primeira reclamação e acaba em 10 minutos, o que não protege ninguém. Por papel, cada limite pode ser justo: a API fica em 10 segundos, o worker em 30, o BI em 60, e cada um só reclama do seu.

Uma transação só de leitura, sem nenhum UPDATE, também causa esses problemas?

Sim, em dois dos três recursos. Um SELECT deixa ACCESS SHARE pendurado até o fim da transação, o que basta para enfileirar uma migração e tudo o que vier atrás dela. E qualquer transação com snapshot ativo segura o horizonte de xmin, então uma consulta de BI em READ COMMITTED que executou um SELECT e ficou parada impede o vacuum do banco inteiro exatamente como um UPDATE faria. A única coisa que ela não segura são locks de linha.

Em vez de reestruturar em três fases, não basta pôr um timeout no fetch dentro da transação?

O timeout reduz o dano, mas mantém o desenho errado. Com 15 segundos de timeout, o lock em pedidos fica preso por até 15 segundos a cada chamada, e com 50 faturamentos por minuto isso é uma fila permanente. Além disso, o timeout que estoura depois de o ERP ter emitido a nota deixa o sistema inconsistente de qualquer jeito, e o ROLLBACK desfaz o registro do banco enquanto a nota existe no ERP. Separar as fases com um efeito pendente resolve os dois problemas: a transação dura milissegundos e a reconciliação garante que a nota emitida seja registrada.

Transação é posse, não só código

Os três incidentes tinham uma raiz só: transações que ficavam abertas enquanto o código esperava algo que o banco não controla, ou que ninguém fechava porque o caminho de saída não previa o fim. Uma transação parada não gasta CPU e não aparece em nenhum painel de consultas lentas, mas segura locks, horizonte de vacuum e conexões por tempo ilimitado, e cobra isso de todo mundo. A correção foi tornar o fim da transação impossível de esquecer pelo código, tirar o mundo externo de dentro dela, dar ao servidor limites por papel para o que o código não cobre e medir a idade da transação mais antiga como indicador de saúde do banco. A pergunta que vale para qualquer serviço é: qual é a transação mais antiga aberta agora, quem a abriu e o que ela está esperando?