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: Security issue - previous versions were buggy

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 requests
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)
# Generate trace event for incoming message
VirtualTimeGenServer.generate_incoming_trace_event(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)
# Generate trace event for incoming message
VirtualTimeGenServer.generate_incoming_trace_event(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
@doc """
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
"""
def sleep(duration) do
backend = get_time_backend()
backend.sleep(duration)
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
# Generate trace events for incoming messages (only used in simulations)
def generate_incoming_trace_event(message, type) do
# Skip internal messages
case message do
:get_stats -> :ok
:__vtgs_get_stats__ -> :ok
{:start_sending, _, _} -> :ok
_ -> send_incoming_trace_event(message, type)
end
end
defp send_incoming_trace_event(message, type) do
case Process.whereis(:trace_collector) do
nil ->
:ok
pid ->
# Get actor name from process context
actor_name = Process.get(:__vtgs_actor_name__)
if actor_name do
# For incoming messages, we don't know the sender, so we use a generic source
# The diagram generator can infer connections from the message patterns
send(pid, {:trace,
%{
timestamp: VirtualClock.now(Process.get(:virtual_clock)),
# We don't know the sender for incoming messages
from: :unknown,
to: actor_name,
message: message,
type: type
}})
end
end
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