By default the record is written on its own. When the method runs inside a transaction - its
own @Transactional, or one a caller opened - the engine waits for that transaction: the
record is written after the commit, and a rollback frees the key so a retry runs the action
again. With no transaction, the record is written the moment the method returns.
Either way that leaves a window: if the process dies between your transaction committing and the record being written, the record stays in progress and a redelivery runs the action again.
completion = "join-transaction" closes it. The engine writes the record inside the
transaction the method is already running in, so the record and your business writes commit
together - a crash before the commit leaves neither, a crash after it leaves both.
@Transactional
@Idempotent(key = "#event.id()", completion = "join-transaction", waitTimeout = "PT0S")
void on(OrderPlaced event) {
orders.save(new Order(event)); // orders: your repository
}Three preconditions
Three things have to be true for this to work, and the library tells you at startup or on entry if they are not. Each failure names itself precisely - the messages are on troubleshooting.
The store must support it. JDBC does; the in-memory and Redis stores do not. Asking for
joined completion against a store that cannot give it fails the context at startup, whether
the request came from idempotency.completion-mode=join-transaction or from a single
@Idempotent(completion = "join-transaction").
A transaction must be active when the method runs. @Transactional on the method or its
class is enough: the idempotency advice runs inside it, with no ordering to configure. A
joined method with no transaction of its own relies on its caller’s. A joined context entered
without an active transaction is an IllegalStateException, not a silent downgrade.
The store needs the caller’s connection. The starter wires a
TransactionAwareConnectionResolver into the JDBC store for you, which runs the joined
completion on the transaction-bound connection and everything else, an autonomous completion
included, on a connection of its own.
What the store guarantees
The behaviour a transactional store must provide is pinned by TransactionalStoreContract,
which every store claiming support has to pass. Four guarantees come out of it, and they are
worth knowing because they decide what a crash leaves behind:
- A completion inside a transaction is not visible from another connection before the commit. A concurrent duplicate on a different connection still sees the record in progress, not complete.
- After the commit, a duplicate sees it. The record and your writes become visible together.
- After a rollback, the record is still in progress - not complete, and not absent. The lease still fences it.
- A rollback followed by a release leaves the record absent, which is what makes the key retriable again.
What moves with the record
Whenever the completion waits on a transaction - always under joined completion, and under
the default whenever the method runs inside one - the terminal lifecycle callback waits with
it: onCompleted fires after the commit, and a rollback releases the lease and fires onFailed with
FailurePhase.ROLLBACK. Exactly one terminal still fires per lease, only later. See
lifecycle callbacks.
Application-wide
Set idempotency.completion-mode=join-transaction to make it the default and leave
completion off the individual annotations. Doing so requires a store that supports it, or
the context fails at startup - which is the intended way to find out.
The property applies to @Idempotent methods only: the HTTP filter runs outside any
transaction a handler opens, so it always completes on its own.