Class OutboxPublishCompletion

java.lang.Object
com.aim2be.platform.outbox.OutboxPublishCompletion

public class OutboxPublishCompletion extends Object
Helper component that executes outbox row status updates in their OWN transaction (Propagation.REQUIRES_NEW).

Necessary because the hot-relay callback fires AFTER_COMMIT — at which point the business transaction is already committed and no longer available to participate in the markSent / markFailureAttempt update. Spring's transaction template requires a NEW transaction here, separate from any parent.

This is a thin wrapper around OutboxRecordRepository bulk updates; it exists as a distinct bean so the @Transactional proxy intercepts the call (self-invocation would bypass the proxy).

Both transition methods are guarded on status = PENDING at the JPQL layer (R3 finding — concurrent terminal-state corruption). A rowcount=0 from the underlying repository call indicates that the row was already in a terminal status (SENT or FAILED); the helper logs the no-op at DEBUG and returns that rowcount — this is the expected outcome when a delayed hot-relay callback fires after the cold poller has already transitioned the row (or vice versa). The rowcount is propagated (not swallowed) so the OutboxBackend SPI's documented 1=applied / 0=no-op contract is honoured truthfully by PgOutboxBackend (Wave A.2 Phase 0a — reviewer R1 A2/B1).

  • Constructor Details

    • OutboxPublishCompletion

      public OutboxPublishCompletion(OutboxRecordRepository repository)
      Parameters:
      repository - the JPA repository for the persistence operations
  • Method Details

    • markSent

      @Transactional(propagation=REQUIRES_NEW) public int markSent(UUID aggregateId, UUID eventId, Instant sentAt)
      Transitions the row to OutboxRecord.Status.SENT in a fresh transaction. Idempotent: if the row was already terminal (e.g. transitioned to OutboxRecord.Status.FAILED by the poller while the hot relay was still in-flight) the JPQL guard on status = PENDING affects zero rows, and the method returns normally after logging a DEBUG no-op.
      Parameters:
      aggregateId - compound-PK component
      eventId - compound-PK component
      sentAt - wall-clock instant at which the publish succeeded
      Returns:
      number of rows updated (1 on applied transition; 0 when the row was already terminal — logged at DEBUG, returned to the caller)
    • markFailureAttempt

      @Transactional(propagation=REQUIRES_NEW) public int markFailureAttempt(UUID aggregateId, UUID eventId, String lastError, int maxRetries)
      Persists a failure-attempt row update (retries++ + lastError) in a fresh transaction. The row stays PENDING; the cold poller picks it up next tick.

      Guarded on status = PENDING — a delayed hot-relay failure callback that fires after the cold poller has already transitioned the row to OutboxRecord.Status.FAILED (or after a parallel hot-path SENT transition) is a no-op (R3 finding).

      R5 fix — the underlying repository UPDATE evaluates the retry budget atomically (see OutboxRecordRepository.markFailureAttempt(UUID, UUID, String, int)) and conditionally transitions the row to OutboxRecord.Status.FAILED when retries + 1 >= maxRetries. Callers no longer need to make the budget decision against an in-memory snapshot.

      Parameters:
      aggregateId - compound-PK component
      eventId - compound-PK component
      lastError - pre-truncated error message (caller is expected to have respected OutboxRecord.LAST_ERROR_MAX_LENGTH)
      maxRetries - retry budget from im2be.outbox.poller.max-retries; passed through to the JPQL UPDATE's CASE-WHEN clause
      Returns:
      number of rows updated (1 on applied increment; 0 when the row was already terminal — logged at DEBUG, returned to the caller)