StatifierOban.Invoke.Worker (StatifierOban v0.17.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 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 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 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 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.