O executor encapsula o ciclo completo: tenta adquirir, desiste sem erro se outro dono estiver ativo, renova em segundo plano enquanto a tarefa roda, sinaliza a tarefa para parar se a renovação falhar e libera no final. A tarefa recebe a geração e um AbortSignal, e fica responsável por conferir o sinal entre unidades de trabalho.
// trava-job.js: lease no PostgreSQL com renovacao e geracao (node-postgres).
import os from 'node:os';
import { randomUUID } from 'node:crypto';
const DONO = os.hostname() + ':' + process.pid + ':' + randomUUID().slice(0, 8);
const SQL_ADQUIRIR =
'INSERT INTO travas_job AS t (nome, dono, geracao, expira_em) ' +
'VALUES ($1, $2, 1, now() + make_interval(secs => $3)) ' +
'ON CONFLICT (nome) DO UPDATE SET dono = EXCLUDED.dono, ' +
'geracao = t.geracao + 1, expira_em = EXCLUDED.expira_em ' +
'WHERE t.expira_em <= now() RETURNING geracao';
const SQL_RENOVAR =
'UPDATE travas_job SET expira_em = now() + make_interval(secs => $3) ' +
'WHERE nome = $1 AND dono = $2 AND geracao = $4 AND expira_em > now()';
const SQL_LIBERAR =
'UPDATE travas_job SET expira_em = now() ' +
'WHERE nome = $1 AND dono = $2 AND geracao = $3';
export async function executarComTrava(pool, nome, ttlSegundos, tarefa) {
const { rows } = await pool.query(SQL_ADQUIRIR, [nome, DONO, ttlSegundos]);
if (rows.length === 0) return { executou: false };
const geracao = rows[0].geracao; // bigint chega como string no node-postgres
const controle = new AbortController();
// Renova a cada terco do prazo. Na duvida (erro ou posse perdida), para a tarefa.
const renovacao = setInterval(async () => {
try {
const r = await pool.query(SQL_RENOVAR, [nome, DONO, ttlSegundos, geracao]);
if (r.rowCount === 0) controle.abort(new Error('trava perdida'));
} catch (erro) {
controle.abort(erro);
}
}, (ttlSegundos * 1000) / 3);
try {
const resultado = await tarefa({ geracao, sinal: controle.signal });
return { executou: true, resultado };
} finally {
clearInterval(renovacao);
await pool.query(SQL_LIBERAR, [nome, DONO, geracao]).catch(() => {});
}
}
O AbortSignal reduz a janela de dano, mas não a elimina. Um processo pausado não executa o callback de renovação, não percebe o abort e não confere o sinal: ele simplesmente volta a rodar a próxima linha, que pode ser uma escrita. Para fechar essa janela, a escrita precisa conferir a posse no mesmo lugar em que acontece. Quando o recurso protegido está no mesmo banco, isso é uma consulta com FOR SHARE dentro da transação da escrita.
// fechamento-diario.js: cada loja e gravada em uma transacao que confere a posse.
import { executarComTrava } from './trava-job.js';
const SQL_CONFERIR_POSSE =
'SELECT 1 FROM travas_job ' +
'WHERE nome = $1 AND geracao = $2 AND expira_em > now() FOR SHARE';
export function fechamentoDiario(pool, dia) {
return executarComTrava(pool, 'fechamento-diario', 90, async ({ geracao, sinal }) => {
const { rows: lojas } = await pool.query('SELECT id FROM lojas WHERE ativa ORDER BY id');
for (const loja of lojas) {
sinal.throwIfAborted();
const cliente = await pool.connect();
try {
await cliente.query('BEGIN');
const posse = await cliente.query(SQL_CONFERIR_POSSE, ['fechamento-diario', geracao]);
if (posse.rowCount === 0) throw new Error('trava perdida antes da escrita');
await cliente.query(
'INSERT INTO comissoes (loja_id, dia, valor) ' +
'SELECT $1::bigint, $2::date, coalesce(sum(total), 0) * 0.05 FROM pedidos ' +
'WHERE loja_id = $1::bigint AND criado_em >= $2::date AND criado_em < $2::date + 1 ' +
'ON CONFLICT (loja_id, dia) DO NOTHING',
[loja.id, dia],
);
await cliente.query('COMMIT');
} catch (erro) {
await cliente.query('ROLLBACK');
throw erro;
} finally {
cliente.release();
}
}
});
}
O FOR SHARE é o que transforma a conferência em cerca. Ele trava a linha da trava em modo compartilhado até o COMMIT, e a tomada de posse por outra instância precisa de um bloqueio de atualização sobre a mesma linha, que conflita com ele. Assim, ou a conferência vê a geração vencida e aborta, ou a escrita termina antes que qualquer outro dono possa assumir. FOR KEY SHARE não serviria, porque não conflita com uma atualização que não muda a chave primária. A restrição única em comissoes com ON CONFLICT DO NOTHING é a última camada: mesmo que tudo acima falhe, a segunda gravação da mesma loja no mesmo dia não produz um segundo lançamento.
Quando o efeito é externo, como uma API de pagamentos ou um envio de e-mail, a mesma ideia vale na forma de fencing token: a geração vai junto com a requisição e o destino recusa qualquer requisição com geração menor que a maior já vista para aquele recurso. Se o destino não oferece isso, o recurso disponível é a chave de idempotência derivada da ocorrência, como fechamento-2026-09-25-loja-42, que faz a segunda chamada devolver o resultado da primeira em vez de repetir o efeito.