The tier-2 scripted simulation scheduler (implementation.md §10,
build-order M2). Test machinery only: it lives under lib/fief/sim/ so
test builds of downstream layers can resolve it, but nothing outside
sim/test code may reference it.
One scheduler process owns the whole simulation: a single queue of pending
events (message deliveries and timer firings), every virtual clock, and the
in-flight count that gates virtual time. Tests script orderings explicitly:
post/4 schedules a delivery, pending/1 inspects the queue, step/3
fires one chosen event, drop/2 discards one (the fault-injection
primitive), and advance/2 / advance_to_next_timer/1 move virtual time —
only at quiescence.
post/4 is never quiescence-gated, so a process may post new deliveries
mid-handler (e.g. Fief.Seam.Sim sends from inside an agent
callback) while an awaited step is in progress: the scheduler's loop is
live — gated ops defer their reply rather than blocking it — so the call is
served and the new event simply joins the queue.
Clocks
The scheduler subsumes Fief.Sim.ManualClock rather than coordinating
ManualClock processes: timer firings must be events in the scheduler's own
queue (scripted ordering, in-flight accounting — ManualClock delivers
straight to the destination), and "all clocks advance together" needs one
atomic state. Each simulated node identity — plus the arbiter — gets a
named virtual clock: a skew offset over one shared base time. clock/3
returns a {module, handle} clock that Fief.Sim.bind_clock/1 accepts and
Fief.Seam.Sim dispatches on; skew/3 jumps one clock forward relative
to the rest — e.g. to sit a node deliberately inside the
TTL − margin…TTL window relative to the arbiter's clock (design §6.4).
Fief.Sim.ManualClock remains for clock-only tests that need no event
queue.
Timers armed through Fief.Seam.send_after/2 by a process bound to a
scheduler clock become pending events when their clock reaches the
deadline (on advance/2 or skew/3), never auto-delivered: firing stays a
scripted step/3, so simultaneous timers and message deliveries interleave
under test control.
Quiescence
A delivery made by step/3 increments the in-flight count; the receiving
process's end-of-processing Fief.Seam.checkpoint(:handled) — routed here
because the process's bound tracer is the scheduler — decrements it.
Virtual time advances only at zero: advance/2, advance_to_next_timer/1,
and skew/3 (and step/3 with await: true, the default)
defer their reply until the count drains; no synchronous ack protocol.
:handled is the one reserved tag; checkpoints with any other tag are
recorded as progress marks (flush_checkpoints/1) and do not affect the
count. A monitored receiver dying also settles its outstanding deliveries.
Joining a simulation
context/3 returns the sim context for one simulated node identity; a
process joins by binding it at init — Fief.Sim.bind(context) — the
instance-scoped wiring M3+ attaches through (build-order M2 notes). The
discipline for every simulated process: bind the context at init, end each
message handling with Fief.Seam.checkpoint(:handled).
Summary
Types
A named virtual clock: a simulated node identity, :arbiter, …
Functions
Advance every clock by ms — quiescence-gated: waits for the in-flight
count to reach zero first. Returns {:ok, newly_due_timer_events}.
Advance (at quiescence) exactly to the earliest armed timer deadline across
all clocks — the idiom protocol tests lean on. Returns
{:ok, advanced_ms, newly_due_timer_events} or {:error, :no_timers}.
Returns a specification to start this module under a supervisor.
A bindable virtual clock for clock_id (created at :offset ms from base
time if absent; an existing clock's offset is kept). The returned
{module, handle} is accepted by Fief.Sim.bind_clock/1 and dispatched by
Fief.Seam.Sim.
The sim context for one simulated node identity: a map a process binds at
init via Fief.Sim.bind/1 (clock + tracer + transport identity, plus
:rand_seed if given). Creates the identity's virtual clock if absent;
:offset (ms, may be negative) skews it from base time at creation.
Discard one pending event without delivering it — the fault-injection primitive (a lost message / dropped peer-link delivery is a posted event the test drops instead of steps). Timer events may be dropped too (a lost timeout — rarely what a test means; prefer dropping message deliveries).
All checkpoints observed since the last flush, oldest first, as {tag, meta, pid}.
Deliveries made but not yet settled by a :handled checkpoint (or receiver death).
Current virtual time of clock_id, ms. Creates the clock if absent.
Pending events, oldest first.
Jump one clock ms forward relative to every other clock (monotonic clocks
never run backwards; to lag a node, skew everyone else — or create it with
a negative :offset). Quiescence-gated like advance/2: skew is a time
move, and no clock moves while work is in flight. The clock's now-due
timers become pending events; returns {:ok, newly_due_events}.
Fire one pending event: deliver its message and count it in flight. With
await: true (the default) the call returns only once the in-flight count
has drained back to zero — one event fully processed — so the test's next
action is a strict schedule point. await: false returns right after the
send, leaving the work in flight (time cannot advance past it).
Armed (not yet due) timers, next-due first. due_at/due_in are in the owning clock's time.
Types
Functions
@spec advance(GenServer.server(), non_neg_integer()) :: {:ok, [event()]}
Advance every clock by ms — quiescence-gated: waits for the in-flight
count to reach zero first. Returns {:ok, newly_due_timer_events}.
@spec advance_to_next_timer(GenServer.server()) :: {:ok, pos_integer(), [event()]} | {:error, :no_timers}
Advance (at quiescence) exactly to the earliest armed timer deadline across
all clocks — the idiom protocol tests lean on. Returns
{:ok, advanced_ms, newly_due_timer_events} or {:error, :no_timers}.
Returns a specification to start this module under a supervisor.
See Supervisor.
@spec clock(GenServer.server(), clock_id(), keyword()) :: {module(), term()}
A bindable virtual clock for clock_id (created at :offset ms from base
time if absent; an existing clock's offset is kept). The returned
{module, handle} is accepted by Fief.Sim.bind_clock/1 and dispatched by
Fief.Seam.Sim.
@spec context(GenServer.server(), clock_id(), keyword()) :: map()
The sim context for one simulated node identity: a map a process binds at
init via Fief.Sim.bind/1 (clock + tracer + transport identity, plus
:rand_seed if given). Creates the identity's virtual clock if absent;
:offset (ms, may be negative) skews it from base time at creation.
The context carries :node_id (defaults to clock_id; :node_id in
opts overrides) and the scheduler's unique :sim_id — together with
:scheduler these bind the simulated transport (Fief.Seam.Sim):
registered vnode names qualified per node identity, sends delivered as
steppable scheduler events. The :node_id here must match the instance's
:node_id opt for names and validation to agree.
@spec drop(GenServer.server(), pos_integer()) :: :ok | {:error, :not_pending}
Discard one pending event without delivering it — the fault-injection primitive (a lost message / dropped peer-link delivery is a posted event the test drops instead of steps). Timer events may be dropped too (a lost timeout — rarely what a test means; prefer dropping message deliveries).
@spec flush_checkpoints(GenServer.server()) :: [{atom(), keyword() | map(), pid()}]
All checkpoints observed since the last flush, oldest first, as {tag, meta, pid}.
@spec in_flight(GenServer.server()) :: non_neg_integer()
Deliveries made but not yet settled by a :handled checkpoint (or receiver death).
@spec now(GenServer.server(), clock_id()) :: integer()
Current virtual time of clock_id, ms. Creates the clock if absent.
@spec pending(GenServer.server()) :: [event()]
Pending events, oldest first.
@spec post(GenServer.server(), pid(), term(), keyword()) :: pos_integer()
Schedule a message delivery to dest; it becomes a pending event the test
fires with step/3. :label names it in pending/1. Returns the event id.
@spec skew(GenServer.server(), clock_id(), non_neg_integer()) :: {:ok, [event()]}
Jump one clock ms forward relative to every other clock (monotonic clocks
never run backwards; to lag a node, skew everyone else — or create it with
a negative :offset). Quiescence-gated like advance/2: skew is a time
move, and no clock moves while work is in flight. The clock's now-due
timers become pending events; returns {:ok, newly_due_events}.
@spec step(GenServer.server(), pos_integer(), keyword()) :: :ok | {:error, :not_pending}
Fire one pending event: deliver its message and count it in flight. With
await: true (the default) the call returns only once the in-flight count
has drained back to zero — one event fully processed — so the test's next
action is a strict schedule point. await: false returns right after the
send, leaving the work in flight (time cannot advance past it).
@spec timers(GenServer.server()) :: [map()]
Armed (not yet due) timers, next-due first. due_at/due_in are in the owning clock's time.