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 - previous versions were buggy
Current section
Files
Jump to
Current section
Files
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
result =
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
# Auto-send ack to VirtualClock after processing message (transparent to user)
send_ack_to_virtual_clock()
result
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
result =
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
# Auto-send ack to VirtualClock AFTER processing message (transparent to user)
send_ack_to_virtual_clock()
result
end
def handle_info(msg, {module, state}) do
# Handle delayed ack messages first
case msg do
{:send_ack_to_clock, clock_pid} ->
# Now send the actual ack - any send_after calls have been processed
send(clock_pid, {:actor_processed, self()})
{:noreply, {module, state}}
_ ->
# Skip acks for internal VirtualClock messages to avoid infinite loops
if match?({:actor_processed, _}, msg) do
# Just pass through internal VirtualClock messages
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
else
result =
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
# Auto-send ack to VirtualClock AFTER processing message (transparent to user)
send_ack_to_virtual_clock()
result
end
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
# Send acknowledgment to VirtualClock that this actor finished processing
defp send_ack_to_virtual_clock do
# Only send ack if we're using virtual time (not real time)
case Process.get(:virtual_clock) do
# Real time mode - no ack needed
nil ->
:ok
clock_pid when is_pid(clock_pid) ->
# IO.puts("DEBUG ACK: Actor #{inspect(self())} scheduling delayed ack to VirtualClock")
# Send ack asynchronously AFTER any send_after calls in message handler
# This ensures the actor has completed all scheduling before we ack
send(self(), {:send_ack_to_clock, clock_pid})
:ok
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
# Get caller information from stacktrace
caller_info = get_caller_info()
# Emit a compilation warning to alert users about potential race conditions
IO.warn(
"""
⚠️ GLOBAL VIRTUAL CLOCK INJECTION DETECTED ⚠️
VirtualTimeGenServer.set_virtual_clock/1 sets a GLOBAL virtual clock that affects
ALL child processes. This can cause race conditions in tests and production!
Consider using test-local virtual clocks instead:
# ❌ Global (can cause race conditions)
VirtualTimeGenServer.set_virtual_clock(clock)
{:ok, server} = MyServer.start_link([])
# ✅ Test-local (isolated, safe)
{:ok, server} = MyServer.start_link([], virtual_clock: clock)
For coordinated simulations, use global clocks intentionally.
For isolated testing, use test-local clocks.
""",
caller_info
)
Process.put(:virtual_clock, clock)
Process.put(:time_backend, VirtualTimeBackend)
end
@doc """
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
"""
def set_virtual_clock(clock, :i_know_what_i_am_doing, explanation)
when is_binary(explanation) do
Process.put(:virtual_clock, clock)
Process.put(:time_backend, VirtualTimeBackend)
end
@doc """
Uses real time (default behavior).
"""
def use_real_time do
# Get caller information from stacktrace
caller_info = get_caller_info()
# Emit a compilation warning to alert users about global time backend changes
IO.warn(
"""
⚠️ GLOBAL TIME BACKEND CHANGE DETECTED ⚠️
VirtualTimeGenServer.use_real_time/0 changes the GLOBAL time backend for
ALL child processes. This can cause race conditions in tests and production!
Consider using test-local time backend instead:
# ❌ Global (can cause race conditions)
VirtualTimeGenServer.use_real_time()
{:ok, server} = MyServer.start_link([])
# ✅ Test-local (isolated, safe)
{:ok, server} = MyServer.start_link([], real_time: true)
For production, the default is already real time.
For testing, use test-local virtual clocks.
""",
caller_info
)
Process.delete(:virtual_clock)
Process.put(:time_backend, RealTimeBackend)
end
@doc """
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
"""
def use_real_time(:i_know_what_i_am_doing, explanation) when is_binary(explanation) 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 """
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:
```elixir
# ❌ 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.
"""
def set_global_trace_collector(collector_pid) do
# Get caller information from stacktrace
caller_info = get_caller_info()
# Emit a compilation warning to alert users about potential race conditions
IO.warn(
"""
⚠️ GLOBAL TRACE COLLECTOR INJECTION DETECTED ⚠️
VirtualTimeGenServer.set_global_trace_collector/1 sets a GLOBAL trace collector that affects
ALL child processes. This can cause race conditions in tests and production!
Consider using test-local trace collectors 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 collectors.
""",
caller_info
)
Process.put(:global_trace_collector, collector_pid)
end
@doc """
Warning-free version of set_global_trace_collector/1 for intentional global usage.
"""
def set_global_trace_collector(collector_pid, :i_know_what_i_am_doing, explanation)
when is_binary(explanation) do
Process.put(:global_trace_collector, collector_pid)
end
@doc """
Gets the current global trace collector.
"""
def get_global_trace_collector do
Process.get(:global_trace_collector)
end
@doc """
Sends a message to a process after a delay (in milliseconds).
Uses the appropriate backend based on the current process configuration.
"""
def send_after(dest, message, delay) do
backend = get_time_backend()
backend.send_after(dest, message, delay)
end
@doc """
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})
"""
def send_immediately(dest, message) do
backend = get_time_backend()
backend.send_immediately(dest, message)
end
@doc """
Cancels a timer created with send_after/3.
Uses the appropriate backend based on the current process configuration.
"""
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.
Uses the appropriate backend based on the current process configuration.
"""
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)
{stats_enabled, opts} = Keyword.pop(opts, :stats_enabled, 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)
# Use injected stats_enabled option, fallback to parent process if not provided
final_stats_enabled = 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 final_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
case GenServer.start_link(VirtualTimeGenServer.Wrapper, {init_fun, module}, opts) do
{:ok, pid} -> {:ok, pid}
error -> error
end
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)
{stats_enabled, opts} = Keyword.pop(opts, :stats_enabled, false)
# Determine which clock and backend to use
{final_clock, final_backend} = determine_time_config(virtual_clock, real_time)
# Use injected stats_enabled option, fallback to parent process if not provided
final_stats_enabled = 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 final_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 """
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:
```elixir
# ❌ 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.
"""
def enable_stats_tracking do
# Get caller information from stacktrace
caller_info = get_caller_info()
# Emit a compilation warning to alert users about potential race conditions
IO.warn(
"""
⚠️ GLOBAL STATS TRACKING DETECTED ⚠️
VirtualTimeGenServer.enable_stats_tracking/0 sets GLOBAL stats tracking that affects
ALL child processes. 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.
""",
caller_info
)
Process.put(:__vtgs_stats_enabled__, true)
Process.put(:__vtgs_stats__, %{sent_count: 0, received_count: 0})
end
@doc """
Warning-free version of enable_stats_tracking/0 for intentional global usage.
"""
def enable_stats_tracking(:i_know_what_i_am_doing, explanation) when is_binary(explanation) 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
VirtualTimeGenServer.send_immediately(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
# Helper function to get caller information from stacktrace
defp get_caller_info do
case Process.info(self(), :current_stacktrace) do
{:current_stacktrace, stacktrace} ->
# Find the first external caller (not from this module)
case find_external_caller(stacktrace) do
{_module, _function, _arity, location} ->
file = Keyword.get(location, :file, "unknown")
line = Keyword.get(location, :line, 0)
[file: to_string(file), line: line]
nil ->
[]
end
_ ->
[]
end
end
defp find_external_caller(stacktrace) do
Enum.find_value(stacktrace, fn
{module, function, arity, location} when module != __MODULE__ ->
{module, function, arity, location}
_ ->
nil
end)
end
end