Pular para conteúdo

0021 — Entrega garantida M2M: at-least-once

Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-12 · Decidido em: 2026-08-10

Contexto

A comunicação máquina-a-máquina entre servers da casa passou a ter um transporte próprio: o xadm-mensageria (0019), que entrega chamada M2M (o poke "algo mudou, vá puxar", watch, encerramento, xadm-push) por outbox — o remetente grava o evento numa tabela local e um relay assíncrono (RelayM2m, @Scheduled) o entrega depois, desacoplando o caminho de escrita da disponibilidade do destino.

Essa escolha tem uma consequência que precisa virar norma, não folclore de quem já apanhou: o relay reentrega. Ele roda em ciclo, sem lock distribuído e sem chave de dedup — se o destino respondeu mas a confirmação se perdeu, ou se dois ciclos se sobrepõem, o mesmo evento chega mais de uma vez. É o contrato at-least-once: entrega ≥ 1 vez, nunca 0, podendo repetir. Um receptor que trate cada chegada como única acumula efeito duplicado (dobra um lançamento, reprocessa um encerramento) — e o bug é silencioso, porque cada chamada isolada é válida.

O que faz os adotantes atuais corretos é justamente absorver a repetição: eles deduplicam por chave natural do domínio antes de aplicar efeito. Isso hoje é padrão implícito de quem escreveu os receptores; sem RD, o próximo a adotar o xadm-mensageria não tem como saber que a idempotência é precondição, não capricho de quem já implementou.

Nota (constituição §1.2 / §3): a mecânica do RelayM2m (@Scheduled, sem lock/dedup) e os receptores idempotentes de referência (EncerramentoService, MdfeVigiadoService, que deduplicam por chave natural) vivem em repos de app, fora deste repositório — são exemplos verificados out-of-band na Fase de mensageria já implementada e publicada, não fatos conferíveis aqui. O contrato at-least-once + idempotência esta decisão ratifica (comportamento shippado). O filtro da SAIDA no relay (bullet "não compete pela fila") nasceu aqui como prescrição nova — o bug, relay competindo em schema compartilhado, foi observado em e2e-maxsul-pied — e foi entregue no xadm-mensageria 0.2.0 (2026-08-25): o RelayM2m reivindica só os tipo que o app registra (RoteadorEnviadores.tiposRegistrados()) e MensageriaService.enfileirar falha-rápido quando o tipo não tem EnviadorMensagem local — os dois lados que esta decisão pedia. App sem enviador = relay no-op; o contorno por convenção deixou de ser necessário (Bibliotecas da casa).

Decisão

A casa entrega M2M com garantia at-least-once (outbox + relay assíncrono sem dedup). Todo receptor de chamada M2M é idempotente por chave natural — isso é precondição do transporte, não opção do receptor.

  • At-least-once é o contrato. O xadm-mensageria garante que o evento chega, podendo chegar repetido. Não garante exactly-once. Quem publica um evento assume que o destino pode vê-lo mais de uma vez.
  • Idempotência por chave natural é obrigação do receptor. Antes de aplicar efeito, o receptor reconhece a identidade de domínio do evento (a mesma chave natural que o espelho usa — 0009, nunca a chave de um terceiro) e trata a segunda chegada como no-op (ou upsert convergente). O efeito de aplicar N vezes é igual ao de aplicar 1 vez.
  • Exactly-once (dedup/lock na lib) fica deferido — REGRA Nº 3. Não se coloca chave de deduplicação nem lock distribuído no xadm-mensageria agora: at-least-once + receptor idempotente resolve o caso da casa com menos peça móvel. Exactly-once só entra se um caso duro pedir — o candidato conhecido é o int-pied Fase 2 sobre o legado /api/v1/xadm, onde o receptor não pode ser tornado idempotente. Até lá, é complexidade que não se paga.
  • Em DB/schema compartilhado, o relay reivindica só o que sabe rotear — não compete pela fila. Quando ≥2 apps adotam o xadm-mensageria sobre o mesmo schema, cada um sobe seu RelayM2m (@Scheduled), e os dois drenam a mesma tabela SAIDA. Sem filtro, o relay do app que não registra o tipo do evento pega a linha, não sabe roteá-la e loga Sem EnviadorMensagem registrado para tipo=... — a mensagem fica presa no relay errado (visto em e2e-maxsul-pied: o integrador pegando um xadm-push do int-pied). Norma: o RelayM2m filtra a SAIDA pelos tipo/EnviadorMensagem que o app registra — a linha que ele não sabe rotear é ignorada (deixada para o dono), nunca pega+ERROR. O filtro é a única forma de particionar a fila em schema compartilhado, e é robusto para app que envia E recebe: cada relay reivindica exatamente os tipos que registra, e nada mais. Um receptor-puro (só recebe, nunca envia) registra zero EnviadorMensagem → seu relay já ignora toda linha da SAIDA (no-op inócuo), sem precisar de nenhum flag. Desligar o relay (MENSAGERIA_RELAY_ENABLED=false) é, no máximo, otimização de loop-ocioso para esse caso — nunca um resolvedor de colisão: colisão de consumidores não se resolve por config por-deploy, coluna de origem, nem relay-broker único (ver Alternativas descartadas). A mecânica do filtro mora no repo da lib (§3, ver nota abaixo).
  • Quem enfileira na SAIDA tem que ter o EnviadorMensagem do tipo — fail-fast. O outro lado da partição: enfileirar um tipo que o próprio app não sabe enviar é erro de programação, não condição de corrida — o app grava uma linha órfã que nenhum relay (nem o dele, nem o do vizinho, que filtra pelos seus tipos) vai rotear, e a mensagem morre presa na fila. Norma: enfileirar exige o EnviadorMensagem do tipo registrado no próprio app; sem ele, falha rápido no enqueue (não grava a linha). Assim a posse da mensagem — quem envia e quem processa — fica cravada pelo registro de enviador, dos dois lados: só enfileira quem sabe enviar, só drena quem sabe rotear.

Correção 2026-09-12: a eleição de instância entre réplicas do mesmo app (advisory lock no tick do relay) é decisão própria — 0035.

Os 3 mecanismos de entrega — onde este se encaixa

Este ADR é sobre um dos três mecanismos de entrega garantida do ecossistema. O mapa completo (o HTTP M2M do xadm-mensageria, a fila PowerSync uploadData/write-back, e o ZIM/dXpEnvio RetornoLote) e o quando cada um vivem na página dona do assunto: Plataforma de Integração §Entrega garantida.

Alternativas descartadas

  • Exactly-once na lib (chave de dedup + lock distribuído no relay). Move a idempotência para a infraestrutura de mensageria — mais estado, mais coordenação (lock que pode travar, chave que pode crescer sem bound), para um problema que o receptor resolve com a chave natural que já tem. Deferido: só se um receptor comprovadamente não puder ser idempotente.
  • At-most-once (entregar no máximo uma vez, aceitar perda). Inaceitável para efeito de negócio — um encerramento perdido não reaparece. A casa prefere repetir a perder.
  • Deixar a idempotência como "boa prática" implícita. É o estado que esta RD corrige: sem norma, o próximo adotante escreve receptor não-idempotente e duplica efeito em silêncio.
  • Deixar o relay drenar tudo da SAIDA (sem filtro). É o estado que causa a competição em schema compartilhado: o relay do app errado pega a linha e a perde num ERRO de roteamento. Preterido o atalho "cada app usa sua própria tabela de outbox" — multiplica schema e quebra a posse única da tabela (0009 / owner-writes); o filtro por tipo custa menos e mantém uma fila só por dono.
  • Resolver a colisão por config por-deploy (MENSAGERIA_RELAY_ENABLED=false). Trata o sintoma no deploy, não a causa no código: desligar um consumidor legítimo para não competir é competing consumers mal-feito — não compõe (o app que envia E recebe não pode se desligar), e some no dia em que o app passa a enviar. O filtro por tipo/enviador resolve na lib, uma vez, para todo deploy. O flag sobra, no máximo, como otimização de loop-ocioso do receptor-puro.
  • Rotear por coluna de origem na SAIDA. Uma coluna origem/app_id que diz "esta linha é do app X" reintroduz no dado o que o registro de enviador já diz: o relay que sabe rotear o tipo é o dono. Coluna de origem é estado a mais para manter em sincronia, e não impede o relay errado de ler a linha alheia (só o filtro por capacidade de roteamento faz isso).
  • Relay-broker único (um processo drena a SAIDA de todos). Centralizar o relay num só serviço que roteia para todos recria um ponto único e um god-node de roteamento (contra 0009): esse broker precisa conhecer todo tipo de todo app, e vira o gargalo/SPOF que o outbox por-dono existe para evitar. Cada app roteia o que registra; ninguém precisa conhecer os tipos dos outros.

Consequências

  • Adotar xadm-mensageria implica escrever receptor idempotente. Faz parte do checklist de quem recebe M2M: a chave natural do evento é reconhecida e a re-entrega é no-op. Sem isso, a adoção está incompleta, não "quase pronta".
  • Adotar em DB/schema compartilhado implica posse única de migração e relay filtrado. Duas metades que a adoção precisa fechar quando o outbox vive num schema que outro app também toca: (1) a tabela mensagem_m2m/SAIDA tem um dono único — o dono cria e migra em prod, os demais consomem com migração test-only (db/migration-test), senão dois Flyway migrando a mesma tabela colidem no boot (relation already exists) — mecânica em java-micronaut §Banco; (2) o relay filtra a SAIDA pelos tipos que o app registra (bullet da §Decisão), senão o relay do app errado drena a linha do vizinho — e, do lado de quem escreve, só enfileira tipo cujo EnviadorMensagem o app registra (fail-fast no enqueue), senão grava linha órfã que ninguém roteia. As duas metades nascem do mesmo fato — schema compartilhado — e nenhuma é opcional na adoção; a partição da fila é só o registro de enviador, dos dois lados, nunca config por-deploy nem coluna de origem.
  • A regressão da partição é verificável nos dois lados. No consumo: publicar um evento de um tipo que o app não registra e confirmar que o relay dele não o consome (a linha segue disponível para o dono). No enqueue: tentar enfileirar um tipo sem EnviadorMensagem registrado e confirmar que falha rápido (não grava linha órfã). Teste proporcional (constituição §6), não confiança de que "só o app certo vai pegar". O fio completo (dois apps, schema compartilhado, o evento chega ao dono certo) é a suíte e2e-local (e2e-local) — foi ela que pegou a competição em e2e-maxsul-pied.
  • O contrato-app↔app (0020) e a idempotência se somam. 0020 dita o shape (OpenAPI do receptor, envelope Bearer, request-id no MDC); esta decisão dita a semântica de entrega (pode repetir → receptor absorve). Um endpoint M2M cumpre os dois.
  • Teste de receptor cobre a re-entrega. A garantia de idempotência é verificável: aplicar o mesmo evento duas vezes deixa o estado igual ao de aplicá-lo uma vez — teste proporcional ao risco (constituição §6), não confiança de que "não vai repetir".
  • Reversível para exactly-once por caso. Se o int-pied (ou outro) provar que precisa, a lib ganha dedup/lock naquele caminho; a norma geral segue at-least-once + idempotência.