Compensation Strategy¶
-
Back to Saga Overview
Return to the Saga Pattern overview page with all topics.
Compensation undoes the effects of completed steps when a saga fails, ensuring resources are properly released and the system returns to a consistent state.
When the storage supports create_run(), the saga runs compensation within a single storage run and persists each successfully compensated step at a checkpoint (by committing the run after each step). This keeps the number of commits low and aligns with the checkpoint model used for forward execution.
Overview¶
When any step fails, all previously completed steps are automatically compensated in reverse order:
- Resources are properly released
- Partial operations are rolled back
- System returns to a consistent state
How Compensation Works¶
Compensation is triggered automatically when a step's act() method raises an exception:
sequenceDiagram
participant Transaction as SagaTransaction
participant Step1 as Step 1
participant Step2 as Step 2
participant Step3 as Step 3
Transaction->>Step1: act(context)
Step1-->>Transaction: Success ✓
Transaction->>Step2: act(context)
Step2-->>Transaction: Success ✓
Transaction->>Step3: act(context)
Step3-->>Transaction: Exception ✗
Note over Transaction: Compensation triggered
Transaction->>Step2: compensate(context)
Transaction->>Step1: compensate(context)
Transaction-->>Transaction: Mark saga as FAILED
Compensation happens in reverse order:
Implementing Compensation¶
Each step handler must implement the compensate() method:
class ReserveInventoryStep(SagaStepHandler[OrderContext, Response]):
async def act(self, context: OrderContext) -> SagaStepResult:
reservation_id = await self._inventory_service.reserve_items(
context.order_id, context.items
)
context.inventory_reservation_id = reservation_id
return self._generate_step_result(Response())
async def compensate(self, context: OrderContext) -> None:
if context.inventory_reservation_id:
await self._inventory_service.release_items(
context.inventory_reservation_id
)
Best Practices¶
- Idempotent — Safe to call multiple times
- Check Context — Verify compensation is needed before executing
- Handle Errors — Gracefully handle missing resources
Idempotent Example¶
async def compensate(self, context: OrderContext) -> None:
if not context.inventory_reservation_id:
return # Already compensated
try:
await self._inventory_service.release_items(
context.inventory_reservation_id
)
context.inventory_reservation_id = None
except ValueError:
pass # Already released - idempotent
Compensation Retry¶
Automatic retry for compensation failures is configured on saga.transaction(...):
saga = OrderSaga() # steps defined on class
async with saga.transaction(
context=context,
container=container,
storage=storage,
compensation_retry_count=3, # Number of retry attempts
compensation_retry_delay=1.0, # Initial delay in seconds
compensation_retry_backoff=2.0, # Exponential backoff multiplier
) as transaction:
async for step_result in transaction:
...
Retry schedule:
- Attempt 1: fails, wait 1.0s
- Attempt 2: fails, wait 2.0s (1.0 × 2.0)
- Attempt 3: fails, wait 4.0s (2.0 × 2.0)
- Attempt 4: final attempt
Compensation Patterns¶
Direct Reversal¶
async def act(self, context: OrderContext) -> SagaStepResult:
resource_id = await service.create_resource(data)
context.resource_id = resource_id
return self._generate_step_result(Response())
async def compensate(self, context: OrderContext) -> None:
if context.resource_id:
await service.delete_resource(context.resource_id)
Compensating Transaction¶
async def act(self, context: OrderContext) -> SagaStepResult:
payment_id = await payment_service.charge(amount)
context.payment_id = payment_id
return self._generate_step_result(Response())
async def compensate(self, context: OrderContext) -> None:
if context.payment_id:
await payment_service.refund(context.payment_id)
No-Op Compensation¶
async def act(self, context: OrderContext) -> SagaStepResult:
await notification_service.send(context.user_id, message)
return self._generate_step_result(Response())
async def compensate(self, context: OrderContext) -> None:
pass # Notifications can't be "unsent"
Common Pitfalls¶
Non-Idempotent Compensation¶
# ❌ Bad: Not idempotent
async def compensate(self, context: OrderContext) -> None:
await service.delete_resource(context.resource_id)
# ✅ Good: Idempotent
async def compensate(self, context: OrderContext) -> None:
if context.resource_id:
try:
await service.delete_resource(context.resource_id)
except NotFoundError:
pass
context.resource_id = None
Missing Context Check¶
# ❌ Bad: May fail if resource doesn't exist
async def compensate(self, context: OrderContext) -> None:
await service.release_items(context.inventory_reservation_id)
# ✅ Good: Checks before compensating
async def compensate(self, context: OrderContext) -> None:
if context.inventory_reservation_id:
await service.release_items(context.inventory_reservation_id)
Best Practices¶
- Idempotent — Safe to call multiple times
- Check Context — Verify compensation is needed
- Handle Failures — Log errors appropriately
- Test Logic — Ensure compensation works correctly