Conexão · Interrompida

Algo não carregou

Parte desta página não chegou até você. Recarregue para tentar novamente — se persistir, verifique sua conexão.

Pular para o conteúdo principal
Backend12 min de leitura

Execução Durável Não É Sobre Agentes — É Sobre Workflows de Backend com Replay

Cheguei aos runtimes de execução durável pela hype dos agentes, mas a restrição que surpreende todo mundo é o determinismo no replay. Estas são minhas anotações trabalhando uma reconciliação de pagamentos de seis passos como um workflow do Restate em TypeScript — a linha que quebrou o replay, o modelo mental que consertou, e os trade-offs que vêm com o padrão.

Todos os Posts
2/4

Cheguei à execução durável pelo ciclo de hype da IA. Todo blog post enquadrava o tema como o runtime que finalmente faz loops de agentes de longa duração sobreviverem a um crash. Depois de rastrear o padrão pelos docs do Temporal, pelo handbook do Restate e pela cobertura da InfoQ sobre o Project Think da Cloudflare, acho que o enquadramento está invertido. A parte interessante da execução durável não é o agente em cima. É o que o runtime exige do código embaixo, e essa exigência é o que surpreende todo engenheiro de backend que pega o tema.

A promessa é curta. Escreva o workflow como uma função normal. O runtime registra cada passo num journal. Se o processo morre no meio do caminho, um worker diferente reexecuta a função do início, faz replay do journal, e aterrissa exatamente na linha onde o crash aconteceu. Sem coordenador, sem compensações de saga, sem máquina de estado feita à mão.

O preço é um contrato. O código precisa produzir a mesma sequência de entradas no journal toda vez que executa. Qualquer coisa que vive fora do journal — uma leitura de wall-clock, um UUID gerado em processo, uma chamada de rede que pulou o slot do journal — transforma o próximo replay numa loteria. A recuperação de crash sai do código da aplicação, mas paga pelo movimento ao forçar todo efeito colateral a ser determinístico ou explicitamente registrado no journal. Essa é a restrição que a hype nunca nomeia.

Quero passar pelo que isso significa em um workflow que não tem nada a ver com agentes. Escolhi o exemplo mais entediante que consegui pensar: um job de reconciliação de pagamentos de seis passos. Puxar um extrato bancário, puxar o ledger, comparar, postar correções para as entradas faltantes, esperar até a janela de fechamento, marcar o lote como reconciliado. O tipo de cron job que vive em todo stack de pagamentos.

Por que o exemplo entediante importa

Um job de reconciliação é interessante porque nada nele é interessante. Sem partições de stream, sem consenso, sem hot keys. O único requisito difícil é que, se o worker morre depois do passo três, o worker retomado pega no passo quatro e não re-posta as correções que já chegaram. Esse é o mesmo requisito que um loop de agente tem quando morre entre uma chamada de LLM e uma invocação de tool, despido do teatro do LLM. Se o padrão vai se sustentar para workflows de backend em geral, ele tem que se sustentar aqui primeiro.

A versão feita à mão é bem batida. Um serviço caminha pelos seis passos, persiste o progresso numa linha do Postgres depois de cada passo, faz retry em falha, e torce para o serviço não morrer entre escrever "passo 3 feito" e começar o passo 4. Bibliotecas de saga deixam isso mais bonito. Elas não mudam o formato do problema.

Um runtime durável colapsa a contabilidade num primitivo. Cada passo é uma entrada no journal. O runtime é dono do journal. O código é o script que produz entradas no journal em ordem.

A versão Restate

Aqui está o workflow como um único handler do Restate em TypeScript. Ele roda contra um servidor Restate local (npx @restatedev/restate-server) registrado neste endpoint. O handler é toda a parte móvel.

typescript
// reconcile.ts
// Inicie o runtime em outro terminal:
//   npx @restatedev/restate-server
// Depois rode este arquivo:
//   npx tsx reconcile.ts
// Dispare:
//   curl localhost:8080/payments.reconcile/2026-05-05/run --json '{}'
import * as restate from "@restatedev/restate-sdk";

type Entry = { id: string; amount: number };

const reconcile = restate.workflow({
  name: "payments.reconcile",
  handlers: {
    run: async (ctx: restate.WorkflowContext) => {
      const batchId = ctx.key;

      // 1. Carimbe a execução dentro de ctx.run para o timestamp cair no journal.
      const startedAt = await ctx.run("started_at", () =>
        new Date().toISOString(),
      );

      // 2. Busca o extrato bancário.
      const bank = await ctx.run("fetch_bank", async () => {
        const r = await fetch(`https://bank.local/statements/${batchId}`);
        return (await r.json()) as { entries: Entry[] };
      });

      // 3. Busca as entradas do ledger para o mesmo lote.
      const ledger = await ctx.run("fetch_ledger", async () => {
        const r = await fetch(`https://ledger.local/entries?batch=${batchId}`);
        return (await r.json()) as { entries: Entry[] };
      });

      // 4. Diff puro. Seguro de rodar fora do journal.
      const known = new Set(ledger.entries.map((l) => l.id));
      const missing = bank.entries.filter((b) => !known.has(b.id));

      // 5. Cada correção é seu próprio slot no journal, chaveado pelo id da entrada.
      for (const entry of missing) {
        await ctx.run(`post_${entry.id}`, async () => {
          await fetch("https://ledger.local/corrections", {
            method: "POST",
            body: JSON.stringify({ ...entry, source: "bank" }),
          });
        });
      }

      // 6. Dorme até o fechamento (60s aqui para o demo), depois carimba o término.
      // ctx.sleep aceita milissegundos ou um Duration; a forma de objeto literal
      // ({ seconds: 60 }) não faz parte do SDK atual.
      await ctx.sleep(60_000);
      const finishedAt = await ctx.run("finished_at", () =>
        new Date().toISOString(),
      );

      return { batchId, startedAt, finishedAt, posted: missing.length };
    },
  },
});

restate.endpoint().bind(reconcile).listen(9080);

Rode com npx tsx reconcile.ts depois que o servidor Restate estiver de pé. O formato que vale estudar é o que está dentro de ctx.run e o que está fora. Tudo que toca o mundo — leituras de wall-clock, chamadas HTTP, o fetch eventual para postar uma correção — está embrulhado. O diff no passo 4 não está. Essa divisão é o modelo mental inteiro.

O que o journal de fato registra

Quando o workflow roda pela primeira vez, o Restate anexa uma entrada no journal para cada bloco ctx.run conforme ele completa. A entrada armazena o nome e o resultado. O passo 1 registra started_at = "2026-05-05T09:00:00Z". O passo 2 registra o extrato bancário parseado. Os passos 5a, 5b, 5c registram cada um um POST bem-sucedido. O sleep registra seu wakeup agendado.

O diagrama abaixo traça como o journal fica antes de um crash forçado e depois que o worker é substituído. O movimento interessante está na linha tracejada: um worker novo começa a mesma função do topo, mas todo bloco embrulhado faz curto-circuito para o valor registrado em vez de re-executar.

O diagrama torna a pequena armadilha óbvia. O crash aconteceu durante post_e9. O runtime não sabe se o efeito colateral chegou ao ledger. Então ele re-emite o slot no novo worker e o handler do POST em ledger.local/corrections precisa ser idempotente no id da entrada. A execução durável não te livra de idempotência; ela a concentra na fronteira entre seu workflow e os sistemas com os quais ele fala. Tenho um conjunto mais longo de notas sobre o que idempotência de fato exige do receptor, mas a regra para este post é curta: qualquer coisa dentro de ctx.run pode rodar zero, uma ou duas vezes da perspectiva do receptor. Projete de acordo.

A linha que quebrou o replay

A linha que me surpreendeu foi o passo 1. Em um rascunho inicial eu tinha escrito assim:

typescript
// QUEBRADO: Date.now() solto fora de ctx.run
const startedAt = new Date().toISOString();

A primeira execução funcionou. O journal registrou os passos 2 a 6 e o workflow retornou um registro sensato. Eu então matei o worker entre os passos 4 e 5 e observei um worker novo pegar. O replay divergiu no valor de startedAt porque new Date() rodou uma segunda vez e produziu um instante posterior. O Restate pegou a divergência na próxima entrada do journal, levantou um erro de "journal mismatch" e parqueou a invocação.

O SDK TypeScript do Temporal esconde essa armadilha com um sandbox. Ele reescreve Date.now() e Math.random() para retornarem valores puxados do contexto do workflow, e faz isso há anos. O SDK TypeScript do Restate não tem sandbox. Fora de ctx.run, é Node puro, e Node puro chama o relógio do sistema. Os docs do Temporal dizem que o código do workflow "precisa ser determinístico entre replays" — essa única regra está sob ambos os runtimes, mas o Temporal move a aplicação para dentro do SDK enquanto o Restate move para o engenheiro.

A correção é a versão do exemplo: embrulhar o timestamp em ctx.run("started_at", () => new Date().toISOString()). A primeira execução roda o closure, a segunda lê do journal, e o replay bate.

A mesma armadilha aparece em mais duas formas quando você começa a olhar. Um limite de loop não-determinístico: for (const entry of missing) está bem porque missing foi derivado de dados do journal, mas for (let i = 0; i < Math.random() * 10; i++) não está. E um fetch fora de banda: se um refactor do passo 4 enfia um await fetch(...) solto no corpo do workflow para "só checar o gateway mais uma vez", o replay chama a rede uma segunda vez, recebe uma resposta diferente, e o próximo slot de ctx.run não bate.

O modelo mental que de fato encaixa

O atalho que adotei são duas frases. Código fora de ctx.run é "o script". Código dentro de ctx.run é "o journal".

O script é replicado verbatim. O script lê do journal mas nunca escreve fora dele. Toda leitura que ele faz do mundo precisa voltar pelo journal, o que significa que toda leitura precisa ser embrulhada.

O journal é a parte durável. Cada slot armazena um nome e um valor. O endereçamento por nome do Restate é por isso que os passos 5a/5b/5c coexistem como slots separados: post_${entry.id} torna o nome único por entrada, e o for-loop caminha pelos mesmos nomes toda vez porque missing é derivado deterministicamente de dados do journal. Nomear não é cosmético. Dois slots com o mesmo nome na mesma invocação colidem e o runtime rejeita o segundo. Dois slots que deveriam bater mas usam nomes diferentes produzem um passo fantasma e o replay diverge.

Esse modelo mental também te diz quando a execução durável é a ferramenta errada. É a ferramenta errada quando o workflow não tem um script — quando todo passo depende de sinais fora de banda que você não consegue representar no journal, ou quando os passos em si são críticos em throughput e a escrita no journal vira gargalo no caminho quente. Os docs do Restate são claros que cada chamada ctx.run custa uma escrita no journal store, e esse é um número real para o qual você precisa orçar em altas taxas de chamada.

Trade-offs que valem nomear

A execução durável desloca a superfície operacional. Coordenadores e máquinas de estado de saga somem; um journal store toma o lugar deles. O Restate roda o store inline, o Temporal roda um cluster separado, o DBOS reusa Postgres. Cada escolha troca latência, footprint e custo de ops de forma diferente — escrevi separadamente sobre escolher entre DBOS e Temporal, e a régua de lá se estende para o Restate como a terceira opção na mesma linha.

A regra de "nada de efeitos colaterais soltos" é real, e o SDK só pega no momento do replay. Um workflow que funcionou em testes ainda pode tropeçar a primeira vez que um worker morre depois de uma chamada não registrada. Duas contramedidas valem o que pesam: um passo de CI que roda um harness de replay forçado (Worker.runReplayHistory do Temporal, RestateTestEnvironment do Restate), e uma regra de code review que sinaliza imports de Date, Math.random, crypto.randomUUID e fetch dentro de qualquer módulo que exporta um handler de workflow.

Mudar código de workflow entre execuções é um problema de versionamento, não de refactor. Se um journal de ontem se referia a um passo post_${entry.id} e o código de hoje renomeia para apply_${entry.id}, o replay sai do journal nesse ponto. Tanto Temporal quanto Restate têm hooks explícitos de versionamento (a API patched do Temporal, a orientação de nomes estáveis do Restate). Use-os.

Um único slot ctx.run pode executar até duas vezes em padrões patológicos de crash. Os sistemas do outro lado precisam deduplicar pelas próprias chaves. O header Idempotency-Key da Stripe é a referência fácil; meu próprio texto sobre idempotência como protocolo de quatro partes cobre o que o receptor de fato precisa fazer além de armazenar a chave.

Quando eu pegaria, e quando não

Pegue quando o workflow tem mais de três efeitos colaterais sequenciais, cada efeito colateral é caro o bastante para justificar uma escrita no journal, e o custo de rodar um passo duas vezes no receptor é limitado por uma chave de idempotência. Reconciliação, provisionamento, captura de pagamento, onboarding multi-fornecedor, fluxos de checkout de longa duração — todos bons encaixes.

Pule num workflow que é uma única transação num único banco de dados. O Postgres já dá durabilidade e atomicidade pra isso. Adicionar um runtime em cima dobra as partes móveis e não compra nada.

Pule em request-response de baixa latência. O imposto da escrita no journal aparece como p99 visível mesmo num runtime saudável, e a resiliência não recupera a latência.

Tenha cautela quando o workflow é principalmente branching em dados fora de banda. Cada branch precisa vir do journal, o que significa que todo sinal precisa ser passado por ctx.run ou um signal de workflow. O journal vira então o grosso do código e o runtime para de pagar por si próprio.

O enquadramento de agente na hype é, no máximo, uma aplicação do padrão. O padrão é uma leitura de 2026 do que os frameworks de saga tentaram fazer pela última década — tornar a recuperação de crash entediante — e o contrato de determinismo é o preço de admissão. Pague-o de propósito, em blocos ctx.run, com slots nomeados, ou pague depois em journals que não batem às 2 da manhã.

Pontos para levar

  • Trate código fora de ctx.run como um script determinístico e código dentro de ctx.run como o journal.
  • Embrulhe todo efeito colateral: leituras de relógio, UUIDs, chamadas de rede, aleatoriedade. O SDK do Temporal coloca alguns desses em sandbox no TypeScript; o SDK do Restate não.
  • Nomeie slots do journal por dados que você controla (post_${entry.id}), não por contadores ou timestamps.
  • Adicione um teste de replay forçado ao CI para que a divergência apareça em build-time, não em recovery-time.
  • Idempotência não se move; ela se concentra nos receptores com os quais os workflows falam.
  • Versione código de workflow com os hooks do runtime (patched do Temporal, nomes estáveis do Restate) — nunca renomeie silenciosamente um passo que já existe num journal.

Pegue execução durável quando há vários efeitos colaterais em sequência, cada um valendo uma escrita no journal, cada um idempotente no receptor. Pule em transações de banco único e em caminhos de request-response sensíveis a latência.

Continue lendo

Curtindo? Talvez goste disso aqui.

Nada parecido — quer tentar outro ângulo?

Isso foi útil?

Deixe uma avaliação ou uma nota rápida — me ajuda a melhorar.