The Oban worker a base-handler invocation becomes.
Uniqueness is the whole point of this module, exactly as it is for
StatifierOban.Timer.Worker: jobs are unique on the
{scope, invoke_id, macrostep} triple (ADR-0003), read off the args
at the top level. Re-executing the same drive after a crash rebuilds
a byte-identical triple - invoke_id is either the author's literal
id, used verbatim, or a deterministic %MachineState{} counter
(st-ADR-0008 as amended), and macrostep is pure fold state stamped
on the effect - so the duplicate insert conflicts with the stored job
and becomes a no-op. That conflict, not any check in host code, is
what makes the at-least-once perform/2 contract (st-ADR-0051
decision 4) safe for the enqueue itself.
macrostep is in the key because invoke_id alone cannot tell a
crash replay from a state re-entry: an authored id (<invoke id="resolve">) is byte-identical on every re-entry of its state, and
a retry loop that re-enters legitimately schedules a fresh
invocation. Invocations start only at the end of the macrostep, for
states still active then, so a {state, invoke_index} pair invokes
at most once per macrostep: within one macrostep the triple collides
exactly when the insert is a replay of the same scheduling decision,
and across macrosteps it never collides at all (ADR-0003 lays this
out).
The unique window is every state over an infinite period: an invoke
whose job already completed, was cancelled, or was discarded must
still swallow a replayed insert, because the replay is the same
scheduling decision, not a new one. The unique fields exclude :queue
and the meta the delivery module rides on, for the same reasons the
timer worker's do. Cancellation never carries a macrostep: it
addresses {scope, invoke_id} across every generation, the same way
timer cancellation addresses every row under a send_id.
perform/1 decodes the stored effect, resolves the handler module the
args carry, calls its run/2 (or its run/1, for a handler that
defines only that arity) - the host's actual work, at least once,
idempotent on invoke_id by that module's own contract - and hands
the result to the job's StatifierOban.Invoke.Delivery module (from
the meta written at enqueue time; absent meta falls back to the
documented default, StatifierOban.Invoke.Delivery.Session), which
owes the run-liveness check before any completion is fed back. The
outcomes map onto Oban states so each is observable on the job row:
- work done and delivered -> the job completes (
:ok); run/1returned{:fan_out, items}-> the invocation is N children rather than an answer:StatifierOban.Invoke.FanOutenqueues one start job per item and the job completes without delivering, leaving the invocation open for the settlement side to answer once (ADR-0007). An emptyitemslist is the exception: it is a successful fan-out over nothing (sb-ADR-0009decision 8), so no start job goes out and the job answers the invocation with[]itself, exactly as it answers with arun/1result. A fan-out refused before any child starts - over the cap, or a list that is not one - cancels with{:fan_out_refused, refusal}and deliverserror.communication.invoke.<invoke_id>on the way past; one that could not be scheduled right now retries;- the run is not live -> the job cancels with
{:discarded, reason}recorded - a completed invoke against a dead or halted run is discarded the same way a fired timer is; run/1returns{:error, reason}-> the job retries with{:run_failed, reason}recorded - the work is idempotent oninvoke_idby contract, so retrying is what at-least-once means; a raise or exit out ofrun/1(or the delivery module) retries the same way;- the attempt that retries is the last one (
attempthas reachedmax_attempts, so Oban will discard rather than retry) -> the same job outcome as above, plusStatifierOban.Invoke.Delivery.deliver_failure/3on the way past, feedingerror.communication.invoke.<invoke_id>into the run behind the same liveness check a completion goes through (st-ADR-0068, ADR-0005); - an undecodable row cancels with
{:undecodable, reason}- no number of retries makes a corrupt row decodable - and deliversStatifierOban.Invoke.Delivery.deliver_failure/3on the way past, through the same liveness-checked door, whenever the row still yields the two plain-string identity fields; a row whosescopeorinvoke_idare themselves undecodable names nobody to tell, so it cancels with no delivery, exactly as it always did; - a codec named on the row that this node cannot resolve, or one that
cannot decode the row right now, returns
{:error, {:invalid_codec, _}}or{:error, {:codec_failed, _}}and retries - an environment fact, fixable by a deploy or by making the key available, not a fact about the row; - a handler or delivery module that cannot be resolved returns
{:error, {:invalid_handler, _}}/{:error, {:invalid_delivery, _}}and retries - environment facts about the host's code, fixable by a deploy, unlike the row facts above.
Failure classes
The :reason string on a failure delivery is this package's
vocabulary to choose: st-ADR-0068 fixes the event and the payload
shape but interprets neither. Four classes are emitted - the two ways
run/1 can exhaust its retries, the one way a job is over before
run/1 is ever reached, and the one way a fan-out is refused:
"run_failed"- the last attempt returned{:error, reason}.:detailis that reason, inspected."run_crashed"- the last attempt raised or exited.:detailis the exception message, or the exit reason inspected."undecodable"- the stored row could not be rebuilt into an effect, so the job cancels rather than retrying.:detailis the typed decode error, inspected."fan_out_refused"-run/1asked to fan out and the fan-out was refused before any child started, so the job cancels: no retry makes a list shorter than the cap.:detailisStatifierOban.Invoke.FanOut.refusal/0inspected, which carries the count and the cap for the over-the-cap case (ADR-0007 decision 8). It is counts and constants only - the fanned-out list itself never reaches the run this way. The class is ADR-0005's, added to its decision 3 by that record's 2026-09-05 Note.
:attempts is the job's attempt on the try that gave up. For the
two run/1 classes that is the terminal attempt, which equals
max_attempts; for "undecodable" and "fan_out_refused" it is the
attempt that found the fault, because that attempt cancels and there
is no later one.
Of the failures that are not about the row, only run/1's own
exhaustion delivers. The environment errors above
(:invalid_handler, :invalid_delivery, :invalid_codec,
:codec_failed) retry and can in principle exhaust too, but they say
nothing about the invocation - they say the deploy is wrong - and
:invalid_delivery has by definition no seam to deliver through.
ADR-0005 records that limit rather than leaving it to be inferred.