IdempotencyEngine.execute is the entire API. It acquires the lease, runs the action with a
heartbeat, encodes and records the result, releases the lease if the action throws, and fires the
lifecycle callbacks around all of it. What comes back is a
sealed Outcome you switch on.
The engine has no framework or transport types in it, so you can drive it directly. Everything else in the library is an adapter over this.
Building a context
The engine needs a store - any IdempotencyStore - and a
ScheduledExecutorService it runs heartbeats on. Share one scheduler across the application
rather than creating one per call.
ScheduledExecutorService scheduler =
Executors.newSingleThreadScheduledExecutor();
IdempotencyEngine engine = new IdempotencyEngine(store, scheduler);
IdempotencyContext context = IdempotencyContext
.builder("ShipmentListener.onOrderShipped", event.id())
.ttl(Duration.ofHours(24))
.leaseDuration(Duration.ofSeconds(30))
.waitTimeout(Duration.ZERO) // decline instead of parking the consumer thread
.build();The builder takes the scope and key pair. Here event is
the message being handled, so its id is the key. Outside Spring there is no method name to
derive a scope from, so you name it - and naming it after the handler keeps it stable when
the method moves.
Add .fingerprint(sha256Hex) when the payload is worth guarding against key reuse. See
fingerprints.
The runnable overload
A caller with nothing for a duplicate to replay uses the runnable overload. Below,
handler.handle(event) is the work being guarded, and consumer.nack stands for however
your broker client asks for a redelivery:
switch (engine.execute(context, () -> handler.handle(event))) {
case Outcome.Executed<Void> ignored -> { /* ran for the first time */ }
case Outcome.Replayed<Void> ignored -> { /* already handled under this key */ }
case Outcome.InFlight<Void> inFlight ->
consumer.nack(inFlight.retryAfter()); // someone else has it; redeliver later
}The codec overload
When a duplicate should get a real result back, pass a PayloadCodec<T> for whatever the
action returns. Here handler.handle(event) returns a Handled of your own, and codec is
a PayloadCodec<Handled>:
Outcome<Handled> outcome =
engine.execute(context, () -> handler.handle(event), codec);Outcome.Replayed carries the decoded value from the original execution, so the same
switch handles a first run and a duplicate without the caller knowing which it got. Writing
the codec is covered in payloads and codecs.
When the store refuses the completion
The action ran and its side effects are durable, so CompletionFailurePolicy decides what
happens next.
PROPAGATE- the engine’s own default. Rethrows the storage failure.LOG_AND_RETURN- logs it and returnsExecutedwith the value anyway, which is what an HTTP adapter wants: the response the handler produced should still reach the client.
The lease is not released either way; the record stays in progress until its lease expires, and a retry after that re-executes.
Joined completion is the exception: its completion failures always propagate, whatever the policy says, because returning normally would let the transaction commit the business writes without the record. When that transaction rolls back, the engine releases the lease, so the key is retryable at once instead of after the lease expires.
IdempotencyEngine engine = new IdempotencyEngine(
store,
scheduler,
List.of(), // lifecycle listeners, none here
IdempotencyConfig.builder()
.completionFailurePolicy(CompletionFailurePolicy.LOG_AND_RETURN)
.build());Listeners outside Spring
Pass lifecycle listeners as the third argument. Here
auditListener is an IdempotencyLifecycleListener of your own:
IdempotencyEngine engine = new IdempotencyEngine(store, scheduler, List.of(auditListener));