Uma saga implementada como uma função async com try/catch funciona até o processo morrer entre o passo 2 e o passo 3, num deploy, num OOM ou num nó que some. Nesse momento, a única cópia do "onde eu estava" desaparece com a memória, e os passos 1 e 2 ficam órfãos exatamente como no checkout original. O estado precisa ser persistido antes de cada chamada externa, num lugar que sobreviva ao processo, e a forma mais direta é uma tabela de execuções e uma tabela de passos no próprio banco do serviço orquestrador.
-- saga.sql
-- O estado da saga mora no banco, nao na memoria do processo: se o orquestrador
-- cair entre o passo 2 e o passo 3, outro processo sabe exatamente onde retomar.
CREATE TABLE saga_execucoes (
id uuid PRIMARY KEY,
tipo text NOT NULL,
estado text NOT NULL CHECK (estado IN
('EXECUTANDO', 'COMPENSANDO', 'CONCLUIDA', 'COMPENSADA', 'INTERVENCAO')),
dados jsonb NOT NULL,
tentativas int NOT NULL DEFAULT 0,
proxima_tentativa timestamptz NOT NULL DEFAULT now(),
atualizado_em timestamptz NOT NULL DEFAULT now()
);
-- Um registro por passo. INICIADO significa "chamamos e nao sabemos o resultado":
-- e o estado que obriga a compensar mesmo sem confirmacao de que o efeito aconteceu.
CREATE TABLE saga_passos (
saga_id uuid NOT NULL REFERENCES saga_execucoes (id),
ordem int NOT NULL,
nome text NOT NULL,
estado text NOT NULL CHECK (estado IN ('INICIADO', 'CONCLUIDO', 'FALHOU', 'COMPENSADO')),
resultado jsonb,
erro text,
atualizado_em timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (saga_id, ordem)
);
-- O worker so procura sagas vivas e vencidas; as terminadas nao entram no indice.
CREATE INDEX saga_execucoes_pendentes
ON saga_execucoes (proxima_tentativa)
WHERE estado IN ('EXECUTANDO', 'COMPENSANDO');
O orquestrador abaixo processa um passo por vez. Ele reivindica uma saga vencida com FOR UPDATE SKIP LOCKED e um arrendamento de dois minutos, executa ou compensa o próximo passo, grava o resultado e agenda a próxima rodada. Nenhuma transação fica aberta durante a chamada ao participante, o que permite rodar quantos workers forem necessários e garante que um worker que morre só atrasa a saga até o arrendamento vencer. A escolha entre orquestração e coreografia, em que cada serviço reage aos eventos do anterior, pesa aqui: com cinco passos e regras de compensação diferentes por tipo, ter um único lugar que conhece a ordem e o estado vale mais do que o desacoplamento da coreografia.
// saga-pedido.js
import pg from 'pg';
import { randomUUID } from 'node:crypto';
export const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
// A ordem e a decisao de arquitetura: compensaveis primeiro, o pivo no meio,
// e depois do pivo so passos que sempre terminam se forem repetidos.
export const passos = [
{ nome: 'reservar_estoque', tipo: 'compensavel' },
{ nome: 'pre_autorizar_pagamento', tipo: 'compensavel' },
{ nome: 'agendar_coleta', tipo: 'compensavel' },
{ nome: 'capturar_pagamento', tipo: 'pivo' },
{ nome: 'emitir_nota_fiscal', tipo: 'retentavel' },
];
// Erro de negocio: o participante respondeu "nao" e nenhum efeito aconteceu
// (cartao recusado, produto sem estoque). Qualquer outro erro e resultado incerto.
export class ErroDefinitivo extends Error {}
const MAX_TENTATIVAS_COMPENSAVEL = 5;
const LIMITE_INTERVENCAO = 20;
const backoffSegundos = (n) => (n === 0 ? 0 : Math.min(2 ** n, 300));
export async function iniciarSaga(dados) {
const id = randomUUID();
await pool.query(
`INSERT INTO saga_execucoes (id, tipo, estado, dados) VALUES ($1, 'pedido', 'EXECUTANDO', $2)`,
[id, dados],
);
return id;
}
async function agendar(sagaId, estado, tentativas) {
await pool.query(
`UPDATE saga_execucoes
SET estado = $2, tentativas = $3,
proxima_tentativa = now() + make_interval(secs => $4),
atualizado_em = now()
WHERE id = $1`,
[sagaId, estado, tentativas, backoffSegundos(tentativas)],
);
}
// Reivindica uma saga vencida com um arrendamento de 2 minutos. Nenhuma transacao
// fica aberta durante a chamada externa: se o worker morrer, o arrendamento expira
// e outro worker retoma. Por isso toda chamada a participante precisa de timeout
// menor que o arrendamento.
async function reivindicar() {
const { rows } = await pool.query(
`UPDATE saga_execucoes
SET proxima_tentativa = now() + interval '2 minutes'
WHERE id = (
SELECT id FROM saga_execucoes
WHERE estado IN ('EXECUTANDO', 'COMPENSANDO') AND proxima_tentativa <= now()
ORDER BY proxima_tentativa
FOR UPDATE SKIP LOCKED
LIMIT 1)
RETURNING *`,
);
return rows[0] ?? null;
}
async function executarProximo(saga, acoes) {
const { rows } = await pool.query(
`SELECT count(*)::int AS n FROM saga_passos WHERE saga_id = $1 AND estado = 'CONCLUIDO'`,
[saga.id],
);
const ordem = rows[0].n;
if (ordem === passos.length) {
await agendar(saga.id, 'CONCLUIDA', 0);
return;
}
const passo = passos[ordem];
// INICIADO e gravado antes da chamada: se o processo cair durante ela, a
// retomada sabe que o efeito pode ter acontecido.
await pool.query(
`INSERT INTO saga_passos (saga_id, ordem, nome, estado) VALUES ($1, $2, $3, 'INICIADO')
ON CONFLICT (saga_id, ordem) DO UPDATE SET estado = 'INICIADO', atualizado_em = now()`,
[saga.id, ordem, passo.nome],
);
try {
// A mesma chave em toda tentativa: repetir o passo nunca duplica o efeito.
const resultado = await acoes[passo.nome].executar(saga.dados, `${saga.id}:${passo.nome}`);
await pool.query(
`UPDATE saga_passos SET estado = 'CONCLUIDO', resultado = $3, erro = NULL, atualizado_em = now()
WHERE saga_id = $1 AND ordem = $2`,
[saga.id, ordem, resultado ?? null],
);
await agendar(saga.id, 'EXECUTANDO', 0);
} catch (erro) {
const definitivo = erro instanceof ErroDefinitivo;
const tentativas = saga.tentativas + 1;
await pool.query(
`UPDATE saga_passos SET estado = $3, erro = $4, atualizado_em = now()
WHERE saga_id = $1 AND ordem = $2`,
[saga.id, ordem, definitivo ? 'FALHOU' : 'INICIADO', String(erro.message)],
);
let proximo;
if (passo.tipo === 'retentavel') {
// Depois do pivo nao existe volta: so seguir em frente ou chamar uma pessoa.
proximo = definitivo || tentativas >= LIMITE_INTERVENCAO ? 'INTERVENCAO' : 'EXECUTANDO';
} else if (definitivo) {
proximo = 'COMPENSANDO';
} else if (passo.tipo === 'compensavel' && tentativas >= MAX_TENTATIVAS_COMPENSAVEL) {
// Incerto e compensavel: desistir e desfazer, inclusive este passo.
proximo = 'COMPENSANDO';
} else {
// Pivo incerto: nunca compensar no escuro. Repetir com a mesma chave ate
// saber se a captura aconteceu.
proximo = tentativas >= LIMITE_INTERVENCAO ? 'INTERVENCAO' : 'EXECUTANDO';
}
await agendar(saga.id, proximo, proximo === 'COMPENSANDO' ? 0 : tentativas);
}
}
async function compensarProximo(saga, acoes) {
// Do ultimo para o primeiro, so o que aconteceu (CONCLUIDO) ou pode ter
// acontecido (INICIADO). FALHOU nao entra: o participante disse que nada mudou.
const { rows } = await pool.query(
`SELECT ordem, nome, resultado FROM saga_passos
WHERE saga_id = $1 AND estado IN ('CONCLUIDO', 'INICIADO')
ORDER BY ordem DESC
LIMIT 1`,
[saga.id],
);
if (rows.length === 0) {
await agendar(saga.id, 'COMPENSADA', 0);
return;
}
const passo = rows[0];
try {
await acoes[passo.nome].compensar(saga.dados, passo.resultado, `${saga.id}:${passo.nome}`);
await pool.query(
`UPDATE saga_passos SET estado = 'COMPENSADO', atualizado_em = now()
WHERE saga_id = $1 AND ordem = $2`,
[saga.id, passo.ordem],
);
await agendar(saga.id, 'COMPENSANDO', 0);
} catch {
// Compensacao nao tem desistencia: repete com backoff e, passado o limite,
// continua parada em INTERVENCAO ate alguem resolver.
const tentativas = saga.tentativas + 1;
await agendar(saga.id, tentativas >= LIMITE_INTERVENCAO ? 'INTERVENCAO' : 'COMPENSANDO', tentativas);
}
}
// Processa um passo de uma saga. Rodar em loop, em quantos workers quiser.
export async function processarUma(acoes) {
const saga = await reivindicar();
if (!saga) return false;
if (saga.estado === 'EXECUTANDO') await executarProximo(saga, acoes);
else await compensarProximo(saga, acoes);
return true;
}
- A chave de idempotência é sagaId:passo e é a mesma em toda tentativa e na compensação. É ela que permite repetir sem medo e que conecta a compensação ao efeito certo no participante.
- O passo é gravado como INICIADO antes da chamada. Se o processo cair durante ela, a retomada encontra INICIADO e trata o efeito como possível, não como inexistente.
- O contador de tentativas é da saga e volta a zero a cada passo concluído, então um passo instável não herda as falhas do anterior.
- Toda chamada a participante precisa de timeout menor que o arrendamento; caso contrário, um worker lento e outro que assumiu a saga chamam o mesmo passo ao mesmo tempo. A chave de idempotência torna isso inofensivo, mas não gratuito.