Blog

Reprocessamento de eventos em massa: reexecutar um dia inteiro de mensagens sem duplicar efeito colateral

Às 9h40 do dia 14 um deploy mudou o cálculo de comissão e passou a dividir o percentual por mil em vez de cem. Até as 17h10, quando alguém percebeu, 38 mil pedidos tinham comissão errada, e outros 4.100 eventos tinham falhado antes de notificar o vendedor porque o provedor de WhatsApp ficou instável à tarde. A correção do código levou vinte minutos. A pergunta difícil veio depois: como reexecutar o dia inteiro com o código certo sem mandar de novo 210 mil mensagens, sem somar duas vezes o total vendido e sem publicar de novo os eventos que o financeiro transforma em repasse. Este artigo mostra por que voltar o offset do consumidor é a pior resposta, como classificar cada efeito antes de reexecutar, como um ledger com chave determinística faz o replay aplicar só o que falta, como rodar o replay ao lado do tráfego ao vivo sem disputar estado, como simular e revisar o diff antes de aplicar e como provar em teste que o segundo replay não faz nada.

2026-10-16 / Arquitetura / 15 min

01

O que acontece quando você só volta o offset

A resposta instintiva em quem usa Kafka, SQS com arquivo ou qualquer log de eventos é resetar o offset do consumer group para a meia-noite do dia 14 e deixar o consumidor corrigido trabalhar. O código do consumidor já é idempotente para o estado, então parece seguro. Não é, porque o consumidor não produz só estado: ele produz efeitos fora do banco, e esses efeitos não têm como ser desfeitos.

  • Cada um dos 210 mil eventos pedido.pago dispara de novo a notificação de comissão no WhatsApp. O vendedor que já recebeu três mensagens no dia 14 recebe mais três, agora com valores diferentes, sem nenhum contexto.
  • O contador de total vendido por vendedor no dia é atualizado com incremento. Reexecutar soma o dia duas vezes, e o painel de metas mostra que todo mundo bateu 200%.
  • O evento derivado pedido.comissionado é publicado de novo com um id novo. O serviço financeiro, que deduplica por id, cria uma segunda linha de repasse para cada pedido.
  • O reset vale para o grupo inteiro e para tudo depois da meia-noite do dia 14, inclusive o dia 15 inteiro, que estava correto. O replay de um dia vira o replay de dois.
  • Enquanto o grupo relê o passado, o tráfego do dia 16 fica parado atrás dele. O lag sobe para horas e o incidente de comissão vira um incidente de atendimento.

Nenhum desses problemas é bug do consumidor. Todos vêm da mesma premissa errada: tratar o reprocessamento como uma repetição do processamento original. Um replay é uma operação diferente, com outro escopo, outra política de efeitos e outro limite de vazão, e precisa ser modelado como tal antes do primeiro evento ser relido.

02

Nem todo efeito é igual: classificar antes de reexecutar

O primeiro artefato de um reprocessamento seguro não é código, é uma tabela com cada efeito que o consumidor produz e o que o replay deve fazer com ele. Essa classificação decide quase todo o resto, e fazê-la durante o incidente, com pressa, é o que costuma gerar a segunda onda de problemas.

EfeitoExemploO que o replay fazMecanismo
Estado sobrescrevívelComissão por pedidoRecalcula e gravaUpsert com guarda de versão do agregado
AcumuladorTotal vendido por vendedor no diaReconstrói a janela inteira, nunca incrementa de novoRecalcular o agregado a partir do estado corrigido
Chamada externa com idempotência no provedorCriar lançamento no ERP, estorno no gatewayAplica só o que faltaLedger de efeitos e chave repassada ao provedor
Chamada externa sem idempotênciaWhatsApp, e-mail, SMSAplica só o que falta, e a falha vira revisão humanaLedger de efeitos com estado incerto
Evento derivadopedido.comissionado para o financeiroRepublica com o mesmo idOutbox com id derivado do evento de origem
Efeito dependente do momentoLembrete "seu pedido saiu há uma hora"SuprimePolítica de supressão por tipo de efeito

Duas linhas merecem atenção. O acumulador é o efeito que mais engana, porque um incremento parece estado. A correção é parar de incrementar durante o replay e, ao final, recalcular o total do dia a partir da tabela de comissões já corrigida, com um insert ... select ... group by que sobrescreve o valor. O evento derivado só é seguro se o id dele for função do evento de origem, como um hash de evento_id mais o tipo do evento derivado. Se o id é gerado na hora com um UUID aleatório, nenhum consumidor a jusante consegue reconhecer a repetição.

Existe também um efeito que o replay não deve tentar reproduzir: a correção para quem recebeu a informação errada. Os vendedores do dia 14 receberam valores errados por WhatsApp. Reenviar a mensagem original com o valor certo não corrige nada, só confunde. Se o negócio decidir avisar, isso é um efeito novo, notificar-correcao, com chave própria, disparado a partir do diff e não a partir do evento.

03

O ledger de efeitos: uma chave determinística por efeito

O mecanismo central é uma tabela que registra cada efeito externo aplicado, com uma chave que depende só do evento e do tipo do efeito: evento_id mais tipo_efeito. O consumidor ao vivo e o replay usam a mesma função de chave e a mesma tabela, então o replay descobre, efeito por efeito, o que já aconteceu no dia 14 e o que ficou faltando. O schema abaixo tem também o arquivo dos eventos, o estado de comissão com a versão do agregado que o produziu, o checkpoint e o diff da simulação.

-- schema.sql
create table evento_arquivado (
  posicao      bigint primary key,     -- ordem global do log de origem
  id           text not null unique,
  tipo         text not null,
  pedido_id    text not null,
  versao       bigint not null,        -- versao do agregado (o pedido)
  payload      jsonb not null,
  ocorrido_em  timestamptz not null
);
create index on evento_arquivado (ocorrido_em, posicao);

create table comissao (
  pedido_id      text primary key,
  valor_centavos bigint not null,
  versao_evento  bigint not null,
  atualizado_em  timestamptz not null default now()
);

-- Um registro por efeito externo, com a mesma chave no consumidor ao vivo e no replay
create table efeito_aplicado (
  chave         text primary key,      -- '<evento_id>:<tipo_efeito>'
  evento_id     text not null,
  tipo_efeito   text not null,
  estado        text not null check (estado in ('reservado', 'concluido', 'incerto')),
  execucao_id   text not null,         -- 'ao-vivo' ou o id do reprocessamento
  resultado     jsonb,
  criado_em     timestamptz not null default now(),
  atualizado_em timestamptz not null default now()
);

create table reprocessamento_checkpoint (
  execucao_id    text primary key,
  ultima_posicao bigint not null,
  atualizado_em  timestamptz not null default now()
);

create table reprocessamento_diff (
  execucao_id text not null,
  pedido_id   text not null,
  antes       bigint,
  depois      bigint not null,
  primary key (execucao_id, pedido_id)
);
// comissao.js
export function calcularComissao({ valorCentavos, percentual }) {
  if (!Number.isInteger(valorCentavos) || typeof percentual !== 'number') {
    throw new Error('payload de pedido.pago invalido');
  }
  // A versao com bug dividia por 1.000: percentual 5 virava 0,5%
  return Math.round((valorCentavos * percentual) / 100);
}

O aplicador abaixo é o mesmo código para o consumidor ao vivo e para o replay; muda só o execucaoId, a política e a flag de simulação. O estado de comissão usa um upsert com guarda de versão: a linha só é sobrescrita se o evento for da mesma versão do pedido ou mais novo. Isso permite que o replay corrija a versão 1 de um pedido sem apagar a versão 2 que o consumidor ao vivo gravou enquanto o replay ainda estava no meio da manhã.

// aplicador.js
import { calcularComissao } from './comissao.js';

// Reserva a chave do efeito. Uma reserva abandonada so pode ser retomada
// quando o provedor deduplica pela chave ($5 = true).
const RESERVAR = `
insert into efeito_aplicado (chave, evento_id, tipo_efeito, estado, execucao_id)
values ($1, $2, $3, 'reservado', $4)
on conflict (chave) do update
  set execucao_id = excluded.execucao_id, atualizado_em = now()
where efeito_aplicado.estado = 'reservado'
  and $5::boolean
  and efeito_aplicado.atualizado_em < now() - interval '5 minutes'
returning chave`;

export function criarAplicador({
  pool, // pg.Pool
  gateway,
  execucaoId, // 'ao-vivo' ou, por exemplo, 'replay-2026-10-14-v2'
  politica, // { [tipoEfeito]: { modo: 'aplicar' | 'suprimir', aoFalhar: 'liberar' | 'manter' } }
  simular = false,
  metricas,
}) {
  async function gravarComissao(evento, valor) {
    // So sobrescreve se o evento for da mesma versao ou mais novo que o da linha atual
    const { rowCount } = await pool.query(
      `insert into comissao (pedido_id, valor_centavos, versao_evento)
       values ($1, $2, $3)
       on conflict (pedido_id) do update
         set valor_centavos = excluded.valor_centavos,
             versao_evento = excluded.versao_evento,
             atualizado_em = now()
       where comissao.versao_evento <= excluded.versao_evento`,
      [evento.pedidoId, valor, evento.versao],
    );
    metricas?.estado(rowCount === 1 ? 'gravado' : 'ignorado-versao-mais-nova');
  }

  async function registrarDiff(evento, valor) {
    const { rows } = await pool.query(
      'select valor_centavos, versao_evento from comissao where pedido_id = $1',
      [evento.pedidoId],
    );
    const atual = rows[0];
    if (atual && Number(atual.versao_evento) > Number(evento.versao)) return;
    await pool.query(
      `insert into reprocessamento_diff (execucao_id, pedido_id, antes, depois)
       values ($1, $2, $3, $4)
       on conflict (execucao_id, pedido_id) do update
         set antes = excluded.antes, depois = excluded.depois`,
      [execucaoId, evento.pedidoId, atual ? atual.valor_centavos : null, valor],
    );
  }

  async function efeitoExterno(evento, tipo, executar) {
    const regra = politica[tipo];
    const chave = `${evento.id}:${tipo}`;

    if (!regra || regra.modo === 'suprimir') {
      metricas?.efeito(tipo, 'suprimido');
      return;
    }
    if (simular) {
      const { rowCount } = await pool.query(
        'select 1 from efeito_aplicado where chave = $1',
        [chave],
      );
      metricas?.efeito(tipo, rowCount ? 'ja-registrado' : 'seria-aplicado');
      return;
    }

    const reserva = await pool.query(RESERVAR, [
      chave,
      evento.id,
      tipo,
      execucaoId,
      regra.aoFalhar === 'liberar',
    ]);
    if (reserva.rowCount === 0) {
      metricas?.efeito(tipo, 'ja-registrado');
      return;
    }

    try {
      const resultado = await executar(chave);
      await pool.query(
        `update efeito_aplicado
            set estado = 'concluido', resultado = $2, atualizado_em = now()
          where chave = $1`,
        [chave, JSON.stringify(resultado ?? null)],
      );
      metricas?.efeito(tipo, 'aplicado');
    } catch (erro) {
      if (regra.aoFalhar === 'liberar') {
        // O provedor deduplica pela chave: tentar de novo nao duplica o efeito
        await pool.query(
          "delete from efeito_aplicado where chave = $1 and estado = 'reservado'",
          [chave],
        );
        throw erro;
      }
      // Sem idempotencia no provedor nao da para saber se a mensagem saiu
      await pool.query(
        "update efeito_aplicado set estado = 'incerto', atualizado_em = now() where chave = $1",
        [chave],
      );
      metricas?.efeito(tipo, 'incerto');
    }
  }

  async function aplicar(evento) {
    if (evento.tipo !== 'pedido.pago') return;
    const valor = calcularComissao(evento.payload);

    if (simular) await registrarDiff(evento, valor);
    else await gravarComissao(evento, valor);

    await efeitoExterno(evento, 'notificar-vendedor', (chave) =>
      gateway.notificarVendedor(
        { pedidoId: evento.pedidoId, valorCentavos: valor },
        { chaveIdempotencia: chave },
      ),
    );
  }

  return { aplicar };
}

O efeito externo segue três passos: reservar a chave, chamar o provedor e marcar como concluído. A reserva é um insert com on conflict, então só uma execução consegue reservar a mesma chave, mesmo com o replay e o consumidor ao vivo processando o mesmo evento ao mesmo tempo. A chave também vai para o provedor como chave de idempotência, e é isso que cobre a janela entre a chamada ter funcionado e a marcação de concluído ter sido gravada.

A diferença entre aoFalhar liberar e manter é a decisão mais importante do ledger. Quando o provedor deduplica pela chave, a falha libera a reserva e o replay tenta de novo; o pior caso é o provedor receber a mesma chave duas vezes e ignorar a segunda. Quando o provedor não deduplica, como a maioria das APIs de mensagem, uma falha depois do envio é indistinguível de uma falha antes dele. O estado incerto existe para isso: o efeito sai do fluxo automático e vai para uma fila de revisão, porque reenviar às cegas troca uma mensagem perdida por uma mensagem duplicada. Pelo mesmo motivo, uma reserva abandonada por um processo que morreu só é retomada automaticamente quando o provedor é idempotente; nos outros casos, um job de varredura move a reserva velha para incerto.

04

O executor: fora do grupo ao vivo, com checkpoint e teto de vazão

O replay não deve usar o consumer group do tráfego ao vivo. Ele lê de uma fonte própria, que pode ser um consumer group separado apontado para os offsets do dia ou, como no exemplo, uma tabela de arquivo dos eventos, que tem a vantagem de não depender da retenção do broker. A leitura é por posição, em lotes, e o cursor é gravado ao fim de cada lote. Se o processo cair no evento 140 mil, a próxima execução com o mesmo id retoma do último lote completo; os eventos do lote interrompido são aplicados de novo, e isso é seguro porque o aplicador é idempotente.

// fonte-arquivo.js
export function criarFonteArquivo({ pool, inicio, fim }) {
  return {
    async lerLote({ depoisDe, limite }) {
      const { rows } = await pool.query(
        `select posicao, id, tipo, pedido_id as "pedidoId", versao, payload
           from evento_arquivado
          where ocorrido_em >= $1 and ocorrido_em < $2 and posicao > $3
          order by posicao
          limit $4`,
        [inicio, fim, depoisDe, limite],
      );
      return rows;
    },
  };
}

// checkpoint.js
export function criarCheckpoint(pool) {
  return {
    async ler(execucaoId) {
      const { rows } = await pool.query(
        'select ultima_posicao from reprocessamento_checkpoint where execucao_id = $1',
        [execucaoId],
      );
      return rows[0]?.ultima_posicao ?? '0';
    },
    async gravar(execucaoId, posicao) {
      await pool.query(
        `insert into reprocessamento_checkpoint (execucao_id, ultima_posicao)
         values ($1, $2)
         on conflict (execucao_id) do update
           set ultima_posicao = excluded.ultima_posicao, atualizado_em = now()`,
        [execucaoId, posicao],
      );
    },
  };
}

// reprocessar.js
const esperar = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

export async function reprocessar({
  fonte,
  aplicador,
  checkpoint,
  execucaoId,
  limitePorSegundo,
  tamanhoLote = 500,
  sinal, // AbortSignal: o botao de parada do replay
}) {
  let cursor = await checkpoint.ler(execucaoId);
  let processados = 0;

  while (!sinal?.aborted) {
    const inicioLote = Date.now();
    const lote = await fonte.lerLote({ depoisDe: cursor, limite: tamanhoLote });
    if (lote.length === 0) break;

    // Em ordem de posicao: eventos do mesmo pedido nunca se invertem
    for (const evento of lote) {
      await aplicador.aplicar(evento);
    }

    cursor = lote.at(-1).posicao;
    await checkpoint.gravar(execucaoId, cursor);
    processados += lote.length;

    // Teto de vazao: o replay nao pode disputar o banco com o trafego ao vivo
    const minimoMs = (lote.length / limitePorSegundo) * 1000;
    await esperar(Math.max(0, minimoMs - (Date.now() - inicioLote)));
  }

  return { cursor, processados };
}
Arquivo do dia 14/10: 210 mil eventos pedido.pago, em ordem de posição
        |
        v
[fonte por posição] --lote de 500--> [aplicador da execução replay-2026-10-14-v2]
        ^                                 |
        |                                 |-- estado: upsert com guarda de versão --> comissao
[checkpoint a cada lote]                  |
                                          '-- efeito externo: reserva a chave no ledger
                                                 |-- chave já registrada -> não faz nada
                                                 '-- chave ausente -> provedor com chaveIdempotencia

[consumidor ao vivo] --> mesmo aplicador, mesmo ledger, execução 'ao-vivo'
(os dois rodam ao mesmo tempo: a guarda de versão e o ledger resolvem a disputa)

O teto de vazão é o que impede o replay de virar o próximo incidente. Sem ele, o executor lê o arquivo o mais rápido que o banco aguenta e disputa conexões, locks e CPU com o tráfego ao vivo. A conta para escolher o teto parte da folga real: se o banco aguenta 3.000 escritas por segundo e o pico do dia usa 1.800, o replay fica com 600 e deixa margem para o imprevisto. Nesse ritmo, 210 mil eventos levam menos de seis minutos, o que é rápido o suficiente para não haver motivo para correr.

A ordem também importa. Ler por posição garante que dois eventos do mesmo pedido nunca se invertam. Para paralelizar, o corte certo é pela chave do agregado, com cada worker lendo apenas os pedidos em que hashtext(pedido_id) módulo N é igual ao seu índice. Paralelizar por intervalo de tempo parece natural e quebra justamente a ordem por pedido, que é a única ordem que a guarda de versão precisa.

05

Simular antes de aplicar: o diff como portão

Com a flag simular, o mesmo aplicador percorre o dia sem gravar comissão e sem chamar provedor. Em vez disso, ele grava na tabela de diff o valor atual e o valor que gravaria, e as métricas contam quantos efeitos seriam aplicados e quantos já estão registrados. O resumo abaixo é o que o time revisa antes de autorizar a execução real.

-- resumo do diff da execucao simulada, revisado antes de aplicar
select count(*) filter (where antes is distinct from depois)     as pedidos_alterados,
       count(*) filter (where antes is null)                     as pedidos_sem_comissao,
       sum(depois - antes) filter (where antes is not null)      as diferenca_centavos,
       max(abs(depois - antes))                                  as maior_diferenca_centavos
  from reprocessamento_diff
 where execucao_id = $1;

O valor desse portão está em comparar o resultado com a expectativa que o incidente já deu. Esperávamos cerca de 38 mil pedidos alterados e cerca de 4.100 notificações a aplicar. Se a simulação diz 205 mil notificações, o ledger não está reconhecendo o que o consumidor ao vivo fez, quase sempre porque a chave mudou de formato entre as versões ou porque o ledger foi criado depois do dia que está sendo reprocessado. Descobrir isso na simulação custa uma consulta; descobrir na execução real custa 200 mil mensagens.

  1. Congelar o escopo: janela de tempo, tipos de evento, versão do código e política de efeitos, tudo registrado junto com o execucaoId.
  2. Rodar a simulação e gerar o resumo do diff e das métricas de efeitos.
  3. Comparar com a expectativa do incidente e investigar qualquer número fora da ordem de grandeza.
  4. Executar com teto de vazão, acompanhando o lag do consumidor ao vivo e a latência do banco, com o AbortSignal ligado a um botão de parada.
  5. Recalcular os acumuladores da janela a partir do estado corrigido.
  6. Revisar a fila de efeitos incertos e decidir, com o negócio, se haverá notificação de correção.

06

Provar em teste que o segundo replay não faz nada

As propriedades que importam cabem em três testes contra um Postgres real: o replay aplica exatamente os efeitos que faltavam, um segundo replay com outro id não aplica nenhum, uma execução interrompida retoma do checkpoint e o estado gravado pelo consumidor ao vivo com versão mais nova sobrevive ao replay. Usar banco real, em vez de um mock, é proposital: as garantias moram no on conflict e no where do upsert, e um mock só testaria o que você acha que o banco faz.

// reprocessamento.test.js
// Requer um Postgres de teste com schema.sql aplicado: DATABASE_URL=... node --test
import { test, beforeEach, after } from 'node:test';
import assert from 'node:assert/strict';
import pg from 'pg';
import { criarAplicador } from './aplicador.js';
import { criarFonteArquivo } from './fonte-arquivo.js';
import { criarCheckpoint } from './checkpoint.js';
import { reprocessar } from './reprocessar.js';

const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
const politica = { 'notificar-vendedor': { modo: 'aplicar', aoFalhar: 'manter' } };

function criarGatewayFalso() {
  const chamadas = [];
  return {
    chamadas,
    async notificarVendedor(_dados, { chaveIdempotencia }) {
      chamadas.push(chaveIdempotencia);
      return { mensagemId: `msg-${chamadas.length}` };
    },
  };
}

async function rodar(execucaoId, gateway) {
  return reprocessar({
    fonte: criarFonteArquivo({
      pool,
      inicio: '2026-10-14T00:00:00Z',
      fim: '2026-10-15T00:00:00Z',
    }),
    aplicador: criarAplicador({ pool, gateway, execucaoId, politica }),
    checkpoint: criarCheckpoint(pool),
    execucaoId,
    limitePorSegundo: 1_000,
  });
}

beforeEach(async () => {
  await pool.query(
    'truncate evento_arquivado, comissao, efeito_aplicado, reprocessamento_checkpoint, reprocessamento_diff',
  );
  await pool.query(`
    insert into evento_arquivado (posicao, id, tipo, pedido_id, versao, payload, ocorrido_em) values
      (1, 'e1', 'pedido.pago', 'A', 1, '{"valorCentavos": 10000, "percentual": 5}', '2026-10-14T10:00:00Z'),
      (2, 'e2', 'pedido.pago', 'B', 1, '{"valorCentavos": 20000, "percentual": 5}', '2026-10-14T11:00:00Z'),
      (3, 'e3', 'pedido.pago', 'C', 1, '{"valorCentavos": 30000, "percentual": 5}', '2026-10-14T12:00:00Z')`);
  // e1 foi notificado pelo consumidor ao vivo; e2 e e3 falharam antes de notificar
  await pool.query(`
    insert into efeito_aplicado (chave, evento_id, tipo_efeito, estado, execucao_id)
    values ('e1:notificar-vendedor', 'e1', 'notificar-vendedor', 'concluido', 'ao-vivo')`);
  // O pedido C ja recebeu um evento mais novo pelo consumidor ao vivo
  await pool.query(`
    insert into comissao (pedido_id, valor_centavos, versao_evento) values ('C', 999, 2)`);
});

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

test('o replay aplica so os efeitos que faltam, e o segundo replay nao aplica nenhum', async () => {
  const gateway = criarGatewayFalso();

  await rodar('replay-1', gateway);
  assert.deepEqual(gateway.chamadas, ['e2:notificar-vendedor', 'e3:notificar-vendedor']);

  const segunda = await rodar('replay-2', gateway);
  assert.equal(segunda.processados, 3);
  assert.equal(gateway.chamadas.length, 2);
});

test('retomar a mesma execucao continua do checkpoint, sem reler o dia', async () => {
  await rodar('replay-1', criarGatewayFalso());
  const retomada = await rodar('replay-1', criarGatewayFalso());
  assert.equal(retomada.processados, 0);
});

test('o replay corrige o estado sem sobrescrever o que o ao vivo gravou depois', async () => {
  await rodar('replay-1', criarGatewayFalso());
  const { rows } = await pool.query(
    'select pedido_id, valor_centavos::int as valor, versao_evento::int as versao from comissao order by pedido_id',
  );
  assert.deepEqual(rows, [
    { pedido_id: 'A', valor: 500, versao: 1 },
    { pedido_id: 'B', valor: 1000, versao: 1 },
    { pedido_id: 'C', valor: 999, versao: 2 },
  ]);
});

O cenário montado no beforeEach é o incidente em miniatura: um evento já notificado ao vivo, dois que falharam antes de notificar e um pedido que recebeu uma versão mais nova depois. Se alguém mudar o formato da chave, trocar o upsert por um update incondicional ou gerar o id do efeito com um UUID aleatório, um desses testes quebra antes de o próximo reprocessamento acontecer em produção.

FAQ

Perguntas frequentes

Por que não simplesmente resetar o offset do consumer group?

Porque o reset reprocessa tudo a partir do ponto escolhido, para o grupo inteiro, com a política de efeitos do consumidor ao vivo e sem limite de vazão. Você reenvia mensagens, incrementa acumuladores de novo, atrasa o tráfego atual e não consegue simular antes. Um consumer group separado ou uma leitura do arquivo, com um execucaoId próprio, dá escopo, política e teto independentes.

E se o ledger de efeitos não existia quando os eventos originais foram processados?

Então o replay não tem como saber o que já aconteceu, e a política padrão precisa ser suprimir os efeitos externos. Antes de aplicar qualquer um, reconstrua o ledger a partir de uma fonte confiável, como os registros do provedor de mensagens ou os lançamentos do ERP, e só depois rode a simulação para conferir se o número de efeitos a aplicar faz sentido.

Como escolher entre suprimir e aplicar só o que falta para um efeito externo?

Pergunte se o efeito ainda tem valor depois do atraso e se ausência dele é um erro. Um lançamento no ERP que nunca foi criado é um erro e deve ser aplicado. Um lembrete de entrega com dois dias de atraso não tem valor e deve ser suprimido. Na dúvida, suprimir e gerar uma lista para revisão é reversível; enviar não é.

Reprocessar é uma operação, não uma repetição

Reexecutar um dia de eventos com segurança depende de decisões tomadas antes do incidente: uma chave determinística por efeito, gravada no mesmo ledger pelo consumidor ao vivo e pelo replay; estado protegido por versão do agregado; acumuladores recalculados em vez de incrementados; e eventos derivados com id estável. Com isso, o replay vira um procedimento com escopo, simulação, diff, teto de vazão e botão de parada. Sem isso, a correção de um bug de comissão vira um segundo incidente, maior que o primeiro.