Outboxer.Storage behaviour (Outboxer v0.1.0)

Copy Markdown View Source

The behaviour between Outboxer and the database that holds the outbox.

Two adapters ship with the library, both built on Ecto:

  • Outboxer.Storage.Postgres - multi-node safe. Each delivery batch runs in a short transaction that holds a transaction-scoped advisory lock on the partition.
  • Outboxer.Storage.SQLite - single node. The one worker per partition is the only owner; delivery happens outside any transaction because SQLite has a single writer.

The adapter is chosen from the repo's Ecto adapter, or set explicitly with the :storage option of Outboxer.

Writing another adapter

The contract an adapter has to honour:

  • insert/2 must take part in the caller's database transaction. That is the whole point of the pattern: the message commits or rolls back with the business write. A store that cannot do this (Redis, for example) can still implement the behaviour, but what you get is a best-effort queue, not a transactional outbox.
  • with_partition/4 must guarantee that at most one process in the whole cluster runs the function for a given outbox partition at a time, and must release that ownership if the process dies.
  • fetch/5 must return ready messages in id order and must leave out every group that has a failed message waiting for its retry.

Summary

Types

The instance configuration. An adapter can rely on these fields

Callbacks

Records successful delivery: deletes the rows or marks them delivered.

Records a failed attempt and schedules the retry.

Returns up to limit deliverable messages of a partition, oldest first.

Inserts rows in the caller's transaction and returns their envelopes.

Moves messages to the dead state after their last allowed attempt.

Serialises concurrent publishers of the same group until their transaction ends. A no-op where the database already has a single writer.

Tells other nodes that a partition has new messages. Optional.

Deletes up to limit delivered messages older than cutoff. Returns how many.

Runs fun as the exclusive owner of a partition, or returns :busy.

Types

conf()

@type conf() :: %{
  :repo => module(),
  :outboxes => %{
    optional(atom()) => %{:table => String.t(), optional(atom()) => term()}
  },
  :prefix => String.t() | nil,
  :name => atom(),
  optional(atom()) => term()
}

The instance configuration. An adapter can rely on these fields:

  • :repo - the Ecto.Repo module
  • :prefix - the schema of the outbox tables, or nil
  • :name - the Outboxer instance name
  • :outboxes - a map of outbox name to its configuration; .table is the table that outbox lives in

Message ids are unique within a table only, which is why every callback that takes ids also takes the outbox they belong to.

outbox()

@type outbox() :: atom()

row()

@type row() :: %{
  outbox: outbox(),
  partition: non_neg_integer(),
  group_key: String.t() | nil,
  type: String.t(),
  payload: binary(),
  headers: map()
}

Callbacks

ack(conf, outbox, ids, retention, now)

@callback ack(
  conf(),
  outbox(),
  ids :: [pos_integer()],
  retention :: :delete | {:keep, pos_integer()},
  now :: DateTime.t()
) :: :ok

Records successful delivery: deletes the rows or marks them delivered.

fail(conf, outbox, ids, retry_at, error)

@callback fail(
  conf(),
  outbox(),
  ids :: [pos_integer()],
  retry_at :: DateTime.t(),
  error :: String.t()
) ::
  :ok

Records a failed attempt and schedules the retry.

fetch(conf, outbox, partition, limit, now)

@callback fetch(
  conf(),
  outbox(),
  partition :: non_neg_integer(),
  limit :: pos_integer(),
  now :: DateTime.t()
) :: [Outboxer.Envelope.t()]

Returns up to limit deliverable messages of a partition, oldest first.

insert(conf, list)

@callback insert(conf(), [row()]) :: [Outboxer.Envelope.t()]

Inserts rows in the caller's transaction and returns their envelopes.

kill(conf, outbox, ids, error, now)

@callback kill(
  conf(),
  outbox(),
  ids :: [pos_integer()],
  error :: String.t(),
  now :: DateTime.t()
) :: :ok

Moves messages to the dead state after their last allowed attempt.

lock_group(conf, outbox, group_key)

@callback lock_group(conf(), outbox(), group_key :: String.t()) :: :ok

Serialises concurrent publishers of the same group until their transaction ends. A no-op where the database already has a single writer.

notify(conf, outbox, partition)

(optional)
@callback notify(conf(), outbox(), partition :: non_neg_integer()) :: :ok

Tells other nodes that a partition has new messages. Optional.

prune(conf, outbox, cutoff, limit)

@callback prune(conf(), outbox(), cutoff :: DateTime.t(), limit :: pos_integer()) ::
  non_neg_integer()

Deletes up to limit delivered messages older than cutoff. Returns how many.

with_partition(conf, outbox, partition, function)

@callback with_partition(conf(), outbox(), partition :: non_neg_integer(), (-> result)) ::
  {:ok, result} | :busy
when result: term()

Runs fun as the exclusive owner of a partition, or returns :busy.