Statifier.Replay (Statifier v2.0.0)

Copy Markdown View Source

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.

What run/1 returns on success: the terminal position, the replayed stream, and its status.

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

halt_reason()

@type halt_reason() :: :done | :cancelled | :budget_exhausted

The three ways a replayed run can come to a stop, mirroring Statifier.Session's halted.

message()

@type message() ::
  {:effect, Statifier.Effect.t()}
  | {:unroutable, Statifier.Effect.t()}
  | {:halted, halt_reason()}

One replayed stream message - the subscriber shapes, envelope stripped.

result()

@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

run(recording)

@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).