Run-to-quiescence over StatifierPersistence.Runs: the loop that answers
the chart's <invoke> calls and keeps stepping until it stops asking.
StatifierPersistence.Runs steps a run once. That is the durable unit
and it is deliberately small - load, step, hand the effects to an
executor, persist - but it is not what a host wants to call. A chart that
invokes a service is not finished when the step that emitted the
<invoke> returns: it is waiting for an answer it has no way to fetch
for itself. Every host that has embedded this package has written the
same loop on top - step, collect the calls, perform them, feed each
answer back, step again - and every hand-written copy of it is a place
where a durable run can quietly stop meaning what the same chart means
under Statifier.Session.
This module is that loop, with the event construction taken from
Statifier.Session's own rather than reinvented beside it.
What one drive does
One call to create/3 or send_event/4 is:
- one
StatifierPersistence.Runsentry point - the durable step, with the run's whole fetch-to-persist tail inside its serialization strategy; - every non-lifecycle effect through the host's
:effectsexecutor, in list order, exactly asRunsalready hands them over; - every
{:invoke, _}effect also through the host's:dispatchfun, synchronously, inside that same tail; - after the tail has returned - never inside it - one further
Runs.step/5per answer, in the order the calls were made, each of which can produce answers of its own; - repeat from 4 until no answer is left. The result is the last step's own result.
The ordering in 3 and 4 is forced rather than stylistic. Dispatch runs inside the tail because a call the chart made and a call the host performed have to be the same event in the same durable step; stepping runs outside it because the tail is already inside the run's serialization strategy, and a step issued from within would ask for exclusion its own caller is holding.
Answers are events, and they are Session's events
Statifier.Session gives a handler-backed invocation's host exactly two
doors: Statifier.Session.done_invocation/3 and, per st-ADR-0068,
Statifier.Session.failed_invocation/3. Both build an external event
and enqueue it. This module builds the same two events, field for field,
from the same Statifier.Evaluator.SystemVariables writers:
{:ok, donedata}from:dispatchbecomesdone.invoke.<invoke_id>, carryingdonedataas its data, itsinvokeid, and C.1'sorigin/origintypepair.{:error, failure}becomeserror.communication.invoke.<invoke_id>, whose data is st-ADR-0068's three string keys -"reason"(default"unknown"),"attempts"and"detail"(both:undefinedwhen the host supplies none, nevernil) - built from the samefailurekeyword listfailed_invocation/3reads.
origin is #_scxml_<session id>, and the session id comes from the
run's own persisted _sessionid (spec 5.10, st-ADR-0008), which
Statifier.Position carries in the datamodel across a restart. A
resumed run therefore answers with the same origin the run started
with, on a node that has never seen it before.
The error arm is permanent failure, in st-ADR-0068's sense: the host's
retry policy is exhausted and no done.invoke will follow. A transient
failure is the host's to retry inside :dispatch before answering, not
something to report to the chart.
What is not answered
A buffered answer is dropped rather than delivered when its invocation
is no longer live by the time its turn comes - spec 6.4.3's drain-time
discard, read off machine_state.active_invocations the same way
Statifier.Interpreter reads it. A step that cancelled an invocation
therefore takes that invocation's answer with it, which is what a
session does.
<invoke type="scxml"> is handed to :dispatch like any other type,
and this module has no opinion about it. A durable driver holds no child
session between steps, so a subchart is the host's to answer or refuse;
durable subcharts are deliberately out of scope here (campaign-023
ruling R-e).
Invocations answered later
A host whose service does not answer inside the drive - an enqueued job,
a webhook, anything that outlives the process that started it - answers
:pending from :dispatch instead. The call has been started; nothing
is buffered for it; the drive rests and the position persists with the
invocation still live in machine_state.active_invocations. There is no
process holding the run in the meantime, which is the point: the run can
wait days and survive a deploy.
The answer arrives later through done_invocation/5 or
failed_invocation/5 - the two doors Statifier.Session gives a live
session's host, on the durable path and keyed by the same
invoke_id. They build the same two events the in-drive path builds and
drive the run from them, so a chart cannot tell which way its answer
came.
The cancel-versus-completion race
An invocation the chart cancels while its call is still running has an
answer coming for something that is no longer live - across a restart,
on a node that has never seen the run. The liveness read that settles it
is active_invocations, which Statifier.Position persists and
Statifier.Interpreter.ExitEntry empties when the invoking state is
exited, and it is taken inside the run's serialization strategy: the
door hands StatifierPersistence.Runs.step/5 an event builder rather
than an event, and the builder reads the loaded position under the same
exclusion the step itself holds. A check taken before the call would
leave a window for a cancel to land between the read and the step.
A cancelled invocation's answer is {:discarded, run} - spec 6.4.3's
discard again, the same rule the in-drive loop applies at drain time -
and the chart never sees it.
This makes re-entry idempotent for the ordinary chart, which transitions
out of the invoking state on its answer: the second delivery finds the
invocation gone. It does not make it idempotent for a chart that stays
in the invoking state after answering, because the core removes an entry
from active_invocations on exit and on nothing else. That is the
in-drive path's behavior too, not something the doors introduce, and it
is where a host's own delivery-once discipline belongs (ADR-0007).
Bounding the loop
A chart whose answer re-arms the call it answered would drive forever.
:max_turns (default 1000) bounds the answer-fed steps in one drive and
returns {:error, {:turns_exhausted, max_turns}} when it is reached.
The run is durable and quiescent at that point - every step that ran,
persisted - so the error names a loop this driver refused to keep
turning, not a lost position.
Example
driver =
StatifierPersistence.Driver.new(store, machine,
dispatch: fn type, params, _context -> MyApp.perform(type, params) end,
effects: fn effect, _context -> MyApp.Timers.consume(effect) end,
invoke_types: Statifier.Invoke.Types.new(types: ["myapp:authorize"]),
serialization: {MyApp.RunLock, MyApp.RunLock}
)
{:ok, run, machine_state} = StatifierPersistence.Driver.create(driver, run_id)
{:ok, run, machine_state} =
StatifierPersistence.Driver.send_event(driver, run_id, Statifier.Event.external("go"))
Summary
Types
Performs one <invoke> and answers it - synchronously inside the durable
step that emitted it, or later through this module's re-entry doors.
What dispatch/0 receives as its third argument: the executor's own
context - the run id and the chart's content hash - plus invoke_id,
this invocation's id.
What one drive returns: the last durable step's own result.
Functions
Creates the run under run_id and drives it to quiescence.
Answers a :pending invocation with donedata and drives the run to
quiescence.
done_invocation/5's failing counterpart: answers a :pending
invocation with a permanent failure and drives the run to quiescence.
Builds a driver over store and machine.
Delivers one external event to the run under run_id and drives it to
quiescence.
Types
@type dispatch() :: (type :: String.t() | nil, params :: term(), context :: dispatch_context() -> {:ok, term()} | {:error, keyword()} | :pending)
Performs one <invoke> and answers it - synchronously inside the durable
step that emitted it, or later through this module's re-entry doors.
Receives the element's own type and resolved params
(Statifier.Effect.Invoke.t/0's fields) plus a dispatch_context/0.
{:ok, donedata} answers done.invoke.<invoke_id> with donedata;
{:error, failure} answers error.communication.invoke.<invoke_id> with
st-ADR-0068's failure keyword list (:reason, :attempts, :detail),
and means permanently failed, not "try again".
:pending is the asynchronous arm: the call has been started and will
be answered later, by done_invocation/5 or failed_invocation/5, from
whatever process - or whatever node, after whatever restart - eventually
has the result. Nothing is buffered for it and the drive rests, so the
run reaches quiescence and persists with the invocation still live.
@type dispatch_context() :: %{ run_id: String.t(), content_hash: String.t(), invoke_id: String.t() }
What dispatch/0 receives as its third argument: the executor's own
context - the run id and the chart's content hash - plus invoke_id,
this invocation's id.
invoke_id is here and not in StatifierPersistence.Executor.context/0
because it is not a property of the run or the step: it names one
<invoke>, and only the dispatch fun is called per invocation. It is
what an asynchronous host keys its job by, and the same string
done_invocation/5 and failed_invocation/5 take back.
@type result() :: {:ok, StatifierPersistence.Run.t(), Statifier.MachineState.t()} | {:discarded, StatifierPersistence.Run.t()} | {:error, StatifierPersistence.Runs.error() | {:turns_exhausted, pos_integer()}}
What one drive returns: the last durable step's own result.
{:ok, run, machine_state} for a run that reached quiescence with
nothing left to answer, {:discarded, run} for an event delivered to a
terminal run, and the error arms of StatifierPersistence.Runs plus
this module's own {:turns_exhausted, max_turns}.
@type t() :: %StatifierPersistence.Driver{ dispatch: dispatch(), effects: StatifierPersistence.Executor.t() | nil, invoke_types: Statifier.MachineState.invoke_types(), machine: Statifier.Machine.t(), max_turns: pos_integer(), serialization: {module(), term()} | nil, store: StatifierPersistence.Storage.t() }
Functions
@spec create( driver :: t(), run_id :: StatifierPersistence.Runs.run_id(), opts :: keyword() ) :: result()
Creates the run under run_id and drives it to quiescence.
StatifierPersistence.Runs.create/4 with this driver's executor, then
the answer loop. opts takes everything create/4 takes except
executor:, which this module supplies - initialize:, metadata:,
routes:, and per-call overrides of the driver's own invoke_types:
and serialization:.
@spec done_invocation( driver :: t(), run_id :: StatifierPersistence.Runs.run_id(), invoke_id :: String.t(), donedata :: term(), opts :: keyword() ) :: result()
Answers a :pending invocation with donedata and drives the run to
quiescence.
Statifier.Session.done_invocation/3's door on the durable path: it
builds the same done.invoke.<invoke_id> event, from the run's own
persisted _sessionid, and steps it. invoke_id is the <invoke>
element's id - the invoke_id :dispatch was handed in its
dispatch_context/0.
Answering an invocation the chart has since cancelled is
{:discarded, run}, spec 6.4.3's discard, decided from the loaded
position inside the run's serialization strategy (the moduledoc's
cancel-versus-completion section). So is answering a terminal run.
The answer can re-arm calls of its own; they are dispatched and driven
exactly as send_event/4 drives them, :pending included.
opts takes what send_event/4 takes.
@spec failed_invocation( driver :: t(), run_id :: StatifierPersistence.Runs.run_id(), invoke_id :: String.t(), failure :: keyword(), opts :: keyword() ) :: result()
done_invocation/5's failing counterpart: answers a :pending
invocation with a permanent failure and drives the run to quiescence.
Statifier.Session.failed_invocation/3's door on the durable path,
building the same error.communication.invoke.<invoke_id> event from
st-ADR-0068's failure keyword list (:reason, :attempts,
:detail). Permanent in that record's sense: the host's retry policy is
exhausted and no done.invoke will follow. A transient failure is the
host's to retry before answering, not something to report to the chart.
Discards, re-armed calls and opts are done_invocation/5's.
@spec new( store :: StatifierPersistence.Storage.t(), machine :: Statifier.Machine.t(), opts :: keyword() ) :: t()
Builds a driver over store and machine.
opts:
dispatch:(required) - thedispatch/0fun every<invoke>is performed through.effects:- aStatifierPersistence.Executor.t/0handed every non-lifecycle effect before the invoke dispatch, for the effects the host observes or persists itself (a<send delay=...>becoming a durable timer, a trace becoming a feed row). Defaults tonil, "the host wants none of them"; an{:error, reason}from it re-enters the chart aserror.communicationexactly as it does throughStatifierPersistence.Runsdirectly.invoke_types:- theStatifier.Invoke.Types.t/0snapshot stamped on every step. A driver-level default rather than a per-call one because the registered set is fixed for a session's lifetime (st-ADR-0051);routes:, which is not, stays per call. Defaults tonil, "the built-in set only".serialization:- the{module, config}per-run strategy every entry point runs inside (ADR-0004 decision 5). Defaults to whateverStatifierPersistence.Runsdefaults to, the adapter's ownlock_run/3.max_turns:- the answer-fed steps one drive will take before refusing to take another. Defaults to 1000.
Every one of these except dispatch: may be overridden per call by
passing the same key in a create/3 or send_event/4 opts list.
@spec send_event( driver :: t(), run_id :: StatifierPersistence.Runs.run_id(), event :: Statifier.Event.t(), opts :: keyword() ) :: result()
Delivers one external event to the run under run_id and drives it to
quiescence.
StatifierPersistence.Runs.step/5 with this driver's executor, then the
answer loop. An event delivered to a terminal run is that function's own
{:discarded, run}, before any position decode and before any dispatch.