# `StatifierOban.Invoke.Worker`
[🔗](https://github.com/riddler/statifier_oban/blob/v0.17.3/lib/statifier_oban/invoke/worker.ex#L1)

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 execution-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/1` returned `{:fan_out, items}` -> the invocation is N children
  rather than an answer: `StatifierOban.Invoke.FanOut` enqueues 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 **empty** `items` list is the exception: it is a
  successful fan-out over nothing (`sb-ADR-0009` decision 8), so no
  start job goes out and the job answers the invocation with `[]`
  itself, exactly as it answers with a `run/1` result. 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 delivers
  `error.communication.invoke.<invoke_id>` on the way past; one that
  could not be scheduled right now retries;
- `run/1` returned `:deferred` -> the work was handed on elsewhere, so
  the job completes **without delivering** and the invocation stays
  open until whoever finishes the work answers it through the host's
  `StatifierOban.Invoke.Delivery` implementation (ADR-0009). Nothing
  in this package waits on that answer;
- the execution is not live -> the job cancels with `{:discarded, reason}`
  recorded - a completed invoke against a dead or halted execution is
  discarded the same way a fired timer is;
- `run/1` returns `{:error, reason}` -> the job retries with
  `{:run_failed, reason}` recorded - the work is idempotent on
  `invoke_id` by contract, so retrying is what at-least-once means; a
  raise or exit out of `run/1` (or the delivery module) retries the
  same way;
- the attempt that retries is the **last** one (`attempt` has reached
  `max_attempts`, so Oban will discard rather than retry) -> the same
  job outcome as above, plus `c:StatifierOban.Invoke.Delivery.deliver_failure/3`
  on the way past, feeding `error.communication.invoke.<invoke_id>`
  into the execution 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 delivers
  `c:StatifierOban.Invoke.Delivery.deliver_failure/3` on the way past,
  through the same liveness-checked door, whenever the row still
  yields the two plain-string identity fields; a row whose `scope` or
  `invoke_id` are 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 by default - environment facts about the host's code,
  fixable by a deploy, unlike the row facts above. Under
  `StatifierOban.Config`'s `:unresolved_handler` `:cancel`, an
  unresolvable **handler** instead cancels with `{:invalid_handler,
  name}` and delivers `error.communication.invoke.<invoke_id>` on the
  way past, once the delivery module itself resolves - an unresolvable
  **delivery** module still retries either way, because there is no
  door to deliver through.

## 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. Five 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, the one way a fan-out is refused, and the one
way an unresolvable handler cancels under the opt-in policy below:

- `"run_failed"` - the last attempt returned `{:error, reason}`.
  `:detail` is that reason, inspected.
- `"run_crashed"` - the last attempt raised, exited, or ran past the
  job's run-time bound. `:detail` is the exception message (for a
  bound, `Oban.TimeoutError`'s), or the exit reason inspected.
- `"undecodable"` - the stored row could not be rebuilt into an
  effect, so the job cancels rather than retrying. `:detail` is the
  typed decode error, inspected.
- `"fan_out_refused"` - `run/1` asked 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. `:detail` is
  `t:StatifierOban.Invoke.FanOut.refusal/0` inspected, 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 execution this way. The class is ADR-0005's, added to
  its decision 3 by that record's 2026-09-05 Note.
- `"invalid_handler"` - the row's handler module did not resolve, and
  `StatifierOban.Config`'s `:unresolved_handler` is `:cancel`, so the
  job cancels rather than retrying. `:detail` is the handler name
  exactly as stored on the row. Under the default `:retry` this class
  is never emitted; the job retries instead (ADR-0005's 2026-09-24
  Amendment).

`: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"`, `"fan_out_refused"` and
`"invalid_handler"` 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 by default. The environment errors above
(`:invalid_handler`, `:invalid_delivery`, `:invalid_codec`,
`:codec_failed`) retry under the default `:unresolved_handler`
`: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. `:unresolved_handler`
`:cancel` is the one opt-in exception: an unresolvable handler cancels
and delivers `"invalid_handler"` instead of retrying, for a host that
wants such a job parked rather than fought through backoff. ADR-0005's
2026-09-24 Amendment records that exception rather than leaving it to
be inferred.

## The run-time bound

A job whose meta carries a bound - the config's `:invoke_timeout`,
written by `StatifierOban.Invoke.Handler` at enqueue - runs the
handler's `run/1` (or `run/2`) in a task linked to the job process
and waits at most that many milliseconds for it. A call that outlives
the bound is killed and the attempt raises `Oban.TimeoutError` from
inside `perform/1`, so it takes the path a raising handler takes: it
retries while attempts remain, and the terminal attempt delivers
`"run_crashed"` on the way past (ADR-0005's 2026-09-24 Note). A raise,
exit or throw inside the task reaches the job process unchanged.

`timeout/1` returns the bound plus 5000 ms, a
backstop that Oban enforces from outside the job process for the work
around the call - the decode before it and the delivery or fan-out
enqueue after it. A job killed
by the backstop delivers nothing, because Oban's kill runs no code in
the worker; the margin exists so the bound inside the worker is the
one that fires. A job with no bound on its row - the `:infinity`
default, and every job stored before the option existed - starts no
task, runs the handler in the job process, and has no timeout.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
