The Outbox Pattern
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:
- A dual write loses an event when the node stops at the wrong moment.
- The outbox keeps it, and the relay publishes it after the restart.
- 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.
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
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
defmodule Shop.Repo do
use Ecto.Repo, otp_app: :shop, adapter: Ecto.Adapters.SQLite3
end
The application starts the repo and the relay.
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
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
defmodule Shop.Order do
use Ecto.Schema
schema "orders" do
field :item, :string
end
end
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.
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.
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.
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.