300 lines
7.1 KiB
Markdown
300 lines
7.1 KiB
Markdown
# Compensating Transactions
|
|
|
|
## Design Principles
|
|
|
|
### Idempotency
|
|
|
|
Execute multiple times with same result:
|
|
|
|
```java
|
|
public void cancelPayment(String paymentId) {
|
|
Payment payment = paymentRepository.findById(paymentId)
|
|
.orElse(null);
|
|
|
|
if (payment == null) {
|
|
// Already cancelled or doesn't exist
|
|
return;
|
|
}
|
|
|
|
if (payment.getStatus() == PaymentStatus.CANCELLED) {
|
|
// Already cancelled, idempotent
|
|
return;
|
|
}
|
|
|
|
payment.setStatus(PaymentStatus.CANCELLED);
|
|
paymentRepository.save(payment);
|
|
|
|
// Refund logic here
|
|
}
|
|
```
|
|
|
|
### Retryability
|
|
|
|
Design operations to handle retries without side effects:
|
|
|
|
```java
|
|
@Retryable(
|
|
value = {TransientException.class},
|
|
maxAttempts = 3,
|
|
backoff = @Backoff(delay = 1000, multiplier = 2)
|
|
)
|
|
public void releaseInventory(String itemId, int quantity) {
|
|
// Use set operations for idempotency
|
|
InventoryItem item = inventoryRepository.findById(itemId)
|
|
.orElseThrow();
|
|
|
|
item.increaseAvailableQuantity(quantity);
|
|
inventoryRepository.save(item);
|
|
}
|
|
```
|
|
|
|
## Compensation Strategies
|
|
|
|
### Backward Recovery
|
|
|
|
Undo completed steps in reverse order:
|
|
|
|
```java
|
|
@SagaEventHandler(associationProperty = "orderId")
|
|
public void handle(PaymentFailedEvent event) {
|
|
logger.error("Payment failed, initiating compensation");
|
|
|
|
// Step 1: Cancel shipment preparation
|
|
commandGateway.send(new CancelShipmentCommand(event.getOrderId()));
|
|
|
|
// Step 2: Release inventory
|
|
commandGateway.send(new ReleaseInventoryCommand(event.getOrderId()));
|
|
|
|
// Step 3: Cancel order
|
|
commandGateway.send(new CancelOrderCommand(event.getOrderId()));
|
|
|
|
end();
|
|
}
|
|
```
|
|
|
|
### Forward Recovery
|
|
|
|
Retry failed operation with exponential backoff:
|
|
|
|
```java
|
|
@SagaEventHandler(associationProperty = "orderId")
|
|
public void handle(PaymentTransientFailureEvent event) {
|
|
if (event.getRetryCount() < MAX_RETRIES) {
|
|
// Retry payment with backoff
|
|
ProcessPaymentCommand retryCommand = new ProcessPaymentCommand(
|
|
event.getPaymentId(),
|
|
event.getOrderId(),
|
|
event.getAmount()
|
|
);
|
|
commandGateway.send(retryCommand);
|
|
} else {
|
|
// After max retries, compensate
|
|
handlePaymentFailure(event);
|
|
}
|
|
}
|
|
```
|
|
|
|
## Semantic Lock Pattern
|
|
|
|
Prevent concurrent modifications during saga execution:
|
|
|
|
```java
|
|
@Entity
|
|
public class Order {
|
|
@Id
|
|
private String orderId;
|
|
|
|
@Enumerated(EnumType.STRING)
|
|
private OrderStatus status;
|
|
|
|
@Version
|
|
private Long version;
|
|
|
|
private Instant lockedUntil;
|
|
|
|
public boolean tryLock(Duration lockDuration) {
|
|
if (isLocked()) {
|
|
return false;
|
|
}
|
|
this.lockedUntil = Instant.now().plus(lockDuration);
|
|
return true;
|
|
}
|
|
|
|
public boolean isLocked() {
|
|
return lockedUntil != null &&
|
|
Instant.now().isBefore(lockedUntil);
|
|
}
|
|
|
|
public void unlock() {
|
|
this.lockedUntil = null;
|
|
}
|
|
}
|
|
```
|
|
|
|
## Compensation in Axon Framework
|
|
|
|
```java
|
|
@Saga
|
|
public class OrderSaga {
|
|
|
|
private String orderId;
|
|
private String paymentId;
|
|
private String inventoryId;
|
|
private boolean compensating = false;
|
|
|
|
@SagaEventHandler(associationProperty = "orderId")
|
|
public void handle(InventoryReservationFailedEvent event) {
|
|
logger.error("Inventory reservation failed");
|
|
compensating = true;
|
|
|
|
// Compensate: refund payment
|
|
RefundPaymentCommand refundCommand = new RefundPaymentCommand(
|
|
paymentId,
|
|
event.getOrderId(),
|
|
event.getReservedAmount(),
|
|
"Inventory unavailable"
|
|
);
|
|
|
|
commandGateway.send(refundCommand);
|
|
}
|
|
|
|
@SagaEventHandler(associationProperty = "orderId")
|
|
public void handle(PaymentRefundedEvent event) {
|
|
if (!compensating) return;
|
|
|
|
logger.info("Payment refunded, cancelling order");
|
|
|
|
// Compensate: cancel order
|
|
CancelOrderCommand command = new CancelOrderCommand(
|
|
event.getOrderId(),
|
|
"Inventory unavailable - payment refunded"
|
|
);
|
|
|
|
commandGateway.send(command);
|
|
}
|
|
|
|
@EndSaga
|
|
@SagaEventHandler(associationProperty = "orderId")
|
|
public void handle(OrderCancelledEvent event) {
|
|
logger.info("Saga completed with compensation");
|
|
}
|
|
}
|
|
```
|
|
|
|
## Handling Compensation Failures
|
|
|
|
Handle cases where compensation itself fails:
|
|
|
|
```java
|
|
@Service
|
|
public class CompensationService {
|
|
|
|
private final DeadLetterQueueService dlqService;
|
|
|
|
public void handleCompensationFailure(String sagaId, String step, Exception cause) {
|
|
logger.error("Compensation failed for saga {} at step {}", sagaId, step, cause);
|
|
|
|
// Send to dead letter queue for manual intervention
|
|
dlqService.send(new FailedCompensation(
|
|
sagaId,
|
|
step,
|
|
cause.getMessage(),
|
|
Instant.now()
|
|
));
|
|
|
|
// Create alert for operations team
|
|
alertingService.alert(
|
|
"Compensation Failure",
|
|
"Saga " + sagaId + " failed compensation at " + step
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
## Testing Compensation
|
|
|
|
Verify that compensation produces expected results:
|
|
|
|
```java
|
|
@Test
|
|
void shouldCompensateWhenPaymentFails() {
|
|
String orderId = "order-123";
|
|
String paymentId = "payment-456";
|
|
|
|
// Arrange: execute payment
|
|
Payment payment = new Payment(paymentId, orderId, BigDecimal.TEN);
|
|
paymentRepository.save(payment);
|
|
orderRepository.save(new Order(orderId, OrderStatus.PENDING));
|
|
|
|
// Act: compensate
|
|
paymentService.cancelPayment(paymentId);
|
|
|
|
// Assert: verify idempotency
|
|
paymentService.cancelPayment(paymentId);
|
|
|
|
Payment result = paymentRepository.findById(paymentId).orElseThrow();
|
|
assertThat(result.getStatus()).isEqualTo(PaymentStatus.CANCELLED);
|
|
}
|
|
```
|
|
|
|
## Common Compensation Patterns
|
|
|
|
### Inventory Release
|
|
|
|
```java
|
|
@Service
|
|
public class InventoryService {
|
|
|
|
public void releaseInventory(String orderId) {
|
|
Order order = orderRepository.findById(orderId).orElseThrow();
|
|
|
|
order.getItems().forEach(item -> {
|
|
InventoryItem inventoryItem = inventoryRepository
|
|
.findById(item.getProductId())
|
|
.orElseThrow();
|
|
|
|
inventoryItem.increaseAvailableQuantity(item.getQuantity());
|
|
inventoryRepository.save(inventoryItem);
|
|
});
|
|
}
|
|
}
|
|
```
|
|
|
|
### Payment Refund
|
|
|
|
```java
|
|
@Service
|
|
public class PaymentService {
|
|
|
|
public void refundPayment(String paymentId) {
|
|
Payment payment = paymentRepository.findById(paymentId)
|
|
.orElseThrow();
|
|
|
|
if (payment.getStatus() == PaymentStatus.PROCESSED) {
|
|
payment.setStatus(PaymentStatus.REFUNDED);
|
|
paymentGateway.refund(payment.getTransactionId());
|
|
paymentRepository.save(payment);
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### Order Cancellation
|
|
|
|
```java
|
|
@Service
|
|
public class OrderService {
|
|
|
|
public void cancelOrder(String orderId, String reason) {
|
|
Order order = orderRepository.findById(orderId)
|
|
.orElseThrow();
|
|
|
|
order.setStatus(OrderStatus.CANCELLED);
|
|
order.setCancellationReason(reason);
|
|
order.setCancelledAt(Instant.now());
|
|
|
|
orderRepository.save(order);
|
|
}
|
|
}
|
|
```
|