Skip to content

Commit 35db9a8

Browse files
feat(s1): saga compensation chain — wire releaseLock on cancel (STA-112) (#111)
* feat(s1): wire saga compensation chain — releaseLock on cancel (STA-112) 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 <noreply@anthropic.com> * fix(s1): use atLeast(1) for releaseLock verification — Temporal retries activity (STA-112) 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 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent d1b15a1 commit 35db9a8

2 files changed

Lines changed: 94 additions & 10 deletions

File tree

payment-orchestrator/payment-orchestrator/src/main/java/com/stablecoin/payments/orchestrator/domain/workflow/PaymentWorkflowImpl.java

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import com.stablecoin.payments.orchestrator.domain.workflow.activity.FxLockActivity;
77
import com.stablecoin.payments.orchestrator.domain.workflow.activity.FxLockRequest;
88
import com.stablecoin.payments.orchestrator.domain.workflow.activity.FxLockResult;
9+
import com.stablecoin.payments.orchestrator.domain.workflow.activity.FxReleaseRequest;
910
import com.stablecoin.payments.orchestrator.domain.workflow.dto.CancelRequest;
1011
import com.stablecoin.payments.orchestrator.domain.workflow.dto.ChainConfirmedSignal;
1112
import com.stablecoin.payments.orchestrator.domain.workflow.dto.FiatCollectedSignal;
@@ -19,6 +20,7 @@
1920
import java.time.Duration;
2021
import java.util.ArrayDeque;
2122
import java.util.Deque;
23+
import java.util.UUID;
2224

2325
/**
2426
* Deterministic Temporal workflow implementation for the cross-border payment saga.
@@ -202,11 +204,14 @@ public String getPaymentState() {
202204
return currentState;
203205
}
204206

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

225229
currentState = "FAILED";
226230
return PaymentResult.failed(request.paymentId(), "Cancelled: " + reason);
227231
}
232+
233+
private void executeCompensationStep(String step, UUID paymentId, String reason) {
234+
try {
235+
if (step.startsWith(RELEASE_FX_LOCK_PREFIX)) {
236+
var lockId = UUID.fromString(step.substring(RELEASE_FX_LOCK_PREFIX.length()));
237+
fxLockActivity.releaseLock(new FxReleaseRequest(lockId, paymentId, reason));
238+
log.info("Successfully released FX lock {} for paymentId={}", lockId, paymentId);
239+
} else {
240+
log.warn("Unknown compensation step: {} for paymentId={}", step, paymentId);
241+
}
242+
} catch (Exception e) {
243+
log.error("Compensation step failed: {} for paymentId={}, error={}",
244+
step, paymentId, e.getMessage(), e);
245+
}
246+
}
228247
}

payment-orchestrator/payment-orchestrator/src/test/java/com/stablecoin/payments/orchestrator/domain/workflow/PaymentWorkflowTest.java

Lines changed: 71 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import com.stablecoin.payments.orchestrator.domain.workflow.activity.FxLockActivity;
77
import com.stablecoin.payments.orchestrator.domain.workflow.activity.FxLockRequest;
88
import com.stablecoin.payments.orchestrator.domain.workflow.activity.FxLockResult;
9+
import com.stablecoin.payments.orchestrator.domain.workflow.activity.FxReleaseRequest;
910
import com.stablecoin.payments.orchestrator.domain.workflow.dto.CancelRequest;
1011
import com.stablecoin.payments.orchestrator.domain.workflow.dto.PaymentResult;
1112
import io.temporal.client.WorkflowClient;
@@ -33,6 +34,8 @@
3334
import static org.mockito.ArgumentMatchers.any;
3435
import static org.mockito.BDDMockito.given;
3536
import static org.mockito.BDDMockito.then;
37+
import static org.mockito.BDDMockito.willThrow;
38+
import static org.mockito.Mockito.atLeast;
3639
import static org.mockito.Mockito.mock;
3740
import static org.mockito.Mockito.never;
3841

@@ -154,17 +157,14 @@ void shouldFailWhenFxLockFails(WorkflowClient workflowClient,
154157
class CancelSignal {
155158

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

164-
// FX lock will succeed, but we send cancel before it returns
165166
given(fxLockActivity.lockFxRate(any()))
166167
.willAnswer(invocation -> {
167-
// Send cancel signal during FX lock activity execution
168168
var stub = workflowClient.newWorkflowStub(
169169
PaymentWorkflow.class,
170170
"payment-" + PAYMENT_ID);
@@ -184,6 +184,71 @@ LOCK_ID, QUOTE_ID, new BigDecimal("0.92"),
184184
assertThat(result)
185185
.usingRecursiveComparison()
186186
.isEqualTo(expected);
187+
188+
then(fxLockActivity).should().releaseLock(new FxReleaseRequest(
189+
LOCK_ID, PAYMENT_ID, "Customer requested cancellation"));
190+
}
191+
192+
@Test
193+
@DisplayName("should not call releaseLock when cancel before FX lock")
194+
void shouldNotReleaseLockWhenCancelBeforeFxLock(WorkflowClient workflowClient,
195+
Worker worker) {
196+
given(complianceActivity.checkCompliance(any()))
197+
.willAnswer(invocation -> {
198+
var stub = workflowClient.newWorkflowStub(
199+
PaymentWorkflow.class,
200+
"payment-" + PAYMENT_ID);
201+
stub.cancelPayment(new CancelRequest(
202+
PAYMENT_ID, "Changed mind", "customer"));
203+
return new ComplianceResult(CHECK_ID, PASSED, null);
204+
});
205+
206+
var workflow = startWorkflow(workflowClient, worker);
207+
var result = getResult(workflowClient);
208+
209+
var expected = PaymentResult.failed(PAYMENT_ID, "Cancelled: Changed mind");
210+
assertThat(result)
211+
.usingRecursiveComparison()
212+
.isEqualTo(expected);
213+
214+
then(fxLockActivity).should(never()).lockFxRate(any());
215+
then(fxLockActivity).should(never()).releaseLock(any());
216+
}
217+
218+
@Test
219+
@DisplayName("should return FAILED even when compensation activity throws")
220+
void shouldReturnFailedWhenCompensationThrows(WorkflowClient workflowClient,
221+
Worker worker) {
222+
given(complianceActivity.checkCompliance(any()))
223+
.willReturn(new ComplianceResult(CHECK_ID, PASSED, null));
224+
225+
given(fxLockActivity.lockFxRate(any()))
226+
.willAnswer(invocation -> {
227+
var stub = workflowClient.newWorkflowStub(
228+
PaymentWorkflow.class,
229+
"payment-" + PAYMENT_ID);
230+
stub.cancelPayment(new CancelRequest(
231+
PAYMENT_ID, "Timeout", "system"));
232+
return new FxLockResult(
233+
LOCK_ID, QUOTE_ID, new BigDecimal("0.92"),
234+
new BigDecimal("920.00"), "EUR",
235+
LOCKED, null);
236+
});
237+
238+
willThrow(new RuntimeException("FX engine unavailable"))
239+
.given(fxLockActivity).releaseLock(any());
240+
241+
var workflow = startWorkflow(workflowClient, worker);
242+
var result = getResult(workflowClient);
243+
244+
var expected = PaymentResult.failed(PAYMENT_ID, "Cancelled: Timeout");
245+
assertThat(result)
246+
.usingRecursiveComparison()
247+
.isEqualTo(expected);
248+
249+
// Temporal retries the activity (maxAttempts=3) before propagating the failure
250+
then(fxLockActivity).should(atLeast(1)).releaseLock(new FxReleaseRequest(
251+
LOCK_ID, PAYMENT_ID, "Timeout"));
187252
}
188253
}
189254

0 commit comments

Comments
 (0)