Statifier.Session.Effects (Statifier v2.0.0)

Copy Markdown View Source

Turns the effect list the pure core returns into an ordered list of instructions for Statifier.Session to perform (ADR-0003). Deciding is here, where it is a pure function of the effect list; performing is there, where the process is.

Every effect produces a {:notify, effect} instruction in its original position, so a subscriber sees the whole stream in order - trace effects included, since they are ordinary list members and not a side channel. Effects that also mean something to the session emit their action immediately after their own :notify.

<send> routing

Every <send>/<send_delayed> effect is planned in this order (6.2.4, 6.2.5, C.1). Steps 1 and 2 are a boundary check, not the primary enforcement: ADR-0047 decision 4 keeps them because Statifier.Session.interpret/2 is public (ADR-0029) and an embedder can hand in an effect the core itself never produced, but an effect the core did produce never reaches these two arms - the core already rejected an invalid target or unsupported type in Statifier.Machine.Content.Send before any {:send, _}/{:send_delayed, _} effect was built.

  1. An unsupported type (Statifier.Send.Target.supported_type?/1) -> {:raise, :platform, "error.execution", ...} on the sender's own internal queue. No delivery, no timer.
  2. An unparseable target (Statifier.Send.Target.parse/1 returns {:invalid, _}) -> the same error.execution (6.2.4's "not supported or invalid").
  3. :self (no target) -> {:enqueue_event, event}, delivered straight to the sending session's own external queue.
  4. :internal (#_internal/_internal) -> {:deliver, :internal, event, effect}, resolved by Statifier.Session through Statifier.Interpreter.deliver_internal/5 (ADR-0039).
  5. {:session, _}, :parent, {:invoke, _} -> {:deliver, route, event, effect}; Statifier.Session resolves the route (self-addressing needs no registry, decision 10; everything else is error.communication until a later bead adds one).

A delayed send takes the same route through {:schedule, send_id, delay_ms, route, event, effect} instead of {:enqueue_event, _} / {:deliver, _, _, _} - the type/target checks above still run at plan time (6.2.3: arguments are evaluated when <send> is evaluated, not when the message is dispatched), but the route itself is only resolved when the timer fires.

<invoke> routing

plan_invoke/3 checks type first, mirroring <send>'s own order (6.2.5's unsupported-type check ahead of target resolution): an unregistered type (Statifier.Invoke.Types.registered?/2, judged against the plan context's :invoke_types snapshot - ADR-0051) plans {:raise, :platform, "error.execution", {:invoke, state_index, invoke_index}, []} and nothing else - 3.12.2 puts an unregistered type in error.execution's class ("errors internal to the execution of the document"), the same class <send>'s own unsupported-type check uses, because no communication is attempted at all. A registered type looks up its handler module in the plan context's :invoke_handlers map (Map.get/3, defaulting to Statifier.Invoke.Handler.Scxml - the built-in handler is just the default map entry, so no branch here names scxml specially, ADR-0051 decision 4) and splices module.start/2's own returned instructions into the plan. The built-in handler's start/2 returns {:start_child, invoke, effect} unchanged from what this module used to produce directly - Statifier.Session resolves the source, seeds the child's datamodel, and starts it (ADR-0027 decision 3, ADR-0038).

The plan context

plan/2's second argument is a plain map, not a bare session id: %{session_id: String.t(), invoke_types: Statifier.Invoke.Types.t() | nil, invoke_handlers: %{String.t() => module()}, invocation_types: %{String.t() => String.t()}} (ADR-0051 decisions 2, 4, and 6). session_id is what every plan_send/3 / plan_send_delayed/3 call used to receive directly; invoke_types is the caller-declared registered set plan_invoke/3 judges against, read off the same %MachineState{} the core was stamped with, so the planner's answer and the core's maybe_record_active_invocation/5 answer cannot drift (ADR-0047 decision 4); invoke_handlers is the per-session handler dispatch map plan_invoke/3 looks a registered type's module up in, and is also exactly the ctx argument every Statifier.Invoke.Handler planning callback receives; invocation_types is the live invoke_id => type snapshot plan_one/2's :cancel_invoke/:autoforward arms look a tracked invocation's own type up in, before doing the same invoke_handlers dispatch plan_invoke/3 does at start time (decision 6). Both Statifier.Session and Statifier.Replay build this map from the %MachineState{}/session state they already hold before calling plan/2, deriving invoke_types, invoke_handlers, and invocation_types from the same source so none of the three can diverge (ADR-0051 decision 3's "one constructor").

:autoforward and :cancel_invoke route through the owning invocation's own handler (ADR-0051 decision 6), the same dispatch plan_invoke/3 uses for start/2: the plan context's :invocation_types map (built by Statifier.Session/Statifier.Replay from the live invocation table, Statifier.Session.Invocations.types/1) answers invoke_id's own type, looked up in :invoke_handlers the same way, defaulting to Statifier.Invoke.Handler.Scxml when invoke_id names nothing tracked - an invocation the built-in handler started (which never records its own type, since its dispatch already defaults to it), or one that is no longer live at all (a cancel_invoke/autoforward naming a dead/never-started invocation). The built-in handler's cancel/2 and forward/3 return exactly the {:stop_child, invoke_id}/{:forward, invoke_id, event} instructions this module used to emit directly, so dispatching through it changes nothing observable for type=scxml.

:autoforward's dispatch carries no type or target check of its own beyond the handler lookup, since the effect is the core's own decision about an invocation this session started, not a <send> with author-written attributes. The built-in handler's {:forward, invoke_id, event} instruction has Statifier.Session look invoke_id up in its invocation table and forward event unmodified (6.4.2's "All the fields specified in 5.10.1 ... MUST have the same values in the forwarded copy"); a miss, or a handler-backed entry with no pid to deliver to, is a silent no-op, not an error (6.4.3's MUST-ignore for a cancelled invocation).

:cancel_invoke's dispatch is unconditional for the same reason :autoforward's is: this is the core's own reaction to a state exiting while one of its <invoke>s is still live, not an author-addressed element. The built-in handler's {:stop_child, invoke_id} instruction has Statifier.Session pop the table entry and, when it held a pid, demonitor and cancel the child (6.4.3); a miss, or an entry with no pid, is a silent no-op - the invocation having already been popped by its own :DOWN or a prior cancel, or never having had a child process at all.

Summary

Types

The pure fold's context (ADR-0051 decisions 2, 4, and 6) - see the moduledoc's "The plan context" section, and Statifier.Invoke.Handler.t:ctx/0, which this is (plus invocation_types, a key no handler callback reads - see its own doc below). session_id is the sending session's own id (spec 5.10's _sessionid); invoke_types is the caller-declared registered-type snapshot plan_invoke/3 judges against, or nil for "no declaration made"; invoke_handlers is the per-session <invoke type> => module dispatch map plan_invoke/3 looks a registered type's handler up in; invocation_types is the live invoke_id => type snapshot plan_one/2's :cancel_invoke/ :autoforward arms judge against before doing the same invoke_handlers dispatch.

One instruction for Statifier.Session to perform.

Which internal-queue writer {:raise, ...} should use - Statifier.Interpreter.deliver_internal/5's own kind.

Functions

Plans effects, the core's own order preserved, into the instructions Statifier.Session performs. context is the plan context (context/0

Types

context()

@type context() :: %{
  session_id: String.t(),
  invoke_types: Statifier.Invoke.Types.t() | nil,
  invoke_handlers: %{required(String.t()) => module()},
  invocation_types: %{required(String.t()) => String.t()}
}

The pure fold's context (ADR-0051 decisions 2, 4, and 6) - see the moduledoc's "The plan context" section, and Statifier.Invoke.Handler.t:ctx/0, which this is (plus invocation_types, a key no handler callback reads - see its own doc below). session_id is the sending session's own id (spec 5.10's _sessionid); invoke_types is the caller-declared registered-type snapshot plan_invoke/3 judges against, or nil for "no declaration made"; invoke_handlers is the per-session <invoke type> => module dispatch map plan_invoke/3 looks a registered type's handler up in; invocation_types is the live invoke_id => type snapshot plan_one/2's :cancel_invoke/ :autoforward arms judge against before doing the same invoke_handlers dispatch.

instruction()

@type instruction() ::
  {:notify, Statifier.Effect.t()}
  | {:enqueue_event, Statifier.Event.t()}
  | {:deliver, Statifier.Send.Target.route(), Statifier.Event.t(),
     Statifier.Effect.t()}
  | {:raise, raise_kind(), name :: String.t(), Statifier.Event.Cause.origin(),
     keyword()}
  | {:schedule, send_id :: String.t() | nil, delay_ms :: non_neg_integer(),
     Statifier.Send.Target.route(), Statifier.Event.t(), Statifier.Effect.t()}
  | {:cancel_timers, send_id :: String.t()}
  | {:start_child, Statifier.Effect.Invoke.t(), Statifier.Effect.t()}
  | {:forward, invoke_id :: String.t(), Statifier.Event.t()}
  | {:stop_child, invoke_id :: String.t()}
  | {:handler, module(), term()}
  | {:unroutable, Statifier.Effect.t()}
  | {:halt, :done | :budget_exhausted}

One instruction for Statifier.Session to perform.

raise_kind()

@type raise_kind() :: :internal | :platform

Which internal-queue writer {:raise, ...} should use - Statifier.Interpreter.deliver_internal/5's own kind.

Functions

plan(effects, context)

@spec plan(effects :: [Statifier.Effect.t()], context :: context()) :: [instruction()]

Plans effects, the core's own order preserved, into the instructions Statifier.Session performs. context is the plan context (context/0

  • see the moduledoc's "The plan context" section): session_id is the sending session's own id (spec 5.10's _sessionid), needed to build a delivered event's origin; invoke_types is the registered-type snapshot plan_invoke/3 judges against, invoke_handlers is the dispatch map it looks a registered type's handler module up in, and invocation_types is the live snapshot :cancel_invoke/:autoforward judge a tracked invocation's own type against before the same dispatch. :log, :datamodel_change, :datamodel_init, and :trace effects plan to nothing but their own {:notify, effect}.