eta_time (eta v0.1.0)
Copy MarkdownA virtual clock and timer wheel — Phase 1 of the DST framework
(design: docs/design.md).
Drop-in replacements for the erlang timer and clock BIFs, backed by a clock that
only moves when the driver moves it. eta_transform rewrites a module's calls to
point here; nothing else about the module changes.
Two things follow, and the second is the one that pays for the phase:
- Determinism. A timeout fires at a point the schedule chooses, not whenever the machine happens to get there, so a run replays identically.
- Speed. Waiting out a one-second timeout costs nothing: with no process runnable, the driver jumps the clock straight to the next deadline. A run that spends most of its wall clock waiting collapses to the work it actually does.
No process of its own
State lives in ETS, and every operation is a direct table access from whichever
process is calling. That is deliberate. A gen_server here would make every
send_after/3 a blocking call, and a blocking call is quiescence as far as
eta_sched is concerned — so each timer set would end a step, cost a scheduling
choice, and pollute the recorded schedule with the framework's own traffic.
Concurrent access is safe because eta_sched guarantees only one process under
test runs at a time, with the driver the only other participant.
Inert unless started
Every function falls back to its real erlang counterpart when no clock is
running. A module compiled with the transform therefore behaves exactly as it did
before outside a simulation, which is what makes the transform safe to leave
enabled in a build that also runs ordinary tests.
The event loop
The driver alternates between stepping processes and advancing time:
eta_sched:run(Sched, MaxSteps, fun eta_time:advance_to_next/0)advance_to_next/0 is called only when nothing is runnable, and moves the clock
to the earliest pending deadline — so time advances in jumps between events, never
in ticks, and never while work remains to be done at the current instant.
Single instance
The tables are named, so one clock exists per VM. Simulations must not run concurrently in the same node.
This documentation is LLM-generated. See the AI disclosure in README.md.
Summary
Functions
Advances the clock by Ms, firing every timer that becomes due. Returns how many
fired.
Advances the clock to the earliest pending deadline and fires every timer due at it, in creation order.
Advances to the earliest deadline whose destination Schedulable accepts.
Arms the virtual timeout behind a rewritten receive ... after.
Arms a deadline a process sets for itself, delivered as Msg.
Cancels a timer, returning the milliseconds that were left, or false if it had
already fired or never existed — the same contract as erlang:cancel_timer/1.
Cancels an armed timeout and removes its message if it already landed.
Cancels a deadline armed by arm_self/2, removing its message if it already
landed.
The earliest pending deadline, or infinity if no timers are set.
The earliest pending deadline whose destination Schedulable accepts.
The virtual clock in milliseconds. Raises if no clock is running.
How many timers are currently pending.
Whether a virtual clock is running. When false, every call here delegates to erlang.
Sets the timer fault policy.
What eta_transform rewrites timer:sleep/1 to.
Starts the virtual clock.
Clock value, pending timer count, and how many timers have fired or been dropped.
How many distinct timers the clock has refused to advance to — see
advance_to_next/1.
Types
-type fault_opts() :: #{drop_p => float(), skew_ms => non_neg_integer()}.
Functions
-spec advance(non_neg_integer()) -> non_neg_integer().
Advances the clock by Ms, firing every timer that becomes due. Returns how many
fired.
advance_to_next/0 is usually what a run wants; this is for a test that needs to
say "and then a second passed" regardless of what is pending.
-spec advance_to_next() -> boolean().
Advances the clock to the earliest pending deadline and fires every timer due at it, in creation order.
Returns true if the clock moved, false if nothing was pending — which is the
signal the event loop uses to decide a run is over. Suitable as eta_sched:run/3's
idle callback.
Advances to the earliest deadline whose destination Schedulable accepts.
A timer is owned by a process, and the scheduler is not obliged to own that
process. A deadline belonging to one it does not own cannot make anything
runnable, so advancing to it moves the clock and records an entry on behalf of
something that can never take a step. Two runs of one seed then differ by an
inserted {clock, Ms} with no step behind it, which is a schedule decided by
something other than the seed.
There are 2 ordinary ways to get such a timer. A process killed while blocked in
a rewritten receive ... after never reaches the clause head that disarms it, so
its deadline outlives it. And a process spawned before the scheduler existed —
anything a system starts during init/2 — is outside the schedule by
construction.
Rejected timers are skipped, not cancelled. Cancelling would silently
disarm a live process's timer, and the goal is only that a stray stops deciding
when the clock moves. If time passes far enough for another reason, fire_due/1
delivers it as before.
advance_to_next/0 accepts everything, which is what you want when driving the
clock by hand with no scheduler to ask.
Arms the virtual timeout behind a rewritten receive ... after.
eta_transform turns the after block into an ordinary receive clause
waiting on {'$eta_after', Ref}, and this is what makes that message arrive. The
handle it returns is opaque and belongs to disarm_after/2.
infinity arms nothing — the generated clause then simply cannot match, which is
exactly receive ... end.
A zero timeout is delivered immediately rather than through a timer. after 0 is
a mailbox poll, not a wait: routing it through the timer wheel would block until
something advanced the clock, turning a non-blocking poll into a wait. Appending to
our own mailbox also keeps the ordering right, since a selective receive scans in
arrival order and therefore still prefers anything already queued.
Exempt from drop_p
Skewed like anything else, never dropped. drop_p models a timer lost with the
process or connection it belonged to (see set_faults/1), and this timer has
neither: a process arms it for itself, and the BEAM does not lose a receive
timeout. eta_net:call/3 arms its call deadline through here for the same
reason.
Dropping one is not a fault, it is a hang. The waiting process has nothing left
that can wake it, so the run stalls or burns its step budget, and the trace says
a timeout never fired rather than that a message was lost — a finding about the
framework wearing the shape of a finding about the system. send_after/3 and
start_timer/3 stay faultable, because a timer a system sets to fire at another
process is exactly the thing that can go missing with it.
-spec arm_self(non_neg_integer(), term()) -> reference().
Arms a deadline a process sets for itself, delivered as Msg.
arm_after/2 with the message chosen by the caller instead of fixed, and the
same reasoning applies to all of it: exempt from drop_p because a process
cannot lose a timer it armed for itself, subject to skew_ms because when it
lands relative to other work is a real question.
eta_statem is the caller — a gen_statem state time-out is exactly this
shape, a deadline the machine sets for itself. Pair with disarm_self/2.
-spec cancel_timer(reference()) -> non_neg_integer() | false.
Cancels a timer, returning the milliseconds that were left, or false if it had
already fired or never existed — the same contract as erlang:cancel_timer/1.
-spec cancel_timer(reference(), list()) -> non_neg_integer() | false | ok.
Cancels an armed timeout and removes its message if it already landed.
The transform calls this at the head of every ordinary receive clause rather
than after the receive, which is what keeps the clause bodies in tail position —
see eta_transform. Flushing is not optional: a timer that fired before its
receive matched something else leaves a {'$eta_after', Ref} nothing will ever
match again.
Cancels a deadline armed by arm_self/2, removing its message if it already
landed.
The flush is what makes a cancel a cancel rather than a race. A deadline that
fired just before this ran has already put Msg in the mailbox, and the caller
has by definition stopped expecting it — for a gen_statem that message would
otherwise arrive as an info event for a time-out that was called off.
Skipped when the cancel succeeded, since a timer removed before it fired never sent anything, and the flush is a selective receive that walks the whole mailbox before giving up.
-spec monotonic_time() -> integer().
-spec monotonic_time(erlang:time_unit()) -> integer().
-spec next_deadline() -> integer() | infinity.
The earliest pending deadline, or infinity if no timers are set.
The earliest pending deadline whose destination Schedulable accepts.
Timers it rejects are stepped over and left in the wheel. They no longer
decide when the clock moves, but if it reaches them for some other reason they
still fire. See advance_to_next/1 for why that distinction matters.
-spec now_ms() -> integer().
The virtual clock in milliseconds. Raises if no clock is running.
-spec pending() -> non_neg_integer().
How many timers are currently pending.
-spec read_timer(reference()) -> non_neg_integer() | false.
-spec running() -> boolean().
Whether a virtual clock is running. When false, every call here delegates to erlang.
-spec send_after(non_neg_integer(), pid() | atom(), term()) -> reference().
-spec set_faults(fault_opts()) -> ok.
Sets the timer fault policy.
drop_p— probability that a timer, once set, never fires. It remains cancellable and readable; it simply produces no message. Models a timer lost with the process or connection it belonged to.skew_ms— timers are created with their deadline perturbed uniformly by up to this many milliseconds either way (never earlier than now). Models a timeout firing in an unlucky window relative to other work.
Skew is applied at creation rather than at firing, so the deadline remains
meaningful to read_timer/1 and cancel_timer/1.
drop_p reaches send_after/3 and start_timer/3 only. A deadline armed
through arm_after/2 — a rewritten receive ... after, a rewritten
timer:sleep/1, or eta_net:call/3's timeout — is skewed but never dropped;
see arm_after/2 for why. skew_ms
applies to everything, which is what makes it the knob for the tie between two
timeouts written with the same number.
-spec sleep(timeout()) -> ok.
What eta_transform rewrites timer:sleep/1 to.
A sleep is receive after T -> ok end inside OTP's timer module, where no
transform of user code reaches — so the call site is rewritten instead, to
this: the after rewrite applied to a receive with no other clauses. Arm
through arm_after/2, wait for the delivery. Under a run the sleeper is
correctly unrunnable until the virtual clock delivers the wakeup, so a
60-second sleep costs microseconds and ends at a point the schedule chose.
With no clock running, arm_after/2 falls back to erlang:send_after/3 and
this is a real sleep.
No disarm: the receive has one clause and always consumes the message.
infinity arms nothing, so the receive blocks forever — which is
timer:sleep(infinity) exactly. Exempt from drop_p like every self-armed
deadline, and subject to skew_ms like every deadline; see arm_after/2.
The one place this must not run is the driver's own process. The clock
advances only to deadlines of processes the scheduler can step, so a sleep on
the driver arms a deadline nothing will ever reach. That is the existing
boundary rule — execute/2 and check/1 must not execute transformed
blocking code — applied to one more form of blocking.
-spec start() -> ok.
Starts a clock with default options. See start/1.
-spec start(#{start_ms => integer(), seed => integer(), faults => fault_opts()}) -> ok.
Starts the virtual clock.
Options:
start_ms— the clock's initial value (default 0).seed— seeds the fault RNG, so an injected timer fault schedule replays.faults— seeset_faults/1.
Idempotent in the sense that starting over a running clock resets it.
-spec start_timer(non_neg_integer(), pid() | atom(), term()) -> reference().
Clock value, pending timer count, and how many timers have fired or been dropped.
-spec stop() -> ok.
-spec strays() -> non_neg_integer().
How many distinct timers the clock has refused to advance to — see
advance_to_next/1.
Counted at the moment one is stepped over rather than by scanning what is still
pending, because a stray is usually gone by the end: some other timer advances
the clock past it and fire_due/1 delivers it on the way. The damage is done
when it is considered, not when it fires.
eta_run reports this as stray_timers, and audit/1 treats a non-zero count
as a system holding a timer the scheduler does not own.
-spec system_time() -> integer().
-spec system_time(erlang:time_unit()) -> integer().
-spec timestamp() -> erlang:timestamp().