Fief.Sim.Scheduler (Fief v0.1.0)

Copy Markdown View Source

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 − marginTTL 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, …

A pending event, as returned by pending/1.

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.

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.

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

clock_id()

@type clock_id() :: term()

A named virtual clock: a simulated node identity, :arbiter, …

event()

@type event() :: %{
  id: pos_integer(),
  kind: :message | :timer,
  dest: pid(),
  msg: term(),
  label: term(),
  clock: clock_id() | nil,
  timer_ref: reference() | nil
}

A pending event, as returned by pending/1.

Functions

advance(sched, ms)

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

advance_to_next_timer(sched)

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

child_spec(init_arg)

Returns a specification to start this module under a supervisor.

See Supervisor.

clock(sched, clock_id, opts \\ [])

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

context(sched, clock_id, opts \\ [])

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

drop(sched, event_id)

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

flush_checkpoints(sched)

@spec flush_checkpoints(GenServer.server()) :: [{atom(), keyword() | map(), pid()}]

All checkpoints observed since the last flush, oldest first, as {tag, meta, pid}.

in_flight(sched)

@spec in_flight(GenServer.server()) :: non_neg_integer()

Deliveries made but not yet settled by a :handled checkpoint (or receiver death).

now(sched, clock_id)

@spec now(GenServer.server(), clock_id()) :: integer()

Current virtual time of clock_id, ms. Creates the clock if absent.

pending(sched)

@spec pending(GenServer.server()) :: [event()]

Pending events, oldest first.

post(sched, dest, msg, opts \\ [])

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

skew(sched, clock_id, ms)

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

start_link(opts \\ [])

step(sched, event_id, opts \\ [])

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

timers(sched)

@spec timers(GenServer.server()) :: [map()]

Armed (not yet due) timers, next-due first. due_at/due_in are in the owning clock's time.