Elixir by Example

Sagas

Edit any cell and run it in your browser Download source

Booking a trip takes a flight, a hotel and a car, from three different services, each with its own database. There's no transaction that spans all three. If the car is sold out after the flight and hotel are booked, nothing rolls those back for you.

A saga does it by hand. Each step comes with a compensation, an action that undoes its effect (cancel the flight, cancel the hotel). The steps run in order. If one fails, the compensations of the steps that already succeeded run in reverse order, and the system ends up where it started.

The three services, as one Agent holding everything booked. sold_out lists what can't be booked, and flaky lists cancellations that fail on the first try.

defmodule Travel do
  def start(opts \\ []) do
    Agent.start_link(fn ->
      %{booked: [], sold_out: opts[:sold_out] || [], flaky: opts[:flaky] || []}
    end)
  end

  def book(travel, kind, what) do
    Agent.get_and_update(travel, fn state ->
      if kind in state.sold_out do
        {{:error, :sold_out}, state}
      else
        booking = {kind, what}
        {{:ok, booking}, %{state | booked: state.booked ++ [booking]}}
      end
    end)
  end

  def cancel(travel, {kind, _} = booking) do
    Agent.get_and_update(travel, fn state ->
      if kind in state.flaky do
        {{:error, :timeout}, %{state | flaky: List.delete(state.flaky, kind)}}
      else
        {:ok, %{state | booked: List.delete(state.booked, booking)}}
      end
    end)
  end

  def booked(travel), do: Agent.get(travel, & &1.booked)
end

The saga runner. A step is a name, an action returning {:ok, result} or {:error, reason}, and a compensation that receives the action's result.

defmodule Saga do
  def run(steps) do
    result =
      Enum.reduce_while(steps, [], fn {name, action, compensate}, done ->
        case action.() do
          {:ok, result} ->
            IO.puts("  #{name}: ok")
            {:cont, [{name, compensate, result} | done]}

          {:error, reason} ->
            IO.puts("  #{name}: failed (#{inspect(reason)})")
            {:halt, {:failed, name, reason, done}}
        end
      end)

    case result do
      {:failed, name, reason, done} ->
        # `done` is newest first: exactly the order to undo.
        Enum.each(done, &compensate/1)
        {:error, name, reason}

      _done ->
        :ok
    end
  end

  # A compensation must not be skipped: a half-cancelled
  # trip is the thing the saga exists to prevent. So it's
  # retried until it works.
  defp compensate({name, compensate, result} = step) do
    case compensate.(result) do
      :ok ->
        IO.puts("  undo #{name}: ok")

      {:error, reason} ->
        IO.puts("  undo #{name}: failed (#{inspect(reason)}), retrying")
        compensate(step)
    end
  end
end

trip = fn travel ->
  [
    {:flight, fn -> Travel.book(travel, :flight, "LIS-OSL") end, &Travel.cancel(travel, &1)},
    {:hotel, fn -> Travel.book(travel, :hotel, "Oslo, 3 nights") end, &Travel.cancel(travel, &1)},
    {:car, fn -> Travel.book(travel, :car, "compact") end, &Travel.cancel(travel, &1)}
  ]
end

Everything available: all three steps succeed.

{:ok, travel} = Travel.start()
IO.puts("trip 1")
IO.inspect(Saga.run(trip.(travel)), label: "  result")
IO.inspect(Travel.booked(travel), label: "  booked")
trip 1
  flight: ok
  hotel: ok
  car: ok
  result: :ok
  booked: [flight: "LIS-OSL", hotel: "Oslo, 3 nights", car: "compact"]

No cars left. The car step fails, the hotel and the flight are cancelled, newest first, and nothing stays booked.

{:ok, travel} = Travel.start(sold_out: [:car])
IO.puts("trip 2")
IO.inspect(Saga.run(trip.(travel)), label: "  result")
IO.inspect(Travel.booked(travel), label: "  booked")
trip 2
  flight: ok
  hotel: ok
  car: failed (:sold_out)
  undo hotel: ok
  undo flight: ok
  result: {:error, :car, :sold_out}
  booked: []

The hotel's cancellation times out once. The runner retries it, and the trip still ends fully undone.

{:ok, travel} = Travel.start(sold_out: [:car], flaky: [:hotel])
IO.puts("trip 3")
IO.inspect(Saga.run(trip.(travel)), label: "  result")
IO.inspect(Travel.booked(travel), label: "  booked")
trip 3
  flight: ok
  hotel: ok
  car: failed (:sold_out)
  undo hotel: failed (:timeout), retrying
  undo hotel: ok
  undo flight: ok
  result: {:error, :car, :sold_out}
  booked: []

Pitfall: this runner keeps its progress in memory. If the node stops halfway, nobody knows a flight was booked and needs cancelling. A real saga writes each step to a database before and after doing it (a saga log), and on restart finishes or undoes whatever was in progress. A job queue that persists its jobs, like Oban, is a common way to drive one.

Pitfall: retried compensations, and retried steps after a restart, can run twice. Both must be idempotent: cancelling a cancelled booking is fine, refunding a refunded payment isn't. See Idempotency and Retries.

Pitfall: a saga isn't isolated like a transaction. Between booking and cancelling, other people can see the hotel room as taken. And some actions can't be undone at all: an email already sent stays sent. Put steps like that last, after everything that can fail.