A relay can lose its acknowledgement after the consumer commits, leaving a PENDING event that will be delivered again.
Spring scheduled relay: recover when the consumer commits before acknowledgement
The exact cut point
The test inserts EVENT-61 for four units, calls the local consumer directly and deliberately leaves the outbox row PENDING. The consumer commits an applied_event key and stock changes from 10 to 6. The poller then selects the event, invokes the same consumer again and acknowledges the outbox row. The second consumer invocation hits the unique event key and does not subtract stock again. Final state is DELIVERED, one applied marker, stock 6.
This is a controlled cut point, not a process crash. It proves the local duplicate path once the event marker is already present. The consumer transaction is the guard. The lease token only controls who may update the outbox row; it cannot undo a consumer commit.
Treat uncertain acknowledgements as ordinary
A real broker publish may succeed even when its acknowledgement is lost. If a relay assumes the timeout means no delivery, it may send the same event again. The event ID should survive retries, and the consumer should store enough state to return a safe duplicate result.
The fixture stores both outbox and consumer tables in one H2 database for convenience, though it calls separate transactions. A real consumer can use another database. Integration tests must kill the relay between consumer commit and outbox acknowledgement and verify recovery after restart.
Checked source
consumeLocally("EVENT-61"); // Consumer commits; outbox remains PENDING.
new Poller(this::consumeLocally, () -> {}).runBatch(1);
assertEquals(6, jdbc.queryForObject(
"select available from stock_balance where sku = 'SKU-29'", Integer.class));Verification boundary
ScheduledOutboxPollerTest.redeliveryAfterLostAcknowledgementDoesNotRepeatStockMutation. The excerpt is shortened or a labelled design sketch; the source kit contains the complete checked fixture.
Costs and limits
The test invokes the consumer twice in one process. It has no broker offset, process kill, network partition, separate consumer database or deduplication retention policy.
Common Mistakes
- Do not equate a lost acknowledgement with a rolled-back consumer.
- Do not reserve an event marker outside the business transaction.
- Do not discard a stable event ID when retrying.
Read next
Spring consumer idempotency: reserve an event ID with the stock mutation, Spring outbox claims: lease expiry and token-fenced acknowledgement, Spring outbox transaction boundaries: where atomicity ends, Spring relay integration tests: prove the boundaries H2 cannot.
