StatifierOban.Invoke.Worker (StatifierOban v0.9.1)

Copy Markdown View Source

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/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;
  • 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/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 StatifierOban.Invoke.Delivery.deliver_failure/3 on the way past, feeding error.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 delivers 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 - 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}. :detail is that reason, inspected.
  • "run_crashed" - the last attempt raised or exited. :detail is 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. :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 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 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.