Skip to content
AITroveRead. Build. Understand.
Make this comfortable

At-least-once delivery: where Spring outbox retries can duplicate work

Last updated: 30 Sept 20264 min read
tutorial
IntermediateBy AITrove Editorial

An outbox relay can send the same event more than once when sending succeeds but its acknowledgement does not persist.

Download Spring source kit

The crash window is real

The source kit checks three separate boundaries: business and outbox rows commit together, a lease can be reclaimed with a new token, and a repeated event ID does not apply the local stock mutation twice. None of those tests includes a broker. Together they define a useful recovery contract: a stable event ID survives retries, the current lease owner may update outbox state, and the destination records the ID with its mutation.

A broker send followed by a database update cannot normally be one ordinary local transaction. A worker might stop between them. Retrying the row then repeats the send. Fencing blocks a stale row update; deduplication blocks a repeated destination mutation. Neither can promise an email or payment provider executed only once without that provider's own idempotency rule.

Operational states still matter

Track pending, leased, delivered and terminal failure separately. Put a bound on attempts and record the last error without copying secrets into logs. Alert on the oldest pending age rather than only queue size; one stuck event can disappear in a large healthy queue. Before launch, test service restart after database commit, after send, and before acknowledgement against the actual broker and database. The local fixture does not cover those failures.

Checked source

Output
// Recovery state, expressed as two checked row predicates:
claim: delivered = false AND (claim_token IS NULL OR lease_until <= now)
ack: event_id = ? AND claim_token = ? AND delivered = false

Verification boundary

OutboxWriteContractTest.receiptAndOutboxRowCommitOrRollBackTogether, OutboxLeaseContractTest.expiredLeaseCanBeReclaimedButOldWorkerCannotAcknowledge and ConsumerDedupContractTest.repeatedEventIdCannotApplyTheStockMutationTwice runs in the downloadable Spring source kit. The excerpt is shortened; the kit contains the complete tests.

Costs and limits

The three tests run on H2 in one process and are independent. They establish row outcomes, not end-to-end delivery, throughput, broker ordering or restart recovery. A relay adds scan, lock, network and retry costs that depend on deployment and batch size.

Common Mistakes

  • Do not call an outbox exactly-once delivery.
  • Do not discard the event ID on retry.
  • Do not assume successful send implies a persisted acknowledgement.

Read next

Spring transactional outbox: commit a receipt and event row together, Spring outbox claims: allow recovery after a worker lease expires, Spring consumer deduplication: commit the event ID with the mutation.

spring
spring-boot
at-least-once-delivery-boundary
Storage details