The GenServer effect interpreter (ADR-0003): the outer while running
loop, the waiting external events, the delayed-send timers, and the
fan-out of the effect stream to subscribers. The pure core decides; this
module performs.
This is the one module under lib/statifier/ allowed to do I/O
(lib/mix/statifier/adr_guard.ex's @effect_interpreter_paths), which is
why the deciding half lives in Statifier.Session.Inbox,
Statifier.Session.Timers, and Statifier.Session.Effects - pure values
and a pure function, testable with no process at all - and only the
performing half lives here. A different path or module name fails the
gate.
A bare start_link/2 stays legal; the runtime is opt-in
start_link/2 and the use GenServer-generated child_spec/1 still work
exactly as before: an embedder can place a session directly
in their own supervision tree, and :name is passed to
GenServer.start_link/3 unchanged, so an embedder-owned
{:via, Registry, _} works with no code here. A session started this way
is legal and unregistered - ADR-0027 decision 2 sanctions this outright,
since C.1 leaves "accessible to a given SCXML Processor" platform-defined,
and an unregistered session is an inaccessible one to every other
session (decision 10 below is the one exception, and it is about
self-addressing, not registration).
Statifier.start_session/2 is the alternative that registers: it starts
the session on Statifier.SessionSupervisor under Statifier.Supervisor
(ADR-0027), and init/1 below registers under Statifier.Registry when
that registry is running - never fatal to the session when it is not, so
a bare start_link/2 embedder pays nothing for a runtime it never placed.
use GenServer, restart: :temporary (ADR-0027 decision 4) is written
explicitly, both because the generated child_spec/1 otherwise defaults
to restart: :permanent - which would have a supervisor restart a
session that halted normally, the opposite of the next section - and
because :temporary itself is a decision: a supervisor restart re-runs
start_link/2, which generates a fresh sess_ id and loses every
bit of the crashed session's state, so restarting is actively wrong, not
merely useless. Recovery that preserves identity is replay, not a
restart flag.
:done idles the session; it does not stop it
The process stays alive with the terminal %MachineState{} and the
retained %Effect.Done{} in hand, so snapshot/1 and status/1 still
answer after termination. Stopping is the caller's move (stop/2), or the
supervisor's. Idle means inspectable, not still acting: further events are
queued but never drained, and every pending delayed-send timer is
cancelled at the halt itself (spec 6.2's discard-on-termination -
discard_pending_timers/2 - not deferred to terminate/2, which an
idled process may never reach). The same is
true, with a narrower meaning, for :cancelled (cancel/1 reached the
same exit_interpreter/1 path) and for :budget_exhausted (ADR-0019):
the session neither retries with a larger budget nor stops itself, and
cancel/1 still works from a budget-halted session, since the
underlying %MachineState{} is still running and the inbox's cancel
entry is checked before any drive of the core - a queued ordinary event
is not. One asymmetry: because the interpreter has not exited under
:budget_exhausted, its already-scheduled delayed sends stay armed and
still deliver; they are discarded only when the eventual cancel/1
halts it :cancelled.
One subscriber stream
There is no :owner concept. Subscribers are a monitored set;
start_link/2 takes :subscribers (default []), and
subscribe/2/unsubscribe/2 manage it afterward. Every message a
subscriber receives is {:statifier, session_id, message}, where
message is one of:
{:effect, effect}- every effect the core (or aninterpret/2caller) hands this session, trace effects included, in non-decreasing(macrostep, round)order - the same orderStatifier.Replayproduces for the same recording (ADR-0044 decision 1). An ADR-0039 re-entry crosses the seam at its own instruction's position but its effects are queued and drained after the batch that triggered it, so a subscriber never sees a later round ahead of an earlier one. Trace effects are ordinary list members here too, never a side channel.This is a guarantee about delivery order, not one a subscriber can re-derive from the structs:
roundis carried only by theStatifier.Effect.Trace.*payloads and byStatifier.Effect.BudgetExhaustedtoday, so a mixed stream cannot be sorted back into this order after the fact (ADR-0044 decision 4 leaves stampingroundonto the rest as follow-on work). Take the order as it arrives.A macrostep may carry more than one
Trace.MacrostepStable- one per core drive that reached quiescence - and there is exactly one per(macrostep, round)(ADR-0044 decision 3). Within a macrostep the last one is that macrostep's last quiescent point, which is not always where the macrostep ends: a macrostep that halts ends withTrace.Doneinstead, either after its finalTrace.MacrostepStableor - when the halting drive is the only one- with no
Trace.MacrostepStableof its own at all.
- with no
{:unroutable, effect}- reserved for an effect this session cannot route; every:send/:send_delayed/:invoke/:cancel_invoke/:autoforwardeffect now routes (seeStatifier.Session.Effects's own moduledoc), so no message currently reaches a subscriber this way.{:halted, :done | :cancelled | :budget_exhausted}- one lifecycle message, following the effects that caused it, and the last message this session sends its subscribers for the run (ADR-0044 decision 2).
A late subscriber catches up by replaying the recording, not from any
buffer this session retains (ADR-0049). subscribe/3 with
catch_up: true returns {:ok, recording} - the session's current
Statifier.Session.Recording.t(), snapshotted in the same handle_call
that adds the pid - or {:error, :not_recorded} without adding it, for a
session not started with record: true.
The invariant that makes the split exact: between GenServer callbacks,
Statifier.Replay.run/1 over this session's current recording produces
exactly the messages this session has notified so far. Each callback is
atomic in the effects' terms - it records its input and notifies every
resulting effect, including ADR-0044's deferred re-entry batches drained
before the callback returns, which is why deferred is documented always
[] between callbacks - so a subscribe call, serialized between
callbacks, observes a recording whose replay is precisely the notified
prefix.
The consumption recipe, as it will actually be written:
{:ok, recording} = Statifier.Session.subscribe(session, self(), catch_up: true)
{:ok, %{stream: prefix}} = Statifier.Replay.run(recording)
# every subsequent {:statifier, session_id, message} is the suffixADR-0044 decision 1's monotone-arrival contract holds across the seam: the
prefix is replay's own order and the suffix continues it. A halted session
composes for free - the recording is complete, the replayed stream ends
{:halted, reason}, the live suffix is empty, and Replay.run/1's
status says so, so post-mortem attachment is the same call as late
attachment. A subscriber wanting only the current picture keeps using
snapshot/1/status/1 with a plain subscribe/2; nothing here replaces
them.
A subscriber that dies is dropped on its own :DOWN.
A cancel is a queue entry, not an out-of-band call
cancel/1 enqueues the cancel marker onto this session's own external
inbox (Statifier.Session.Inbox.enqueue_cancel/1), so a cancellation is
recorded in the same ordered queue as every event
(docs/observability.md constraint 6) rather than racing ahead of it
through a side channel. Draining it runs Statifier.Interpreter.cancel/1
- Appendix D's
running = falsefollowed byexitInterpreter()- and halts the session:cancelled, even though the{:done, _}effect that walk produces is still forwarded to subscribers exactly as natural termination's is.
<send> routing
A <send>/<send_delayed> with no target lands on this session's own
external queue. #_internal and a self-addressed #_scxml_<sessionid>
(decision 10: a session is always accessible to itself, registry or not)
both resolve with no registry at all - the former through
Statifier.Interpreter.deliver_internal/5 (ADR-0039), the latter onto
this session's own inbox. Every other #_scxml_<sessionid> resolves
through Registry.lookup(Statifier.Registry, sid) (ADR-0027 decision 2):
a hit casts the event onto that session's inbox via send_event/2; an
empty lookup - the id never existed, named a bare unregistered session,
or named a session that has since died - takes C.1's mandated
error.communication path on the sending session's own internal queue,
through the private communication_error/4 resolver. #_parent/_parent
(6.4.4/C.1's spelling disagreement, decision 8) resolves through
state.invoked_by: a live invocation's child stamps invokeid (5.10.1)
and delivers straight to the parent's external queue; a session that was
never invoked has no parent and takes the same communication_error/4
path as any other unreachable route. #_<invokeid> resolves through the
same invocation table this module holds (Statifier.Session.Invocations,
Invocations.fetch/2) rather than the registry: a live entry's pid gets
the event delivered to its external queue directly, and an invokeid
naming no live invocation - never one, or since cancelled or exited -
takes the same communication_error/4 path as any other unreachable
route. An
unsupported type or an unparseable target raises error.execution the
same way, at plan time. :cancel_invoke plans {:stop_child, invoke_id}
("Cancelling an invocation" below). A child reaching a top-level final
returns done.invoke.<invokeid> to its parent's external queue the same
direct way ("Starting an invocation's child session" below names the
reciprocal obligation this halt-time delivery completes). See
Statifier.Session.Effects and Statifier.Send.Target.
Autoforward delivery
{:autoforward, %Effect.Autoforward{}} plans {:forward, invoke_id, event} unconditionally (Statifier.Session.Effects) - every external
event the parent removes from its queue, forwarded verbatim to each
autoforwarding invocation, at the point 6.4.2 puts it: inside
apply_invoke_passes/2's own turn, before the next Inbox.next/1.
Performing it looks invoke_id up in state.invocations and
send_event/2s the event unmodified; a miss - the invocation was
cancelled or the child died between the core's pass and this instruction -
is a silent no-op, not an error.
Starting an invocation's child session
{:invoke, %Effect.Invoke{}} with a supported type plans
{:start_child, invoke, effect} (Statifier.Session.Effects). This is
never performed from init/1: an initial configuration's own <invoke>
would otherwise start the child through Statifier.start_session/2 while
this session's own start is still being served by the same
Statifier.SessionSupervisor, and that supervisor process cannot answer
the inner call until it has answered the outer one. Every
planned instruction - this one included, at whatever position
Statifier.Session.Effects.plan/2 gave it - performs from
handle_continue/2, one message-loop turn after init/1 returns.
Performing it resolves invoke.content/invoke.src through Statifier.Invoke.Source
(state.invoke_source, the embedder-supplied resolver from start_link/2
ADR-0038 hands off to), seeds the resolved child Machine.t()'s datamodel
through Statifier.Session.Invocations.seed_datamodel/2 (spec 6.4.3's
name-matched <param>/namelist seeding), and starts it on
Statifier.SessionSupervisor via Statifier.start_session/2 with
invoked_by: {self(), invoke.invoke_id}. Success monitors the child and
records {pid, session_id, monitor_ref, autoforward} in
state.invocations; either a resolve failure or a
Statifier.start_session/2 failure raises error.communication on this
session's own internal queue instead (Decision 4: 3.12.2 names <invoke>
outright as a communication-error source), through the private
invoke_error/4 resolver, and writes no table entry - "terminate the
processing of the element without further action."
A session started with invoked_by: {parent_pid, invoke_id} (an invoked
child) monitors parent_pid in turn: the parent's :DOWN stops this
session (a child whose parent is gone has nobody to report to), and a
monitored child's own :DOWN pops its entry from state.invocations -
both checked ahead of the ordinary subscriber :DOWN clause.
start_link/2's :inherit_observers starts a child with this session's
:trace and subscriber pids at the moment it starts (ADR-0050), rather than
leaving observation to a post-hoc attach, because an attach cannot
substitute here: start_link/2 runs Interpreter.initialize/2 to
quiescence, and the child is started from inside the parent's own invoke
pass, so a subscriber added after the child's pid is knowable has already
missed that child's Trace.EntrySet, Trace.ContentExecuted,
Trace.InvokePass, and Trace.MacrostepStable.
Cancelling an invocation
{:cancel_invoke, %Effect.CancelInvoke{}} - the core's own reaction to a
state exiting while one of its <invoke>s is still live - plans
{:stop_child, invoke_id} unconditionally (Statifier.Session.Effects).
Performing it pops invoke_id's table entry before touching the child,
Process.demonitor/2s the ref with [:flush], and calls this module's own
cancel/1 on the child - never stop/2: 6.4.3 requires the cancelled
session to "exit at the end of the next microstep" having "execute[d] the
<onexit> handlers for all active states", which is Interpreter.cancel/1's
own exit_interpreter/1 walk, and GenServer.stop/2 would skip every one
of them. The child is left to idle :cancelled exactly as any cancelled
session does; this session is no longer monitoring it and no longer holds
its id, so it is silent by construction. A miss - the invocation already
popped by its own :DOWN, or a second cancel of the same id - is a silent
no-op.
Popping the entry first is what makes the drain-time discard below correct: any event this child already delivered is un-keyed the instant the pop happens, not only once the cancelled child actually halts.
The discard of a cancelled invocation's queued events
6.4.3: after cancelling, this session "MUST ignore any events it receives
from that session. In particular it MUST NOT ... insert them into the
external event queue of the invoking session" (the doubled "not" is
verbatim in the REC). handle_continue(:drain, _) applies this at the one
point every queued entry passes through it: an {:invoked_event, invoke_id, _} entry whose invoke_id is no longer a key of
state.invocations is dropped and the drain continues, with no separate
retired-id bookkeeping - the live table is already the predicate (the
invoke child-session plan's own Decision 6), and cancelling removes the
key, so every event from that invocation - queued before the cancel or
arriving after it - is dropped this way.
The predicate reads the entry kind, which send_invoked_event/3 sets
only on the child-to-parent direction, and never event.invokeid. 6.4.2
requires an autoforwarded copy to preserve every 5.10.1 field, so an event
this session forwards to one of its children arrives there still carrying
a sibling invocation's invokeid - an id that names nothing in the
receiving session's own table. Keying the discard on that field would drop
exactly the copy 6.4.2 requires be delivered; keying it on where the entry
came from is also what ADR-0027 already says ("every queued entry
originating from a cancelled invokeid").
Statifier.Session.Inbox still needs no keyed discard of its own: the
predicate is applied to Inbox.next/1's result, not inside the queue,
which is what keeps Inbox ignorant of the invocation table even while
it carries the entry's origin.
terminate/2 cancels every entry still in state.invocations alongside
its existing timer cancellation, so an orderly stop of this session leaves
no orphaned children; the child-side parent monitor above already covers
the disorderly case.
Two snapshot shapes
snapshot/1 returns the whole %MachineState{} - the complete, resumable
position tooling and replay need. status/1 returns a small projection
for a caller polling in a loop, since snapshot/1 copies the entire
compiled machine on every call. For a halted session, the projection's
configuration is read from the retained %Effect.Done{} rather than
%MachineState{}.configuration, which exit_interpreter/1 empties by
construction - mirroring the restore test/support/case.ex performs for
the same reason.
interpret/2 is a public seam, not a test hook
Decided by ADR-0029; see its own @doc for the recording contract it
carries.
Recording taps the input clauses, never the inbox
:record (start_link/2) builds a Statifier.Session.Recording.t() that
each of the five input-handling clauses appends one entry to, before that
entry's effects are ever planned or performed - the same "one recordable
input path" the converging-paths comment above handle_info/2's fired-timer
clause already describes. ADR-0029 named the
four inputs a sound recording needs; ADR-0034 decided replay re-derives core
effects and re-injects interpret/2 batches rather than replaying against a
live session, which is why the tap sits on the input side and not on the
effect stream notify/2 fans out. recording/1 reads the value back; see
its own @doc for the ordering caveat on when it is safe to call.
Summary
Types
One live invocation, as invocations/1 reports it: the author-or-core
invoke_id this session knows the invocation by, the child's own sess_
id, and its pid.
What resolve_resume/2 decided: an ordinary start, or a resumed position.
The small status projection status/1 returns, as the counterpart to
snapshot/1's whole %MachineState{}.
subscribe/3 options. catch_up: true asks for the recording snapshot
alongside the subscription; the default is false.
Functions
Enqueues the cancel marker onto this session's external inbox - a queue
entry, not an out-of-band call, so a cancellation is recorded in the same
ordered queue as every event: one queued behind several events is
processed in that order, not ahead of them. Draining it runs
Statifier.Interpreter.cancel/1 - Appendix D's running = false
followed by exitInterpreter() - and halts this session :cancelled.
Returns a specification to start this module under a supervisor.
The door a non-scxml <invoke> handler's host uses when its externally
run service has finished (ADR-0051 decision 5) - the generalization of
what a child Statifier.Session does for itself through
return_done_event/2 when it halts :done. Constructs
done.invoke.<invoke_id> from donedata (spec 6.4's own shape: the
service's <donedata>, or whatever a process-less host's own equivalent
is - 6.4's MUST here is on the service, not this engine, which only
provides the door and documents what arrives through it), stamps
invokeid, and delivers it exactly as send_invoked_event/3 would -
through the same invocation-tagged entry, subject to the same 6.4.3
drain-time discard if invoke_id is no longer live by the time it is
dequeued (a late arrival for an invocation already cancelled). server
is invoke_id's own owning session - the one whose <invoke> started
it, not a child of it, since a handler-backed invocation has no child
session at all; the built-in scxml handler's own completion
(return_done_event/2) calls this the same way, on its own parent.
Hands effects - any list of Statifier.Effect.t() values, from any
driver of the pure core - to this session's own effect-interpretation
path: planned through Statifier.Session.Effects.plan/1 and performed
exactly as the effects this session's own drive of the core produces.
ADR-0003's "Embedders can supply their own effect interpreter", read the
other way: an embedder that drives the core itself can still lean on this
session for timer and routing service. It is also what makes
:send_delayed/:cancel testable end to end before any document can
produce them, and it funnels through the same internal cast path
send_event/2 uses, so it opens no side door around the inbox.
This session's live invocations - one entry per <invoke> whose child
session is still running under it, sorted by invoke_id, and [] for a
session with none. The counterpart to status/1 for the invoke tree: an
observer holding a parent can name each child and subscribe/2 to it, or
recurse with invocations/1 again for a grandchild.
The Statifier.Session.Recording.t() this session has captured so far, or
{:error, :not_recording} if it was not started with record: true.
Note the neighbouring atom: subscribe/3 answers the same condition
with {:error, :not_recorded}. The two are one letter apart and are
deliberately not unified here - each reads correctly in its own sentence
("this session is not recording"; "that material was not recorded") - so
match on the one belonging to the function you called.
Delivers event to this session's external inbox (asynchronously - :ok
is returned before the event is necessarily processed). A plain string is
a convenience over Statifier.Event.external/2 carrying no data; a
caller that needs event data builds the %Statifier.Event{} directly.
Queued behind whatever else is already waiting, and processed in that
order.
Delivers event to server's external queue as an entry originating from
server's own invocation invoke_id - the child-to-parent direction, and
the only direction 6.4.3's "MUST ignore any events it receives from that
[cancelled] session" applies to. send_event/2 is what every other caller
wants, autoforwarded copies included; see Statifier.Session.Inbox's
entry typedoc for why the two are distinct entries rather than one entry
read two ways.
This session's sess_ id - datamodel["_sessionid"], held apart for routing.
The whole %Statifier.MachineState{} this session currently holds - a
complete, resumable position (docs/observability.md constraint 1). A
term copy and nothing more: MachineState carries no pid, ref, port, or
fun.
Starts a session over machine (already compiled - Statifier.compile/1),
running Statifier.Interpreter.initialize/2 to quiescence before
returning. A document that reaches a stable configuration, or even
terminates, before any external event is ever sent is corpus-normal; the
session comes up already :running, :done, or :budget_exhausted
accordingly.
A small status projection - session_id, status, configuration (as
string ids), the three step counters, and the queued-event and
pending-timer counts - for a caller polling in a loop, since snapshot/1
copies the entire compiled machine on every call. For a halted session,
configuration is read from the retained %Statifier.Effect.Done{}
rather than the (by-then-empty) %MachineState{}.configuration.
Stops the session. terminate/2 cancels every outstanding delayed-send
timer before the process exits (spec 6.2's discard-on-termination), so
nothing scheduled is ever delivered after this call returns.
Adds pid to this session's monitored subscriber set. Idempotent - a
pid already subscribed is not monitored twice.
Adds pid to this session's monitored subscriber set, optionally handing
back the material the pid needs to reconstruct what it missed (ADR-0049).
Removes pid from this session's subscriber set. A no-op if it was never in it.
Types
@type invocation() :: Statifier.Session.Invocations.public_entry()
One live invocation, as invocations/1 reports it: the author-or-core
invoke_id this session knows the invocation by, the child's own sess_
id, and its pid.
@type resume() :: :fresh | {:resumed, Statifier.MachineState.t()}
What resolve_resume/2 decided: an ordinary start, or a resumed position.
@type server() :: GenServer.server()
A GenServer server reference - a pid, or whatever :name was started with.
@type status() :: %{ session_id: String.t(), status: :running | :done | :cancelled | :budget_exhausted, configuration: MapSet.t(String.t()), macrostep: non_neg_integer(), microstep: non_neg_integer(), round: non_neg_integer(), queued_events: non_neg_integer(), pending_timers: non_neg_integer() }
The small status projection status/1 returns, as the counterpart to
snapshot/1's whole %MachineState{}.
@type subscribe_opts() :: [{:catch_up, boolean()}]
subscribe/3 options. catch_up: true asks for the recording snapshot
alongside the subscription; the default is false.
Functions
@spec cancel(server :: server()) :: :ok
Enqueues the cancel marker onto this session's external inbox - a queue
entry, not an out-of-band call, so a cancellation is recorded in the same
ordered queue as every event: one queued behind several events is
processed in that order, not ahead of them. Draining it runs
Statifier.Interpreter.cancel/1 - Appendix D's running = false
followed by exitInterpreter() - and halts this session :cancelled.
Returns a specification to start this module under a supervisor.
See Supervisor.
The door a non-scxml <invoke> handler's host uses when its externally
run service has finished (ADR-0051 decision 5) - the generalization of
what a child Statifier.Session does for itself through
return_done_event/2 when it halts :done. Constructs
done.invoke.<invoke_id> from donedata (spec 6.4's own shape: the
service's <donedata>, or whatever a process-less host's own equivalent
is - 6.4's MUST here is on the service, not this engine, which only
provides the door and documents what arrives through it), stamps
invokeid, and delivers it exactly as send_invoked_event/3 would -
through the same invocation-tagged entry, subject to the same 6.4.3
drain-time discard if invoke_id is no longer live by the time it is
dequeued (a late arrival for an invocation already cancelled). server
is invoke_id's own owning session - the one whose <invoke> started
it, not a child of it, since a handler-backed invocation has no child
session at all; the built-in scxml handler's own completion
(return_done_event/2) calls this the same way, on its own parent.
invoke_id's table entry is popped once the delivered event has cleared
the drain that decides whether to discard it - not synchronously here,
which would make the very entry this call is reporting on already look
gone to that same drain-time check (handle_info/2's {:pop_invocation, _} clause below carries the full reasoning). A handler-backed
invocation's entry (ADR-0051 decision 6) has no pid for a :DOWN to pop
on its own, so this call is the only place it is ever removed; calling
this for an invocation this session already popped (a prior cancel, or a
second done_invocation/3 call for the same id) is a harmless no-op both
times - the discard drops the event, and the pop finds nothing.
@spec interpret(server :: server(), effects :: [Statifier.Effect.t()]) :: :ok
Hands effects - any list of Statifier.Effect.t() values, from any
driver of the pure core - to this session's own effect-interpretation
path: planned through Statifier.Session.Effects.plan/1 and performed
exactly as the effects this session's own drive of the core produces.
ADR-0003's "Embedders can supply their own effect interpreter", read the
other way: an embedder that drives the core itself can still lean on this
session for timer and routing service. It is also what makes
:send_delayed/:cancel testable end to end before any document can
produce them, and it funnels through the same internal cast path
send_event/2 uses, so it opens no side door around the inbox.
This widens what a recording has to contain (ADR-0029). The
three-input replay tuple - (machine, initial data, external event log) - reconstructs a run only when every effect this session
interpreted came from Statifier.Interpreter.initialize/2 or
handle_event/2. An interpret/2 call hands the session effects that no
such call produced, so replaying a run that used it needs a fourth
input: each interpret/2 batch, recorded at its position in this
session's serialized input order alongside the event log
(docs/observability.md constraint 6). Calling this function does not
void the replay guarantee - it obligates the recording. That is a
statement about the recording's contents, not a leak in the boundary:
the calls are still ordered, still observable, still on the one input
path.
@spec invocations(server :: server()) :: [invocation()]
This session's live invocations - one entry per <invoke> whose child
session is still running under it, sorted by invoke_id, and [] for a
session with none. The counterpart to status/1 for the invoke tree: an
observer holding a parent can name each child and subscribe/2 to it, or
recurse with invocations/1 again for a grandchild.
An entry is present from the moment the child is started until the
invocation is cancelled or the child exits - the same liveness
#_<invokeid> routing is judged against. A caller reading this against a
running session is reading a value that may already have changed; it is a
snapshot, not a subscription.
A child started before this session opted into :inherit_observers (or
one under a session that never did) has its own subscriber set, so
attaching to it here observes it only from the moment of the
subscribe/2. Catch-up does not close that gap for a child: children
are not started with record: true, so subscribe/3 with
catch_up: true on one answers {:error, :not_recorded} - see
start_link/2's :inherit_observers for why that is not equivalent to
inheriting from the start (ADR-0050 decisions 3 and 6).
@spec recording(server :: server()) :: {:ok, Statifier.Session.Recording.t()} | {:error, :not_recording}
The Statifier.Session.Recording.t() this session has captured so far, or
{:error, :not_recording} if it was not started with record: true.
Note the neighbouring atom: subscribe/3 answers the same condition
with {:error, :not_recorded}. The two are one letter apart and are
deliberately not unified here - each reads correctly in its own sentence
("this session is not recording"; "that material was not recorded") - so
match on the one belonging to the function you called.
Call this only after the run has quiesced relative to whatever this caller
is waiting on - a timer firing arrives as a message with no ordering
guarantee against this call, so a recording/1 issued before an
assert_receive on the effect it produced (or a wait_for_status/3-style
poll) can race the entry it is meant to observe.
A caller that also wants to subscribe should use subscribe/3 with
catch_up: true instead: recording/1 followed by subscribe/2 has a
window between the two calls, while subscribe/3 snapshots and adds the
pid atomically, in the same handle_call (ADR-0049).
@spec send_event(server :: server(), event :: Statifier.Event.t() | String.t()) :: :ok
Delivers event to this session's external inbox (asynchronously - :ok
is returned before the event is necessarily processed). A plain string is
a convenience over Statifier.Event.external/2 carrying no data; a
caller that needs event data builds the %Statifier.Event{} directly.
Queued behind whatever else is already waiting, and processed in that
order.
@spec send_invoked_event( server :: server(), invoke_id :: String.t(), event :: Statifier.Event.t() ) :: :ok
Delivers event to server's external queue as an entry originating from
server's own invocation invoke_id - the child-to-parent direction, and
the only direction 6.4.3's "MUST ignore any events it receives from that
[cancelled] session" applies to. send_event/2 is what every other caller
wants, autoforwarded copies included; see Statifier.Session.Inbox's
entry typedoc for why the two are distinct entries rather than one entry
read two ways.
This session's sess_ id - datamodel["_sessionid"], held apart for routing.
@spec snapshot(server :: server()) :: Statifier.MachineState.t()
The whole %Statifier.MachineState{} this session currently holds - a
complete, resumable position (docs/observability.md constraint 1). A
term copy and nothing more: MachineState carries no pid, ref, port, or
fun.
@spec start_link(machine :: Statifier.Machine.t(), opts :: keyword()) :: GenServer.on_start()
Starts a session over machine (already compiled - Statifier.compile/1),
running Statifier.Interpreter.initialize/2 to quiescence before
returning. A document that reaches a stable configuration, or even
terminates, before any external event is ever sent is corpus-normal; the
session comes up already :running, :done, or :budget_exhausted
accordingly.
opts:
:name- passed toGenServer.start_link/3unchanged, so{:via, Registry, _}works today with no code here.:session_id,:trace,:datamodel,:max_macrostep_rounds-Statifier.MachineState.new/2's own options, passed straight through. The session never generates thesess_id itself (ADR-0008); it reads it back offmachine_state.datamodel["_sessionid"]oncenew/2has written it, since there is nosession_idfield on%MachineState{}.:subscribers- pids to monitor and forward the effect stream to from the start (default[]);subscribe/2adds more afterward.:record- whentrue(defaultfalse), builds aStatifier.Session.Recording.t()that captures every delivered event, timer firing, cancel marker, andinterpret/2batch this session handles, in input order (ADR-0029). Read it back withrecording/1.:invoke_source- a(src :: String.t() -> {:ok, Machine.t()} | {:error, term()})function this session hands toStatifier.Invoke.Source.resolve/2for every<invoke src="...">it starts (ADR-0038).nil(the default) leaves everysrc-only invocation unresolved, perStatifier.Invoke.Source's own contract.:invoke_handlers- a%{type_string => module}map ofStatifier.Invoke.Handler-implementing modules this session dispatches a registered<invoke type="...">to (ADR-0051). Default%{}, which registers no type beyond the built-inscxml/bare-URI setStatifier.Invoke.Handler.Scxmlalready serves as the default handler for. The%MachineState{}invoke_typessnapshot this session's core is stamped with is derived from this same map's keys, so the registered-type set and the dispatch map cannot diverge (decision 3).:invoked_by-{parent_pid, invoke_id}, set byStatifier.Sessionitself when it starts a child for an<invoke>(never set by an ordinary caller). Makes this session monitorparent_pid, so a dead parent stops it in turn ("Starting an invocation's child session" above).:inherit_observers- whentrue, every child session this session starts for an<invoke>is started with this session's:tracesetting, this session's subscriber pids as of the moment the child starts, andinherit_observers: trueof its own, so one opt-in at the root traces the whole invoke tree (ADR-0050). Defaultfalse, which starts children exactly as before. Each inherited subscriber receives the child's messages under the child's ownsession_idin the{:statifier, session_id, message}envelope, so a mixed stream demultiplexes on that field;{:halted, _}is still end-of-stream per session id (ADR-0044 decision 2), not for the mailbox as a whole. It is a snapshot:subscribe/2andunsubscribe/2after a child has started do not reach that child, andinvocations/1plussubscribe/2on the child is how an already-running child is attached to.:resume- boots this session at a persisted position (ADR-0060) instead of runningStatifier.Interpreter.initialize/2. Accepts either aStatifier.Position.to_binary/1blob (decoded viaStatifier.Position.from_binary/2againstmachine, which inherits the full ADR-0052 identity gate) or an already-decoded%Statifier.MachineState{}(theStatifier.Position.import/2migration path - checked instead againstmachine's own identity viaStatifier.Machine.Identity.matches?/2, one identity rule for both shapes).machinestays the positional%Machine{}either way; the position's ownmachinefield is rebound to it, so one compiledMachineterm is shared by the resumed state.A resumed session comes up with
machine_state's configuration, datamodel, history values,entered_states,states_to_invoke,active_invocations, and all six counters exactly as persisted - noInterpreter.initialize/2call, no re-entry of the chart's initial states, no<script>or<onentry>block run a second time.:session_idresolves to the caller's own option if supplied, otherwise tomachine_state.datamodel["_sessionid"]; supplying it explicitly rewritesdatamodel["_sessionid"]to agree, so thesession_id == datamodel["_sessionid"]invariant always holds.record: trueon a resumed session anchors the newStatifier.Session.Recording.t()at the resumed position rather than at the chart's initial configuration (ADR-0060 decision 6), so catch-up (subscribe/3withcatch_up: true) andStatifier.Replaystill reproduce exactly the notified prefix.What a resume does not restore, each for a reason ADR-0060 decision 7 records: in-flight delayed-send timers (no scheduling deadline is ever stored - a resumed session starts with
Statifier.Session.Timers.new()and an emptytimer_refsmap; a durable host re-arms them itself from theSendDelayed/Canceleffect vocabulary, ADR-0054/0055/0059); live invoked children (pids, monitor refs, and child session ids are process-local and were never part of a position -active_invocationscarries forward verbatim as the record of what was invoked,Statifier.Session.Invocationsstarts empty, and re-establishing the processes behind those ids is the host's job through the invoke handler registry, ADR-0051); and the external inbox (Statifier.Session.Inboxlives outside%MachineState{}by ADR-0002's core/session split - anything queued but not yet dequeued at persist time is lost with the process that held it).Refuses with
{:error, {:resume, reason}}rather than booting a silently-wrong session, forreasonin:{:conflicting_options, opts}-:resumewas passed alongside:trace,:datamodel, or:max_macrostep_rounds(Statifier.MachineState.new/2's own options, not read on this path) or:invoked_by(a child session is always library-started, never resumed).:not_a_statifier_blob,{:unsupported_format_version, v},{:identity_mismatch, expected, actual}, or:unidentified_chartStatifier.Position.from_binary/2's own errors (blob form), or the equivalent checks againstmachine_state.machine's identity (struct form).
:position_not_quiescent- the position's internal event queue is non-empty (Statifier.MachineState.internal_queue_empty?/1); booting mid-macrostep would produce effects with no ADR-0048 input boundary behind them. A host drains to quiescence before persisting, the same instructionStatifier.Position.export/1already gives.:position_not_running- the position hasrunning: false(status: :done); booting aGenServerthat is already terminated, has notified nobody, and hashalted: nilis the surprising outcome. A host that wants to inspect a finished position usesStatifier.Position.from_binary/2andStatifier.active_leaf_states/1directly, no session required.
See ADR-0060 for the full decision record.
A small status projection - session_id, status, configuration (as
string ids), the three step counters, and the queued-event and
pending-timer counts - for a caller polling in a loop, since snapshot/1
copies the entire compiled machine on every call. For a halted session,
configuration is read from the retained %Statifier.Effect.Done{}
rather than the (by-then-empty) %MachineState{}.configuration.
Stops the session. terminate/2 cancels every outstanding delayed-send
timer before the process exits (spec 6.2's discard-on-termination), so
nothing scheduled is ever delivered after this call returns.
Adds pid to this session's monitored subscriber set. Idempotent - a
pid already subscribed is not monitored twice.
@spec subscribe(server :: server(), pid :: pid(), opts :: subscribe_opts()) :: :ok | {:ok, Statifier.Session.Recording.t()} | {:error, :not_recorded}
Adds pid to this session's monitored subscriber set, optionally handing
back the material the pid needs to reconstruct what it missed (ADR-0049).
With catch_up: false (the default) this is subscribe/2: :ok, delivery
from now on.
With catch_up: true on a session started with record: true, returns
{:ok, recording} - the session's current
Statifier.Session.Recording.t(), snapshotted in the same handle_call
that adds pid. The missed prefix is
Statifier.Replay.run(recording)'s result.stream, computed by the
caller; the suffix is everything pid receives from now on. There is no
overlap and no gap between them, and no dedup key is needed - see the
moduledoc's "One subscriber stream" section for why, and for the
prefix ++ suffix consumption recipe.
With catch_up: true on a session started without record: true,
returns {:error, :not_recorded} and does not add pid - there is
nothing to re-derive from, and this record adds no second retention
mechanism (ADR-0049 decision 2). A caller for whom live-only delivery is
acceptable falls back to subscribe/2. Note the neighbouring atom:
recording/1 answers the same condition with {:error, :not_recording},
one letter apart and deliberately not unified - match on the one belonging
to the function you called.
Removes pid from this session's subscriber set. A no-op if it was never in it.