Configuration for running Statifier effects on a host-supplied Oban instance.
Per ADR-0002, this package never owns, starts, or names an Oban instance:
the host supplies its own instance's name and every public entry point in
this package takes it from here. There is no default - not even Oban's own
default name Oban - so a missing instance is a configuration error at
the call site, never a silent fallback into whatever instance happens to
be running.
Queues are the host's for the same reason (ADR-0002 fixes instance
ownership and says each job kind's queue travels here as a further
field). :timers_queue names the host queue that delayed-send timer
jobs target; it is required with no default, so a job never falls back
silently into a host's :default queue.
:invoke_queue names the host queue that invoke-handler jobs
(StatifierOban.Invoke.Worker) target. It is optional because a
timers-only host has no invoke jobs to queue - but it has no default
either: a host whose handlers are built on
StatifierOban.Invoke.Handler gets {:error, {:missing_option, :invoke_queue}} from the first perform/2 rather than a silent
fallback queue.
The delivery seams are the options with defaults, and each default is a
documented choice rather than a fallback: :delivery (the
execution-liveness seam fired timers go through,
StatifierOban.Timer.Delivery) and
:invoke_delivery (the seam a completed invoke's done.invoke goes
through, StatifierOban.Invoke.Delivery) both default to their
Statifier.Session-backed check, which is correct for any host running
sessions with the session id as scope. A host answering liveness from
its own execution store supplies its implementations here.
Examples
iex> StatifierOban.Config.new(oban: MyApp.Oban, timers_queue: :statifier_timers)
{:ok, %StatifierOban.Config{oban: MyApp.Oban, timers_queue: :statifier_timers}}
iex> StatifierOban.Config.new(oban: MyApp.Oban)
{:error, {:missing_option, :timers_queue}}
iex> StatifierOban.Config.new([])
{:error, {:missing_option, :oban}}
iex> StatifierOban.Config.new(oban: MyApp.Oban, timers_queue: :t, queue: :timers)
{:error, {:unknown_options, [:queue]}}
iex> StatifierOban.Config.new(oban: MyApp.Oban, timers_queue: :t, delivery: MyApp.RunStore)
{:ok, %StatifierOban.Config{oban: MyApp.Oban, timers_queue: :t, delivery: MyApp.RunStore}}
iex> StatifierOban.Config.new(oban: MyApp.Oban, timers_queue: :t, delivery: "MyApp.RunStore")
{:error, {:invalid_option, :delivery, "MyApp.RunStore"}}
iex> StatifierOban.Config.new(oban: MyApp.Oban, timers_queue: :t, invoke_queue: :statifier_invokes)
{:ok, %StatifierOban.Config{oban: MyApp.Oban, timers_queue: :t, invoke_queue: :statifier_invokes}}
iex> StatifierOban.Config.new(oban: MyApp.Oban, timers_queue: :t, invoke_queue: 42)
{:error, {:invalid_option, :invoke_queue, 42}}
iex> StatifierOban.Config.new(oban: MyApp.Oban, timers_queue: :t, opaque_codec: MyApp.ArgsCodec)
{:ok, %StatifierOban.Config{oban: MyApp.Oban, timers_queue: :t, opaque_codec: MyApp.ArgsCodec}}
iex> StatifierOban.Config.new(oban: MyApp.Oban, timers_queue: :t, opaque_codec: "MyApp.ArgsCodec")
{:error, {:invalid_option, :opaque_codec, "MyApp.ArgsCodec"}}
iex> StatifierOban.Config.new(oban: MyApp.Oban, timers_queue: :t, child_starter: MyApp.Starter)
{:ok, %StatifierOban.Config{oban: MyApp.Oban, timers_queue: :t, child_starter: MyApp.Starter}}
iex> StatifierOban.Config.new(oban: MyApp.Oban, timers_queue: :t, child_starter: "MyApp.Starter")
{:error, {:invalid_option, :child_starter, "MyApp.Starter"}}
iex> StatifierOban.Config.new(oban: MyApp.Oban, timers_queue: :t, max_fan_out: 50)
{:ok, %StatifierOban.Config{oban: MyApp.Oban, timers_queue: :t, max_fan_out: 50}}
iex> StatifierOban.Config.new(oban: MyApp.Oban, timers_queue: :t, max_fan_out: 0)
{:error, {:invalid_option, :max_fan_out, 0}}
iex> StatifierOban.Config.new(oban: MyApp.Oban, timers_queue: :t, invoke_timeout: 30_000)
{:ok, %StatifierOban.Config{oban: MyApp.Oban, timers_queue: :t, invoke_timeout: 30_000}}
iex> StatifierOban.Config.new(oban: MyApp.Oban, timers_queue: :t, timer_timeout: 0)
{:error, {:invalid_option, :timer_timeout, 0}}
iex> StatifierOban.Config.new(oban: MyApp.Oban, timers_queue: :t, unresolved_handler: :cancel)
{:ok, %StatifierOban.Config{oban: MyApp.Oban, timers_queue: :t, unresolved_handler: :cancel}}
iex> StatifierOban.Config.new(oban: MyApp.Oban, timers_queue: :t, unresolved_handler: :park)
{:error, {:invalid_option, :unresolved_handler, :park}}The :opaque_codec seam (ADR-0002 stance)
:opaque_codec is optional and defaults to nil - identity, today's
plain t2b64 encoding, unchanged. A host that wants a transform over
the bytes of the four host-opaque job-arg fields (a timer's data and
caller_context, an invoke's params and content) names a module
implementing StatifierOban.OpaqueTerm.Codec here. As with :oban,
:delivery, and every other seam this module owns, there is no ambient
or application-env fallback: a host that wants the transform states it
in the keyword list handed to new/1, explicitly, every time.
The fan-out options (ADR-0007)
:child_starter is the third seam and the fan-out half's own: the
module implementing StatifierOban.Invoke.ChildStarter that a child
start job calls to create child i of the invocation. It is optional
and defaults to nil, because a host with no core.map in any of its
charts has no children to start - and it has no default module either,
for the reason :invoke_queue has none: this package does not create
executions, so there is nothing here to fall back to. A fan-out on a
config without one is refused on the invocation's error route rather
than starting nothing silently.
:max_fan_out is the runtime cap ADR-0007 decision 8 sends to the
host, given a number here at last: a positive integer, defaulting to
1_000, checked by the fan-out before the first child start. A
fan-out wider than the cap starts no children at all and fails the
invocation on error.communication.invoke.<invoke_id> with the count
and the cap in detail. The default is the largest N the family's
records have reasoned about rather than a measurement of any
particular deployment; a host that measures its own raises or lowers
it here.
The run-time bounds
:invoke_timeout, :child_start_timeout and :timer_timeout bound
how long one attempt of each job kind may run: an invoke job
(StatifierOban.Invoke.Worker), a fan-out child start job
(StatifierOban.Invoke.ChildStartWorker) and a fired-timer job
(StatifierOban.Timer.Worker). Each is a positive integer of
milliseconds or :infinity, and each defaults to :infinity - no
bound, exactly what the workers did before the options existed.
A bound is capped by the largest timeout the BEAM accepts,
4_294_967_295 ms (just under 50 days): :child_start_timeout and
:timer_timeout may be at most that, and :invoke_timeout at most
that less the invoke worker's 5000 ms backstop margin, 4_294_962_295.
A larger integer is rejected as an invalid option.
A bound is fixed at enqueue time: the enqueue site writes it into the
job's meta, and the worker's timeout/1 reads it back off the row, so
a job stored before a host changes its bound keeps the bound it was
stored with, and a job with no bound on its row runs unbounded. An
attempt that runs past its bound fails like any other failed attempt
and retries while attempts remain.
The invoke bound is also enforced inside the worker, around the
handler's run/1 (or run/2): the call runs in a task linked to the
job process, and an attempt that outlives the bound fails with
Oban.TimeoutError raised from inside perform/1, so a timed-out
terminal attempt still delivers error.communication.invoke.<invoke_id>
with the "run_crashed" class (ADR-0005). timeout/1 on that worker
returns the bound plus a fixed margin, as a backstop for the delivery
that follows the call. With the default :infinity no task is started
and run/1 runs in the job process, as it always has.
The unresolvable-handler policy
:unresolved_handler decides what an invoke job does when the handler
module named on its row does not resolve - renamed, removed, or simply
not yet deployed to this node. It takes :retry (the default) or
:cancel; any other value is rejected by new/1.
Under the default :retry, an unresolvable handler behaves exactly as
it always has: the attempt fails with {:error, {:invalid_handler, name}} and retries while attempts remain, delivering nothing even on
exhaustion (StatifierOban.Invoke.Worker's "Failure classes" section,
ADR-0005 decision 6).
Under :cancel, an unresolvable handler instead cancels the job on the
attempt that finds it, and delivers
error.communication.invoke.<invoke_id> with "reason"
"invalid_handler" and "detail" the handler name exactly as stored,
provided the job's delivery module itself resolves. An unresolvable
delivery module still retries under either value: there is no door
to deliver through. The policy is fixed at enqueue time, from the
config in force when the job was stored: StatifierOban.Invoke.Handler
writes it into the job's meta, and a job stored before the option
existed (or one with a hand-edited row) reads as :retry.
A host that deploys handler code in a separate release from the one
enqueueing invocations is what this exists for: rather than fighting a
retry backoff until the handler ships, :cancel parks the job and
tells the chart at once, so a transition on error.communication can
re-enqueue the invocation once the deploy lands.
Summary
Types
The host-supplied Oban configuration.
A per-attempt run-time bound: milliseconds, or :infinity for none.
The unresolvable-handler policy: :retry (the default) retries an
unresolvable handler to exhaustion and delivers nothing; :cancel
cancels the job on the attempt that finds it and delivers
error.communication.invoke.<invoke_id> with "reason"
"invalid_handler".
Types
@type t() :: %StatifierOban.Config{ child_start_timeout: timeout_bound(), child_starter: module() | nil, delivery: module(), invoke_delivery: module(), invoke_queue: atom() | String.t() | nil, invoke_timeout: timeout_bound(), max_fan_out: pos_integer(), oban: Oban.name(), opaque_codec: module() | nil, timer_timeout: timeout_bound(), timers_queue: atom() | String.t(), unresolved_handler: unresolved_handler() }
The host-supplied Oban configuration.
:oban is the name of the host's Oban instance, as given to
Oban.start_link/1 - anything Oban.name/0 allows. :timers_queue
is the host queue delayed-send timer jobs are inserted into - an atom or
string, exactly as the host names it in its own Oban :queues.
:delivery is the module implementing StatifierOban.Timer.Delivery
that fired timer jobs go through. :invoke_queue is the host queue
invoke-handler jobs are inserted into (nil on a timers-only host), and
:invoke_delivery is the module implementing
StatifierOban.Invoke.Delivery that a completed invoke's done.invoke
goes back through. :opaque_codec is the module implementing
StatifierOban.OpaqueTerm.Codec that the two enqueue sites run the
host-opaque job-arg fields through (nil - the default - is the
identity encoding). :child_starter is the module implementing
StatifierOban.Invoke.ChildStarter that fan-out child start jobs
create their child through (nil on a host with no fan-out), and
:max_fan_out is the positive integer cap on a fan-out's width.
:invoke_timeout, :child_start_timeout and :timer_timeout are the
per-attempt run-time bounds, in milliseconds or :infinity.
:unresolved_handler is :retry (the default) or :cancel - what an
invoke job does when its handler module does not resolve (see the
moduledoc's unresolvable-handler policy section).
@type timeout_bound() :: pos_integer() | :infinity
A per-attempt run-time bound: milliseconds, or :infinity for none.
@type unresolved_handler() :: :retry | :cancel
The unresolvable-handler policy: :retry (the default) retries an
unresolvable handler to exhaustion and delivers nothing; :cancel
cancels the job on the attempt that finds it and delivers
error.communication.invoke.<invoke_id> with "reason"
"invalid_handler".
Functions
Builds a config from the host's options.
:oban and :timers_queue are required; :invoke_queue is optional
with no default (see the moduledoc); :delivery and :invoke_delivery
are optional and default to the Statifier.Session-backed seams;
:opaque_codec is optional and defaults to nil (identity - see the
moduledoc's ADR-0002 stance); :child_starter is optional and defaults
to nil, and :max_fan_out is optional and defaults to 1_000 (see
the moduledoc's fan-out section). :invoke_timeout,
:child_start_timeout and :timer_timeout are optional and default
to :infinity (see the moduledoc's run-time bounds section).
:unresolved_handler is optional and defaults to :retry - the other
accepted value is :cancel (see the moduledoc's unresolvable-handler
policy section). Unknown options are rejected rather than ignored, so
a typo fails loudly instead of silently dropping a setting.