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
tiporeivindicado particiona a saída entre apps diferentes; as duas réplicas do mesmo app registram os mesmostipo, casam o mesmo predicado e drenam a mesma linha. Medido em produção: 92PUTpara 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 dointegrador-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-mensageria0.3.4 passou a eleger uma réplica por tick compg_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). ORelayJoblocal e a constantepied_relayforam removidos, emensageria.relay.enabledvoltou atrue. 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.
PiedClusterLock(@Singleton, pacotecomum):comLock(String nome, Runnable work)fazpg_try_advisory_lock(hashtext(nome))(try-lock não-bloqueante); adquiriu → rodaworke solta nofinally(pg_advisory_unlock); não adquiriu → coalesce (não roda, devolvefalse, 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).- 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. - 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. - Runtime do
@Connectable: o@Schedulednão abre contexto de conexão do Micronaut Data;@Connectableabre um para o método. Isso exige a libmicronaut-data-jdbcem runtime — o int-pied só tinha omicronaut-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-libraryde propósito (não arrastar a BOM do Micronaut); caberia um core puro(DataSource, nome, Runnable)com adapter@Connectablepor-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 daBancoFixture) 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.