Blog

Saga sem compensação: desfazer um fluxo distribuído que falhou no terceiro de cinco passos

O checkout de um e-commerce tem cinco passos que falam com quatro sistemas diferentes: reservar o estoque no serviço de inventário, cobrar o cartão no provedor de pagamento, agendar a coleta na transportadora, emitir a nota fiscal e avisar o cliente. Numa terça-feira, a API da transportadora começa a responder em 40 segundos e o checkout passa a falhar no terceiro passo. O código faz o que foi escrito para fazer: registra o erro e devolve 500. Uma semana depois, o financeiro encontra 312 cartões cobrados sem pedido, o estoque mostra 1.140 unidades reservadas para ninguém, e a transportadora fatura 27 coletas que chegaram a ser agendadas, porque o timeout aconteceu depois que ela já tinha gravado o pedido. Não houve bug de lógica; houve um fluxo distribuído sem caminho de volta. Este artigo mostra por que compensação não é rollback, como ordenar os passos em compensáveis, pivô e re-tentáveis para que exista um ponto de não retorno bem definido, como persistir o estado da saga para sobreviver à queda do orquestrador, como tratar o timeout que pode ou não ter produzido efeito, qual isolamento a saga não oferece e como compensar essa ausência, e como provar em teste que nenhuma falha, em nenhum passo, deixa efeito órfão.

2026-10-23 / Arquitetura / 16 min

01

O que sobra quando o terceiro passo falha: efeitos que ninguém vai desfazer sozinho

Dentro de um único banco, uma falha no meio da operação é resolvida pelo ROLLBACK: nada do que foi escrito na transação chega a existir para os outros. Quando os passos atravessam serviços, não há transação que os envolva. Cada chamada bem-sucedida é um fato consumado em outro sistema, com outra equipe, outro banco e outro ciclo de vida. Se o passo 3 falha, os passos 1 e 2 continuam valendo até que alguém execute, de propósito, uma ação que os anule. O fluxo original não tinha essa ação, então quem a executava era o financeiro, com planilha, uma semana depois.

O primeiro exercício é olhar cada passo e responder três perguntas: que efeito ele deixa no mundo, que ação o anula e se essa anulação devolve exatamente o estado anterior. A resposta raramente é sim. Estornar uma cobrança não apaga a cobrança: gera um segundo lançamento, aparece na fatura do cliente e pode levar dias. Uma nota fiscal autorizada só pode ser cancelada dentro do prazo da SEFAZ, normalmente 24 horas, e depois disso só com carta de correção ou nota de devolução. Um e-mail enviado não volta.

PassoEfeito no mundoAção que anulaAnulação é perfeita?
Reservar estoqueUnidades indisponíveis para outros clientesLiberar a reservaSim, se feita antes do vencimento da reserva
Cobrar o cartão (captura)Dinheiro movimentado, lançamento na faturaEstorno (refund)Não: dois lançamentos, prazo de dias, taxa do adquirente
Pré-autorizar o cartãoLimite bloqueado, sem lançamentoCancelar a pré-autorização (void)Quase: o limite volta, sem lançamento na fatura
Agendar coletaTransportadora reserva veículo e janelaCancelar a coletaSim, se cancelada antes da janela
Emitir nota fiscalDocumento fiscal autorizadoCancelamento na SEFAZSó dentro do prazo; depois, só nota de devolução
Avisar o clienteMensagem entregueNenhumaNão existe; no máximo, uma segunda mensagem

A tabela já sugere o desenho. Passos com anulação perfeita e barata podem vir antes de qualquer coisa arriscada. Passos com anulação cara ou inexistente precisam vir depois de todo o resto ter dado certo. O erro do checkout original não era a falta de compensação no passo 3; era cobrar o cartão, o passo de anulação mais cara, logo no segundo passo.

02

Compensável, pivô e re-tentável: a ordem dos passos é a decisão de arquitetura

Uma saga é uma sequência de transações locais em que cada uma tem uma transação de compensação semanticamente inversa. A formulação original, de Garcia-Molina e Salem em 1987, já deixava claro que compensar não é desfazer: é executar uma nova ação de negócio que neutraliza o efeito da anterior. Na prática, os passos se dividem em três tipos. Compensáveis são os que têm uma ação de anulação confiável. O pivô é o passo que decide o destino da saga: se ele confirma, a saga vai até o fim; se ele recusa, tudo antes dele é compensado. Re-tentáveis são os passos posteriores ao pivô, que não podem ser desfeitos e por isso precisam ter a garantia de que, repetidos, eventualmente terminam.

Com essa classificação, o checkout é reordenado. A cobrança vira duas operações: uma pré-autorização no passo 2, que bloqueia o limite e é anulável com um void sem lançamento na fatura, e a captura no passo 4, que é o pivô. A coleta entra antes da captura, porque é cancelável. A nota fiscal vem depois, porque só deve existir se o pagamento existir, e a emissão é re-tentável: a SEFAZ pode estar fora, mas uma hora volta, e a chave de idempotência garante que a repetição não gera duas notas.

Pedido: cinco passos, um pivô, e o que acontece quando algo falha em cada trecho.

 [1] reservar estoque        compensável   desfaz: liberar reserva
 [2] pré-autorizar cartão    compensável   desfaz: cancelar pré-autorização (void)
 [3] agendar coleta          compensável   desfaz: cancelar coleta (aceita "não existe")
 ─────────────────────────── PIVÔ ───────────────────────────────────────────
 [4] capturar pagamento      pivô          sucesso: só existe ir em frente
                                           recusa: compensar 3, 2, 1
                                           timeout: repetir com a mesma chave
 ────────────────────────────────────────────────────────────────────────────
 [5] emitir nota fiscal      re-tentável   falhou: repetir até emitir ou chamar alguém

 Falha definitiva em 1..4  ──>  COMPENSANDO  ──>  desfaz do último ao primeiro  ──>  COMPENSADA
 Timeout em 1..3 (5 vezes) ──>  COMPENSANDO  (o passo incerto também é desfeito)
 Qualquer falha em 5       ──>  repetir      ──>  CONCLUIDA ou INTERVENCAO, nunca COMPENSADA

O valor desse desenho está no que ele proíbe. Nenhum caminho leva a compensar um passo depois do pivô, então nunca é preciso cancelar nota fiscal ou estornar captura por causa de uma falha técnica. Toda falha antes do pivô é resolvida com ações baratas e quase perfeitas. Toda falha depois dele é resolvida insistindo, e o pior caso é uma pessoa sendo chamada para destravar uma emissão, não um cliente cobrado sem pedido. Quando um passo de anulação cara precisa existir antes do pivô, isso é um sinal de que o fluxo deve ser redesenhado, não de que a compensação deve ficar mais esperta.

03

O estado da saga mora no banco: orquestrador que cai no meio não pode esquecer onde parou

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.

04

O timeout não é falha: compensar o que talvez tenha acontecido

As 27 coletas faturadas do incidente vieram de um detalhe que o try/catch esconde: o timeout diz que a resposta não chegou, não que a ação não aconteceu. A transportadora recebeu o pedido, gravou a coleta e demorou para responder; o checkout desistiu e tratou o passo como não executado. Por isso o orquestrador distingue dois tipos de erro. ErroDefinitivo é a resposta explícita do participante dizendo que nada mudou, como cartão recusado ou produto sem estoque, e o passo vai para FALHOU, fora da compensação. Qualquer outro erro, timeout, 502, conexão resetada, deixa o passo em INICIADO, e a compensação inclui esse passo.

Compensar algo que talvez não tenha acontecido só é seguro se a compensação aceitar "não existe" como sucesso. E há um caso pior: a compensação pode chegar antes da ação. O orquestrador desistiu por timeout e mandou cancelar, mas o pedido original ainda estava numa fila interna da transportadora e é processado dois segundos depois do cancelamento. Se o cancelamento simplesmente não encontrar nada e responder 200, a coleta é agendada logo em seguida e fica órfã para sempre. A defesa é do lado do participante: o cancelamento grava uma lápide na chave de idempotência, e a criação atrasada encontra a lápide e se recusa.

// coletas.js (servico da transportadora, lado do participante)
// A compensacao pode chegar antes da acao: o orquestrador desistiu por timeout
// enquanto o pedido de coleta ainda estava na fila do participante. O cancelamento
// grava uma lapide na chave; a criacao atrasada encontra a lapide e nao agenda nada.
//
// CREATE TABLE coletas (
//   chave_idempotencia text PRIMARY KEY,
//   pedido_id          bigint,
//   estado             text NOT NULL CHECK (estado IN ('AGENDADA', 'CANCELADA'))
// );

export async function agendarColeta(db, chave, pedidoId) {
  const { rows } = await db.query(
    `INSERT INTO coletas (chave_idempotencia, pedido_id, estado)
     VALUES ($1, $2, 'AGENDADA')
     ON CONFLICT (chave_idempotencia) DO NOTHING
     RETURNING chave_idempotencia, estado`,
    [chave, pedidoId],
  );
  if (rows.length > 0) return { status: 201, coleta: rows[0] };

  // Chave repetida: devolve o que ja existe, seja a coleta criada antes ou a lapide.
  const { rows: [existente] } = await db.query(
    'SELECT chave_idempotencia, estado FROM coletas WHERE chave_idempotencia = $1',
    [chave],
  );
  return { status: existente.estado === 'CANCELADA' ? 409 : 200, coleta: existente };
}

export async function cancelarColeta(db, chave) {
  // Idempotente e tolerante a ausencia: cancelar o que nao existe grava a lapide,
  // cancelar duas vezes nao muda nada, e a resposta e sempre sucesso.
  await db.query(
    `INSERT INTO coletas (chave_idempotencia, pedido_id, estado)
     VALUES ($1, NULL, 'CANCELADA')
     ON CONFLICT (chave_idempotencia) DO UPDATE SET estado = 'CANCELADA'`,
    [chave],
  );
  return { status: 200 };
}

Esse contrato precisa estar escrito para cada participante, e é aí que mora a maior parte do trabalho real: serviços internos aceitam a mudança; APIs de terceiros nem sempre. Quando o terceiro não oferece idempotência nem cancelamento por chave, a alternativa é consultar antes de compensar, buscando a coleta pelo identificador do pedido, e tratar a consulta como mais um passo que pode ser incerto. O pivô é o caso em que não se compensa no escuro: captura incerta é repetida com a mesma chave até o provedor responder se capturou, porque compensar uma captura que não aconteceu e depois descobrir que ela aconteceu é exatamente o cliente cobrado sem pedido.

05

O isolamento que a saga não tem: o cliente vê o pedido que vai ser desfeito

Uma saga tem atomicidade eventual, consistência eventual e durabilidade, mas não tem isolamento. Entre o passo 1 e a compensação, a reserva de estoque é visível para todo mundo: outro cliente vê o produto como esgotado, um relatório conta a venda, e a tela de pedidos mostra "confirmado" para algo que vai ser desfeito trinta segundos depois. São as mesmas anomalias que os níveis de isolamento de um banco existem para impedir, agora sem banco para impedir.

AnomaliaComo aparece no checkoutContramedida
Leitura sujaCliente recebe "pedido confirmado" de uma saga que depois é compensadaBloqueio semântico: pedido nasce PENDENTE e só vira CONFIRMADO depois do pivô; nenhuma comunicação antes disso
Atualização perdidaCompensação devolve 3 unidades ao estoque com SET quantidade = 10, apagando uma venda concorrenteOperações comutativas: compensar com quantidade = quantidade + 3, nunca com valor absoluto
Leitura não repetívelPreço muda entre a reserva e a captura, e a captura cobra o valor novoReler e comparar: a captura usa o valor gravado na saga e falha definitivamente se divergir
Decisão sobre dado provisórioAntifraude aprova um segundo pedido porque o primeiro, ainda não capturado, conta como históricoExpor o estado provisório: consumidores filtram PENDENTE ou o tratam explicitamente

A contramedida mais importante é a primeira, e ela muda o produto, não só o código. O pedido precisa ter um estado visível que significa "em andamento", e a interface precisa saber mostrá-lo: "estamos confirmando seu pagamento" é honesto; "pedido confirmado" seguido de um e-mail de cancelamento não é. É também por isso que avisar o cliente fica depois do pivô, de preferência disparado pelo evento de saga concluída, e não como um passo da saga.

06

Provar que nenhuma falha deixa efeito órfão: o teste que injeta erro em cada passo

A propriedade que importa numa saga é uma invariante global: ao final, ou todos os efeitos aconteceram e nenhum foi desfeito, ou todo efeito que aconteceu foi desfeito. Ela não aparece em teste de caminho feliz, e o caminho feliz é o único que costuma ser testado. O teste abaixo usa participantes falsos que registram cada efeito por chave, injeta falha definitiva e timeout em cada trecho do fluxo, roda o orquestrador real contra um Postgres descartável e verifica a invariante. O modo timeout aplica o efeito antes de lançar o erro, que é o caso que gerou as coletas órfãs.

// saga-pedido.test.js
// Roda contra um Postgres descartavel com saga.sql aplicado:
//   DATABASE_URL=postgres://localhost/saga_teste node --test
import { test, after } from 'node:test';
import assert from 'node:assert/strict';
import { passos, processarUma, iniciarSaga, ErroDefinitivo, pool } from './saga-pedido.js';

after(() => pool.end());

// Participantes falsos que registram cada efeito por chave. O modo 'timeout'
// aplica o efeito e depois lanca erro: e o pior caso real, o participante fez
// e a resposta se perdeu no caminho.
function participantesFalsos({ falharEm, modo, vezes }) {
  const efeitos = new Map();
  let falhasRestantes = vezes;
  const acoes = {};
  for (const { nome } of passos) {
    acoes[nome] = {
      async executar(_dados, chave) {
        if (nome === falharEm && falhasRestantes > 0) {
          falhasRestantes -= 1;
          if (modo === 'definitivo') throw new ErroDefinitivo('recusado');
          efeitos.set(chave, 'feito');
          throw new Error('timeout');
        }
        efeitos.set(chave, 'feito');
        return { ok: true };
      },
      async compensar(_dados, _resultado, chave) {
        if (efeitos.get(chave) === 'feito') efeitos.set(chave, 'desfeito');
      },
    };
  }
  return { acoes, efeitos };
}

async function rodarAteParar(id, acoes) {
  for (let i = 0; i < 200; i += 1) {
    const { rows: [saga] } = await pool.query('SELECT estado FROM saga_execucoes WHERE id = $1', [id]);
    if (['CONCLUIDA', 'COMPENSADA', 'INTERVENCAO'].includes(saga.estado)) return saga.estado;
    // O teste nao espera o backoff: torna a saga elegivel de novo na hora.
    await pool.query('UPDATE saga_execucoes SET proxima_tentativa = now() WHERE id = $1', [id]);
    await processarUma(acoes);
  }
  throw new Error('saga nao terminou em 200 iteracoes');
}

const casos = [
  { falharEm: 'reservar_estoque', modo: 'definitivo', vezes: Infinity, esperado: 'COMPENSADA' },
  { falharEm: 'agendar_coleta', modo: 'definitivo', vezes: Infinity, esperado: 'COMPENSADA' },
  { falharEm: 'agendar_coleta', modo: 'timeout', vezes: Infinity, esperado: 'COMPENSADA' },
  { falharEm: 'capturar_pagamento', modo: 'definitivo', vezes: Infinity, esperado: 'COMPENSADA' },
  { falharEm: 'capturar_pagamento', modo: 'timeout', vezes: 2, esperado: 'CONCLUIDA' },
  { falharEm: 'emitir_nota_fiscal', modo: 'timeout', vezes: 3, esperado: 'CONCLUIDA' },
];

for (const caso of casos) {
  test(`falha ${caso.modo} em ${caso.falharEm} termina ${caso.esperado} sem efeito orfao`, async () => {
    await pool.query('TRUNCATE saga_passos, saga_execucoes');
    const { acoes, efeitos } = participantesFalsos(caso);
    const id = await iniciarSaga({ pedidoId: 42 });

    const final = await rodarAteParar(id, acoes);
    assert.equal(final, caso.esperado);

    const estados = [...efeitos.values()];
    if (final === 'COMPENSADA') {
      // Invariante: tudo o que aconteceu foi desfeito, inclusive o passo incerto.
      assert.ok(estados.every((e) => e === 'desfeito'), JSON.stringify([...efeitos]));
    } else {
      // Invariante: todos os passos aconteceram e nenhum foi desfeito.
      assert.equal(efeitos.size, passos.length);
      assert.ok(estados.every((e) => e === 'feito'));
    }
  });
}

Os seis casos cobrem as quatro fronteiras que o desenho cria: falha definitiva antes do pivô, incerteza persistente num passo compensável, recusa e incerteza no próprio pivô, e falha depois dele. Vale acrescentar dois testes que exigem um pouco mais de estrutura: matar o worker entre gravar INICIADO e chamar o participante, verificando que outro worker retoma depois do arrendamento, e fazer a compensação falhar algumas vezes antes de passar, verificando que a saga não termina COMPENSADA com um passo ainda CONCLUIDO. Em produção, o complemento do teste é uma consulta diária por sagas em INTERVENCAO e por sagas em EXECUTANDO ou COMPENSANDO há mais de uma hora; qualquer linha nessa consulta é um efeito potencialmente órfão com nome, chave e passo.

FAQ

Perguntas frequentes

Não seria mais simples usar uma transação distribuída com two-phase commit?

Só se todos os participantes suportarem o protocolo, e provedores de pagamento, transportadoras e a SEFAZ não suportam. Mesmo entre serviços internos, o two-phase commit mantém locks em todos os participantes enquanto o coordenador decide, e um coordenador que cai na hora errada deixa recursos travados até alguém intervir. A saga troca isolamento por disponibilidade: cada passo confirma localmente e libera seus recursos na hora, e o preço é ter que desenhar a compensação e lidar com estados intermediários visíveis. Para fluxos que atravessam fronteiras de empresa, é a única opção real.

Preciso de um motor de workflow como Temporal ou Step Functions para fazer isso?

Não para começar. As duas tabelas e o orquestrador do artigo cabem em um serviço existente e resolvem o problema central: estado persistido, retomada depois de queda, retentativa com backoff e compensação em ordem reversa. Um motor de workflow passa a compensar quando há muitas sagas diferentes, passos que esperam dias por um evento humano, necessidade de versionar o fluxo com execuções antigas ainda rodando ou de visualizar o histórico de cada execução. Nesses casos, o motor entrega a infraestrutura pronta, mas a parte difícil continua sendo sua: classificar os passos, ordenar pelo pivô e escrever compensações idempotentes que aceitam "não existe".

O que fazer quando a compensação também falha?

Repetir, porque a compensação não tem alternativa: desistir dela é aceitar o efeito órfão. O orquestrador repete com backoff exponencial e, depois de um limite, coloca a saga em INTERVENCAO, que é um estado parado, visível e alertado, não um estado terminal. A falha de compensação mais comum é transitória, o serviço de estoque fora por alguns minutos, e se resolve sozinha. A menos comum é semântica, a reserva já expirou ou a coleta já foi realizada, e precisa de uma decisão de negócio. Por isso cada compensação deve devolver sucesso para "já está no estado desejado" e erro só quando o estado desejado é impossível, para que a fila de intervenção contenha apenas o que realmente precisa de uma pessoa.

Toda falha no meio de um fluxo distribuído já tem um plano de volta; a questão é se ele está no código ou na planilha do financeiro

Um fluxo que atravessa serviços deixa um fato consumado a cada passo, e sem compensação explícita esses fatos ficam órfãos sempre que algo falha no meio. O caminho é classificar os passos em compensáveis, pivô e re-tentáveis e ordená-los de modo que nada caro de desfazer aconteça antes do ponto de não retorno; persistir o estado da saga antes de cada chamada para sobreviver à queda do orquestrador; tratar timeout como resultado incerto, compensando passos compensáveis e repetindo o pivô até ter certeza; exigir dos participantes compensações idempotentes, tolerantes a ausência e com lápide para a ação atrasada; tornar visível o estado provisório para compensar a falta de isolamento; e provar com teste de injeção de falha que a invariante "tudo ou nada, eventualmente" vale para cada passo.