refactor: adapter 구현중..

This commit is contained in:
DongHyeonka
2026-08-13 02:21:34 +09:00
parent 0cd959a494
commit 0a6dd0e419
86 changed files with 3088 additions and 302 deletions
@@ -13,7 +13,24 @@ public class IdempotencyInFlightException extends RuntimeException {
private final transient IdempotencyScope scope;
public IdempotencyInFlightException(IdempotencyScope scope) {
super("idempotent request still in flight: " + (scope == null ? "<null>" : scope.storageKey()));
this(scope == null ? "<null>" : scope.storageKey(), scope);
}
/**
* Creates the exception for the owner-safe lifecycle, which never holds the raw scope: the
* principal and the client key are digested before they reach the store, so the opaque digest
* diagnostic is the only identity available. {@link #scope()} is {@code null} on this path, as it
* already is for any deserialized instance — the field is {@code transient}.
*
* @param scopeDigestDiagnostic opaque scope-digest diagnostic, for logs only
* @return the exception
*/
public static IdempotencyInFlightException forScopeDigest(String scopeDigestDiagnostic) {
return new IdempotencyInFlightException(scopeDigestDiagnostic, null);
}
private IdempotencyInFlightException(String scopeDiagnostic, IdempotencyScope scope) {
super("idempotent request still in flight: " + scopeDiagnostic);
this.scope = scope;
}
@@ -13,9 +13,24 @@ public class IdempotencyRequestMismatchException extends RuntimeException {
private final transient IdempotencyScope scope;
public IdempotencyRequestMismatchException(IdempotencyScope scope) {
super(
"idempotency key reused with a different request body: "
+ (scope == null ? "<null>" : scope.storageKey()));
this(scope == null ? "<null>" : scope.storageKey(), scope);
}
/**
* Creates the exception for the owner-safe lifecycle, which never holds the raw scope: the
* principal and the client key are digested before they reach the store, so the opaque digest
* diagnostic is the only identity available. {@link #scope()} is {@code null} on this path, as it
* already is for any deserialized instance — the field is {@code transient}.
*
* @param scopeDigestDiagnostic opaque scope-digest diagnostic, for logs only
* @return the exception
*/
public static IdempotencyRequestMismatchException forScopeDigest(String scopeDigestDiagnostic) {
return new IdempotencyRequestMismatchException(scopeDigestDiagnostic, null);
}
private IdempotencyRequestMismatchException(String scopeDiagnostic, IdempotencyScope scope) {
super("idempotency key reused with a different request body: " + scopeDiagnostic);
this.scope = scope;
}
@@ -0,0 +1,328 @@
package dev.caskeleton.application.idempotency.v2;
import dev.caskeleton.application.idempotency.IdempotencyInFlightException;
import dev.caskeleton.application.idempotency.IdempotencyRecoveryRequiredException;
import dev.caskeleton.application.idempotency.IdempotencyRequestMismatchException;
import dev.caskeleton.application.idempotency.IdempotencyUnavailableException;
import dev.caskeleton.application.idempotency.IdempotentAction;
import dev.caskeleton.application.idempotency.IdempotentResponseCodec;
import dev.caskeleton.application.idempotency.RequestFingerprint;
import dev.caskeleton.application.idempotency.StoredResponse;
import dev.caskeleton.application.transaction.OperationId;
import java.time.Duration;
import java.util.Objects;
import java.util.Optional;
/**
* Owner-safe request-replay lifecycle, over the contract the providers actually implement.
*
* <p>There were two V2 store contracts with the same name in neighbouring packages, and this
* orchestration was written against the one nothing implements. Both owner-safe stores — PostgreSQL
* and Redis — implement {@link IdempotencyStorePortV2} here, so selecting a V2 provider required a
* bean that could not exist and the executor could never be composed at all.
*
* <p>Porting it was not a rename. This contract threads the owner handle through every mutation: a
* confirmed transition returns the owner with its state revision advanced, and the next call must
* present <em>that</em> handle. Re-using the claim's original owner would present a stale revision,
* which the store is built to refuse — that refusal is the whole point of the compare-and-set, so a
* caller that defeats it has an owner-safe store and no owner safety.
*
* <p>The action runs only after a confirmed start. This preserves request-replay evidence; it does
* not create a cross-store exactly-once boundary, and nothing here should be read as claiming one.
*/
public final class IdempotencyExecutorV2 {
private final IdempotencyStorePortV2 store;
private final Duration processingLeaseTtl;
private final Duration replayTtl;
private final Duration failureRetention;
private final String responseCodecId;
private final int policyRevision;
/**
* Creates the executor.
*
* @param store the owner-safe store
* @param processingLeaseTtl how long a claim holds the record before another caller may take over
* @param replayTtl how long a completed response is replayable
* @param failureRetention how long a failed attempt's evidence is kept
* @param responseCodecId the identifier of the codec the stored payload was written with
* @param policyRevision the replay policy revision this deployment enforces
*/
public IdempotencyExecutorV2(
IdempotencyStorePortV2 store,
Duration processingLeaseTtl,
Duration replayTtl,
Duration failureRetention,
String responseCodecId,
int policyRevision) {
this.store = Objects.requireNonNull(store, "store must be non-null");
this.processingLeaseTtl =
Objects.requireNonNull(processingLeaseTtl, "processing lease TTL must be non-null");
this.replayTtl = Objects.requireNonNull(replayTtl, "replay TTL must be non-null");
this.failureRetention =
Objects.requireNonNull(failureRetention, "failure retention must be non-null");
this.responseCodecId =
Objects.requireNonNull(responseCodecId, "response codec identifier must be non-null");
this.policyRevision = policyRevision;
if (failureRetention.isZero() || failureRetention.isNegative()) {
throw new IllegalArgumentException("the failure retention must be positive");
}
}
/**
* Mints the attempt a caller retains across a lost response.
*
* @param operationId the caller's operation identifier
* @return the claim attempt
*/
public IdempotencyClaimAttempt newAttempt(OperationId operationId) {
return store.newClaimAttempt(operationId);
}
/**
* Claims the scope, runs the action once, and stores its response for replay.
*
* @param scope the digested scope; the raw client key never reaches this class
* @param fingerprint the request fingerprint a replay is checked against
* @param attempt the retained claim attempt
* @param action the action to run at most once
* @param codec serialises and deserialises the action's result
* @param <R> the action's result type
* @return the action's result, or the replayed one
*/
public <R> R execute(
IdempotencyScopeDigest scope,
RequestFingerprint fingerprint,
IdempotencyClaimAttempt attempt,
IdempotentAction<R> action,
IdempotentResponseCodec<R> codec) {
Objects.requireNonNull(action, "action must be non-null");
Objects.requireNonNull(codec, "codec must be non-null");
IdempotencyClaimRequest request =
new IdempotencyClaimRequest(
scope,
fingerprint,
attempt,
processingLeaseTtl,
replayTtl,
responseCodecId,
policyRevision);
return switch (store.claim(request)) {
case IdempotencyClaimOutcome.CompletedReplay replay ->
codec.deserialize(replay.response().payload());
case IdempotencyClaimOutcome.FingerprintMismatch ignored ->
throw IdempotencyRequestMismatchException.forScopeDigest(diagnostic(scope));
case IdempotencyClaimOutcome.InProgress ignored ->
throw IdempotencyInFlightException.forScopeDigest(diagnostic(scope));
case IdempotencyClaimOutcome.RecoveryRequired ignored ->
throw recovery("claim requires reconciliation");
case IdempotencyClaimOutcome.OwnerOperationConflict ignored ->
throw recovery("claim requires reconciliation");
case IdempotencyClaimOutcome.Unavailable ignored ->
throw new IdempotencyUnavailableException();
// The response was lost, not the attempt. The retained attempt is what makes the record
// findable, so the only safe move is to ask the store what actually happened.
case IdempotencyClaimOutcome.Indeterminate ignored -> reconcile(request, action, codec);
case IdempotencyClaimOutcome.Acquired acquired ->
startAndRun(request, acquired.owner(), action, codec, false);
case IdempotencyClaimOutcome.ReplayedAcquire replayed ->
startAndRun(request, replayed.owner(), action, codec, false);
case IdempotencyClaimOutcome.TakenOverClaimed takenOver ->
startAndRun(request, takenOver.owner(), action, codec, false);
};
}
private <R> R reconcile(
IdempotencyClaimRequest request,
IdempotentAction<R> action,
IdempotentResponseCodec<R> codec) {
IdempotencyInspection inspection = inspect(request);
return switch (inspection.outcome()) {
case CLAIMED_SAME_OPERATION ->
startAndRun(request, requireOwner(inspection), action, codec, false);
case EXECUTING_SAME_OPERATION -> runStarted(request, requireOwner(inspection), action, codec);
case COMPLETED_REPLAY -> codec.deserialize(requireResponse(inspection).payload());
case FINGERPRINT_MISMATCH ->
throw IdempotencyRequestMismatchException.forScopeDigest(diagnostic(request.scope()));
case IN_PROGRESS_OTHER ->
throw IdempotencyInFlightException.forScopeDigest(diagnostic(request.scope()));
case UNAVAILABLE -> throw new IdempotencyUnavailableException();
// ABSENT, FAILED_RETRYABLE, ABANDONED and OPERATION_CONFLICT all mean the same thing here:
// this caller cannot prove what happened to its own attempt, and guessing is the one thing an
// owner-safe store exists to prevent.
default -> throw recovery("an indeterminate claim could not be resumed safely");
};
}
private <R> R startAndRun(
IdempotencyClaimRequest request,
IdempotencyOwner owner,
IdempotentAction<R> action,
IdempotentResponseCodec<R> codec,
boolean alreadyRetried) {
IdempotencyMutationResult<IdempotencyStartOutcome> started =
store.markExecutionStarted(owner, request.claimAttempt().operationId());
return switch (started.outcome()) {
// The owner handle from the transition, not the one from the claim: the state revision has
// advanced and the next compare-and-set is against the new one.
case STARTED, ALREADY_STARTED_SAME_OPERATION ->
runStarted(request, advanced(started, owner), action, codec);
case UNAVAILABLE -> throw new IdempotencyUnavailableException();
case INDETERMINATE -> {
if (alreadyRetried) {
throw recovery("execution start stayed indeterminate after reconciliation");
}
yield resumeAfterIndeterminateStart(request, action, codec);
}
default -> throw recovery("execution start was not confirmed for this exact operation");
};
}
private <R> R resumeAfterIndeterminateStart(
IdempotencyClaimRequest request,
IdempotentAction<R> action,
IdempotentResponseCodec<R> codec) {
IdempotencyInspection inspection = inspect(request);
return switch (inspection.outcome()) {
case CLAIMED_SAME_OPERATION ->
startAndRun(request, requireOwner(inspection), action, codec, true);
case EXECUTING_SAME_OPERATION -> runStarted(request, requireOwner(inspection), action, codec);
case COMPLETED_REPLAY -> codec.deserialize(requireResponse(inspection).payload());
case UNAVAILABLE -> throw new IdempotencyUnavailableException();
default -> throw recovery("execution start is indeterminate");
};
}
private <R> R runStarted(
IdempotencyClaimRequest request,
IdempotencyOwner owner,
IdempotentAction<R> action,
IdempotentResponseCodec<R> codec) {
OperationId operationId = request.claimAttempt().operationId();
IdempotentAction.Outcome<R> outcome;
try {
outcome = Objects.requireNonNull(action.run(), "action outcome must be non-null");
} catch (RuntimeException failure) {
// The action threw without saying whether it had an effect, so the record must not say
// "retryable": that would invite a second execution of something that may already have run.
preserveUnknown(owner, operationId);
throw failure;
}
return switch (outcome) {
case IdempotentAction.Outcome.Success<R> success ->
complete(request, owner, success.result(), codec, false);
case IdempotentAction.Outcome.RetryableNoEffect<R> retryable -> {
store.markFailed(
owner,
IdempotencyFailureDisposition.NO_EFFECT_RETRYABLE,
failureRetention,
operationId);
throw retryable.failure();
}
case IdempotentAction.Outcome.EffectUnknown<R> unknown -> {
preserveUnknown(owner, operationId);
throw unknown.failure();
}
};
}
private <R> R complete(
IdempotencyClaimRequest request,
IdempotencyOwner owner,
R result,
IdempotentResponseCodec<R> codec,
boolean alreadyRetried) {
StoredResponse response = new StoredResponse(codec.serialize(result));
return switch (store.complete(
owner, response, replayTtl, request.claimAttempt().operationId())) {
case COMPLETED, ALREADY_COMPLETED_SAME_RESULT -> result;
case UNAVAILABLE -> throw new IdempotencyUnavailableException();
case INDETERMINATE -> reconcileCompletion(request, result, codec, alreadyRetried);
default -> throw recovery("completion was not confirmed");
};
}
/**
* Resolves a completion whose response was lost.
*
* <p>Deliberately does not carry the caller's owner handle forward: after an indeterminate
* completion the caller's handle may already be stale, and the only handle worth presenting is
* the one the store reports now.
*/
private <R> R reconcileCompletion(
IdempotencyClaimRequest request,
R result,
IdempotentResponseCodec<R> codec,
boolean alreadyRetried) {
IdempotencyInspection inspection = inspect(request);
return switch (inspection.outcome()) {
case COMPLETED_REPLAY -> {
StoredResponse stored = requireResponse(inspection);
if (stored.payload().equals(codec.serialize(result))) {
yield result;
}
// The stored response is somebody else's answer to this scope. Returning either one would
// be asserting a fact this caller cannot establish.
throw recovery("the completed response conflicts with the one this caller produced");
}
case EXECUTING_SAME_OPERATION -> {
if (alreadyRetried) {
throw recovery("completion stayed indeterminate after reconciliation");
}
yield complete(request, requireOwner(inspection), result, codec, true);
}
case UNAVAILABLE -> throw new IdempotencyUnavailableException();
default -> throw recovery("the completion response is indeterminate and was not reconciled");
};
}
private void preserveUnknown(IdempotencyOwner owner, OperationId operationId) {
store.markFailed(
owner,
IdempotencyFailureDisposition.EFFECT_UNKNOWN_ABANDONED,
failureRetention,
operationId);
}
private IdempotencyInspection inspect(IdempotencyClaimRequest request) {
return store.inspect(
new IdempotencyInspectionRequest(
request.scope(), request.requestFingerprint(), request.claimAttempt()));
}
private static IdempotencyOwner advanced(
IdempotencyMutationResult<IdempotencyStartOutcome> transition, IdempotencyOwner fallback) {
return transition.owner().orElse(fallback);
}
private static IdempotencyOwner requireOwner(IdempotencyInspection inspection) {
Optional<IdempotencyOwner> owner = inspection.owner();
return owner.orElseThrow(
() ->
recovery(
"the store reported "
+ inspection.outcome()
+ " without the owner handle it needs"));
}
private static StoredResponse requireResponse(IdempotencyInspection inspection) {
return inspection
.response()
.orElseThrow(() -> recovery("the store reported a completed replay with no response"));
}
/**
* Renders the scope for a diagnostic. Every component is already opaque — the raw client key and
* principal were digested before the scope was constructed — so this is safe to log, and it is
* the only scope identity this lifecycle ever holds.
*/
private static String diagnostic(IdempotencyScopeDigest scope) {
return scope.operationCode() + "::v" + scope.keyDigestVersion() + "::" + scope.digest();
}
private static IdempotencyRecoveryRequiredException recovery(String message) {
return new IdempotencyRecoveryRequiredException(message);
}
}
@@ -0,0 +1,343 @@
package dev.caskeleton.application.idempotency.v2;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import dev.caskeleton.application.idempotency.IdempotentAction;
import dev.caskeleton.application.idempotency.IdempotentResponseCodec;
import dev.caskeleton.application.idempotency.RequestFingerprint;
import dev.caskeleton.application.idempotency.StoredResponse;
import dev.caskeleton.application.transaction.OperationId;
import java.time.Duration;
import java.time.Instant;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicBoolean;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
/**
* The request-replay lifecycle, over the contract the stores implement.
*
* <p>Carried over from a test of the same name that exercised the duplicate V2 contract in the
* parent package — the one nothing implements. The behaviours below are the ones worth keeping: the
* action runs at most once, a lost response is reconciled rather than re-run, and an outcome the
* caller cannot classify is never recorded as "no effect".
*
* <p>One behaviour is new, because the contract is: every confirmed transition hands back the owner
* with its state revision advanced, and the next call must present that handle rather than the one
* the claim returned.
*/
class IdempotencyExecutorV2Test {
private static final String OWNER_TOKEN = "b".repeat(64);
private static final OperationId OPERATION = new OperationId("operation-aaaaaaaaaa");
private static final IdempotencyScopeDigest SCOPE =
new IdempotencyScopeDigest("a".repeat(64), 1, "CREATE_WORKLOG");
private static final RequestFingerprint FINGERPRINT = new RequestFingerprint("c".repeat(64));
private static final IdempotencyClaimAttempt ATTEMPT =
new IdempotencyClaimAttempt(OWNER_TOKEN, OPERATION);
private static final Instant LEASE_UNTIL = Instant.parse("2026-07-29T12:00:30Z");
private static IdempotencyOwner owner(long stateRevision) {
return new IdempotencyOwner(SCOPE, OWNER_TOKEN, 1, stateRevision, OPERATION);
}
@Test
@DisplayName("the action runs only after a confirmed start, and then completes")
void actionRunsOnlyAfterAConfirmedStart() {
FakeStore store = new FakeStore();
store.claim = new IdempotencyClaimOutcome.Acquired(owner(0), LEASE_UNTIL);
store.start = IdempotencyStartOutcome.STARTED;
AtomicBoolean ran = new AtomicBoolean();
String result =
executor(store)
.execute(
SCOPE,
FINGERPRINT,
ATTEMPT,
() -> {
ran.set(true);
return new IdempotentAction.Outcome.Success<>("created");
},
codec());
assertThat(result).isEqualTo("created");
assertThat(ran).isTrue();
assertThat(store.completeCalls).isEqualTo(1);
assertThat(store.failedCalls).isZero();
}
@Test
@DisplayName("each mutation presents the owner handle the previous one returned")
void theAdvancedOwnerHandleIsCarriedForward() {
// The contract's whole point. The store advances the state revision on a confirmed transition
// and refuses a stale one; a caller that kept presenting the claim's original handle would be
// refused by its own store, so an owner-safe store would buy nothing.
FakeStore store = new FakeStore();
store.claim = new IdempotencyClaimOutcome.Acquired(owner(0), LEASE_UNTIL);
store.start = IdempotencyStartOutcome.STARTED;
store.startOwner = owner(1);
executor(store)
.execute(
SCOPE,
FINGERPRINT,
ATTEMPT,
() -> new IdempotentAction.Outcome.Success<>("created"),
codec());
assertThat(store.completeOwner.stateRevision())
.as("complete must present the revision the start returned, not the claim's")
.isEqualTo(1);
}
@Test
@DisplayName("an indeterminate claim is reconciled rather than re-run")
void anIndeterminateClaimIsReconciled() {
FakeStore store = new FakeStore();
store.claim = new IdempotencyClaimOutcome.Indeterminate(OPERATION);
store.inspection =
new IdempotencyInspection(
IdempotencyInspectionOutcome.EXECUTING_SAME_OPERATION,
Optional.of(owner(1)),
Optional.of(LEASE_UNTIL),
Optional.empty(),
Optional.empty());
AtomicBoolean ran = new AtomicBoolean();
assertThat(
executor(store)
.execute(
SCOPE,
FINGERPRINT,
ATTEMPT,
() -> {
ran.set(true);
return new IdempotentAction.Outcome.Success<>("created");
},
codec()))
.isEqualTo("created");
assertThat(ran).isTrue();
assertThat(store.startCalls)
.as("the record already says EXECUTING; starting it again would be a second transition")
.isZero();
}
@Test
@DisplayName("a lost completion is inspected without running the action a second time")
void aLostCompletionIsInspectedNotRepeated() {
FakeStore store = startedStore();
store.complete = IdempotencyCompleteOutcome.INDETERMINATE;
store.inspectionAfterComplete =
new IdempotencyInspection(
IdempotencyInspectionOutcome.COMPLETED_REPLAY,
Optional.empty(),
Optional.empty(),
Optional.of(new StoredResponse("created")),
Optional.of(Instant.parse("2026-07-29T13:00:00Z")));
int[] actionCalls = {0};
assertThat(
executor(store)
.execute(
SCOPE,
FINGERPRINT,
ATTEMPT,
() -> {
actionCalls[0]++;
return new IdempotentAction.Outcome.Success<>("created");
},
codec()))
.isEqualTo("created");
assertThat(actionCalls[0]).isEqualTo(1);
assertThat(store.completeCalls).isEqualTo(1);
}
@Test
@DisplayName("a replayed response that disagrees with this caller's is a recovery, not a result")
void aConflictingReplayIsNotReturned() {
FakeStore store = startedStore();
store.complete = IdempotencyCompleteOutcome.INDETERMINATE;
store.inspectionAfterComplete =
new IdempotencyInspection(
IdempotencyInspectionOutcome.COMPLETED_REPLAY,
Optional.empty(),
Optional.empty(),
Optional.of(new StoredResponse("somebody-elses-answer")),
Optional.of(Instant.parse("2026-07-29T13:00:00Z")));
assertThatThrownBy(
() ->
executor(store)
.execute(
SCOPE,
FINGERPRINT,
ATTEMPT,
() -> new IdempotentAction.Outcome.Success<>("created"),
codec()))
.hasMessageContaining("conflicts");
}
@Test
@DisplayName("an unclassified throw and an unknown effect are never recorded as no-effect")
void unknownEffectsAreNeverDiscarded() {
FakeStore thrown = startedStore();
RuntimeException unclassified = new IllegalStateException("unknown effect");
assertThatThrownBy(
() ->
executor(thrown)
.execute(
SCOPE,
FINGERPRINT,
ATTEMPT,
() -> {
throw unclassified;
},
codec()))
.isSameAs(unclassified);
assertThat(thrown.lastDisposition)
.isEqualTo(IdempotencyFailureDisposition.EFFECT_UNKNOWN_ABANDONED);
FakeStore declared = startedStore();
RuntimeException classified = new IllegalArgumentException("provider response unknown");
assertThatThrownBy(
() ->
executor(declared)
.execute(
SCOPE,
FINGERPRINT,
ATTEMPT,
() -> new IdempotentAction.Outcome.EffectUnknown<>(classified),
codec()))
.isSameAs(classified);
assertThat(declared.lastDisposition)
.isEqualTo(IdempotencyFailureDisposition.EFFECT_UNKNOWN_ABANDONED);
}
@Test
@DisplayName("only an explicitly effect-free failure is recorded as retryable")
void onlyDeclaredNoEffectIsRetryable() {
FakeStore store = startedStore();
RuntimeException failure = new IllegalArgumentException("validation");
assertThatThrownBy(
() ->
executor(store)
.execute(
SCOPE,
FINGERPRINT,
ATTEMPT,
() -> new IdempotentAction.Outcome.RetryableNoEffect<>(failure),
codec()))
.isSameAs(failure);
assertThat(store.lastDisposition).isEqualTo(IdempotencyFailureDisposition.NO_EFFECT_RETRYABLE);
}
private static FakeStore startedStore() {
FakeStore store = new FakeStore();
store.claim = new IdempotencyClaimOutcome.Acquired(owner(0), LEASE_UNTIL);
store.start = IdempotencyStartOutcome.STARTED;
return store;
}
private static IdempotencyExecutorV2 executor(FakeStore store) {
return new IdempotencyExecutorV2(
store, Duration.ofSeconds(30), Duration.ofHours(1), Duration.ofHours(1), "json-v2", 1);
}
private static IdempotentResponseCodec<String> codec() {
return new IdempotentResponseCodec<>() {
@Override
public String serialize(String result) {
return result;
}
@Override
public String deserialize(String payload) {
return payload;
}
};
}
private static final class FakeStore implements IdempotencyStorePortV2 {
private IdempotencyClaimOutcome claim;
private IdempotencyStartOutcome start;
private IdempotencyOwner startOwner;
private IdempotencyCompleteOutcome complete = IdempotencyCompleteOutcome.COMPLETED;
private IdempotencyInspection inspection =
IdempotencyInspection.outcome(IdempotencyInspectionOutcome.UNAVAILABLE);
private IdempotencyInspection inspectionAfterComplete;
private int startCalls;
private int completeCalls;
private int failedCalls;
private IdempotencyOwner completeOwner;
private IdempotencyFailureDisposition lastDisposition;
@Override
public IdempotencyClaimAttempt newClaimAttempt(OperationId operationId) {
return new IdempotencyClaimAttempt(OWNER_TOKEN, operationId);
}
@Override
public IdempotencyClaimOutcome claim(IdempotencyClaimRequest request) {
return claim;
}
@Override
public IdempotencyMutationResult<IdempotencyStartOutcome> markExecutionStarted(
IdempotencyOwner owner, OperationId operationId) {
startCalls++;
IdempotencyOwner advanced =
start.carriesOwner() ? (startOwner == null ? owner : startOwner) : null;
return new IdempotencyMutationResult<>(
start, advanced, IdempotencyStartOutcome::carriesOwner);
}
@Override
public IdempotencyMutationResult<IdempotencyRenewOutcome> renew(
IdempotencyOwner owner, Duration processingLeaseTtl, OperationId operationId) {
throw new UnsupportedOperationException("the executor does not renew");
}
@Override
public IdempotencyCompleteOutcome complete(
IdempotencyOwner owner,
StoredResponse response,
Duration replayTtl,
OperationId operationId) {
completeCalls++;
completeOwner = owner;
if (inspectionAfterComplete != null) {
inspection = inspectionAfterComplete;
}
return complete;
}
@Override
public IdempotencyFailOutcome markFailed(
IdempotencyOwner owner,
IdempotencyFailureDisposition disposition,
Duration retention,
OperationId operationId) {
failedCalls++;
lastDisposition = disposition;
return disposition == IdempotencyFailureDisposition.NO_EFFECT_RETRYABLE
? IdempotencyFailOutcome.MARKED_RETRYABLE
: IdempotencyFailOutcome.MARKED_ABANDONED;
}
@Override
public IdempotencyReleaseOutcome releaseBeforeExecution(
IdempotencyOwner owner, OperationId operationId) {
throw new UnsupportedOperationException("the executor never releases before execution");
}
@Override
public IdempotencyInspection inspect(IdempotencyInspectionRequest request) {
return inspection;
}
}
}