The offline producer of the v1 trace wire format (ADR-0017): a pure
function that turns a persisted session event log into the same
StatifierUI.Trace.Message stream StatifierUI.Trace.Subscriber
produces from a live session - with no Statifier.Session, no process,
and no clock.
A host that persists its own session event log - because it restarts, or
because it stores runs for audit and renders them later - has the inputs
a run was driven by, the compiled chart, and the options the session ran
under, and nothing left to subscribe to. from_events/4 is what it calls
instead, and recording/3 is the step in the middle it can reach for on
its own.
The four steps, and why they are the subscriber's four steps
from_events/4 builds a Statifier.Session.Recording through that
module's public constructors, hands it to Statifier.Replay.run/1, emits
the session.start manifest, and folds the returned stream through the
normalizer - which is exactly what
StatifierUI.Trace.Subscriber's catch-up attach path does, with the
same calls in the same order:
StatifierUI.Trace.Manifest.build/3forsession.start, stampedseq: 0;StatifierUI.Trace.Normalizer.normalize/2for every stream element, with the same four-keyctx(soerror.locationresolves the same way);StatifierUI.Trace.Otel.stamp/2and thenStatifierUI.Trace.Projection.project/2, in that order, for every message - ADR-0013 putsotelin the never-projected set, and stamping first is what proves a projected stream carries the key unchanged.
No message shape is constructed in this module. Parity with the
subscriber (ADR-0017 decision 3) is therefore structural rather than
promised: identical types, identical order, identical seq values, and
identical payload bytes under StatifierUI.Trace.Json.encode_lines/1.
What it deliberately does not do
- No
session.terminated. There is no process offline and no exit to observe; an offline stream's end is the end of the entry list.session.haltedis unaffected - it comes from a{:halted, reason}stream element and is produced normally. - No capacity, no fan-out, no diagnostics counter. A function
returning a list drops nothing and sends to nobody, so the
subscriber's bounded buffer, its listener set, and its
stats/1have no offline counterpart. The whole list is held in memory, which the subscriber's bound does not have to. - No timers and no clock.
Statifier.Replayconverts a schedule into a pending-timer credit rather than a realProcess.send_after/3, and a recorded firing is delivered at its recorded position and nowhere else. Wall-clock time never enters, which is what makes this function deterministic.
The recording, on its own
recording/3 is the first of the four steps, exposed. A host that stores
its run as rows rather than as a Statifier.Session.Recording.to_binary/1
blob has to turn those rows back into Statifier.Session.Recording.entry/0
values before anything can replay them, and the six-clause fold that puts
them into a recording through the module's public constructors is the same
fold whether the caller wants messages afterwards or the recording itself
- to hand to
Statifier.Replay.run/1directly, toto_binary/1for storage, or to compare against one it already had.
from_events/4 calls it, so the two cannot drift. It stops where the
recording is built: :trace and :session_id are checked by
from_events/4 and not here, because they are requirements of the
message stream rather than of the recording (a recording made without
trace: true replays perfectly well; it just carries no trace effects).
docs/ops-embedding.md's "From a persisted event log" section carries the
row-to-entry mapping table for all six shapes, and the timer rule below.
The timer rule
A fired delayed send is {:timer, send_id, event, routes} and never
{:event, event, routes}, even though the engine delivered an ordinary
external event either way. The difference is the matching:
Statifier.Replay turns each recorded <send> with a delay into a
pending-timer credit under its send_id, and only a {:timer, ...}
entry draws one (statifier lib/statifier/replay.ex:337-346, :359-374).
So a firing named as an event is not checked against anything. The
replay accepts a firing the chart never scheduled, instead of returning
{:error, {:unscheduled_timer_firing, send_id}}; and the credit it
should have consumed stays outstanding, where a later
{:cancel_timers, send_id} moves it into the raced pool and a
subsequent firing can still draw it. A single fired timer with nothing
after it produces the same stream under either name, which is exactly why
the rule has to be stated rather than discovered.
The name
This module and Statifier.Replay collide under alias. That is
intentional: the engine module is aliased here as EngineReplay at its
single call site rather than naming this one something that hides what it
is.
Summary
Functions
Produces the wire-format message stream for a recorded run, offline.
Builds the Statifier.Session.Recording a host's stored inputs describe.
Types
@type opts() :: [ source: String.t(), fixtures: map(), parent_session: String.t(), invokeid: String.t(), projection: StatifierUI.Trace.Projection.Profile.t(), otel_context: StatifierUI.Trace.Otel.resolver() ]
This producer's own options - the subscriber's emission options and no others.
:source, :fixtures, :parent_session and :invokeid are forwarded
verbatim to StatifierUI.Trace.Manifest.build/3; :source additionally
widens the normalizer's ctx exactly as the subscriber's does.
:projection and :otel_context are the chokepoint's two options.
Deliberately absent are :capacity, :listeners and :name: a buffer,
a fan-out and a process name are process concerns, and this is a
function.
Functions
@spec from_events( machine :: Statifier.Machine.t(), initialize_opts :: keyword(), events :: [Statifier.Session.Recording.entry()], opts :: opts() ) :: {:ok, [StatifierUI.Trace.Message.t()]} | {:error, term()}
Produces the wire-format message stream for a recorded run, offline.
machine- the compiled chart the log was produced over, the same argumentStatifierUI.Trace.Manifest.build/3andStatifier.Session.Recording.new/3take.initialize_opts- the session options the recorded run was made under, inStatifier.Session.Recording.new/3's normalized vocabulary::session_id,:trace,:datamodel,:max_macrostep_rounds,:routes,:invoke_typesand:invoke_handlers.:session_idmust be a binary - every message's envelope carries it.events- the persisted log, in the session's serialized input order, asStatifier.Session.Recording.entry/0values.opts- this producer's own options; seeopts/0.
Errors are values, and the call fails closed
The first failure returns {:error, reason} and no partial list. Where
the subscriber continues past a bad effect and records a diagnostic - it
would otherwise lose a live stream it can never recover - an offline
caller still has the log in hand and can retry, report, or investigate,
and a partial list returned as {:ok, messages} would be
indistinguishable from a whole one (ADR-0017 decision 5).
The reasons:
{:initialize_opts, :trace_disabled}wheninitialize_optsdoes not carry a truthy:trace.Statifier.Session.Recording.new/3defaults the flag tofalse, and a run made without it completes successfully while emitting notrace.*messages at all - ten of the format's twenty-five types missing, silently. The flag is the caller's to supply, because defaulting it on would produce a stream the recorded run never produced.{:initialize_opts, :missing_session_id}when:session_idis absent or is not a binary.{:unknown_entry, entry}for an entry shape this module does not know.Statifier.Session.Recording.entry/0has six shapes today, and a seventh appearing upstream must not be dropped silently - the same reasoningStatifierUI.Trace.Normalizer.normalize/2gives for{:unknown_effect, tag}.- anything
Statifier.Replay.run/1returns, unwrapped - notably{:unscheduled_timer_firing, send_id}. Its{:anchor, _}arm cannot be reached from here:from_events/4takes no anchor, so the recording it builds always replays fromStatifier.Interpreter.initialize/2(a host holding an anchored recording has the blob, and wantsStatifier.Replay.run/1directly). - anything
StatifierUI.Trace.Manifest.build/3orStatifierUI.Trace.Normalizer.normalize/2returns.
StatifierUI.Trace.Normalizer.normalize/2 has no third answer to fold in:
it returned :skip while one engine trace effect had no message to map
onto, and ADR-0018 gave it one. Fail-closed (decision 5) applies to
{:error, _} and is untouched.
Examples
iex> {:ok, machine} = Statifier.compile(~s(<scxml xmlns="http://www.w3.org/2005/07/scxml" version="1.0" initial="a"><state id="a"/></scxml>))
iex> {:ok, [manifest | _]} =
...> StatifierUI.Trace.Replay.from_events(machine, [session_id: "s", trace: true], [])
iex> {manifest.type, manifest.session, manifest.seq}
{"session.start", "s", 0}
iex> {:ok, machine} = Statifier.compile(~s(<scxml xmlns="http://www.w3.org/2005/07/scxml" version="1.0" initial="a"><state id="a"/></scxml>))
iex> StatifierUI.Trace.Replay.from_events(machine, [session_id: "s"], [])
{:error, {:initialize_opts, :trace_disabled}}
@spec recording( machine :: Statifier.Machine.t(), initialize_opts :: keyword(), events :: [Statifier.Session.Recording.entry()] ) :: {:ok, Statifier.Session.Recording.t()} | {:error, {:unknown_entry, term()}}
Builds the Statifier.Session.Recording a host's stored inputs describe.
Built through the public constructors, never as a struct literal:
Statifier.Session.Recording.t() is @opaque upstream, and building it
by hand would couple this repository to a struct deliberately closed
(ADR-0017 decision 2). One clause per Statifier.Session.Recording.entry/0
shape, and an unrecognized shape is {:error, {:unknown_entry, entry}}
rather than a skip - the same fail-closed reading from_events/4 gives,
and for the same reason: a seventh shape appearing upstream must not be
dropped silently.
The arguments are from_events/4's first three, unchanged - see there for
what each carries, and docs/ops-embedding.md's "From a persisted event
log" for the table that says which stored row becomes which entry shape.
A fired delayed send is {:timer, send_id, event, routes}; see this
module's "The timer rule".
This is where from_events/4 gets its recording, so the two cannot drift.
Call it directly when the recording rather than the message stream is what
you want: Statifier.Replay.run/1 on it, Recording.to_binary/1 to store
it, or a comparison against one you already hold.
Examples
iex> {:ok, machine} = Statifier.compile(~s(<scxml xmlns="http://www.w3.org/2005/07/scxml" version="1.0" initial="a"><state id="a"/></scxml>))
iex> {:ok, recording} = StatifierUI.Trace.Replay.recording(machine, [session_id: "s", trace: true], [])
iex> Statifier.Session.Recording.entries(recording)
[]
iex> {:ok, machine} = Statifier.compile(~s(<scxml xmlns="http://www.w3.org/2005/07/scxml" version="1.0" initial="a"><state id="a"/></scxml>))
iex> StatifierUI.Trace.Replay.recording(machine, [session_id: "s", trace: true], [{:teleport, :somewhere}])
{:error, {:unknown_entry, {:teleport, :somewhere}}}