Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import com.stablecoin.payments.orchestrator.domain.workflow.activity.FxLockActivity;
import com.stablecoin.payments.orchestrator.domain.workflow.activity.FxLockRequest;
import com.stablecoin.payments.orchestrator.domain.workflow.activity.FxLockResult;
import com.stablecoin.payments.orchestrator.domain.workflow.activity.FxReleaseRequest;
import com.stablecoin.payments.orchestrator.domain.workflow.dto.CancelRequest;
import com.stablecoin.payments.orchestrator.domain.workflow.dto.ChainConfirmedSignal;
import com.stablecoin.payments.orchestrator.domain.workflow.dto.FiatCollectedSignal;
Expand All @@ -19,6 +20,7 @@
import java.time.Duration;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.UUID;

/**
* Deterministic Temporal workflow implementation for the cross-border payment saga.
Expand Down Expand Up @@ -202,11 +204,14 @@ public String getPaymentState() {
return currentState;
}

private static final String RELEASE_FX_LOCK_PREFIX = "RELEASE_FX_LOCK:";

/**
* Runs compensation stack in LIFO order and returns a FAILED result.
* <p>
* Currently logs compensation steps. Actual compensation activities
* (e.g., release FX lock, refund fiat) will be added in Phase 3.
* Each compensation step is parsed and the corresponding activity is invoked.
* Compensation failures are logged but do not prevent remaining compensations
* from executing — the saga always reaches FAILED state.
*/
private PaymentResult handleCancellation(PaymentRequest request) {
currentState = "COMPENSATING";
Expand All @@ -218,11 +223,25 @@ private PaymentResult handleCancellation(PaymentRequest request) {
while (!compensationStack.isEmpty()) {
var step = compensationStack.pop();
log.info("Compensating step: {} for paymentId={}", step, request.paymentId());
// Phase 3: execute compensation activities here
// e.g., if step starts with "RELEASE_FX_LOCK:" → call fxLockActivity.releaseLock(quoteId)
executeCompensationStep(step, request.paymentId(), reason);
}

currentState = "FAILED";
return PaymentResult.failed(request.paymentId(), "Cancelled: " + reason);
}

private void executeCompensationStep(String step, UUID paymentId, String reason) {
try {
if (step.startsWith(RELEASE_FX_LOCK_PREFIX)) {
var lockId = UUID.fromString(step.substring(RELEASE_FX_LOCK_PREFIX.length()));
fxLockActivity.releaseLock(new FxReleaseRequest(lockId, paymentId, reason));
log.info("Successfully released FX lock {} for paymentId={}", lockId, paymentId);
} else {
log.warn("Unknown compensation step: {} for paymentId={}", step, paymentId);
}
} catch (Exception e) {
log.error("Compensation step failed: {} for paymentId={}, error={}",
step, paymentId, e.getMessage(), e);
}
}
Comment on lines +233 to +246

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Compensation retry semantics — architectural consideration.

The fxLockActivity stub used here has setMaximumAttempts(3) configured at Lines 63-64. This means compensation retries exhaustively before the catch block is reached, which may be intentional for resilience.

If compensation should fail fast (e.g., single attempt with immediate fallback), consider creating a separate activity stub with different RetryOptions for compensation scenarios. This also aligns with saga patterns where compensations often have distinct retry/timeout policies.

Not blocking — current behavior is functionally correct.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@payment-orchestrator/payment-orchestrator/src/main/java/com/stablecoin/payments/orchestrator/domain/workflow/PaymentWorkflowImpl.java`
around lines 233 - 246, The compensation logic in executeCompensationStep
currently uses the fxLockActivity stub which was configured with
setMaximumAttempts(3), causing retries before the catch; change this by creating
a dedicated compensation activity stub (e.g., fxLockCompensationActivity) with
RetryOptions tuned for fail-fast behavior (single attempt/short timeout) and use
that stub inside executeCompensationStep when handling RELEASE_FX_LOCK_PREFIX,
leaving the original fxLockActivity unchanged for normal workflow calls.

}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import com.stablecoin.payments.orchestrator.domain.workflow.activity.FxLockActivity;
import com.stablecoin.payments.orchestrator.domain.workflow.activity.FxLockRequest;
import com.stablecoin.payments.orchestrator.domain.workflow.activity.FxLockResult;
import com.stablecoin.payments.orchestrator.domain.workflow.activity.FxReleaseRequest;
import com.stablecoin.payments.orchestrator.domain.workflow.dto.CancelRequest;
import com.stablecoin.payments.orchestrator.domain.workflow.dto.PaymentResult;
import io.temporal.client.WorkflowClient;
Expand Down Expand Up @@ -33,6 +34,8 @@
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.BDDMockito.then;
import static org.mockito.BDDMockito.willThrow;
import static org.mockito.Mockito.atLeast;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;

Expand Down Expand Up @@ -154,17 +157,14 @@ void shouldFailWhenFxLockFails(WorkflowClient workflowClient,
class CancelSignal {

@Test
@DisplayName("should run compensation when cancel signal received after FX lock")
void shouldCompensateOnCancelAfterFxLock(WorkflowClient workflowClient,
Worker worker) {
// Set up: compliance passes, FX locks, but then cancel
@DisplayName("should release FX lock when cancel signal received after FX lock succeeds")
void shouldReleaseFxLockOnCancelAfterFxLock(WorkflowClient workflowClient,
Worker worker) {
given(complianceActivity.checkCompliance(any()))
.willReturn(new ComplianceResult(CHECK_ID, PASSED, null));

// FX lock will succeed, but we send cancel before it returns
given(fxLockActivity.lockFxRate(any()))
.willAnswer(invocation -> {
// Send cancel signal during FX lock activity execution
var stub = workflowClient.newWorkflowStub(
PaymentWorkflow.class,
"payment-" + PAYMENT_ID);
Expand All @@ -184,6 +184,71 @@ LOCK_ID, QUOTE_ID, new BigDecimal("0.92"),
assertThat(result)
.usingRecursiveComparison()
.isEqualTo(expected);

then(fxLockActivity).should().releaseLock(new FxReleaseRequest(
LOCK_ID, PAYMENT_ID, "Customer requested cancellation"));
}

@Test
@DisplayName("should not call releaseLock when cancel before FX lock")
void shouldNotReleaseLockWhenCancelBeforeFxLock(WorkflowClient workflowClient,
Worker worker) {
given(complianceActivity.checkCompliance(any()))
.willAnswer(invocation -> {
var stub = workflowClient.newWorkflowStub(
PaymentWorkflow.class,
"payment-" + PAYMENT_ID);
stub.cancelPayment(new CancelRequest(
PAYMENT_ID, "Changed mind", "customer"));
return new ComplianceResult(CHECK_ID, PASSED, null);
});

var workflow = startWorkflow(workflowClient, worker);
var result = getResult(workflowClient);

var expected = PaymentResult.failed(PAYMENT_ID, "Cancelled: Changed mind");
assertThat(result)
.usingRecursiveComparison()
.isEqualTo(expected);

then(fxLockActivity).should(never()).lockFxRate(any());
then(fxLockActivity).should(never()).releaseLock(any());
}

@Test
@DisplayName("should return FAILED even when compensation activity throws")
void shouldReturnFailedWhenCompensationThrows(WorkflowClient workflowClient,
Worker worker) {
given(complianceActivity.checkCompliance(any()))
.willReturn(new ComplianceResult(CHECK_ID, PASSED, null));

given(fxLockActivity.lockFxRate(any()))
.willAnswer(invocation -> {
var stub = workflowClient.newWorkflowStub(
PaymentWorkflow.class,
"payment-" + PAYMENT_ID);
stub.cancelPayment(new CancelRequest(
PAYMENT_ID, "Timeout", "system"));
return new FxLockResult(
LOCK_ID, QUOTE_ID, new BigDecimal("0.92"),
new BigDecimal("920.00"), "EUR",
LOCKED, null);
});

willThrow(new RuntimeException("FX engine unavailable"))
.given(fxLockActivity).releaseLock(any());

var workflow = startWorkflow(workflowClient, worker);
var result = getResult(workflowClient);

var expected = PaymentResult.failed(PAYMENT_ID, "Cancelled: Timeout");
assertThat(result)
.usingRecursiveComparison()
.isEqualTo(expected);

// Temporal retries the activity (maxAttempts=3) before propagating the failure
then(fxLockActivity).should(atLeast(1)).releaseLock(new FxReleaseRequest(
LOCK_ID, PAYMENT_ID, "Timeout"));
}
}

Expand Down
Loading