Pular para o conteúdo

Pedido distribuído

Reservar, cobrar e despachar em três serviços — e desfazer em ordem inversa quando um falha.

Não existe transação que atravesse a rede: BEGIN no serviço de estoque não alcança o de cobrança. A resposta é a saga — cada passo declara como se desfaz, e uma falha no meio desfaz, em ordem inversa, o que já aconteceu.

PeçaO que ela exercita
Malha.sagaos passos e as compensações
o estado da sagao que cada passo produziu
conferir()o passo que escreve sem compensação, antes de rodar
orfasa compensação que falhou, para um humano

Estrutura#

text
pedidos-distribuidos/
  src/
    estoque.df     reservar / liberar
    cobranca.df    cobrar / estornar
    envio.df       despachar
    saga.df        liga os tres
  tests/
forge.toml
[project]
name = "pedidos-distribuidos"
version = "0.1.0"
description = "Saga de pedido"
entry = "src/main.df"
dataforge = ">=1.1"

[dependencies]

[scripts]
start = "run src/main.df"
test = "test tests/"

O núcleo#

Este bloco roda sozinho — copie para um arquivo e rode dataforge run. Ele termina com assert, e é assim que esta página é conferida a cada build.

src/saga.df
adopt Arcane.Malha as Malha

estoque := {"cafe": 3}
cobrado := []
log := []

action reservar(estado, chave):
    given estoque["cafe"] smaller estado["qtd"]:
        trigger "sem estoque"
    estoque["cafe"] -= estado["qtd"]
    log.append("reservou")
    yield {"reserva": "R-1"}

action liberar(estado, chave):
    estoque["cafe"] += estado["qtd"]
    log.append("liberou")

action cobrar(estado, chave):
    given estado["cartao"] is "recusado":
        trigger "cartao recusado"
    cobrado.append(chave)
    log.append("cobrou")
    yield {"cobranca": "C-1"}

action estornar(estado, chave):
    cobrado.remove(chave)
    log.append("estornou")

action despachar(estado, chave):
    given estado["cep"] is "00000-000":
        trigger "cep nao atendido"
    log.append("despachou")

action saga_de_pedido():
    s := Malha.saga("pedido")
    s.passo("reservar", reservar, liberar)
    s.passo("cobrar", cobrar, estornar)
    s.passo("despachar", despachar, void, no)
    yield s

s := saga_de_pedido()
assert s.conferir() is []

r := s.executar({"qtd": 2, "cartao": "ok", "cep": "00000-000"})
out log
assert not r["ok"]
assert r["falhou_em"] is "despachar"
// Ordem INVERSA: estorna antes de liberar.
assert log is ["reservou", "cobrou", "estornou", "liberou"]
assert estoque["cafe"] is 3 and len(cobrado) is 0

O teste#

No projeto, a regra mora em src/ e o teste a importa pelo caminho relativo — dataforge test tests/ descobre o arquivo sozinho.

tests/nucleo_test.df
adopt ../src/saga as S

crucible "saga":
    trial "o passo que falhou nao e compensado":
        r := S.saga_de_pedido().executar({"qtd": 1, "cartao": "recusado", "cep": "1"})
        expect r["desfeitos"] is ["reservar"]

As decisões#

DecisãoSem ela
compensar em ordem inversao cliente fica sem dinheiro e sem produto por uma janela
o passo que falhou não é desfeitoo estorno de uma cobrança que não aconteceu
a chave de idempotência por passorepetir a saga depois de uma queda cobra duas vezes
orfas não é engolidoum estorno que falhou deixa o sistema inconsistente, e ninguém sabe

Para ir além#

  • Chamadas HTTP de verdade com disjuntor e retentativa: Microsserviços.
  • A saga não dá isolamento — entre reservar e cobrar, outro pedido vê o estoque reservado.
  • A referência: Arcane.Malha.

Volte para todos os tipos de projeto.