StatifierPersistence.Driver (StatifierPersistence v0.2.0)

Copy Markdown View Source

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:

  1. one StatifierPersistence.Runs entry point - the durable step, with the run's whole fetch-to-persist tail inside its serialization strategy;
  2. every non-lifecycle effect through the host's :effects executor, in list order, exactly as Runs already hands them over;
  3. every {:invoke, _} effect also through the host's :dispatch fun, synchronously, inside that same tail;
  4. after the tail has returned - never inside it - one further Runs.step/5 per answer, in the order the calls were made, each of which can produce answers of its own;
  5. 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 :dispatch becomes done.invoke.<invoke_id>, carrying donedata as its data, its invokeid, and C.1's origin/origintype pair.
  • {:error, failure} becomes error.communication.invoke.<invoke_id>, whose data is st-ADR-0068's three string keys - "reason" (default "unknown"), "attempts" and "detail" (both :undefined when the host supplies none, never nil) - built from the same failure keyword list failed_invocation/3 reads.

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).

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.

What one drive returns: the last durable step's own result.

t()

Functions

Creates the run under run_id and drives it 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

dispatch()

@type dispatch() :: (type :: String.t() | nil,
               params :: term(),
               context :: StatifierPersistence.Executor.context() ->
                 {:ok, term()} | {:error, keyword()})

Performs one <invoke> and answers it, synchronously, inside the durable step that emitted it.

Receives the element's own type and resolved params (Statifier.Effect.Invoke.t/0's fields) plus the executor's context - the run id and the chart's content hash. {: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".

result()

@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}.

t()

@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

create(driver, run_id, opts \\ [])

@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:.

new(store, machine, opts)

@spec new(
  store :: StatifierPersistence.Storage.t(),
  machine :: Statifier.Machine.t(),
  opts :: keyword()
) :: t()

Builds a driver over store and machine.

opts:

  • dispatch: (required) - the dispatch/0 fun every <invoke> is performed through.
  • effects: - a StatifierPersistence.Executor.t/0 handed 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 to nil, "the host wants none of them"; an {:error, reason} from it re-enters the chart as error.communication exactly as it does through StatifierPersistence.Runs directly.
  • invoke_types: - the Statifier.Invoke.Types.t/0 snapshot 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 to nil, "the built-in set only".
  • serialization: - the {module, config} per-run strategy every entry point runs inside (ADR-0004 decision 5). Defaults to whatever StatifierPersistence.Runs defaults to, the adapter's own lock_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.

send_event(driver, run_id, event, opts \\ [])

@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.