Pular para conteúdo

0014 — Guarda de cluster (advisory lock) para os @Scheduled

Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-09 · Decidido em: 2026-09-01

Contexto

Meta da casa 50/50 jar/native: todo app Micronaut roda a imagem jar e a native (GraalVM) ao mesmo tempo, lado a lado, contra o mesmo banco (ganho duplo — metade da frota validando native + HA/failover). O maxsul-pied é scheduler/poller: com 2 instâncias ativas, todo @Scheduled dispara nas duas → double-fire:

  • PollJob (1h) — poll REST da PIED dobrado;
  • ReconciliacaoJob (1m) — GET Integrador dobrado + e-mail de alerta DUPLICADO (o dano mais visível);
  • TransformJob (5m) — normaliza + enfileira dobrado;
  • RetencaoJob (24h) — DELETE por idade (idempotente, 2× é no-op).

Os toggles pied.poll.habilitado / pied.integracao.habilitada / pied.retencao.habilitado são config estática por-instância (ligados nas duas, disparam nas duas) — não servem de guarda cross-instância.

Já safe 2× (fora do escopo): a entrega M2M (RelayM2m da lib xadm-mensageria) é N-instâncias-safe por desenho (despacho at-least-once + dedup no receptor; cada relay reivindica só seus tipos). (Errado — ver as duas revisões abaixo.)

Revisão (2026-09-08) — a entrega M2M NÃO era safe 2×; entrou no escopo. O tipo reivindicado particiona a saída entre apps diferentes; as duas réplicas do mesmo app registram os mesmos tipo, casam o mesmo predicado e drenam a mesma linha. Medido em produção: 92 PUT para 52 payloads distintos em 24h, com uma única linha no outbox por pedido. O upsert do Integrador absorvia a duplicata, então não era corrupção — o custo era um commit extra no espelho por entrega, e cada commit é mais uma janela para o PowerSync aterrissar no meio da coleta do integrador-client. O app passou a desligar o agendador da lib (mensageria.relay.enabled=false) e reagendar sob um lock próprio (pied_relay), com o §6 registrado para o central.

Revisão (2026-09-09) — a guarda voltou para a lib; o job local saiu. A xadm-mensageria 0.3.4 passou a eleger uma réplica por tick com pg_try_advisory_lock(hashtext('<micronaut.application.name>_relay_m2m')) — a mesma mecânica, agora mantida pela casa (piso de versão no 0019: abaixo de 0.3.4 é defeito, não preferência). O RelayJob local e a constante pied_relay foram removidos, e mensageria.relay.enabled voltou a true. A chave é distinta de propósito (maxsul-pied_relay_m2m × pied_<job>): lock igual elegeria a MESMA instância para o relay e para os jobs do app, serializando o que pode correr junto. Os cinco locks abaixo seguem sendo do app.

Decisão

Guarda advisory-lock por-job, molde do SweepClusterLock do webstorm-ecom.

  1. PiedClusterLock (@Singleton, pacote comum): comLock(String nome, Runnable work) faz pg_try_advisory_lock(hashtext(nome)) (try-lock não-bloqueante); adquiriu → roda work e solta no finally (pg_advisory_unlock); não adquiriu → coalesce (não roda, devolve false, o próximo tick reencaixa). Lock, work e unlock na mesma sessão. Advisory lock é por-sessão → solta sozinho na queda da instância (HA/failover, sem lease/heartbeat).
  2. Cópia por-app (não compartilhado): a classe vive local no int-pied. Zero churn de lib publicada. Deferido: promover a componente reusável em xadm-commons — hoje há 3 consumidores do mesmo padrão (webstorm + int-pied + int-sascar); avaliar quando a 3ª cópia confirmar o desenho.
  3. Per-job locks: um nome estável por job (pied_poll/pied_reconciliacao/pied_transform/ pied_retencao) — jobs independentes, sem recurso single-connection, então nomes distintos dão paralelismo (o poll de uma instância não bloqueia o transform da outra). Os quatro jobs embrulham o corpo no lock após o check do toggle. Só o caminho @Scheduled é lockado — as ações manuais/console (reconciliar(String), clique na fila) não.
  4. Runtime do @Connectable: o @Scheduled não abre contexto de conexão do Micronaut Data; @Connectable abre um para o método. Isso exige a lib micronaut-data-jdbc em runtime — o int-pied só tinha o micronaut-data-processor (SQL direto, decisão 0012), então a dep foi adicionada (build.gradle.kts). Inerte fora do lock (sem @Repository).

Alternativas descartadas

  • Confiar nos toggles como guarda: são por-instância, disparam nos dois. Não coordenam cluster.
  • Núcleo Java-puro em xadm-comum-util + adapter por-app: xadm-comum-util é java-library de propósito (não arrastar a BOM do Micronaut); caberia um core puro (DataSource, nome, Runnable) com adapter @Connectable por-app. Descartado agora junto com a promoção a compartilhado (ver Decisão 2) — mantém o escopo na guarda, sem tocar lib publicada.
  • Desembrulhar DelegatingDataSource→Hikari (idioma test-only da BancoFixture) e dispensar @Connectable: funcionaria em prod e teste sem AOP, mas promoveria um atalho de teste a produção. Preferiu-se ficar fiel ao molde (@Connectable) + adicionar a dep de runtime.
  • Lock único cross-job: mataria o paralelismo à toa (não há recurso single-connection aqui — contraste com o int-sascar, que usará lock único pela trava "1 conexão por login" da Sascar).

Consequência

O maxsul-pied pode subir 2 instâncias (jar + native) no mesmo banco sem double-fire: cada rodada de cada job executa uma vez só (PIED consultada 1×, e-mail de alerta 1×, fila drenada 1×); a outra instância coalesce e assume no failover. Prova: PiedClusterLockIT (contenção do lock: coalesce / roda / nomes distintos não contendem) + ReconciliacaoJobClusterIT (fim-a-fim: lock ocupado ⇒ zero POST no Resend). Fora de escopo (follow-up de infra): portar build_native no pipeline + criar o recurso Coolify native com split weighted jar/native.