Statifier.Session.Invocations (Statifier v2.0.0)

Copy Markdown View Source

The parent-held invocation table, as a value (ADR-0027 decision 3): invokeid -> {child_session_id, pid, monitor_ref}, plus the autoforward flag the delivery half ({:forward, invoke_id, event}) reads. A reverse index, pid -> invoke_id, is carried alongside so a child's own :DOWN can be resolved with no scan.

A handler-backed entry (ADR-0051)

Not every live invocation is a child session. An <invoke> dispatched to a non-scxml Statifier.Invoke.Handler (ADR-0051 decision 4) has no process of its own, so its entry carries session_id: nil, pid: nil, and monitor_ref: nil - there is no child to identify, hand a pid for, or monitor. It still carries type (needed to route a later cancel_invoke/autoforward effect back to the same handler) and autoforward, exactly like a child-session entry. put/3 skips the by_pid reverse index for such an entry (there is no pid to index under), and pop/2 skips the matching by_pid delete symmetrically; pop_by_pid/2 needs no change at all - a pid-less entry was never reachable through it.

Not Statifier.MachineState.active_invocations

MachineState.active_invocations (lib/statifier/machine_state.ex:60-87) holds the core's view of a live invocation: {state_index, invoke_index} => invoke_id, immutable compiled-document identity, no pid, no monitor ref, no session id. This module holds the session's view: process identity the core never touches and never needs to. Statifier.Session builds this table on top of that one, not in place of it - the two answer different questions ("which invocations does Appendix D's cancelInvoke walk still see" versus "which OS process does invocation i1 currently run as, and who is watching it").

Pure by design (Decision 3)

Every function here is a pure map transformation - no Process.monitor/1, no send/2, no DynamicSupervisor call. That keeps Mix.Statifier.AdrGuard's @effect_interpreter_paths at exactly two entries: lib/statifier/session.ex makes every process call this table's data describes, and reads the result back through this module's API, exactly the split Statifier.Session.Timers and Statifier.Session.Inbox already model for delayed sends and the external queue.

seed_datamodel/2 lives in this module rather than a sibling for the same reason: it is pure, it is read only from the {:start_child, _, _} performer that also writes this table, and a third pure module for one function would be structure without a second caller.

Summary

Types

What the table remembers about one live invocation - process identity the core never holds. session_id is the child's own sess_ UXID, read back once the child has started; pid/monitor_ref are the parent's own handle on it; autoforward is the <invoke autoforward> attribute, copied off Statifier.Effect.Invoke at start time; type is the <invoke type> value itself - Statifier.Session writes it into every entry it records, a built-in scxml entry included, so an invoke_handlers map that explicitly overrides the literal "scxml" type is honored on cancel/forward the same way it already is on start. The key can still be absent (optional/1) on an entry built by older code or by hand in a test; Statifier.Session.Effects.plan_one/2's own dispatch treats that the same as an unrecorded type, defaulting to the built-in handler. session_id, pid, and monitor_ref are nil for a handler-backed entry (see the moduledoc's "A handler-backed entry" section) - there is no child process behind it.

The public projection of one live invocation - invoke_id plus the child's own session id and pid, and deliberately not the parent's monitor_ref or the <invoke autoforward> flag (ADR-0050 decision 1).

t()

Functions

The number of live invocations.

The whole invoke_id => entry map.

Looks up invoke_id's entry, :error when it names nothing live.

Every live invoke id, in no particular order.

Every live invocation as its public projection, sorted by invoke_id - a stable order across reads, which invoke_ids/1's map-key order is not (ADR-0050 decision 1).

Whether invoke_id names a live invocation - the discard predicate a later phase drains against.

An empty invocation table.

Removes invoke_id's entry from both maps, returning it (nil when it named nothing live) alongside the table with it gone. The by_pid delete is skipped, symmetrically with put/3, when the popped entry's pid is nil (a handler-backed entry never occupied by_pid to begin with).

Removes whichever entry pid names, by the reverse index, returning {invoke_id, entry} (nil when pid names nothing live) alongside the table with it gone. What a child's own :DOWN resolves through.

Records entry under invoke_id, and indexes it under entry.pid in the reverse map - skipped when entry.pid is nil (a handler-backed entry, see the moduledoc's "A handler-backed entry" section), since there is no pid to index under. A second put/3 for the same invoke_id (an author-written id on a re-entered <invoke>, Decision 6's residual) overwrites the first entry in both maps rather than merging with it.

6.4.3's name-matched seeding: keeps only those keys of params (the Statifier.Effect.Invoke struct's already-coerced <param>/namelist map) that match a top-level <data> id of child_machine, and drops the rest - "If the names do not match, the Processor MUST NOT add the value."

Every live invocation's own type, invoke_id => type - what Statifier.Session.Effects.plan_one/2 looks a cancel_invoke/ autoforward effect's handler module up in (ADR-0051 decision 6). Every entry Statifier.Session writes carries type (a built-in scxml entry's own literal type string included, not omitted - so an invoke_handlers map that explicitly overrides "scxml" is still honored on cancel/forward, not just on start), but an entry built by older code or by hand in a test can still leave the key off; such an entry is left out of this projection entirely rather than included with a nil value, so the caller's own Map.get(_, invoke_id, ScxmlHandler)-shaped default does the same work either way.

Types

entry()

@type entry() :: %{
  optional(:type) => String.t() | nil,
  session_id: String.t() | nil,
  pid: pid() | nil,
  monitor_ref: reference() | nil,
  autoforward: boolean()
}

What the table remembers about one live invocation - process identity the core never holds. session_id is the child's own sess_ UXID, read back once the child has started; pid/monitor_ref are the parent's own handle on it; autoforward is the <invoke autoforward> attribute, copied off Statifier.Effect.Invoke at start time; type is the <invoke type> value itself - Statifier.Session writes it into every entry it records, a built-in scxml entry included, so an invoke_handlers map that explicitly overrides the literal "scxml" type is honored on cancel/forward the same way it already is on start. The key can still be absent (optional/1) on an entry built by older code or by hand in a test; Statifier.Session.Effects.plan_one/2's own dispatch treats that the same as an unrecorded type, defaulting to the built-in handler. session_id, pid, and monitor_ref are nil for a handler-backed entry (see the moduledoc's "A handler-backed entry" section) - there is no child process behind it.

public_entry()

@type public_entry() :: %{invoke_id: String.t(), session_id: String.t(), pid: pid()}

The public projection of one live invocation - invoke_id plus the child's own session id and pid, and deliberately not the parent's monitor_ref or the <invoke autoforward> flag (ADR-0050 decision 1).

t()

@opaque t()

Functions

count(invocations)

@spec count(invocations :: t()) :: non_neg_integer()

The number of live invocations.

entries(invocations)

@spec entries(invocations :: t()) :: %{required(String.t()) => entry()}

The whole invoke_id => entry map.

fetch(invocations, invoke_id)

@spec fetch(invocations :: t(), invoke_id :: String.t()) :: {:ok, entry()} | :error

Looks up invoke_id's entry, :error when it names nothing live.

invoke_ids(invocations)

@spec invoke_ids(invocations :: t()) :: [String.t()]

Every live invoke id, in no particular order.

list(invocations)

@spec list(invocations :: t()) :: [public_entry()]

Every live invocation as its public projection, sorted by invoke_id - a stable order across reads, which invoke_ids/1's map-key order is not (ADR-0050 decision 1).

live?(invocations, invoke_id)

@spec live?(invocations :: t(), invoke_id :: String.t()) :: boolean()

Whether invoke_id names a live invocation - the discard predicate a later phase drains against.

new()

@spec new() :: t()

An empty invocation table.

pop(invocations, invoke_id)

@spec pop(invocations :: t(), invoke_id :: String.t()) :: {entry() | nil, t()}

Removes invoke_id's entry from both maps, returning it (nil when it named nothing live) alongside the table with it gone. The by_pid delete is skipped, symmetrically with put/3, when the popped entry's pid is nil (a handler-backed entry never occupied by_pid to begin with).

pop_by_pid(invocations, pid)

@spec pop_by_pid(invocations :: t(), pid :: pid()) ::
  {{String.t(), entry()} | nil, t()}

Removes whichever entry pid names, by the reverse index, returning {invoke_id, entry} (nil when pid names nothing live) alongside the table with it gone. What a child's own :DOWN resolves through.

put(invocations, invoke_id, entry)

@spec put(invocations :: t(), invoke_id :: String.t(), entry :: entry()) :: t()

Records entry under invoke_id, and indexes it under entry.pid in the reverse map - skipped when entry.pid is nil (a handler-backed entry, see the moduledoc's "A handler-backed entry" section), since there is no pid to index under. A second put/3 for the same invoke_id (an author-written id on a re-entered <invoke>, Decision 6's residual) overwrites the first entry in both maps rather than merging with it.

seed_datamodel(params, child_machine)

@spec seed_datamodel(
  params :: map() | :undefined | nil,
  child_machine :: Statifier.Machine.t()
) ::
  map()

6.4.3's name-matched seeding: keeps only those keys of params (the Statifier.Effect.Invoke struct's already-coerced <param>/namelist map) that match a top-level <data> id of child_machine, and drops the rest - "If the names do not match, the Processor MUST NOT add the value."

params is :undefined - "no data", ADR-0037's sentinel - when the invocation carried no <param>/namelist at all (Statifier.EventData.coerce({:params, []})'s own empty-is-:undefined rule). That seeds nothing, the same as an empty map would. nil is accepted for the same outcome but means predicator's null rather than absence, and no coercion produces it here.

types(invocations)

@spec types(invocations :: t()) :: %{required(String.t()) => String.t()}

Every live invocation's own type, invoke_id => type - what Statifier.Session.Effects.plan_one/2 looks a cancel_invoke/ autoforward effect's handler module up in (ADR-0051 decision 6). Every entry Statifier.Session writes carries type (a built-in scxml entry's own literal type string included, not omitted - so an invoke_handlers map that explicitly overrides "scxml" is still honored on cancel/forward, not just on start), but an entry built by older code or by hand in a test can still leave the key off; such an entry is left out of this projection entirely rather than included with a nil value, so the caller's own Map.get(_, invoke_id, ScxmlHandler)-shaped default does the same work either way.