The engine

IdempotencyEngine.execute without Spring - building a context, the overloads, StoragePolicy, and CompletionFailurePolicy.

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();
Java

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
}
Java

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);
Java

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.

Releasing the key instead of storing

Some results mean nothing happened - a refusal before any downstream call, for instance - and should not be replayed. Pass a StoragePolicy<T> as a fourth argument and return false for those. Here Handled.refused() is a method of your own:

Outcome<Handled> outcome =
        engine.execute(context, () -> handler.handle(event), codec, handled -> !handled.refused());
Java

The engine asks the policy once, after a fresh action returns and before the result is encoded; a duplicate, an in-flight refusal or an action that threw never reaches it. On false the engine releases the lease instead of completing, fires onReleased, and still returns Outcome.Executed with the value. The next caller with that key runs the action as if the key had never been used.

  • false is permission to run again, not an undo. It does not reverse anything the action did and does not roll back a transaction.
  • Only an explicit false releases. A policy that throws is a completion failure: the record is kept and CompletionFailurePolicy applies, because the action may have done its work.
  • Inside a transaction, the release waits for the commit, with the heartbeat still running. A rollback releases the lease and fires onFailed with FailurePhase.ROLLBACK as usual.
  • A release the store refuses with an exception is logged, fires onFailed with FailurePhase.RELEASE, and the caller keeps its result. An Error fires the same callback and propagates.

StoragePolicy.always() stores every result, which is what the overloads without a policy do.

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 returns Executed with 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());
Java

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));
Java