Helpers for the scriba_dead_letters table — failed events recorded for
later inspection or replay.
An event arrives here in five ways (§9). Two are handler failures, after retry exhaustion:
- The handler returned
{:error, reason}. - The handler raised — the engine catches it and tags it
{:exception, exception, stacktrace}.
Three more bypass the retry layer entirely, because retrying them changes nothing:
- The commit failed with an integrity-class error, isolated by the
per-event fallback pass (
{:commit_error, reason}). - The handler returned a
{:multi, _}whose operation names collide with another event's in the same batch ({:multi_key_collision, keys}). - The handler returned a shape the target cannot apply.
All of them end up routed to a row in scriba_dead_letters with a serialized
copy of the event and an error description. Per §9.2, the projection's
position advances past the dead-lettered event — the projection does not
block. There is no replay function yet; replaying means reading the row and
re-dispatching the event yourself.
Transient and structural commit failures are not dead-letter paths: a
transient one replays the whole batch, a structural one halts the
projection. See Scriba.Target.
This module exposes raw helpers; the routing decision (when to insert) lives
in the Pipeline, which partitions failure-shape handler results out of the
batch and passes them to the target as dead_letters.
Summary
Functions
Builds a row map for the scriba_dead_letters table from a failing event
and an error tuple/exception.
Counts dead letters for a projection. Takes the same filters as list/3.
Inserts a single dead-letter row directly via repo. Used by callers that
manage their own transaction (e.g. retry-then-dead-letter flows).
Lists dead letters for a projection, newest first.
Appends a dead-letter insert to an Ecto.Multi. Useful for the Pipeline's
"dead-letter and advance position" transaction after a target apply_batch
has already failed and rolled back.
A summary an operator can page on: how many, of what kind, over what span.
Types
@type projection() :: %{name: String.t(), version: pos_integer()}
Functions
@spec build_row(projection(), Scriba.Event.t(), term()) :: map()
Builds a row map for the scriba_dead_letters table from a failing event
and an error tuple/exception.
Accepts:
{:error, reason}—kind = "error",message = inspect(reason){:exception, exception, stacktrace}—kindis the exception module as a string, message + formatted stacktrace{:commit_error, reason}—kind = "commit:<SQLSTATE label>"{:multi_key_collision, keys}—kind = "multi_key_collision"- anything else —
kind = "invalid_return"
@spec count(module(), projection(), keyword()) :: non_neg_integer()
Counts dead letters for a projection. Takes the same filters as list/3.
@spec insert(module(), projection(), Scriba.Event.t(), term()) :: {:ok, term()} | {:error, term()}
Inserts a single dead-letter row directly via repo. Used by callers that
manage their own transaction (e.g. retry-then-dead-letter flows).
@spec list(module(), projection(), keyword()) :: [map()]
Lists dead letters for a projection, newest first.
Returns maps with :id, :position, :stream_id, :event_type,
:error_kind, :error_message, :occurred_at and :event_data.
Options
:limit(default50) — how many rows.:offset(default0) — for paging.:stream_id— only this stream.:error_kind— only this kind, e.g."commit:23505 (unique_violation)"or"Elixir.ArgumentError". Matches exactly.:since— only rows at or after thisDateTime.:order—:desc(default, newest first) or:asc(oldest first, which is the order you would replay in).
:event_data comes back as the map that was stored, not the original
struct: build_row/3 serializes __struct__ to a string, so a row
records what failed rather than a value you can re-dispatch. Replay reads
the event from the source by :position.
@spec multi(Ecto.Multi.t(), projection(), Scriba.Event.t(), term()) :: Ecto.Multi.t()
Appends a dead-letter insert to an Ecto.Multi. Useful for the Pipeline's
"dead-letter and advance position" transaction after a target apply_batch
has already failed and rolled back.
@spec stats(module(), projection(), keyword()) :: map()
A summary an operator can page on: how many, of what kind, over what span.
%{
total: 143,
by_error_kind: %{"commit:23505 (unique_violation)" => 140, "Elixir.ArgumentError" => 3},
oldest: ~U[...],
newest: ~U[...]
}The shape of by_error_kind is the diagnosis. One kind dominating a single
stream is a poison event; one kind spread across every stream is a schema
or handler problem that dead-lettering is papering over — the case
Scriba.Circuit halts on when it catches a whole batch at once.