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
gen_server_virtual_time lib virtual_time_gen_server.ex
Raw

lib/virtual_time_gen_server.ex

defmodule VirtualTimeGenServer.Wrapper do
@moduledoc false
use GenServer
def init({init_fun, module}) do
case init_fun.() do
{:ok, {^module, state}} -> {:ok, {module, state}}
{:ok, {^module, state}, timeout} -> {:ok, {module, state}, timeout}
{:ok, {^module, state}, {:continue, arg}} -> {:ok, {module, state}, {:continue, arg}}
other -> other
end
end
def handle_call(request, from, {module, state}) do
# Handle internal stats request
case request do
:__vtgs_get_stats__ ->
stats = VirtualTimeGenServer.get_process_stats()
{:reply, stats, {module, state}}
_ ->
# Track incoming call only if stats tracking is enabled (in simulations)
if Process.get(:__vtgs_stats_enabled__) do
track_received_message(request, :call)
end
case module.handle_call(request, from, state) do
{:reply, reply, new_state} -> {:reply, reply, {module, new_state}}
{:reply, reply, new_state, timeout} -> {:reply, reply, {module, new_state}, timeout}
{:noreply, new_state} -> {:noreply, {module, new_state}}
{:noreply, new_state, timeout} -> {:noreply, {module, new_state}, timeout}
{:stop, reason, reply, new_state} -> {:stop, reason, reply, {module, new_state}}
{:stop, reason, new_state} -> {:stop, reason, {module, new_state}}
end
end
end
def handle_cast(request, {module, state}) do
# Track incoming cast only if stats tracking is enabled (in simulations)
if Process.get(:__vtgs_stats_enabled__) do
track_received_message(request, :cast)
end
case module.handle_cast(request, state) do
{:noreply, new_state} -> {:noreply, {module, new_state}}
{:noreply, new_state, timeout} -> {:noreply, {module, new_state}, timeout}
{:stop, reason, new_state} -> {:stop, reason, {module, new_state}}
end
end
def handle_info(msg, {module, state}) do
case module.handle_info(msg, state) do
{:noreply, new_state} -> {:noreply, {module, new_state}}
{:noreply, new_state, {:continue, arg}} -> {:noreply, {module, new_state}, {:continue, arg}}
{:noreply, new_state, timeout} -> {:noreply, {module, new_state}, timeout}
{:stop, reason, new_state} -> {:stop, reason, {module, new_state}}
end
end
def handle_continue(arg, {module, state}) do
if function_exported?(module, :handle_continue, 2) do
case module.handle_continue(arg, state) do
{:noreply, new_state} ->
{:noreply, {module, new_state}}
{:noreply, new_state, {:continue, next_arg}} ->
{:noreply, {module, new_state}, {:continue, next_arg}}
{:noreply, new_state, timeout} ->
{:noreply, {module, new_state}, timeout}
{:stop, reason, new_state} ->
{:stop, reason, {module, new_state}}
end
else
# Module doesn't implement handle_continue, just continue with no-op
{:noreply, {module, state}}
end
end
def terminate(reason, {module, state}) do
if function_exported?(module, :terminate, 2) do
module.terminate(reason, state)
else
:ok
end
end
def code_change(old_vsn, {module, state}, extra) do
if function_exported?(module, :code_change, 3) do
case module.code_change(old_vsn, state, extra) do
{:ok, new_state} -> {:ok, {module, new_state}}
other -> other
end
else
{:ok, {module, state}}
end
end
# Message tracking helpers (only used in simulations)
defp track_received_message(message, _type) do
# Skip internal messages
case message do
:get_stats ->
:ok
:__vtgs_get_stats__ ->
:ok
{:start_sending, _, _} ->
:ok
# Skip internal scheduled messages
:send_random_message ->
:ok
_ ->
# Only track if stats are enabled (in simulations)
if Process.get(:__vtgs_stats_enabled__) do
stats = Process.get(:__vtgs_stats__, %{sent_count: 0, received_count: 0})
new_stats = %{stats | received_count: stats.received_count + 1}
Process.put(:__vtgs_stats__, new_stats)
end
end
end
end
defmodule VirtualTimeGenServer do
@moduledoc """
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
end
## Testing 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
end
## Testing 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
end
## Clock Configuration Options
The virtual clock can be configured in three ways (in priority order):
1. **Local Clock Injection** - Pass `virtual_clock: clock_pid` to `start_link/3`:
- Highest priority, overrides global settings
- Useful for isolated simulations or testing components independently
- Each server can have its own timeline
2. **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
3. **Real Time** - Pass `real_time: true` or use `use_real_time/0`:
- Default behavior, uses `Process.send_after/3`
- For production or integration tests with external systems
See the "Development Documentation" for a detailed explanation of when to use
global vs local clocks.
"""
@doc """
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
"""
def set_virtual_clock(clock) do
Process.put(:virtual_clock, clock)
Process.put(:time_backend, VirtualTimeBackend)
end
@doc """
Uses real time (default behavior).
"""
def use_real_time do
Process.delete(:virtual_clock)
Process.put(:time_backend, RealTimeBackend)
end
@doc """
Gets the current time backend.
"""
def get_time_backend do
Process.get(:time_backend, RealTimeBackend)
end
@doc """
Sends a message after a delay using the configured time backend.
"""
def send_after(dest, message, delay) do
backend = get_time_backend()
backend.send_after(dest, message, delay)
end
@doc """
Cancels a timer created with send_after/3.
"""
def cancel_timer(ref) do
backend = get_time_backend()
backend.cancel_timer(ref)
end
defmacro __using__(_opts) do
quote do
use GenServer
@doc """
Sends a message to this process after a delay.
Works with both real and virtual time.
"""
def send_after_self(message, delay) do
VirtualTimeGenServer.send_after(self(), message, delay)
end
end
end
# Custom start_link that propagates virtual clock to child process
def start_link(module, init_arg, opts \\ []) do
# Extract time-related options from opts
{virtual_clock, opts} = Keyword.pop(opts, :virtual_clock)
{real_time, opts} = Keyword.pop(opts, :real_time, false)
# Determine which clock and backend to use
# Priority: local options > global Process dictionary
{final_clock, final_backend} = determine_time_config(virtual_clock, real_time)
# Get stats tracking flag from parent process
stats_enabled = Process.get(:__vtgs_stats_enabled__, false)
# Use a wrapper to inject virtual clock into spawned process
init_fun = fn ->
if final_clock do
Process.put(:virtual_clock, final_clock)
end
Process.put(:time_backend, final_backend)
# Propagate stats tracking to child process
if stats_enabled do
Process.put(:__vtgs_stats_enabled__, true)
Process.put(:__vtgs_stats__, %{sent_count: 0, received_count: 0})
end
case module.init(init_arg) do
{:ok, state} -> {:ok, {module, state}}
{:ok, state, {:continue, arg}} -> {:ok, {module, state}, {:continue, arg}}
{:ok, state, timeout} -> {:ok, {module, state}, timeout}
:ignore -> :ignore
{:stop, reason} -> {:stop, reason}
end
end
# Start with wrapper module
GenServer.start_link(VirtualTimeGenServer.Wrapper, {init_fun, module}, opts)
end
def start(module, init_arg, opts \\ []) do
# Extract time-related options from opts
{virtual_clock, opts} = Keyword.pop(opts, :virtual_clock)
{real_time, opts} = Keyword.pop(opts, :real_time, false)
# Determine which clock and backend to use
{final_clock, final_backend} = determine_time_config(virtual_clock, real_time)
# Get stats tracking flag from parent process
stats_enabled = Process.get(:__vtgs_stats_enabled__, false)
init_fun = fn ->
if final_clock do
Process.put(:virtual_clock, final_clock)
end
Process.put(:time_backend, final_backend)
# Propagate stats tracking to child process
if stats_enabled do
Process.put(:__vtgs_stats_enabled__, true)
Process.put(:__vtgs_stats__, %{sent_count: 0, received_count: 0})
end
case module.init(init_arg) do
{:ok, state} -> {:ok, {module, state}}
{:ok, state, {:continue, arg}} -> {:ok, {module, state}, {:continue, arg}}
{:ok, state, timeout} -> {:ok, {module, state}, timeout}
:ignore -> :ignore
{:stop, reason} -> {:stop, reason}
end
end
GenServer.start(VirtualTimeGenServer.Wrapper, {init_fun, module}, opts)
end
# Private helper to determine time configuration
# Priority: explicit local options > global Process dictionary
defp determine_time_config(nil, false) do
# No local options - use global settings
global_clock = Process.get(:virtual_clock)
global_backend = Process.get(:time_backend, RealTimeBackend)
{global_clock, global_backend}
end
defp determine_time_config(nil, true) do
# Explicit real_time: true - ignore global settings
{nil, RealTimeBackend}
end
defp determine_time_config(local_clock, _) when is_pid(local_clock) do
# Explicit local clock provided - use it regardless of global settings
{local_clock, VirtualTimeBackend}
end
@doc """
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.
"""
def call(server, request, timeout \\ 5000) do
# Only track if explicitly enabled - zero overhead otherwise
if Process.get(:__vtgs_stats_enabled__) do
track_sent_message(request, :call)
end
GenServer.call(server, request, timeout)
end
@doc """
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.
"""
def cast(server, request) do
# Only track if explicitly enabled - zero overhead otherwise
if Process.get(:__vtgs_stats_enabled__) do
track_sent_message(request, :cast)
end
GenServer.cast(server, request)
end
defdelegate stop(server, reason \\ :normal, timeout \\ :infinity), to: GenServer
# Message tracking helpers (only used in simulations)
defp track_sent_message(message, _type) do
# Skip internal messages
case message do
:get_stats ->
:ok
:__vtgs_get_stats__ ->
:ok
{:start_sending, _, _} ->
:ok
_ ->
stats = Process.get(:__vtgs_stats__, %{sent_count: 0, received_count: 0})
new_stats = %{stats | sent_count: stats.sent_count + 1}
Process.put(:__vtgs_stats__, new_stats)
end
end
@doc false
# Internal API for ActorSimulation to enable stats tracking
def enable_stats_tracking do
Process.put(:__vtgs_stats_enabled__, true)
Process.put(:__vtgs_stats__, %{sent_count: 0, received_count: 0})
end
@doc false
# Internal API for ActorSimulation to get process stats
def get_process_stats do
stats = Process.get(:__vtgs_stats__, %{sent_count: 0, received_count: 0})
%{
sent_count: stats.sent_count,
received_count: stats.received_count,
sent_messages: [],
received_messages: []
}
end
end