Interface OutboxBackend
- All Known Implementing Classes:
PgOutboxBackend
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:
persistPending(OutboxRecord)— write a freshly-minted PENDING row before the relay is registered (the publisher's enqueue step).findPendingBatch(int)— fetch a bounded FIFO batch of PENDING rows for the cold poller's sweep.markSent(UUID, UUID, Instant)— the PENDING → SENT transition (driven by both the hot AFTER_COMMIT relay and the cold poller).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 Summary
Modifier and TypeMethodDescriptionfindPendingBatch(int batchSize) Fetches up tobatchSizeOutboxRecord.Status.PENDINGrows in FIFO (createdAt ASC) order for the cold poller's sweep.intmarkFailureAttempt(UUID aggregateId, UUID eventId, String lastError, int maxRetries) Records a publish-failure attempt: increments the row'sretriescounter, stores the (pre-truncated) error message, AND atomically transitions the row toOutboxRecord.Status.FAILEDwhen the post-increment retries count reachesmaxRetries.intTransitions a row toOutboxRecord.Status.SENT, clearing any priorlastError.voidpersistPending(OutboxRecord record) Persists a freshly-minted outbox row inOutboxRecord.Status.PENDING.booleanWhetherpersistPending(OutboxRecord)requires an active Spring-managed transaction to be in progress on the calling thread.
-
Method Details
-
persistPending
Persists a freshly-minted outbox row inOutboxRecord.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; nevernull
-
findPendingBatch
Fetches up tobatchSizeOutboxRecord.Status.PENDINGrows 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 passesim2be.outbox.poller.batch-size)- Returns:
- a list of PENDING rows in FIFO creation order; empty when none
match, never
null
-
markSent
Transitions a row toOutboxRecord.Status.SENT, clearing any priorlastError. Idempotent: when the row is already terminal (SENT, FAILED, or ARCHIVED) the status-guarded write affects zero rows and the method returns0without 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 componenteventId- compound-PK componentsentAt- 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
Records a publish-failure attempt: increments the row'sretriescounter, stores the (pre-truncated) error message, AND atomically transitions the row toOutboxRecord.Status.FAILEDwhen the post-increment retries count reachesmaxRetries. 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 arowcount = 0no-op.Mirrors
OutboxPublishCompletion.markFailureAttempt(UUID, UUID, String, int).- Parameters:
aggregateId- compound-PK componenteventId- compound-PK componentlastError- pre-truncated error message (caller respectsOutboxRecord.LAST_ERROR_MAX_LENGTH)maxRetries- retry budget fromim2be.outbox.poller.max-retries- Returns:
- number of rows updated (1 on applied increment; 0 when the row was already terminal)
-
requiresActiveTransaction
boolean requiresActiveTransaction()WhetherpersistPending(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);OutboxPublisherenforces this with anisActualTransactionActive()guard. A non-relational backend that achieves atomicity without a Spring transaction returnsfalse, relaxing that guard.- Returns:
truewhen the publisher MUST run inside an active transaction (PG default),falsewhen atomicity is achieved outside Spring transaction management- API Note:
- This flag gates ONLY the
persistPendingactive-transaction precondition inOutboxPublisher. It does NOT, on its own, make the wholepublish()flow transaction-free: the AFTER_COMMIT hot relay is registered viaregisterSynchronization(...)unconditionally, so a backend returningfalsethat is invoked outside a Spring transaction will still fail at the relay-registration step withIllegalStateException("Transaction synchronization is not active"). Decoupling the relay for a fully transaction-free backend — and a possible rename topersistRequiresActiveTransaction()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.
-