A relay is a module with two required callbacks. For a one-off, you may not even need a module.
The quick way: a function
audit: [relay: {Outboxer.Relay.Function, fun: &MyApp.Audit.forward/1}]def forward(%Outboxer.Envelope{} = envelope) do
case MyApp.AuditClient.post(envelope.type, envelope.payload) do
{:ok, _response} -> :ok
{:error, reason} -> {:error, reason}
end
endMessages are passed one at a time, in order. Returning {:error, reason} or raising marks the
message as failed and holds back the rest of its group.
A relay module
defmodule MyApp.Relay.Webhook do
@behaviour Outboxer.Relay
@impl true
def init(opts) do
case Keyword.fetch(opts, :url) do
{:ok, url} when is_binary(url) -> {:ok, %{url: url, timeout: Keyword.get(opts, :timeout, 5_000)}}
_missing -> {:error, "MyApp.Relay.Webhook requires a :url"}
end
end
@impl true
def deliver(envelopes, state) do
Outboxer.Relay.sequential(envelopes, fn envelope ->
headers = [{"idempotency-key", Outboxer.Envelope.dedup_id(envelope)}]
case Req.post(state.url, body: envelope.payload, headers: headers, receive_timeout: state.timeout) do
{:ok, %{status: status}} when status in 200..299 -> :ok
{:ok, %{status: status}} -> {:error, "HTTP #{status}"}
{:error, exception} -> {:error, exception}
end
end)
end
endhooks: [relay: {MyApp.Relay.Webhook, url: "https://example.com/hooks"}]init/1
Runs once, when the Outboxer supervisor starts. Validate options here so a typo stops the boot
instead of failing every message later. The returned state is passed to every deliver/2 call. It
is stored in :persistent_term, so keep it small and immutable: configuration, not connections.
children/1 (optional)
Return child specs for processes the relay needs: a connection pool, a broker client. They are started under the outbox's supervisor before its workers and restarted with them.
@impl true
def children(state), do: [{MyApp.BrokerConnection, name: state.connection, url: state.url}]deliver/2
Receives a non-empty list of envelopes in delivery order and returns one of:
| Return | Meaning |
|---|---|
:ok | all delivered |
{:error, reason} | none delivered |
{:partial, %{id => :ok | {:error, reason}}} | per message; ids not in the map were not attempted |
It is called from the partition's worker process. On Postgres it runs inside the batch transaction, so the repo calls you make there join it, and the time you spend holds a database connection.
Rules a relay must follow
- Report success only when the destination has durably accepted the message. After
:okthe row is deleted. - Keep the ordering rule. Once a message of a group fails, do not deliver later messages of
that group in the same call.
Outboxer.Relay.sequential/2does this for you. A relay that sends real batches must make the batch all-or-nothing per group, as the Kafka relay does per topic-partition. - Bound your time. Use timeouts. A relay that hangs holds its partition, and on Postgres a connection and an open transaction.
- Expect to be called again with the same message. Pass
Outboxer.Envelope.dedup_id/1to the destination when it supports idempotency keys. - Do not swallow errors. Return them. Retrying and backoff are Outboxer's job; sleeping and
retrying inside
deliver/2only blocks the partition.
Testing a relay
Call it directly; no database is needed:
test "posts the payload" do
{:ok, state} = MyApp.Relay.Webhook.init(url: bypass_url)
envelope = %Outboxer.Envelope{id: 1, outbox: :hooks, type: "t", payload: ~s({"a":1})}
assert {:partial, %{1 => :ok}} = MyApp.Relay.Webhook.deliver([envelope], state)
endFor the whole path, publish and call Outboxer.Testing.drain/2.