← Release Manager: versões, prontidão e operação
08 / 10 · 60 MIN

Replay, reconciliação e recuperação

Coordena retoma após falha ou restauro distinguindo intenção, efeito confirmado e estado ainda desconhecido.

Fixar a fronteira da transação

Uma aplicação fictícia grava a operação e a intenção de notificar na mesma transação local. Essa tabela de intenções pendentes é a outbox. Um componente separado trata da entrega posterior. No laboratório, uma exceção entre as duas inserções provoca rollback explícito e nenhuma linha fica persistida. Depois de um commit bem-sucedido, a operação e a intenção existem, mas o consumidor ainda não recebeu nada. Esta diferença é central para a coordenação: commit local não é conclusão do serviço inteiro. O padrão reduz uma lacuna entre gravação e notificação, mas não transforma dois sistemas numa transação única. O plano deve mostrar quem observa pendências, quem pode retomar e que evidência confirma o efeito final. Os dados do exercício são inventados e não representam instruções financeiras reais.

Ensaiar a confirmação perdida

O consumidor recebe event-A com valor 125 e grava recibo e efeito numa transação própria. O laboratório omite deliberadamente a confirmação ao emissor. A outbox continua pendente apesar de o efeito já existir. Num retry com o mesmo ID e conteúdo, o recibo permite reconhecer a repetição e manter um único efeito local. Se chegar o mesmo ID com valor 999, o modelo sinaliza conflito, em vez de o tratar como repetição equivalente. Preserva a identidade da intenção entre tentativas e confirma a fronteira em que recibo e efeito são gravados. Se o recibo fosse confirmado primeiro e o efeito falhasse depois, o retry poderia ser ignorado sem nunca produzir resultado. A implementação exercitada reverte ambos perante essa falha. Isto não é uma garantia universal de exactly-once.

Reconciliar depois do ponto de recuperação

O checkpoint do emissor foi feito quando existia A. O consumidor aplica A e depois B, mas só o emissor é restaurado. Na cópia restaurada há uma operação; no consumidor há dois efeitos, com total fictício de 205. O restauro não anulou B. Reconciliar exige conhecer operações posteriores ao checkpoint, recibos e efeitos em cada componente, com responsáveis por resolver diferenças. Repetir A pode ser controlado se o recibo continua disponível. Retenção de dois dias não cobre automaticamente replay permitido de sete dias. O laboratório remove um recibo mantendo o efeito e encontra uma violação de unicidade ao tentar repetir: esse erro preserva o efeito, mas exige investigação, não prova retoma normal. Não apagues diferenças apenas para alinhar contagens. Compensações de negócio exigem regras e autoridade próprias.

Praticar a decisão de retoma com APS

Guarda o código completo abaixo num ficheiro release-state.py e executa python3 release-state.py com Python 3.13 ou posterior. Lê as dezasseis verificações. São bases SQLite em memória, falhas injetadas por exceção, um checkpoint copiado entre bases isoladas e cálculos com entradas fictícias. Não há broker, carga concorrente, timeout de rede nem restauro de produção. O exercício profissional consiste em explicar a APS o que cada resultado demonstra e o que permanece por validar. Antes de retomar, identifica entrada suspensa, trabalho já aceite, estado dos workers, identidades estáveis, recibos retidos e operações sem resultado conhecido. Desligar uma flag na API não cancela mensagens entregues a workers que não a consultam. Termina com uma decisão que indique âmbito da retoma, limites, responsáveis e condição de paragem. A disponibilidade do processo e a reconciliação dos resultados são evidências distintas.

"""Original isolated SQLite release-state lab. No broker, cloud, bank or production system."""
import sqlite3,json,platform,hashlib
from pathlib import Path
from fractions import Fraction
checks=[]
def check(name,actual,expected):
 assert actual==expected,(name,actual,expected)
 checks.append(dict(name=name,actual=actual,expected=expected,passed=True))
def db():return sqlite3.connect(':memory:',isolation_level=None)
def count(c,t):return c.execute('SELECT count(*) FROM '+t).fetchone()[0]
# Fixture 1: a deliberately explicit-column old writer survives a nullable addition.
schema=db();schema.execute('CREATE TABLE positions(id TEXT PRIMARY KEY, amount INTEGER NOT NULL)')
schema.execute("INSERT INTO positions(id,amount) VALUES('old-1',100)")
schema.execute('ALTER TABLE positions ADD COLUMN currency TEXT')
schema.execute("INSERT INTO positions(id,amount) VALUES('old-2',200)")
schema.execute("INSERT INTO positions(id,amount,currency) VALUES('new-1',300,'EUR')")
check('expanded_schema_accepts_both_writers',schema.execute('SELECT id,currency FROM positions ORDER BY id').fetchall(),[('new-1','EUR'),('old-1',None),('old-2',None)])
implicit_failed=False
try:schema.execute("INSERT INTO positions VALUES('implicit',400)")
except sqlite3.OperationalError:implicit_failed=True
check('implicit_column_count_not_compatible',dict(rejected=implicit_failed,rows=count(schema,'positions')),dict(rejected=True,rows=3))
# A separate fixture represents the post-contract shape, not a production migration recipe.
contract=db();contract.execute('CREATE TABLE positions(id TEXT PRIMARY KEY,amount INTEGER NOT NULL,currency TEXT NOT NULL)')
old_failed=False
try:contract.execute("INSERT INTO positions(id,amount) VALUES('old-3',400)")
except sqlite3.IntegrityError:old_failed=True
check('contract_shape_rejects_old_writer',dict(rejected=old_failed,rows=count(contract,'positions')),dict(rejected=True,rows=0))
contract.execute("INSERT INTO positions(id,amount,currency) VALUES('new-2',400,'EUR')")
check('contract_shape_accepts_new_writer',count(contract,'positions'),1)
# Two separate stores: an application-side outbox and a receiver with a local ledger.
sender=db();receiver=db()
sender.executescript('CREATE TABLE operations(id TEXT PRIMARY KEY,amount INTEGER NOT NULL); CREATE TABLE outbox(id TEXT PRIMARY KEY,payload TEXT NOT NULL,acked INTEGER NOT NULL DEFAULT 0);')
receiver.executescript('CREATE TABLE receipts(id TEXT PRIMARY KEY,payload TEXT NOT NULL); CREATE TABLE ledger(id TEXT PRIMARY KEY,amount INTEGER NOT NULL);')
def create_operation(key,amount,fail_between=False):
 sender.execute('BEGIN')
 try:
  sender.execute('INSERT INTO operations VALUES(?,?)',(key,amount))
  if fail_between:raise RuntimeError('injected-before-outbox')
  sender.execute('INSERT INTO outbox(id,payload) VALUES(?,?)',(key,json.dumps(dict(amount=amount),sort_keys=True)))
  sender.execute('COMMIT')
 except Exception:
  sender.execute('ROLLBACK');raise
try:create_operation('aborted',999,True)
except RuntimeError:pass
check('injected_failure_rolls_back_operation_and_outbox',[count(sender,'operations'),count(sender,'outbox')],[0,0])
create_operation('event-A',125)
check('commit_before_delivery_is_pending',[count(sender,'operations'),count(sender,'outbox'),count(receiver,'ledger')],[1,1,0])
checkpoint=db();sender.backup(checkpoint)
def receive(key,payload,fail_before_effect=False):
 receiver.execute('BEGIN IMMEDIATE')
 try:
  prior=receiver.execute('SELECT payload FROM receipts WHERE id=?',(key,)).fetchone()
  if prior is not None:
   if prior[0]!=payload:raise ValueError('same-id-different-payload')
   receiver.execute('COMMIT');return 'duplicate'
  receiver.execute('INSERT INTO receipts VALUES(?,?)',(key,payload))
  if fail_before_effect:raise RuntimeError('injected-before-ledger')
  receiver.execute('INSERT INTO ledger VALUES(?,?)',(key,json.loads(payload)['amount']))
  receiver.execute('COMMIT');return 'applied'
 except Exception:
  receiver.execute('ROLLBACK');raise
key,payload=sender.execute('SELECT id,payload FROM outbox').fetchone()
try:receive(key,payload,True)
except RuntimeError:pass
check('receipt_and_effect_rollback_together',[count(receiver,'receipts'),count(receiver,'ledger')],[0,0])
result=receive(key,payload) # Deliberately omit acknowledgment to the sender.
check('receiver_committed_sender_ack_missing',[result,sender.execute('SELECT acked FROM outbox').fetchone()[0],count(receiver,'ledger')],['applied',0,1])
result=receive(key,payload)
sender.execute('UPDATE outbox SET acked=1 WHERE id=?',(key,))
check('retry_with_retained_receipt_has_one_effect',[result,count(receiver,'ledger'),receiver.execute('SELECT sum(amount) FROM ledger').fetchone()[0]],['duplicate',1,125])
conflict=False
try:receive(key,json.dumps(dict(amount=999),sort_keys=True))
except ValueError:conflict=True
check('same_id_changed_payload_is_conflict',[conflict,receiver.execute('SELECT sum(amount) FROM ledger').fetchone()[0]],[True,125])
# Restore only a synthetic sender checkpoint. The receiver remains at its later state.
restored=db();checkpoint.backup(restored);oldkey,oldpayload=restored.execute('SELECT id,payload FROM outbox WHERE acked=0').fetchone()
check('restored_sender_replays_known_event',[receive(oldkey,oldpayload),count(receiver,'ledger')],['duplicate',1])
# A genuinely later operation is absent from the sender checkpoint, even if accepted downstream.
create_operation('event-B',80);bkey,bpayload=sender.execute("SELECT id,payload FROM outbox WHERE id='event-B'").fetchone();receive(bkey,bpayload)
check('sender_checkpoint_does_not_rewind_receiver',{'restoredOperations':count(restored,'operations'),'receiverEffects':count(receiver,'ledger'),'receiverTotal':receiver.execute('SELECT sum(amount) FROM ledger').fetchone()[0]},dict(restoredOperations=1,receiverEffects=2,receiverTotal=205))
# The safety claim fails if deduplication history is removed while effects remain.
receiver.execute("DELETE FROM receipts WHERE id='event-A'")
replay_blocked=False
try:receive(key,payload)
except sqlite3.IntegrityError:replay_blocked=True
check('missing_receipt_needs_reconciliation',[replay_blocked,count(receiver,'ledger'),receiver.execute('SELECT count(*) FROM receipts WHERE id=?',(key,)).fetchone()[0]],[True,2,0])
# Atomic version check applies only to this metadata update, not to external deployment commands.
state=db();state.execute('CREATE TABLE target(id TEXT PRIMARY KEY,generation INTEGER NOT NULL,version TEXT NOT NULL)');state.execute("INSERT INTO target VALUES('test',7,'v7')")
fresh=state.execute("UPDATE target SET generation=8,version='v8' WHERE id='test' AND generation=7").rowcount
stale=state.execute("UPDATE target SET generation=8,version='v6' WHERE id='test' AND generation=7").rowcount
check('stale_state_update_changes_zero_rows',[fresh,stale,state.execute('SELECT version FROM target').fetchone()[0]],[1,0,'v8'])
check('recovery_deadline_minutes',23*60-(20+15+10),22*60+15)
check('synthetic_segment_rates',{'canary':str(Fraction(6,150)),'control':str(Fraction(9,9850)),'global':str(Fraction(15,10000))},{'canary':'1/25','control':'9/9850','global':'3/2000'})
for conn in [schema,contract,sender,receiver,checkpoint,restored,state]:conn.close()
print(json.dumps(dict(python=platform.python_version(),sqlite=sqlite3.sqlite_version,scriptSha256=hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),groups=len(checks),scope='Executed isolated in-memory SQLite transactions, schema shapes, synthetic sender restore and failure injection; arithmetic fixtures use fictional inputs. No real broker, distributed exactly-once guarantee, production restore, rollout, concurrency load or independent specialist review.',checks=checks),indent=2))
NA PRÁTICA

Emissor restaurado: uma operação. Consumidor preservado: dois efeitos, total 205. Recuperar um componente não recua automaticamente o outro.

Armadilhas comuns

Timeout como falha certa; ID novo em cada retry; recibo separado do efeito; restauro local como consistência global; limpeza de recibos antes de definir replay.

Tópicos relacionados: Recuperação e transição para APS · Artefactos e evidência

Leva esta ideia contigo

A retoma precisa de distinguir o que foi pedido, o que já teve efeito e o que ainda exige reconciliação.

Criar conta

Referência: Transactional outbox pattern · Google SRE release and canary guidance; GitHub immutable releases and GitLab release evidence and deployment safety; DORA five-metric model; inspected 2026-10-01