Lifecycle callbacks

IdempotencyLifecycleListener and its contract - synchronous, swallowed exceptions, exactly one terminal per lease.

Register an IdempotencyLifecycleListener bean to observe the idempotent boundary. The starter picks up every listener bean and honours @Order; no other configuration is needed.

The interface has five callbacks, all default methods, so you override only the ones you need: onAcquired, onCompleted and onFailed, shown below, plus onDuplicate and onInFlight. AuditService stands for your own audit component.

@Bean
public IdempotencyLifecycleListener auditListener(AuditService audit) {
    return new IdempotencyLifecycleListener() {
        @Override
        public void onAcquired(IdempotencyContext ctx, String leaseId) {
            audit.begin(ctx.key());   // runs on the calling thread, before the action
        }

        @Override
        public void onCompleted(IdempotencyContext ctx, String leaseId, Payload payload) {
            audit.end(ctx.key());
        }

        @Override
        public void onFailed(IdempotencyContext ctx, String leaseId, Throwable cause, FailurePhase phase) {
            audit.abandon(ctx.key(), phase);
        }
    };
}
Java

The contract

Callbacks run synchronously on the calling thread, in registration order. That is deliberate: it lets a listener bind thread-local state that the guarded action then sees. A listener that blocks blocks the call.

Exceptions thrown by a listener are logged at WARN and swallowed. An Error is not. They never change the stored payload, the return value, or the exception the engine is propagating. Observation must not be able to break the thing being observed.

Every acquired lease gets exactly one terminal callback: onCompleted or onFailed, always preceded by onAcquired with the same lease. Use the pair to unbind whatever onAcquired bound.

onDuplicate and onInFlight stand alone. Neither acquires a lease, so no terminal callback follows. A listener that unbinds state in its terminal callback will never see one for these, which is the usual source of a leak in a listener written against the happy path alone.

onCompleted fires only once the store has confirmed the completion. An unconfirmed durability guarantee counts as onFailed with FailurePhase.COMPLETION, which means the action’s side effects happened but a retry will most likely run them again. That distinction is the whole reason the phase is on the callback.

A fingerprint mismatch acquires no lease and fires nothing. Heartbeat activity is not surfaced either.

When the completion waits on a transaction, the terminal callback waits with it - always under join-transaction, and under autonomous whenever the method runs inside one: onCompleted fires after the commit, and a rollback fires onFailed with FailurePhase.ROLLBACK. See joining your transaction.

Outside Spring

Pass the listeners to the engine directly. store and scheduler are the engine’s two required arguments, covered on the engine page:

IdempotencyEngine engine = new IdempotencyEngine(store, scheduler, List.of(auditListener));
Java

The listener cannot break the call

Worth being concrete about what “swallowed” means, because it decides how defensively you have to write one. A listener that throws is logged at WARN, naming the listener and the callback it threw from, and rendering the record as its scope and key digest rather than the key itself. The engine then carries on exactly as it would have.

What to use them for

Metrics and audit trails, which need the boundary rather than the business method. Binding and unbinding a correlation id that the guarded action reads. Counting replays, which is the number that tells you whether the keys your clients generate are the ones you expected.

Do not use them to perform side effects that must happen exactly once. A swallowed exception means the listener can fail silently, and a listener is not covered by the record it observes.