Elixir by Example

The Outbox Pattern

Every command ran when the site was built; the output is real Download project

Placing an order means two things: save the order in your database, and tell other systems about it (billing, shipping, email) through a message broker. The obvious code does one and then the other. That's a dual write, and it has a gap: if the node stops between the two (a deploy, an out-of-memory kill, a lost machine), the order exists and nobody is ever told. Doing them the other way round has the opposite gap: an announced order that doesn't exist.

There's no transaction that spans your database and a broker. The outbox pattern avoids needing one. The event is written to an outbox table in the same transaction as the order, so both exist or neither does. A separate relay process reads the table, publishes what it finds, and marks it as sent. If the node dies, the event waits in the table until the relay runs again.

On this page:

  1. A dual write loses an event when the node stops at the wrong moment.
  2. The outbox keeps it, and the relay publishes it after the restart.
  3. The price: the relay can publish an event twice, so consumers must be ready for duplicates.

The project

The same setup as Transactions and Constraints: Ecto on SQLite.

mix.exs
defmodule Shop.MixProject do
  use Mix.Project

  def project do
    [app: :shop, version: "0.1.0", elixir: "~> 1.20", deps: deps()]
  end

  def application do
    [mod: {Shop.Application, []}]
  end

  defp deps do
    [{:ecto_sqlite3, "~> 0.26"}]
  end
end
config/config.exs
import Config

config :shop, ecto_repos: [Shop.Repo]
config :shop, Shop.Repo, database: "shop.db", log: false

# Map columns are stored as JSON text. Elixir's built-in
# `JSON` module does the encoding, so no extra dependency.
config :ecto_sqlite3, json_library: JSON
lib/shop/repo.ex
defmodule Shop.Repo do
  use Ecto.Repo, otp_app: :shop, adapter: Ecto.Adapters.SQLite3
end

The application starts the repo and the relay.

lib/shop/application.ex
defmodule Shop.Application do
  use Application

  @impl true
  def start(_type, _args) do
    children = [Shop.Repo, Shop.Relay]
    Supervisor.start_link(children, strategy: :one_for_one, name: Shop.Supervisor)
  end
end
priv/repo/migrations/20261108000000_create_orders.exs
defmodule Shop.Repo.Migrations.CreateOrders do
  use Ecto.Migration

  def change do
    create table(:orders) do
      add :item, :string, null: false
    end

    # Events waiting to be published. `published_at`
    # stays empty until the relay has sent the event.
    create table(:outbox) do
      add :topic, :string, null: false
      add :payload, :map, null: false
      add :published_at, :utc_datetime
    end
  end
end
lib/shop/order.ex
defmodule Shop.Order do
  use Ecto.Schema

  schema "orders" do
    field :item, :string
  end
end
lib/shop/outbox_event.ex
defmodule Shop.OutboxEvent do
  use Ecto.Schema

  schema "outbox" do
    field :topic, :string
    field :payload, :map
    field :published_at, :utc_datetime
  end
end

The "broker" is a file standing in for Kafka or RabbitMQ. What matters is that it lives outside the shop's node: what it was sent survives the node going down, and so does the database. Everything in the node's memory doesn't.

lib/shop/broker.ex
defmodule Shop.Broker do
  @moduledoc """
  A stand-in for a message broker (Kafka, RabbitMQ, SNS):
  another system, which keeps what it was sent even
  when the shop's node goes down. Here it's an
  append-only file.
  """

  @log "broker.log"

  def publish(id, topic, payload) do
    File.write!(@log, "#{id} #{topic} #{payload["item"]}\n", [:append])
  end

  def received do
    case File.read(@log) do
      {:ok, text} -> String.split(text, "\n", trim: true)
      {:error, :enoent} -> []
    end
  end
end

Both ways of placing an order. crash_after_commit: true halts the node right after the transaction commits, the worst moment for a dual write.

lib/shop.ex
defmodule Shop do
  alias Shop.{Broker, Order, OutboxEvent, Repo}
  alias Ecto.Multi

  def orders, do: Order |> Repo.all() |> Enum.map(&{&1.id, &1.item})

  # The dual write: save the order, then tell the broker.
  # `crash_after_commit: true` stops the node between the
  # two, like a deploy or an out-of-memory kill would.
  def place_order_dual_write(item, opts \\ []) do
    {:ok, order} = Repo.insert(%Order{item: item})
    if opts[:crash_after_commit], do: crash("order #{order.id} committed, node stops")
    Broker.publish("dual-#{order.id}", "order_placed", %{"item" => item})
    {:ok, order.id}
  end

  # The outbox: the order and the event are saved in the
  # same transaction. Nothing is sent here. The relay
  # sends it later, from the table.
  def place_order(item, opts \\ []) do
    {:ok, %{order: order}} =
      Multi.new()
      |> Multi.insert(:order, %Order{item: item})
      |> Multi.insert(:event, %OutboxEvent{topic: "order_placed", payload: %{"item" => item}})
      |> Repo.transaction()

    if opts[:crash_after_commit], do: crash("order #{order.id} committed, node stops")
    {:ok, order.id}
  end

  # Stands in for a deploy or an out-of-memory kill: the
  # node stops on the spot, with exit status 1.
  def crash(message) do
    IO.puts(message)
    System.halt(1)
  end
end

The relay polls the outbox once a second. flush/1 runs a pass on demand so the output below doesn't depend on timing, and crash_before_mark: true halts the node after publishing an event but before marking it as sent.

lib/shop/relay.ex
defmodule Shop.Relay do
  @moduledoc """
  Publishes unsent outbox events, oldest first, and
  marks each one as published. It polls once a second;
  `flush/1` runs a pass right now.
  """
  use GenServer
  import Ecto.Query
  alias Shop.{Broker, OutboxEvent, Repo}

  @interval 1_000

  def start_link(_), do: GenServer.start_link(__MODULE__, nil, name: __MODULE__)
  def flush(opts \\ []), do: GenServer.call(__MODULE__, {:flush, opts})

  @impl true
  def init(nil) do
    Process.send_after(self(), :poll, @interval)
    {:ok, nil}
  end

  @impl true
  def handle_call({:flush, opts}, _from, state) do
    {:reply, publish_pending(opts), state}
  end

  @impl true
  def handle_info(:poll, state) do
    publish_pending([])
    Process.send_after(self(), :poll, @interval)
    {:noreply, state}
  end

  defp publish_pending(opts) do
    pending = Repo.all(from e in OutboxEvent, where: is_nil(e.published_at), order_by: e.id)

    for event <- pending do
      Broker.publish("outbox-#{event.id}", event.topic, event.payload)
      # Crash between sending and marking as sent.
      if opts[:crash_before_mark], do: Shop.crash("outbox-#{event.id} published, node stops")
      now = DateTime.utc_now(:second)
      Repo.update!(Ecto.Changeset.change(event, published_at: now))
    end

    length(pending)
  end
end

Set up

$ mix deps.get > /dev/null
$ mix deps.compile > /dev/null 2>&1
$ mix compile
$ mix ecto.create --quiet
$ mix ecto.migrate --quiet
Compiling 7 files (.ex)
Generated shop app

A dual write loses the event

The node stops between the commit and the publish. mix run exits with status 1, as the node would have.

$ mix run -e 'Shop.place_order_dual_write("book", crash_after_commit: true)'
order 1 committed, node stops

After a restart the order is there. The broker never heard of it, and nothing remembers that it should have.

$ mix run -e 'IO.inspect(Shop.orders(), label: "orders"); IO.inspect(Shop.Broker.received(), label: "broker")'
orders: [{1, "book"}]
broker: []

The outbox keeps it

The same crash, with the outbox.

$ mix run -e 'Shop.place_order("lamp", crash_after_commit: true)'
order 2 committed, node stops

After the restart the event is still in the outbox, and the relay's first pass publishes it.

$ mix run -e '
$ IO.inspect(Shop.orders(), label: "orders")
$ IO.inspect(Shop.Relay.flush(), label: "events published")
$ Enum.each(Shop.Broker.received(), &IO.puts/1)
$ '
orders: [{1, "book"}, {2, "lamp"}]
events published: 1
outbox-1 order_placed lamp

At least once, not exactly once

The relay publishes and then marks the row. If the node stops between the two, the row still looks unsent.

$ mix run -e 'Shop.place_order("pen"); Shop.Relay.flush(crash_before_mark: true)'
outbox-2 published, node stops

So after the restart the relay publishes it again, and the broker has the pen event twice.

$ mix run -e '
$ IO.inspect(Shop.Relay.flush(), label: "events published")
$ Enum.each(Shop.Broker.received(), &IO.puts/1)
$ '
events published: 1
outbox-1 order_placed lamp
outbox-2 order_placed pen
outbox-2 order_placed pen

Marking first and publishing second would turn the duplicate into a loss, which is worse. No ordering of the two steps gives exactly once. The outbox gives at least once, and the consumers make repeats harmless: every event carries a unique id (outbox-2 here), and a consumer that has already handled an id skips it. See Idempotency and Retries.

Pitfall: don't publish from inside the transaction "to be safe". If the transaction then rolls back, the event announces an order that never existed, and you can't take it back.

Pitfall: with more than one relay (one per node), two relays can pick up the same row and publish it twice, more often than any crash would. Run one relay per cluster (a singleton, or a lease as on Leases and Fencing Tokens), or have each relay claim rows (Postgres: SELECT ... FOR UPDATE SKIP LOCKED).

Pitfall: the outbox table only grows. Delete published rows after a while, or the relay's query gets slower every day.