Per-projection failure state that has to outlive the producer.
The source's producer dies on purpose to rewind its subscription, so it cannot remember anything across a commit failure. Two decisions need memory it does not have:
How long to wait before dying again. Without a delay the transient path crashes as fast as batches form — roughly ten times a second at
batch_timeout: 100— which burns a supervisor's restart budget in seconds regardless of how generous that budget is.Whether repeated single-event integrity failures are one bad row or a systemic mismatch. See
record_wipeout/2.
State lives in one shared ETS table for the whole BEAM, keyed by
{name, version}, following Scriba.Position's model: created once at
application start, one atom regardless of projection count.
Summary
Functions
Records a transient commit failure and returns how long the producer should wait before dying, in milliseconds.
Records a batch in which every attempted write failed on integrity grounds and none committed, and returns whether the projection should halt.
Clears all failure state — called whenever a batch commits.
Functions
@spec record_transient(String.t(), pos_integer()) :: non_neg_integer()
Records a transient commit failure and returns how long the producer should wait before dying, in milliseconds.
@spec record_wipeout(String.t(), pos_integer(), pos_integer()) :: :halt | :continue
Records a batch in which every attempted write failed on integrity grounds and none committed, and returns whether the projection should halt.
SQLSTATE says a failure is deterministic and event-specific. It does not say
how many events share the defect. A tightened column type or a NOT NULL
added to a field the handler never populates makes every insert fail with
a class 22/23 code — each one individually dead-letterable, so the per-event
fallback would drain the entire stream into scriba_dead_letters, advance
the cursor to head, and leave Scriba.info/2 reporting a fully caught-up
projection over an empty read model. That is precisely the outcome
:structural exists to prevent, reached through a different code class.
Blast radius is the guard that SQLSTATE cannot provide:
- More than one event attempted and all of them failed → systemic, halt now. One poison row does not take its whole batch with it.
- Exactly one event attempted → ambiguous, so dead-letter it. If it keeps happening with nothing ever committing (3 batches running), it is systemic after all.
@spec reset(String.t(), pos_integer()) :: :ok
Clears all failure state — called whenever a batch commits.