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.