Skip to content

fix(ledger): move optimistic retry outside the idempotency transaction - #136

Merged
tiana-code merged 2 commits into
mainfrom
feat/E-01-optimistic-retry-topology
Jun 15, 2026
Merged

fix(ledger): move optimistic retry outside the idempotency transaction#136
tiana-code merged 2 commits into
mainfrom
feat/E-01-optimistic-retry-topology

Conversation

@tiana-code

Copy link
Copy Markdown
Owner

What

The optimistic-retry loop sat in TransactionServiceImpl, between the @Transactional IdempotencyStore.runOrReplay (which opens the unit-of-work transaction TX#1) and the @Transactional TransactionPoster (which joins TX#1). On an OptimisticLockingFailureException the joined poster marked the shared TX#1 rollback-only, so the retry re-entered a doomed transaction and the commit raised UnexpectedRollbackException. The retry never worked on the real request path; it only passed where the poster was a MockK mock.

This moves the bounded retry loop into IdempotencyServiceImpl.execute, so each attempt re-invokes runOrReplay in a fresh physical transaction. The idempotency reservation, business rows and audit row commit or roll back together per attempt (no REQUIRES_NEW, no split reservation). The race recovery stays single-shot and does not consume a retry attempt; a body conflict still propagates without retry.

Why

Pre-existing correctness defect surfaced by the #49 pre-mortem. Under real contention on a hot account, the broken retry could lose a committed-looking write together with its audit row - an audit-integrity gap on exactly the operations auditors scrutinise.

Changes

  • IdempotencyServiceImpl: bounded optimistic-retry loop (MAX_ATTEMPTS=3) wrapping store.runOrReplay; ConcurrencyConflictException on exhaustion (503 + Retry-After, unchanged surface).
  • TransactionServiceImpl: post/reverse become plain delegations; withOptimisticRetry and MAX_ATTEMPTS removed.
  • Tests: retry unit tests re-homed to IdempotencyServiceImplTest; AuditRetryTopologyIT gains contended cases on real committing boundaries (NOT_SUPPORTED) with a forced optimistic failure - exactly one business + audit row on retry, zero of each plus no reserved key on full rollback.

Verification

Local gates green: :test (11/11 unit), :detekt, :spotlessCheck, :assemble, :compileIntegrationTestKotlin. The contended integration tests run on CI (Testcontainers).

Closes #134

@tanya_r added 2 commits June 15, 2026 14:40
The optimistic-retry loop lived in TransactionServiceImpl, between the
@transactional IdempotencyStore.runOrReplay that opens the unit-of-work
transaction and the @transactional TransactionPoster that joins it. On an
OptimisticLockingFailureException the joined poster marked the shared
transaction rollback-only, so the retry re-entered a doomed transaction and the
commit raised UnexpectedRollbackException. Retry never worked on the real
request path; it only passed where the poster was mocked.

Move the bounded retry loop into IdempotencyServiceImpl.execute so each attempt
re-invokes runOrReplay in a fresh physical transaction. Reservation, business
rows and audit row commit or roll back together per attempt, with no
REQUIRES_NEW and no split reservation. The idempotency race recovery stays
single-shot and does not consume a retry attempt; a body conflict still
propagates without retry.

Add AuditRetryTopologyIT contended cases on real committing boundaries with a
forced optimistic failure, asserting exactly one business and audit row on
retry and zero of each plus no reserved key on full rollback.

Closes #134
… tests

AuditRetryTopologyIT runs with NOT_SUPPORTED so each runOrReplay commits for
real (required to exercise the retry-in-a-fresh-transaction topology). The
committed outbox rows leaked into the shared Testcontainers Postgres and
inflated the global-count outbox assertions in TransactionBalanceServiceIT and
OutboxEventPublisherIT. Delete the outbox table in @AfterEach, mirroring
OutboxEventPublisherIT. Audit rows stay (append-only trigger 018 rejects
delete); other ITs scope audit by unique resourceId so they are unaffected.
@tiana-code
tiana-code merged commit 40fb511 into main Jun 15, 2026
6 checks passed
@tiana-code
tiana-code deleted the feat/E-01-optimistic-retry-topology branch June 15, 2026 17:58
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.

Optimistic retry cannot function inside the idempotency transaction

1 participant