Interface OutboxBackend

All Known Implementing Classes:
PgOutboxBackend

public interface OutboxBackend
Storage SPI for the transactional outbox — the abstraction seam between the relay/poller orchestration (OutboxPublisher + OutboxPollerWorker) and the underlying persistence engine (Wave A.2 Phase 0a, design doc .planning/32-wave-a2-redis-outbox-design.md refinement #11).

The seam covers exactly the storage touchpoints the orchestration layer needs and nothing more:

  1. persistPending(OutboxRecord) — write a freshly-minted PENDING row before the relay is registered (the publisher's enqueue step).
  2. findPendingBatch(int) — fetch a bounded FIFO batch of PENDING rows for the cold poller's sweep.
  3. markSent(UUID, UUID, Instant) — the PENDING → SENT transition (driven by both the hot AFTER_COMMIT relay and the cold poller).
  4. markFailureAttempt(UUID, UUID, String, int) — the atomic retry-budget transition on publish failure.

The two transition method signatures intentionally mirror OutboxPublishCompletion.markSent(UUID, UUID, Instant) and OutboxPublishCompletion.markFailureAttempt(UUID, UUID, String, int): a backend implementation owns the same idempotent, status-guarded semantics (a rowcount = 0 no-op when the row is already terminal) so the orchestration layer need not know which engine backs the store.

Transaction contract — requiresActiveTransaction(). The default PgOutboxBackend persists through a Spring-managed JPA transaction: persistPending(OutboxRecord) MUST share the caller's business transaction so the row and the business state commit (or roll back) atomically (ADR-0014 D-7). It therefore returns true, and OutboxPublisher keeps its isActualTransactionActive() guard active. A future non-relational backend (e.g. a Redis Lua-EVAL store, Phase 0b) that achieves atomicity WITHOUT a Spring transaction returns false, relaxing that guard — but Phase 0a ships only the PG backend, so the guard's observable behaviour is unchanged.

Phase 0a extracts this interface from the existing PG logic with ZERO behaviour change; Phase 0b adds the Redis implementation in a separate Maven module so PG consumers never pull in spring-data-redis.

  • Method Details

    • persistPending

      void persistPending(OutboxRecord record)
      Persists a freshly-minted outbox row in OutboxRecord.Status.PENDING.

      For the PG backend this is the JPA repository.save(record) that shares the caller's active business transaction (the row commits atomically with the business state). The orchestration layer has already populated the row's fields (aggregateId, eventId, status, topic, payload, schemaVersion, retries=0, createdAt) and defensively cloned the payload bytes; the backend stores the record as-is and does not mutate it.

      Parameters:
      record - the PENDING row to persist; never null
    • findPendingBatch

      List<OutboxRecord> findPendingBatch(int batchSize)
      Fetches up to batchSize OutboxRecord.Status.PENDING rows in FIFO (createdAt ASC) order for the cold poller's sweep.

      For the PG backend this delegates to OutboxRecordRepository.findByStatusOrderByCreatedAtAsc(OutboxRecord.Status, org.springframework.data.domain.Pageable) with a single bounded page; the (status, created_at) compound index keeps the scan O(batchSize), not O(table size).

      Parameters:
      batchSize - maximum number of rows to return (the poller passes im2be.outbox.poller.batch-size)
      Returns:
      a list of PENDING rows in FIFO creation order; empty when none match, never null
    • markSent

      int markSent(UUID aggregateId, UUID eventId, Instant sentAt)
      Transitions a row to OutboxRecord.Status.SENT, clearing any prior lastError. Idempotent: when the row is already terminal (SENT, FAILED, or ARCHIVED) the status-guarded write affects zero rows and the method returns 0 without throwing — the expected outcome when a delayed hot-relay callback fires after the cold poller already transitioned the row (or vice versa).

      Mirrors OutboxPublishCompletion.markSent(UUID, UUID, Instant).

      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)
    • markFailureAttempt

      int markFailureAttempt(UUID aggregateId, UUID eventId, String lastError, int maxRetries)
      Records a publish-failure attempt: increments the row's retries counter, stores the (pre-truncated) error message, AND atomically transitions the row to OutboxRecord.Status.FAILED when the post-increment retries count reaches maxRetries. The budget is evaluated against the store's CURRENT retries value at write time, so the orchestration layer never decides the budget against a stale in-memory snapshot (ADR-0014 D-9 / R5 fix).

      Status-guarded like markSent(UUID, UUID, Instant): a late failure callback racing a terminal transition is a rowcount = 0 no-op.

      Mirrors OutboxPublishCompletion.markFailureAttempt(UUID, UUID, String, int).

      Parameters:
      aggregateId - compound-PK component
      eventId - compound-PK component
      lastError - pre-truncated error message (caller respects OutboxRecord.LAST_ERROR_MAX_LENGTH)
      maxRetries - retry budget from im2be.outbox.poller.max-retries
      Returns:
      number of rows updated (1 on applied increment; 0 when the row was already terminal)
    • requiresActiveTransaction

      boolean requiresActiveTransaction()
      Whether persistPending(OutboxRecord) requires an active Spring-managed transaction to be in progress on the calling thread.

      The PG backend returns true — its persist shares the caller's business transaction so the outbox row and the business state commit atomically (ADR-0014 D-7); OutboxPublisher enforces this with an isActualTransactionActive() guard. A non-relational backend that achieves atomicity without a Spring transaction returns false, relaxing that guard.

      Returns:
      true when the publisher MUST run inside an active transaction (PG default), false when atomicity is achieved outside Spring transaction management
      API Note:
      This flag gates ONLY the persistPending active-transaction precondition in OutboxPublisher. It does NOT, on its own, make the whole publish() flow transaction-free: the AFTER_COMMIT hot relay is registered via registerSynchronization(...) unconditionally, so a backend returning false that is invoked outside a Spring transaction will still fail at the relay-registration step with IllegalStateException("Transaction synchronization is not active"). Decoupling the relay for a fully transaction-free backend — and a possible rename to persistRequiresActiveTransaction() to lexically narrow the scope — is a Phase 0b concern that lands with the Redis backend (reviewer R3, tracked as tech-debt). Phase 0a ships only the PG backend (true), so the observable behaviour is unchanged.