From 176614e4440304dbc144cdc2687195ed04323aa8 Mon Sep 17 00:00:00 2001 From: Puneethkumar CK Date: Sun, 8 Mar 2026 10:21:29 +0100 Subject: [PATCH 1/2] =?UTF-8?q?feat(s1):=20wire=20saga=20compensation=20ch?= =?UTF-8?q?ain=20=E2=80=94=20releaseLock=20on=20cancel=20(STA-112)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace placeholder logging in handleCancellation() with actual activity execution. Compensation steps are parsed from the LIFO stack and dispatched to the appropriate activity (RELEASE_FX_LOCK → fxLockActivity.releaseLock). Each compensation is wrapped in try-catch so failures are logged but never block remaining compensations. Tests verify: releaseLock called after FX lock cancel, releaseLock NOT called when cancel arrives before FX lock, and compensation failure still produces FAILED result. Co-Authored-By: Claude Opus 4.6 --- .../domain/workflow/PaymentWorkflowImpl.java | 27 ++++++- .../domain/workflow/PaymentWorkflowTest.java | 75 +++++++++++++++++-- 2 files changed, 92 insertions(+), 10 deletions(-) diff --git a/payment-orchestrator/payment-orchestrator/src/main/java/com/stablecoin/payments/orchestrator/domain/workflow/PaymentWorkflowImpl.java b/payment-orchestrator/payment-orchestrator/src/main/java/com/stablecoin/payments/orchestrator/domain/workflow/PaymentWorkflowImpl.java index 60a90625..b16c0dcc 100644 --- a/payment-orchestrator/payment-orchestrator/src/main/java/com/stablecoin/payments/orchestrator/domain/workflow/PaymentWorkflowImpl.java +++ b/payment-orchestrator/payment-orchestrator/src/main/java/com/stablecoin/payments/orchestrator/domain/workflow/PaymentWorkflowImpl.java @@ -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; @@ -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. @@ -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. *

- * 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"; @@ -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); + } + } } diff --git a/payment-orchestrator/payment-orchestrator/src/test/java/com/stablecoin/payments/orchestrator/domain/workflow/PaymentWorkflowTest.java b/payment-orchestrator/payment-orchestrator/src/test/java/com/stablecoin/payments/orchestrator/domain/workflow/PaymentWorkflowTest.java index c5f8abad..9377cfb3 100644 --- a/payment-orchestrator/payment-orchestrator/src/test/java/com/stablecoin/payments/orchestrator/domain/workflow/PaymentWorkflowTest.java +++ b/payment-orchestrator/payment-orchestrator/src/test/java/com/stablecoin/payments/orchestrator/domain/workflow/PaymentWorkflowTest.java @@ -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; @@ -33,6 +34,7 @@ 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.mock; import static org.mockito.Mockito.never; @@ -154,17 +156,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); @@ -184,6 +183,70 @@ 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); + + then(fxLockActivity).should().releaseLock(new FxReleaseRequest( + LOCK_ID, PAYMENT_ID, "Timeout")); } } From 6dc5be2e45496f03861b185b5ccf15fbf1c8e8c3 Mon Sep 17 00:00:00 2001 From: Puneethkumar CK Date: Sun, 8 Mar 2026 10:33:54 +0100 Subject: [PATCH 2/2] =?UTF-8?q?fix(s1):=20use=20atLeast(1)=20for=20release?= =?UTF-8?q?Lock=20verification=20=E2=80=94=20Temporal=20retries=20activity?= =?UTF-8?q?=20(STA-112)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Temporal retries the activity 3 times (maxAttempts=3) before the failure propagates to the workflow's catch block, so exact count verification fails. Co-Authored-By: Claude Opus 4.6 --- .../orchestrator/domain/workflow/PaymentWorkflowTest.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/payment-orchestrator/payment-orchestrator/src/test/java/com/stablecoin/payments/orchestrator/domain/workflow/PaymentWorkflowTest.java b/payment-orchestrator/payment-orchestrator/src/test/java/com/stablecoin/payments/orchestrator/domain/workflow/PaymentWorkflowTest.java index 9377cfb3..71d22301 100644 --- a/payment-orchestrator/payment-orchestrator/src/test/java/com/stablecoin/payments/orchestrator/domain/workflow/PaymentWorkflowTest.java +++ b/payment-orchestrator/payment-orchestrator/src/test/java/com/stablecoin/payments/orchestrator/domain/workflow/PaymentWorkflowTest.java @@ -35,6 +35,7 @@ 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; @@ -245,7 +246,8 @@ LOCK_ID, QUOTE_ID, new BigDecimal("0.92"), .usingRecursiveComparison() .isEqualTo(expected); - then(fxLockActivity).should().releaseLock(new FxReleaseRequest( + // Temporal retries the activity (maxAttempts=3) before propagating the failure + then(fxLockActivity).should(atLeast(1)).releaseLock(new FxReleaseRequest( LOCK_ID, PAYMENT_ID, "Timeout")); } }