A behavior module for GenServers with virtual time support.
This module wraps GenServer and provides a send_after/3 function that can
work with either real time (production) or virtual time (testing).
Example
defmodule MyTimedServer do
use VirtualTimeGenServer
def start_link(opts) do
VirtualTimeGenServer.start_link(__MODULE__, :ok, opts)
end
@impl true
def init(:ok) do
# Schedule a tick every 1000ms
schedule_tick()
{:ok, %{count: 0}}
end
@impl true
def handle_info(:tick, state) do
new_count = state.count + 1
schedule_tick()
{:noreply, %{state | count: new_count}}
end
defp schedule_tick do
VirtualTimeGenServer.send_after(self(), :tick, 1000)
end
endTesting with Virtual Time - Global Clock (Coordinated Simulation)
test "server ticks correctly" do
{:ok, clock} = VirtualClock.start_link()
VirtualTimeGenServer.set_virtual_clock(clock)
{:ok, server} = MyTimedServer.start_link([])
# Advance virtual time by 5 seconds
VirtualClock.advance(clock, 5000)
# Server will have ticked 5 times
assert get_count(server) == 5
endTesting with Virtual Time - Local Clock (Isolated Simulation)
test "isolated simulation with local clock" do
{:ok, clock} = VirtualClock.start_link()
# Pass clock directly to this specific server
{:ok, server} = VirtualTimeGenServer.start_link(MyTimedServer, :ok, virtual_clock: clock)
# Advance only this server's timeline
VirtualClock.advance(clock, 5000)
assert get_count(server) == 5
endClock Configuration Options
The virtual clock can be configured in three ways (in priority order):
Local Clock Injection - Pass
virtual_clock: clock_pidtostart_link/3:- Highest priority, overrides global settings
- Useful for isolated simulations or testing components independently
- Each server can have its own timeline
Global Clock - Use
set_virtual_clock/1:- Inherited by all child processes
- Essential for coordinated actor systems where timing relationships matter
- All actors share the same timeline
Real Time - Pass
real_time: trueor useuse_real_time/0:- Default behavior, uses
Process.send_after/3 - For production or integration tests with external systems
- Default behavior, uses
See the "Development Documentation" for a detailed explanation of when to use global vs local clocks.
Summary
Functions
Makes a synchronous call to a server.
Cancels a timer created with send_after/3. Uses the appropriate backend based on the current process configuration.
Sends an asynchronous request to a server.
Sets GLOBAL stats tracking for all child processes.
Warning-free version of enable_stats_tracking/0 for intentional global usage.
Gets the current global trace collector.
Gets the current time backend.
Sends a message to a process after a delay in milliseconds. Uses the appropriate backend based on the current process configuration.
Sends a message immediately in virtual time.
Sets a GLOBAL trace collector for all child processes.
Warning-free version of set_global_trace_collector/1 for intentional global usage.
Sets the virtual clock for the current process. All child processes will inherit this setting.
Sets the virtual clock for the current process without emitting warnings.
Sleeps for the specified duration in milliseconds.
Uses real time (default behavior).
Uses real time without emitting warnings.
Functions
Makes a synchronous call to a server.
When stats tracking is enabled (in simulations), this tracks the sent message. Otherwise, it's a direct passthrough to GenServer.call with zero overhead.
Cancels a timer created with send_after/3. Uses the appropriate backend based on the current process configuration.
Sends an asynchronous request to a server.
When stats tracking is enabled (in simulations), this tracks the sent message. Otherwise, it's a direct passthrough to GenServer.cast with zero overhead.
Sets GLOBAL stats tracking for all child processes.
⚠️ WARNING: This can cause race conditions in tests and production!
Consider using test-local stats injection instead:
# ❌ Global (can cause race conditions)
VirtualTimeGenServer.enable_stats_tracking()
{:ok, server} = MyServer.start_link([])
# ✅ Test-local (isolated, safe)
{:ok, server} = MyServer.start_link([], stats_enabled: true)For coordinated stats tracking, use global tracking intentionally. For isolated testing, use test-local stats injection.
Warning-free version of enable_stats_tracking/0 for intentional global usage.
Gets the current global trace collector.
Gets the current time backend.
Sends a message to a process after a delay in milliseconds. Uses the appropriate backend based on the current process configuration.
Sends a message immediately in virtual time.
With virtual time, this schedules the message for the current virtual time, ensuring it gets processed in the next event cycle. With real time, this sends the message immediately.
This is useful for triggering immediate responses or state changes within the virtual time simulation.
Examples
# Send immediate message to self
VirtualTimeGenServer.send_immediately(self(), :process_now)
# Send immediate message to another process
VirtualTimeGenServer.send_immediately(other_pid, {:urgent, data})
Sets a GLOBAL trace collector for all child processes.
⚠️ WARNING: This can cause race conditions in tests and production!
Consider using test-local trace injection instead:
# ❌ Global (can cause race conditions)
VirtualTimeGenServer.set_global_trace_collector(collector_pid)
{:ok, server} = MyServer.start_link([])
# ✅ Test-local (isolated, safe)
{:ok, server} = MyServer.start_link([], trace_collector: collector_pid)For coordinated tracing, use global collectors intentionally. For isolated testing, use test-local trace injection.
Warning-free version of set_global_trace_collector/1 for intentional global usage.
Sets the virtual clock for the current process. All child processes will inherit this setting.
Example
iex> {:ok, clock} = VirtualClock.start_link()
iex> VirtualTimeGenServer.set_virtual_clock(clock)
VirtualTimeBackend
iex> VirtualTimeGenServer.get_time_backend()
VirtualTimeBackend
Sets the virtual clock for the current process without emitting warnings.
Use this when you intentionally want global virtual clock behavior and understand the implications. The explanation message should describe why global clock is needed.
Example
iex> {:ok, clock} = VirtualClock.start_link()
iex> VirtualTimeGenServer.set_virtual_clock(clock, :i_know_what_i_am_doing, "coordinated simulation")
VirtualTimeBackend
Sleeps for the specified duration in milliseconds.
With virtual time, this advances the actor's position in the timeline without consuming real time. With real time, this blocks using Process.sleep/1.
This is useful for simulating work or processing delays within actors.
Examples
# In a GenServer callback - simulate 100ms of processing time
def handle_call(:compute, _from, state) do
VirtualTimeGenServer.sleep(100) # Simulated work
{:reply, :done, state}
end
# With virtual time: returns instantly but advances virtual clock
# With real time: blocks for 100ms
See GenServer.stop/3.
Uses real time (default behavior).
Uses real time without emitting warnings.
Use this when you intentionally want global real time behavior and understand the implications. The explanation message should describe why global real time is needed.
Example
iex> VirtualTimeGenServer.use_real_time(:i_know_what_i_am_doing, "production mode")
RealTimeBackend