Skip to content

fix: outbox self-invocation, optimistic locking, idempotency race - #14

Merged
lmoraesdev merged 4 commits into
mainfrom
fix/optimistic-locking-outbox-race
Jul 23, 2026
Merged

fix: outbox self-invocation, optimistic locking, idempotency race#14
lmoraesdev merged 4 commits into
mainfrom
fix/optimistic-locking-outbox-race

Conversation

@lmoraesdev

@lmoraesdev lmoraesdev commented Jul 23, 2026

Copy link
Copy Markdown
Owner

Summary

  • fix(outbox): OutboxRelay called claimBatch()/markPublished()/revertToPending() via this., bypassing the Spring AOP proxy — @Transactional was a silent no-op. Extracted OutboxClaimCoordinator (separate bean, real proxy) and added a reaper for orphaned IN_FLIGHT events stuck past 2 minutes (new claimed_at column, migration V4).
  • fix(charges): added @Version optimistic locking to charges (migration V5). ChargeExpirationJob now catches conflicts per-charge via an extracted ChargeExpirationCoordinator (own transaction, so one stale charge doesn't block the batch); ProcessWebhookService lets the conflict propagate to a new GlobalExceptionHandler 409 mapping. Webhook status is now validated before any transition (InvalidChargeStatusException, 422) instead of leaking a raw IllegalArgumentException. CreateChargeService now handles the idempotency-key race correctly: the loser of a concurrent create gets a proper replay of the winner's result instead of a raw 500 from the unique constraint violation (extracted ChargeCreationCoordinator for the transactional write).
  • fix(charges): ChargeJpaEntity now implements Persistable<UUID> (isNew() = version == null) — since the Charge id is assigned in code (not DB-generated), Spring Data had no way to distinguish insert from update by id alone and always called merge(), which produced a false ObjectOptimisticLockingFailureException on brand-new charges.
  • fix(docs): corrected the CI badge URL in README (repo moved from java-payment-hexagonal to java-payment-core).

Test plan

  • Unit/slice tests (55) pass
  • spotless + checkstyle clean
  • Testcontainers ITs (ChargeRepositoryIT, OutboxClaimCoordinatorTransactionalIT, CreateChargeServiceConcurrencyIT, etc.) — pass locally with Docker; CI will confirm via the "Lint + Unit Tests" required check

…lama eventos IN_FLIGHT órfãos

Extrai claimBatch()/markPublished()/revertToPending() de OutboxRelay pra
OutboxClaimCoordinator, um @component novo. OutboxRelay chamava esses métodos
via this.*, o que faz o proxy AOP do Spring nunca interceptar a chamada e
deixa @transactional como no-op silencioso. Agora OutboxRelay recebe o
coordinator por injeção de dependência e chama através dele, passando pelo
proxy de verdade.

Adiciona reaper de eventos IN_FLIGHT travados: claimBatch() primeiro reclama
de volta pra PENDING qualquer evento IN_FLIGHT com claimed_at mais velho que
2 minutos, antes de buscar o próximo lote PENDING. Nova coluna claimed_at
(migration V4) marca quando um evento foi reivindicado.
…play correto sob concorrência na idempotência

Adiciona @Version (coluna version, migration V5) em charges. ProcessWebhookService
e ChargeExpirationJob podiam sobrescrever a mesma charge concorrentemente sem
avisar ninguém. No webhook, o conflito agora propaga OptimisticLockingFailureException
até o GlobalExceptionHandler, que responde 409 (o provedor reenvia; dedup por
event_id garante que reprocessar é seguro). No job de expiração, extrai
ChargeExpirationCoordinator (@transactional por charge, evitando o self-invocation
que anularia a anotação) para que uma charge em conflito seja pulada nesse ciclo
sem impedir as demais de serem processadas.

ProcessWebhookService agora valida o status recebido antes de qualquer transição,
lançando InvalidChargeStatusException (422) em vez de deixar um IllegalArgumentException
cru virar 500.

CreateChargeService: duas requisições concorrentes com a mesma Idempotency-Key nova
faziam a perdedora receber um 500 da violação de unicidade, mesmo com o rollback já
evitando duplicar dados. Extrai ChargeCreationCoordinator (@transactional) para a
escrita; ao capturar DataIntegrityViolationException UMA CAMADA ACIMA da transação
(depois do rollback completo do Postgres), CreateChargeService busca o registro de
idempotência da vencedora numa transação nova e retorna o replay em vez do erro.

Testes cobrem os três cenários; a concorrência real (duas threads com a mesma key)
é provada via Testcontainers em CreateChargeServiceConcurrencyIT.
… testes

ChargeJpaEntity agora implementa Persistable<UUID> com isNew() baseado em
(version == null). O id da Charge é atribuído em código (UUID.randomUUID()),
não pelo banco, então Spring Data não tinha como distinguir insert de update
só pelo id e sempre chamava merge() — com @Version presente, merge() de uma
entidade nova faz o Hibernate suspeitar que a linha foi apagada por outra
transação e lança ObjectOptimisticLockingFailureException.

Corrige também OutboxClaimCoordinatorTransactionalIT: removido um
thenCallRealMethod() estubado num método de repository Spring Data (proxy
sem corpo Java real, Mockito não consegue chamar); o @MockitoSpyBean já
delega pro objeto real automaticamente em qualquer chamada não estubada.

Corrige ChargeRepositoryIT: os dois casos que esperavam inserir uma charge
nova usavam ChargeTestData.aCharge().build(), que via Charge.restore()
monta uma charge com version=0 (representando uma linha já persistida).
Isso fazia isNew() reportar corretamente false e o save() tentar um merge()
numa linha inexistente. Trocado por Charge.create(...), a fábrica de
domínio real usada em produção para charges novas (version=null).
@lmoraesdev
lmoraesdev merged commit 8e1f847 into main Jul 23, 2026
2 checks passed
@lmoraesdev
lmoraesdev deleted the fix/optimistic-locking-outbox-race branch July 23, 2026 17:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant