Packages
Virtual time extension to GenServer and GenStateMachine allowing testing time-based actor systems orders of magnitude faster than in wallclock-time. Includes actor simulation DSL with statistics, tracing, and code generation into other Actor Model implementations in C++, Pony, Go, Rust, Java.
Retired package: Deprecated - retired
Current section
Files
Jump to
Current section
Files
lib/virtual_clock.ex
defmodule VirtualClock do
@moduledoc """
A GenServer that manages virtual time for testing time-dependent behaviors.
The VirtualClock maintains a virtual timestamp and scheduled events.
Time can be advanced manually, triggering all events scheduled up to that point.
## Example
iex> {:ok, clock} = VirtualClock.start_link()
iex> VirtualClock.now(clock)
0
iex> VirtualClock.advance(clock, 1000)
{:ok, 1000}
iex> VirtualClock.now(clock)
1000
"""
use GenServer
defmodule State do
@moduledoc false
defstruct current_time: 0, scheduled: []
end
defmodule ScheduledEvent do
@moduledoc false
defstruct [:trigger_time, :dest, :message, :ref]
end
# Client API
@doc """
Starts a new virtual clock.
"""
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, :ok, opts)
end
@doc """
Gets the current virtual time.
"""
def now(clock) do
GenServer.call(clock, :now)
end
@doc """
Schedules a message to be sent after a delay in virtual time.
Returns a reference that can be used to cancel the timer.
"""
def send_after(clock, dest, message, delay) do
GenServer.call(clock, {:send_after, dest, message, delay})
end
@doc """
Cancels a scheduled timer.
"""
def cancel_timer(clock, ref) do
GenServer.call(clock, {:cancel_timer, ref})
end
@doc """
Advances the virtual clock by the specified amount.
All events scheduled up to the new time will be triggered.
This advances time incrementally, processing each scheduled event
and allowing new events to be scheduled in response.
"""
def advance(clock, amount) do
GenServer.call(clock, {:advance, amount}, :infinity)
end
@doc """
Advances the virtual clock to the next scheduled event.
Returns the time advanced, or 0 if no events are scheduled.
"""
def advance_to_next(clock) do
GenServer.call(clock, :advance_to_next)
end
@doc """
Returns the number of events currently scheduled.
"""
def scheduled_count(clock) do
GenServer.call(clock, :scheduled_count)
end
# Server callbacks
@impl true
def init(:ok) do
{:ok, %State{}}
end
@impl true
def handle_call(:now, _from, state) do
{:reply, state.current_time, state}
end
@impl true
def handle_call({:send_after, dest, message, delay}, _from, state) do
ref = make_ref()
trigger_time = state.current_time + delay
event = %ScheduledEvent{
trigger_time: trigger_time,
dest: dest,
message: message,
ref: ref
}
new_scheduled = [event | state.scheduled]
{:reply, ref, %{state | scheduled: new_scheduled}}
end
@impl true
def handle_call({:cancel_timer, ref}, _from, state) do
new_scheduled = Enum.reject(state.scheduled, fn event -> event.ref == ref end)
result = if length(new_scheduled) < length(state.scheduled), do: :ok, else: false
{:reply, result, %{state | scheduled: new_scheduled}}
end
@impl true
def handle_call({:advance, amount}, from, state) do
target_time = state.current_time + amount
# Start the advance process
send(self(), {:do_advance, target_time, from})
{:noreply, state}
end
@impl true
def handle_call(:advance_to_next, _from, state) do
case find_next_event_time(state.scheduled) do
nil ->
{:reply, 0, state}
next_time ->
amount = next_time - state.current_time
{triggered, remaining} = split_events(state.scheduled, next_time)
Enum.each(triggered, fn event ->
send(event.dest, event.message)
end)
{:reply, amount, %{state | current_time: next_time, scheduled: remaining}}
end
end
@impl true
def handle_call(:scheduled_count, _from, state) do
{:reply, length(state.scheduled), state}
end
@impl true
def handle_info({:do_advance, target_time, from}, state) do
case find_next_event_time_up_to(state.scheduled, target_time) do
nil ->
# No more events, finish advance
new_state = %{state | current_time: target_time}
GenServer.reply(from, {:ok, target_time})
{:noreply, new_state}
next_time ->
# Trigger all events at this exact time
{triggered, remaining} = split_events_at_time(state.scheduled, next_time)
Enum.each(triggered, fn event ->
send(event.dest, event.message)
end)
# Update state and continue advancing
# Add a tiny delay to allow other processes to handle messages
new_state = %{state | current_time: next_time, scheduled: remaining}
# Schedule continuation with a tiny delay for message processing
# The delay allows triggered processes to handle their messages
# and make new send_after calls that we'll process
:erlang.send_after(0, self(), {:do_advance, target_time, from})
{:noreply, new_state}
end
end
# Private helpers
defp split_events(scheduled, time) do
Enum.split_with(scheduled, fn event -> event.trigger_time <= time end)
end
defp split_events_at_time(scheduled, time) do
Enum.split_with(scheduled, fn event -> event.trigger_time == time end)
end
defp find_next_event_time([]), do: nil
defp find_next_event_time(scheduled) do
scheduled
|> Enum.map(& &1.trigger_time)
|> Enum.min()
end
defp find_next_event_time_up_to([], _target), do: nil
defp find_next_event_time_up_to(scheduled, target_time) do
scheduled
|> Enum.filter(fn event -> event.trigger_time <= target_time end)
|> case do
[] -> nil
events -> events |> Enum.map(& &1.trigger_time) |> Enum.min()
end
end
end