Compensations (Saga Pattern)
Handle distributed transactions that need rollback when something fails.
What Are Compensations?
When a workflow performs actions with external side effects (booking flights, charging payments, sending emails), you often need to undo those actions if a later step fails. The saga pattern solves this by defining compensation handlers that run in reverse order when failures occur.
Quick Example
defmodule MyApp.BookTripWorkflow do use Durable use Durable.Helpers
workflow "book_trip" do # Step 1: Book flight (with compensation) step :book_flight, [compensate: :cancel_flight], fn ctx -> booking = FlightAPI.book(ctx["flight"]) {:ok, assign(ctx, :flight_id, booking.id)} end
# Step 2: Book hotel (with compensation) step :book_hotel, [compensate: :cancel_hotel], fn ctx -> booking = HotelAPI.book(ctx["hotel"]) {:ok, assign(ctx, :hotel_id, booking.id)} end
# Step 3: Charge payment (no compensation needed - it's the last step) step :charge_payment, fn ctx -> # If this fails, compensations run automatically! PaymentService.charge(ctx.total) {:ok, ctx} end
# Compensation handlers compensate :cancel_flight, fn ctx -> FlightAPI.cancel(ctx.flight_id) {:ok, ctx} end
compensate :cancel_hotel, fn ctx -> HotelAPI.cancel(ctx.hotel_id) {:ok, ctx} end endendWhat happens when payment fails:
:book_flightcompletes successfully:book_hotelcompletes successfully:charge_paymentraises an error- Durable automatically runs compensations in reverse order:
- First:
:cancel_hotel(undo hotel) - Then:
:cancel_flight(undo flight)
- First:
- Workflow status becomes
:compensated
DSL Reference
Defining Compensations
compensate :handler_name, fn ctx -> # Undo logic here # Has access to workflow context {:ok, ctx}endLinking Steps to Compensations
step :my_step, [compensate: :my_handler], fn ctx -> # Step logic {:ok, ctx}endCompensation Options
compensate :handler_name, [retry: [max_attempts: 3, backoff: :exponential]], fn ctx -> # Compensation with retry {:ok, ctx}endWorkflow Status States
| Status | Description |
|---|---|
:compensating |
Currently running compensation handlers |
:compensated |
All compensations completed successfully |
:compensation_failed |
One or more compensations failed |
How It Works
- Tracking: Durable tracks which steps with compensations have completed
- Failure: When any step fails, compensation is triggered
- Reverse Order: Compensations run in LIFO order (last completed first)
- Recording: Each compensation is recorded as a step execution
- Continuation: Compensation failures don’t stop the chain - all compensations attempt to run
Best Practices
Make Compensations Idempotent
Compensations may run multiple times (retries, manual recovery). Design them to be safe to repeat:
compensate :cancel_booking, fn ctx -> booking_id = ctx.booking_id
# Check if already cancelled before cancelling case BookingAPI.get(booking_id) do {:ok, %{status: :cancelled}} -> {:ok, ctx} {:ok, _booking} -> BookingAPI.cancel(booking_id); {:ok, ctx} {:error, :not_found} -> {:ok, ctx} endendStore IDs for Cleanup
Always store resource IDs in data so compensations can find them:
step :create_resource, [compensate: :delete_resource], fn ctx -> resource = ExternalAPI.create(ctx.params) {:ok, assign(ctx, :resource_id, resource.id)} # Store for compensationend
compensate :delete_resource, fn ctx -> ExternalAPI.delete(ctx.resource_id) {:ok, ctx}endHandle Partial Failures
Not all compensations may succeed. Check compensation_results for details:
{:ok, execution} = Durable.get_execution(workflow_id)
case execution.status do :compensated -> IO.puts("All compensations succeeded")
:compensation_failed -> IO.puts("Some compensations failed:") Enum.each(execution.compensation_results, fn result -> IO.inspect(result) end)endQuerying Compensation Results
Execution Results
{:ok, execution} = Durable.get_execution(workflow_id)
# Check compensation statusexecution.status # :compensated or :compensation_failedexecution.compensated_at # When compensation completedexecution.compensation_results # Array of results
# Result structure:# [# %{# "step" => "book_hotel",# "compensation" => "cancel_hotel",# "result" => %{"status" => "completed"}# },# %{# "step" => "book_flight",# "compensation" => "cancel_flight",# "result" => %{"status" => "completed"}# }# ]Step Executions
{:ok, execution} = Durable.get_execution(workflow_id, include_steps: true)
# Find compensation step executionscompensation_steps = Enum.filter(execution.steps, & &1.is_compensation)
Enum.each(compensation_steps, fn step -> IO.puts("#{step.step_name}: #{step.status}") IO.puts(" Compensating for: #{step.compensation_for}") IO.puts(" Duration: #{step.duration_ms}ms")end)Complete Example: E-Commerce Order
defmodule MyApp.ProcessOrderWorkflow do use Durable use Durable.Helpers
workflow "process_order" do step :reserve_inventory, [compensate: :release_inventory], fn ctx -> items = ctx["items"]
reservations = Enum.map(items, fn item -> {:ok, res} = Inventory.reserve(item.sku, item.quantity) res.id end)
{:ok, assign(ctx, :reservation_ids, reservations)} end
step :charge_customer, [compensate: :refund_customer], fn ctx -> amount = ctx["total"] {:ok, charge} = Payments.charge(ctx["customer_id"], amount) {:ok, assign(ctx, :charge_id, charge.id)} end
step :create_shipment, fn ctx -> # If shipping fails, inventory is released and payment refunded {:ok, shipment} = Shipping.create(ctx["address"], ctx["items"]) {:ok, assign(ctx, :shipment_id, shipment.id)} end
step :send_confirmation, fn ctx -> Email.send_order_confirmation(ctx["customer_email"], %{ shipment_id: ctx.shipment_id, charge_id: ctx.charge_id }) {:ok, ctx} end
# Compensations compensate :release_inventory, fn ctx -> Enum.each(ctx.reservation_ids, fn id -> Inventory.release(id) end) {:ok, ctx} end
compensate :refund_customer, fn ctx -> Payments.refund(ctx.charge_id) {:ok, ctx} end endend