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 - deprecated the initial state of the library
Current section
Files
Jump to
Current section
Files
lib/actor_simulation.ex
defmodule ActorSimulation do
@moduledoc """
A DSL for simulating actor systems with message rates and statistics.
This module provides a way to define actors, their message sending patterns,
and simulate their interactions using virtual time.
## Example
simulation =
ActorSimulation.new()
|> ActorSimulation.add_actor(:producer,
send_pattern: {:periodic, 100, {:data, :id}},
targets: [:consumer])
|> ActorSimulation.add_actor(:consumer,
on_receive: fn msg, state ->
# Process message and maybe send response
{:ok, state}
end)
|> ActorSimulation.run(duration: 5000)
stats = ActorSimulation.get_stats(simulation)
IO.inspect(stats)
"""
alias ActorSimulation.{Definition, Actor, Stats}
defstruct [:clock, :actors, :stats, :running, :trace, :trace_enabled]
@doc """
Creates a new actor simulation.
Options:
- `:trace` - Enable message tracing for sequence diagrams (default: false)
## Example
iex> simulation = ActorSimulation.new()
iex> is_pid(simulation.clock)
true
iex> simulation.actors
%{}
"""
def new(opts \\ []) do
{:ok, clock} = VirtualClock.start_link()
trace_enabled = Keyword.get(opts, :trace, false)
%__MODULE__{
clock: clock,
actors: %{},
stats: Stats.new(),
running: false,
trace: [],
trace_enabled: trace_enabled
}
end
@doc """
Enables message tracing for the simulation.
"""
def enable_trace(simulation) do
%{simulation | trace_enabled: true}
end
@doc """
Adds an actor to the simulation.
Options:
- `:send_pattern` - How this actor sends messages:
- `{:periodic, interval, message}` - Send message every interval ms
- `{:rate, messages_per_second, message}` - Send at a specific rate
- `{:burst, count, interval, message}` - Send count messages every interval
- `:targets` - List of actor names to send messages to
- `:on_receive` - Function called when receiving a message: `fn msg, state -> {:ok, new_state} | {:send, msgs, new_state} end`
- `:on_match` - Pattern matching responses: `[{pattern, response_fn}]`
- `:initial_state` - Initial state for the actor (default: %{})
"""
def add_actor(simulation, name, opts \\ []) do
actor_def = Definition.new(name, opts)
{:ok, pid} = Actor.start_link(actor_def, simulation.clock)
actors = Map.put(simulation.actors, name, %{pid: pid, definition: actor_def, type: :simulated})
%{simulation | actors: actors}
end
@doc """
Adds a real GenServerVirtualTime process to the simulation ("Process in the Loop").
This allows you to test real GenServer implementations alongside simulated actors.
Options:
- `:module` - The GenServer module to start (required)
- `:args` - Arguments to pass to the module's init/1
- `:targets` - List of actor names this process can send to (optional)
## Example
defmodule MyRealServer do
use VirtualTimeGenServer
def init(args), do: {:ok, args}
def handle_call(:ping, _from, state), do: {:reply, :pong, state}
end
simulation =
ActorSimulation.new()
|> ActorSimulation.add_process(:my_server, module: MyRealServer, args: %{})
|> ActorSimulation.add_actor(:pinger,
send_pattern: {:periodic, 100, {:call, :my_server, :ping}})
"""
def add_process(simulation, name, opts \\ []) do
module = Keyword.fetch!(opts, :module)
args = Keyword.get(opts, :args, nil)
targets = Keyword.get(opts, :targets, [])
# Start the real GenServer with virtual time
VirtualTimeGenServer.set_virtual_clock(simulation.clock)
{:ok, pid} = VirtualTimeGenServer.start_link(module, args, [])
actors = Map.put(simulation.actors, name, %{
pid: pid,
type: :real_process,
module: module,
targets: targets
})
%{simulation | actors: actors}
end
@doc """
Runs the simulation for the specified duration (in milliseconds).
"""
def run(simulation, opts \\ []) do
duration = Keyword.get(opts, :duration, 10_000)
# Register trace collector if tracing enabled
if simulation.trace_enabled do
# Unregister if already exists
case Process.whereis(:trace_collector) do
nil -> Process.register(self(), :trace_collector)
pid when pid == self() -> :ok # Already registered to us
_other -> Process.unregister(:trace_collector); Process.register(self(), :trace_collector)
end
end
# Start all actors (only simulated actors need setup)
Enum.each(simulation.actors, fn {_name, actor_info} ->
case actor_info.type do
:simulated ->
Actor.start_sending(actor_info.pid, simulation.actors, simulation.trace_enabled)
:real_process ->
# Real processes are already started and don't need actor map
:ok
end
end)
# Advance virtual time
VirtualClock.advance(simulation.clock, duration)
# Collect statistics and trace
stats = collect_stats(simulation)
trace = if simulation.trace_enabled, do: collect_trace(), else: []
%{simulation | stats: stats, trace: trace, running: false}
end
@doc """
Gets statistics from the simulation.
"""
def get_stats(simulation) do
simulation.stats
end
@doc """
Gets the message trace from the simulation.
Returns a list of trace events for building sequence diagrams.
Each event is a map with:
- `:timestamp` - Virtual time when message was sent
- `:from` - Sender actor name
- `:to` - Receiver actor name
- `:message` - The message sent
- `:type` - `:cast`, `:call`, or `:send`
"""
def get_trace(simulation) do
simulation.trace
end
@doc """
Formats the trace as a PlantUML sequence diagram.
## Example
simulation = ActorSimulation.new(trace: true)
|> add_actor(:client, send_pattern: {:periodic, 100, :ping}, targets: [:server])
|> add_actor(:server)
|> run(duration: 200)
plantuml = ActorSimulation.trace_to_plantuml(simulation)
File.write!("sequence.puml", plantuml)
"""
def trace_to_plantuml(simulation) do
lines = ["@startuml", ""]
message_lines = Enum.map(simulation.trace, fn event ->
arrow = case event.type do
:call -> "->>"
:cast -> "->>"
:send -> "->"
end
msg = inspect(event.message)
"#{event.from} #{arrow} #{event.to}: #{msg}"
end)
lines ++ message_lines ++ ["", "@enduml"]
|> Enum.join("\n")
end
@doc """
Formats the trace as a Mermaid sequence diagram with enhanced styling.
Mermaid is widely supported in GitHub, GitLab, and many markdown viewers.
Uses features from [Mermaid sequence diagrams](https://docs.mermaidchart.com/mermaid-oss/syntax/sequenceDiagram.html):
- Different arrow types for call/cast/send
- Activation boxes for processing
- Notes with timestamps
- Background highlighting for grouped interactions
## Example
iex> simulation = %ActorSimulation{trace: [
...> %{from: :alice, to: :bob, message: :hello, type: :send, timestamp: 100},
...> %{from: :bob, to: :alice, message: :hi, type: :send, timestamp: 200}
...> ]}
iex> mermaid = ActorSimulation.trace_to_mermaid(simulation)
iex> String.contains?(mermaid, "sequenceDiagram")
true
iex> String.contains?(mermaid, "alice->>bob")
true
"""
def trace_to_mermaid(simulation, opts \\ []) do
enhanced = Keyword.get(opts, :enhanced, true)
show_timestamps = Keyword.get(opts, :timestamps, false)
lines = ["sequenceDiagram"]
# Group events by time ranges for background highlighting
message_lines = if enhanced do
generate_enhanced_mermaid(simulation.trace, show_timestamps)
else
generate_simple_mermaid(simulation.trace)
end
(lines ++ message_lines)
|> Enum.join("\n")
end
defp generate_enhanced_mermaid(trace, show_timestamps) do
trace
|> Enum.map(fn event ->
# Different arrow styles based on message type
# See: https://docs.mermaidchart.com/mermaid-oss/syntax/sequenceDiagram.html#messages
arrow = case event.type do
:call -> "->>" # Solid line with arrowhead (synchronous)
:cast -> "-->>" # Dotted line with arrowhead (asynchronous)
:send -> "->>" # Solid line with arrowhead
end
msg = inspect(event.message)
# Add activation for calls (shows processing)
lines = if event.type == :call do
[
" activate #{event.to}",
" #{event.from}#{arrow}#{event.to}: #{msg}",
" deactivate #{event.to}"
]
else
[" #{event.from}#{arrow}#{event.to}: #{msg}"]
end
# Add timestamp note if requested
if show_timestamps && event.timestamp do
lines ++ [" Note over #{event.from},#{event.to}: t=#{event.timestamp}ms"]
else
lines
end
end)
|> List.flatten()
end
defp generate_simple_mermaid(trace) do
Enum.map(trace, fn event ->
arrow = case event.type do
:call -> "->>"
:cast -> "->>"
:send -> "->>"
end
msg = inspect(event.message)
" #{event.from}#{arrow}#{event.to}: #{msg}"
end)
end
@doc """
Stops the simulation and cleans up resources.
"""
def stop(simulation) do
Enum.each(simulation.actors, fn {_name, %{pid: pid}} ->
GenServer.stop(pid, :normal, :infinity)
end)
GenServer.stop(simulation.clock)
:ok
end
# Private functions
defp collect_stats(simulation) do
Enum.reduce(simulation.actors, simulation.stats, fn {name, actor_info}, stats ->
case actor_info.type do
:simulated ->
actor_stats = Actor.get_stats(actor_info.pid)
Stats.add_actor_stats(stats, name, actor_stats)
:real_process ->
# For real processes, we can't easily get stats unless they implement a stats protocol
# For now, add empty stats
Stats.add_actor_stats(stats, name, %{sent_count: 0, received_count: 0, sent_messages: [], received_messages: []})
end
end)
end
defp collect_trace do
# Collect all trace messages
collect_trace_messages([])
end
defp collect_trace_messages(acc) do
receive do
{:trace, event} -> collect_trace_messages([event | acc])
after
0 -> Enum.reverse(acc)
end
end
end