Re-drives a Statifier.Session.Recording through the pure core, with no
process and no timer (ADR-0034).
The reuse boundary
run/1 reuses every deciding component a live Statifier.Session
uses, unchanged: Statifier.Interpreter.initialize/2,
Statifier.Interpreter.handle_event/2, Statifier.Interpreter.cancel/1,
Statifier.Session.Effects.plan/1, and Statifier.Session.Inbox. This
module's drain/1 mirrors Statifier.Session's
handle_continue(:drain, _) line for line, and its perform/2 mirrors
perform_instruction/3 for four of the seven instruction kinds
Statifier.Session.Effects.instruction() names:
{:notify, effect}, {:enqueue_event, event}, {:unroutable, effect},
and {:halt, reason} are handled exactly as the live session handles
them.
The other three - {:schedule, ...}, and the process-facing halves of
{:notify, ...}/{:unroutable, ...} that send/2 to subscribers - are
the ones lib/statifier/session.ex performs by calling
Process.send_after/3 or delivering to a subscriber process. This module
is not a process and has no subscribers, so it records what those
instructions mean instead of performing them: a {:schedule, ...}
becomes a pending-timer credit rather than a real Process.send_after/3
call, and every {:notify, ...}/{:unroutable, ...} becomes a message
appended to result().stream instead of a send/2. Everything above
that line (deciding what to do) is reused byte for byte; everything below
it (performing it against a real process and a real clock) is replaced,
which is exactly the split lib/statifier/session.ex's own moduledoc
documents and ADR-0003 is the warrant for (ADR-0034).
Why replacing {:schedule, ...} dissolves the double-delivery seam
A live session's {:schedule, ...} clause arms a real
Process.send_after/3 timer. Feeding a recording that already holds both
the {:interpret, effects, routes} entry that scheduled a :send_delayed
and the later {:timer, send_id, event, routes} entry recording its firing
into a live
session would arm a second, real timer on top of the firing already
waiting in the recording - delivering the event twice. This module never
arms a timer at all: {:schedule, send_id, _delay_ms, _event} only
increments a plain count under send_id in pending, and a
{:timer, send_id, event, routes} entry draws one credit from that count
before
enqueuing the event. Each recorded firing is therefore delivered exactly
once, at its recorded position (ADR-0034 decision 1).
pending and raced are plain %{send_id() => non_neg_integer()} count
maps rather than Statifier.Session.Timers, which is keyed by
reference() - minting a reference here would be the one impure call in
a module whose entire claim is purity. Counts are sufficient because
replay never cancels a real timer and never needs to correlate a firing
to the specific arming that produced it; spec 6.3's "cancel them all" is a
whole-key move either way. nil (an unnamed send_delayed) is a
legitimate key in both maps.
A {:cancel_timers, send_id} instruction does not delete send_id's
pending count - it moves the whole count from pending into raced.
This models Process.cancel_timer/1 returning false when the delay has
already elapsed and the delayed-send message is already sitting in the
live session's mailbox: the cancel does not unsend it, and
lib/statifier/session.ex's handle_info/2 enqueues it unconditionally
when it arrives. A recording can therefore legitimately hold a
{:timer, send_id, event, routes} entry after the {:cancel, ...} effect
that
cancelled that same id - the cancel and the fire raced, and the fire won.
A firing draws credit from pending first and raced second, delivered
normally either way; only a firing with credit in neither map means the
recording is inconsistent with the machine it is being replayed against,
which run/1 reports as {:error, {:unscheduled_timer_firing, send_id}}
rather than silently proceeding.
{:deliver, ...}/{:raise, ...} defer to the recorded {:internal, ...} entry
A live session's {:raise, ...} instruction and every {:deliver, ...}
route except a self-addressed {:session, sid} reach
Statifier.Interpreter.deliver_internal/5 through
Statifier.Session's own private seam, and every one of those calls is
recorded as an {:internal, kind, name, origin, opts, routes} entry
(ADR-0039, ADR-0029) at its own position in the session's serialized
input order - interleaved with, not nested inside, the entry that
triggered it. Performing the delivery again while re-deriving that
triggering entry's own effects would raise the same internal event twice,
so this module's perform_instruction/3 clauses for {:raise, ...} and
{:deliver, ...} (other than the self-match) are no-ops: the only
place Interpreter.deliver_internal/5 is called from this module is
apply_entry/2's own {:internal, ...} clause, walking the recording's
flat entry list. A self-addressed {:session, sid} needs no recorded
entry at all and resolves locally by plain equality against
state.machine_state.datamodel["_sessionid"], deterministically, with no
registry involved live or in replay (decision 10) - the same reason
Statifier.Session's own resolver never calls deliver_internal/5 for
that one route either.
Replay re-supplies the recorded snapshot rather than rebuilding one
ADR-0048 decision 2 has the caller stamp a route snapshot before every core
drive; decision 3 has each recorded entry carry the snapshot that drive was
judged against. apply_entry/2 therefore does exactly one thing with each
entry's trailing Statifier.Send.Routes.t() | nil field before triggering
the drive it names: Statifier.MachineState.put_routes/2 it onto
state.machine_state. It never recomputes a snapshot from
Statifier.Registry or from any other live fact - this module holds no
registry reference anywhere, which is exactly what keeps it a pure fold
over the recording (ADR-0034) rather than a second implementation of
ADR-0048's session-side construction. Re-supplying rather than rebuilding
is also what keeps replay cost bounded by the recording's own size, the
ground ADR-0048 decision 1 argues from.
No Appendix D function is reimplemented
Every state transition this module performs is a call into
Statifier.Interpreter or Statifier.Session.Effects; nothing here
duplicates a pseudocode-named function under a new name. drain/1 is the
one loop this module owns, and it is not part of Appendix D itself - it is
this codebase's own port of mainEventLoop's dequeue tail
(lib/statifier/session.ex:750-757's own comment), reused here with a
Statifier.Session.Inbox in place of a process mailbox, exactly as the
live session already reuses it in place of the blocking
externalQueue.dequeue().
Summary
Types
The three ways a replayed run can come to a stop, mirroring Statifier.Session's halted.
One replayed stream message - the subscriber shapes, envelope stripped.
Functions
Replays recording from its starting point, then folds Recording.entries/1
in order, draining after each one exactly as the live session that produced
the recording did.
Types
@type halt_reason() :: :done | :cancelled | :budget_exhausted
The three ways a replayed run can come to a stop, mirroring Statifier.Session's halted.
@type message() :: {:effect, Statifier.Effect.t()} | {:unroutable, Statifier.Effect.t()} | {:halted, halt_reason()}
One replayed stream message - the subscriber shapes, envelope stripped.
@type result() :: %{ machine_state: Statifier.MachineState.t(), stream: [message()], status: :running | halt_reason() }
What run/1 returns on success: the terminal position, the replayed stream, and its status.
Functions
@spec run(recording :: Statifier.Session.Recording.t()) :: {:ok, result()} | {:error, {:unscheduled_timer_firing, String.t() | nil}} | {:error, {:anchor, term()}}
Replays recording from its starting point, then folds Recording.entries/1
in order, draining after each one exactly as the live session that produced
the recording did.
The starting point is Recording.anchor/1: nil (every recording made
before ADR-0060, and any made since without a resume behind it) replays
from scratch, Statifier.Interpreter.initialize/2 over Recording.machine/1
and Recording.opts/1 - today's path, unchanged. A blob anchor instead
decodes the persisted position via Statifier.Position.from_binary/2
against Recording.machine/1, performing no effects and draining nothing
before the first entry - a resumed session performed no initialization
either (see the moduledoc's anchored-recordings cross-reference and
Statifier.Session.Recording's "Anchored recordings" section).
Returns {:error, {:anchor, reason}} when the anchor blob's identity does
not match Recording.machine/1, or is otherwise malformed -
Statifier.Position.from_binary/2's own error, wrapped rather than
flattened so a caller can tell an anchor failure from an
{:unscheduled_timer_firing, _} one.
Returns {:error, {:unscheduled_timer_firing, send_id}} the moment a
{:timer, send_id, _event} entry has no credit in either the pending
or raced bookkeeping - see the moduledoc for what that means and the
one legitimate case (the cancel/fire race) it does not misfire on.
Also supports a mid-run use case: a recording captured from a session
still running replays to status: :running and a stream that is the
notified prefix so far, which is what
Statifier.Session.subscribe(server, pid, catch_up: true) hands its
caller for a late subscriber to catch up on (ADR-0049).