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.

Current section

Files

Jump to
gen_server_virtual_time lib actor_simulation caf_generator.ex
Raw

lib/actor_simulation/caf_generator.ex

defmodule ActorSimulation.CAFGenerator do
@moduledoc """
Generates C++ Actor Framework (CAF) code from ActorSimulation DSL.
This module translates ActorSimulation definitions into production-ready
CAF C++ projects with:
- Typed actor implementations
- Callback interfaces for custom behavior
- CMake build configuration with Conan
- CI pipeline for automated testing
- Complete documentation
## Key Features
- **Callback Modules**: Generate callback interfaces so users can add custom
behavior WITHOUT touching generated code
- **Type-Safe**: Uses CAF's typed actors for compile-time message checking
- **Modern C++17**: Clean, idiomatic C++ code
- **Production Ready**: Includes CI/CD pipeline and build system
## Example
simulation = ActorSimulation.new()
|> ActorSimulation.add_actor(:sender,
send_pattern: {:periodic, 100, :msg},
targets: [:receiver])
|> ActorSimulation.add_actor(:receiver)
{:ok, files} = CAFGenerator.generate(simulation,
project_name: "MyActors",
enable_callbacks: true)
CAFGenerator.write_to_directory(files, "caf_output/")
"""
@doc """
Generates complete CAF project files from an ActorSimulation.
## Options
- `:project_name` (required) - Name of the C++ project
- `:enable_callbacks` (default: true) - Generate callback interfaces
- `:caf_version` (default: "1.0.2") - CAF version for Conan
## Returns
`{:ok, files}` where files is a list of `{filename, content}` tuples
"""
def generate(simulation, opts \\ []) do
project_name = Keyword.fetch!(opts, :project_name)
enable_callbacks = Keyword.get(opts, :enable_callbacks, true)
caf_version = Keyword.get(opts, :caf_version, "1.0.2")
actors = simulation.actors
files =
[]
|> add_atoms_header(actors)
|> add_actor_files(actors, enable_callbacks)
|> add_main_file(actors, project_name)
|> add_test_files(actors, project_name)
|> add_cmake_file(actors, project_name)
|> add_conan_file(caf_version)
|> add_ci_pipeline()
|> add_readme(project_name)
{:ok, files}
end
@doc """
Writes generated files to a directory.
Creates the directory and all subdirectories as needed.
"""
def write_to_directory(files, output_dir) do
ActorSimulation.GeneratorUtils.write_to_directory(files, output_dir)
end
# Private functions
defp add_atoms_header(files, actors) do
content = generate_atoms_header(actors)
[{"atoms.hpp", content} | files]
end
defp add_actor_files(files, actors, enable_callbacks) do
Enum.reduce(actors, files, fn {name, actor_info}, acc ->
case actor_info.type do
:simulated ->
definition = actor_info.definition
actor_name = actor_snake_case(name)
header = generate_actor_header(name, definition, enable_callbacks)
source = generate_actor_source(name, definition, enable_callbacks)
new_files = [
{"#{actor_name}_actor.cpp", source},
{"#{actor_name}_actor.hpp", header}
]
new_files =
if enable_callbacks do
callback_impl = generate_callback_impl(name, definition)
new_files ++ [{"#{actor_name}_callbacks_impl.cpp", callback_impl}]
else
new_files
end
new_files ++ acc
:real_process ->
# Skip real processes for CAF generation
acc
end
end)
end
defp add_main_file(files, actors, project_name) do
content = generate_main(actors, project_name)
[{"main.cpp", content} | files]
end
defp add_test_files(files, actors, project_name) do
test_content = generate_test_file(actors, project_name)
[{"test_actors.cpp", test_content} | files]
end
defp add_cmake_file(files, actors, project_name) do
content = generate_cmake(actors, project_name)
[{"CMakeLists.txt", content} | files]
end
defp add_conan_file(files, caf_version) do
content = generate_conan(caf_version)
[{"conanfile.txt", content} | files]
end
defp add_ci_pipeline(files) do
content = generate_ci_pipeline()
[{".github/workflows/ci.yml", content} | files]
end
defp add_readme(files, project_name) do
content = generate_readme(project_name)
[{"README.md", content} | files]
end
defp generate_actor_header(name, definition, enable_callbacks) do
actor_name = actor_snake_case(name)
class_name = "#{actor_name}_actor"
callback_interface =
if enable_callbacks do
generate_callback_interface_definition(name, definition)
else
""
end
callback_member =
if enable_callbacks do
"""
std::shared_ptr<#{actor_name}_callbacks> callbacks_;
"""
else
""
end
target_names = Enum.map(definition.targets, &actor_snake_case/1)
target_members =
if length(target_names) > 0 do
"""
std::vector<caf::actor> targets_;
int send_count_ = 0;
"""
else
""
end
"""
// Generated from ActorSimulation DSL
// Actor: #{name}
// DO NOT EDIT - This file is auto-generated
#pragma once
#include <caf/all.hpp>
#include <chrono>
#include <vector>
#include "atoms.hpp"
#{callback_interface}
class #{class_name} : public caf::event_based_actor {
public:
#{class_name}(caf::actor_config& cfg, const std::vector<caf::actor>& targets);
caf::behavior make_behavior() override;
private:
void schedule_next_send();
void send_to_targets();
#{callback_member}#{target_members}
};
"""
end
defp generate_actor_source(name, definition, enable_callbacks) do
actor_name = actor_snake_case(name)
class_name = "#{actor_name}_actor"
callback_init =
if enable_callbacks do
"""
callbacks_ = std::make_shared<#{actor_name}_callbacks>();
"""
else
""
end
# Only initialize targets_ if actor has targets
has_targets = length(definition.targets) > 0
targets_init = if has_targets, do: ", targets_(targets)", else: ""
# Suppress warning for unused targets parameter when not needed
unused_targets_suppress =
if has_targets,
do: "",
else: " (void)targets; // Unused but required for API consistency\n"
behavior_handlers = generate_behavior_handlers(name, definition, enable_callbacks)
schedule_impl = generate_schedule_impl(definition)
send_impl = generate_send_impl(definition)
"""
// Generated from ActorSimulation DSL
// Actor: #{name}
#include "#{actor_name}_actor.hpp"
#include <iostream>
#{class_name}::#{class_name}(caf::actor_config& cfg, const std::vector<caf::actor>& targets)
: caf::event_based_actor(cfg)#{targets_init} {
#{unused_targets_suppress}#{callback_init}}
caf::behavior #{class_name}::make_behavior() {
schedule_next_send();
return {
#{behavior_handlers} };
}
void #{class_name}::schedule_next_send() {
#{schedule_impl}}
void #{class_name}::send_to_targets() {
#{send_impl}}
"""
end
defp generate_behavior_handlers(_name, definition, enable_callbacks) do
messages = extract_messages_from_pattern(definition.send_pattern)
handlers =
Enum.map(messages, fn msg ->
msg_name = message_name(msg)
callback_call =
if enable_callbacks do
"""
callbacks_->on_#{msg_name}();
"""
else
"""
// Message received: #{msg}
"""
end
# CAF 1.0: Use atom constant in handler signature
atom_name = get_atom_name_from_message(msg)
"""
[=](#{atom_name}_atom) {
#{callback_call} send_to_targets();
schedule_next_send();
}
"""
end)
if length(handlers) > 0 do
Enum.join(handlers, ",\n")
else
"""
[=](event_atom) {
// Default message handler
send_to_targets();
schedule_next_send();
}
"""
end
end
defp generate_schedule_impl(definition) do
case definition.send_pattern do
nil ->
"""
// No automatic sending pattern
"""
{:periodic, interval_ms, message} ->
msg_atom = message_to_atom(message)
"""
// CAF 1.0: Use mail API instead of deprecated delayed_send
mail(#{msg_atom}).delay(std::chrono::milliseconds(#{interval_ms})).send(this);
"""
{:rate, per_second, message} ->
interval_ms = div(1000, per_second)
msg_atom = message_to_atom(message)
"""
// CAF 1.0: Use mail API instead of deprecated delayed_send
mail(#{msg_atom}).delay(std::chrono::milliseconds(#{interval_ms})).send(this);
"""
{:burst, count, interval_ms, message} ->
msg_atom = message_to_atom(message)
"""
// CAF 1.0: Use mail API instead of deprecated delayed_send
for (int i = 0; i < #{count}; i++) {
mail(#{msg_atom}).delay(std::chrono::milliseconds(#{interval_ms})).send(this);
}
"""
{:self_message, delay_ms, message} ->
msg_atom = message_to_atom(message)
"""
// CAF 1.0: Send message to self after delay (one-shot) using mail API
mail(#{msg_atom}).delay(std::chrono::milliseconds(#{delay_ms})).send(this);
"""
end
end
defp generate_send_impl(definition) do
if length(definition.targets) > 0 do
"""
for (auto& target : targets_) {
// CAF 1.0: Use mail API instead of send
mail(msg_atom_v).send(target);
send_count_++;
}
"""
else
"""
// No targets to send to
"""
end
end
defp generate_callback_interface_definition(name, definition) do
actor_name = actor_snake_case(name)
class_name = "#{actor_name}_callbacks"
messages = extract_messages_from_pattern(definition.send_pattern)
methods =
Enum.map(messages, fn msg ->
msg_name = message_name(msg)
" virtual void on_#{msg_name}();"
end)
methods_str =
if length(methods) > 0 do
Enum.join(methods, "\n")
else
" virtual void on_message();"
end
"""
// Callback interface for: #{name}
// This is the contract - do not modify
class #{class_name} {
public:
virtual ~#{class_name}() = default;
#{methods_str}
};
"""
end
defp generate_callback_impl(name, definition) do
actor_name = actor_snake_case(name)
class_name = "#{actor_name}_callbacks"
messages = extract_messages_from_pattern(definition.send_pattern)
methods =
Enum.map(messages, fn msg ->
msg_name = message_name(msg)
# If actor has send_pattern, it's the sender (publisher)
action = if definition.send_pattern, do: "Sending", else: "Received"
"""
void #{class_name}::on_#{msg_name}() {
// TODO: Implement custom behavior for #{msg}
// This is called when the actor #{if action == "Sending", do: "sends", else: "receives"} a #{msg} message
std::cout << "#{name}: #{action} #{msg} message" << std::endl;
}
"""
end)
methods_str =
if length(methods) > 0 do
Enum.join(methods, "\n")
else
"""
void #{class_name}::on_message() {
// TODO: Implement custom behavior
std::cout << "#{name}: Processing message" << std::endl;
}
"""
end
"""
// Generated from ActorSimulation DSL
// Callback implementation for: #{name}
//
// CUSTOMIZE THIS FILE - This is where you add your custom behavior!
// The interface is defined in #{actor_name}_actor.hpp
#include "#{actor_name}_actor.hpp"
#include <iostream>
#{methods_str}
"""
end
defp generate_main(actors, project_name) do
simulated_actors =
actors
|> Enum.filter(fn {_name, info} -> info.type == :simulated end)
|> Enum.map(fn {name, info} -> {name, info.definition} end)
includes =
Enum.map(simulated_actors, fn {name, _def} ->
"#include \"#{actor_snake_case(name)}_actor.hpp\""
end)
spawn_code = generate_spawn_code(simulated_actors)
"""
// Generated from ActorSimulation DSL
// Main entry point for #{project_name}
#include <caf/all.hpp>
#include <iostream>
#include <string>
#{Enum.join(includes, "\n")}
using namespace caf;
int caf_main(actor_system& system) {
// Spawn all actors
#{spawn_code}
// Keep system alive - wait for user input to exit
std::cout << "Actor system started. Press Ctrl+C to exit." << std::endl;
std::cout << "Press Enter to stop..." << std::endl;
// Keep the system running
std::string line;
std::getline(std::cin, line);
return 0;
}
CAF_MAIN()
"""
end
defp generate_spawn_code(actors) do
# First pass: spawn actors without targets (collect them first)
spawn_statements_pass1 =
Enum.map(actors, fn {name, _def} ->
actor_name = actor_snake_case(name)
" auto #{actor_name} = system.spawn<#{actor_name}_actor>(std::vector<actor>{});"
end)
# Second pass: re-spawn actors with proper targets
spawn_statements_pass2 =
Enum.map(actors, fn {name, def} ->
actor_name = actor_snake_case(name)
if length(def.targets) > 0 do
target_refs = Enum.map_join(def.targets, ", ", &actor_snake_case/1)
" // Re-spawn #{actor_name} with proper targets\n #{actor_name} = system.spawn<#{actor_name}_actor>(std::vector<actor>{#{target_refs}});"
else
""
end
end)
|> Enum.filter(&(&1 != ""))
all_spawns = spawn_statements_pass1 ++ spawn_statements_pass2
Enum.join(all_spawns, "\n")
end
defp generate_test_file(actors, project_name) do
simulated_actors =
actors
|> Enum.filter(fn {_name, info} -> info.type == :simulated end)
|> Enum.map(fn {name, info} -> {name, info.definition} end)
includes =
Enum.map(simulated_actors, fn {name, _def} ->
"#include \"#{actor_snake_case(name)}_actor.hpp\""
end)
"""
// Generated from ActorSimulation DSL
// Catch2 tests for #{project_name}
#include <catch2/catch_test_macros.hpp>
#include <caf/all.hpp>
#include "atoms.hpp"
#{Enum.join(includes, "\n")}
using namespace caf;
// Simple compilation tests - verifying that generated code compiles
// Note: CAF 1.0 requires init_global_meta_objects<>() before creating actor_system
// This is handled by CAF_MAIN() in the main application
TEST_CASE("Headers compile successfully", "[compilation]") {
// Just verify that all headers can be included without errors
SUCCEED("All headers compiled successfully");
}
TEST_CASE("Atom definitions are valid", "[atoms]") {
// Verify atoms are accessible (event_atom exists in all generated examples)
[[maybe_unused]] auto test_atom = event_atom_v;
SUCCEED("Atoms defined successfully");
}
"""
end
defp generate_cmake(actors, project_name) do
simulated_actors =
actors
|> Enum.filter(fn {_name, info} -> info.type == :simulated end)
|> Enum.map(fn {name, _info} -> name end)
sources =
simulated_actors
|> Enum.flat_map(fn name ->
actor_name = actor_snake_case(name)
[" #{actor_name}_actor.cpp", " #{actor_name}_callbacks_impl.cpp"]
end)
sources_str = Enum.join([" main.cpp" | sources], "\n")
"""
cmake_minimum_required(VERSION 3.15)
project(#{project_name} CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
# Detect OS for binary naming: {example}.caf.{os}
if(CMAKE_SYSTEM_NAME STREQUAL "Darwin")
set(OS_SUFFIX "darwin")
elseif(CMAKE_SYSTEM_NAME STREQUAL "Linux")
set(OS_SUFFIX "linux")
elseif(CMAKE_SYSTEM_NAME STREQUAL "Windows")
set(OS_SUFFIX "exe")
else()
set(OS_SUFFIX "bin")
endif()
# Find CAF
find_package(CAF REQUIRED COMPONENTS core io)
# Find Catch2 for testing
find_package(Catch2 3 REQUIRED)
# Source files
set(SOURCES
#{sources_str}
)
# Create executable
add_executable(#{project_name} ${SOURCES})
# Set output binary name: {example}.caf.{os}
set_target_properties(#{project_name} PROPERTIES
OUTPUT_NAME "#{project_name}.caf.${OS_SUFFIX}"
)
# Link CAF libraries
target_link_libraries(#{project_name}
PRIVATE
CAF::core
CAF::io
)
# Enable warnings
if(MSVC)
target_compile_options(#{project_name} PRIVATE /W4)
else()
target_compile_options(#{project_name} PRIVATE -Wall -Wextra -pedantic)
endif()
# Test executable (needs actor sources)
set(TEST_SOURCES
test_actors.cpp
#{Enum.join(sources |> Enum.filter(&(&1 != " main.cpp")), "\n")}
)
add_executable(#{project_name}_test ${TEST_SOURCES})
target_link_libraries(#{project_name}_test
PRIVATE
CAF::core
CAF::io
Catch2::Catch2WithMain
)
# Enable testing
enable_testing()
add_test(NAME #{project_name}_test COMMAND #{project_name}_test)
# Generate JUnit XML report for CI
add_test(
NAME #{project_name}_test_junit
COMMAND #{project_name}_test --reporter junit --out test-results.xml
)
"""
end
defp generate_conan(caf_version) do
"""
[requires]
caf/#{caf_version}
catch2/3.7.1
[generators]
CMakeDeps
CMakeToolchain
[options]
caf/*:shared=False
"""
end
defp generate_ci_pipeline do
"""
name: CI
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
jobs:
build:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, macos-latest]
build_type: [Debug, Release]
steps:
- uses: actions/checkout@v3
- name: Cache Conan packages
uses: actions/cache@v3
with:
path: ~/.conan2
key: ${{ runner.os }}-conan-${{ matrix.build_type }}-${{ hashFiles('**/conanfile.txt') }}
restore-keys: |
${{ runner.os }}-conan-${{ matrix.build_type }}-
${{ runner.os }}-conan-
- name: Install Conan
run: |
pip install conan
conan profile detect --force
- name: Install dependencies
run: |
mkdir -p build
cd build
conan install .. --build=missing -s build_type=${{ matrix.build_type }}
- name: Configure
run: |
cd build
cmake .. -DCMAKE_BUILD_TYPE=${{ matrix.build_type }} -DCMAKE_TOOLCHAIN_FILE=conan_toolchain.cmake
- name: Build
run: |
cd build
cmake --build . --config ${{ matrix.build_type }}
- name: Run Demo
run: |
cd build
# Determine binary name: {project}.caf.{os}
OS_NAME=$(uname -s | tr '[:upper:]' '[:lower:]')
PROJECT_NAME=$(grep -o 'project([^)]*)' ../CMakeLists.txt | head -1 | sed 's/project(\\([^ ]*\\).*/\\1/')
BINARY="${PROJECT_NAME}.caf.${OS_NAME}"
# Run demo for 3 seconds
timeout 3s ./"${BINARY}" || true
- name: Test
run: |
cd build
ctest -C ${{ matrix.build_type }} --output-on-failure
- name: Run Catch2 tests with verbose output
run: |
cd build
./*_test --success
- name: Generate JUnit test report
if: always()
run: |
cd build
./*_test --reporter junit --out test-results.xml || true
- name: Publish Test Results
if: always()
uses: EnricoMi/publish-unit-test-result-action@v2
with:
files: |
build/test-results.xml
check_name: "Test Results (${{ matrix.os }}, ${{ matrix.build_type }})"
"""
end
defp generate_readme(project_name) do
"""
# #{project_name}
Generated from ActorSimulation DSL using CAF (C++ Actor Framework).
## About
This project uses the [C++ Actor Framework (CAF)](https://actor-framework.org/) to implement
a distributed actor system. The code is generated from a high-level Elixir DSL and provides:
- Type-safe actor implementations
- Callback interfaces for custom behavior
- Modern C++17 code
- Production-ready build system
## Prerequisites
- CMake 3.15+
- C++17 compiler (GCC 7+, Clang 5+, MSVC 2019+)
- Conan package manager
- CAF library (installed via Conan)
## Building
```bash
# Install dependencies
mkdir build && cd build
conan install .. --build=missing
# Configure and build
cmake .. -DCMAKE_TOOLCHAIN_FILE=conan_toolchain.cmake
cmake --build .
# Run
./#{project_name}
```
## Testing
```bash
# Run tests with CTest
cd build
ctest --output-on-failure
# Run Catch2 tests with verbose output
./#{project_name}_test --success
# Generate JUnit XML report
./#{project_name}_test --reporter junit --out test-results.xml
```
The CI pipeline automatically generates and publishes JUnit test reports.
## Customizing Behavior
The generated actor code uses callback interfaces to allow customization WITHOUT
modifying generated files:
1. Find the `*_callbacks_impl.cpp` files
2. Implement your custom logic in the callback methods
3. Rebuild the project
The generated actor code will automatically call your callbacks.
## Project Structure
- `main.cpp` - Entry point and actor system setup
- `*_actor.hpp/cpp` - Generated actor implementations (DO NOT EDIT)
- `*_callbacks.hpp` - Callback interface definitions (DO NOT EDIT)
- `*_callbacks_impl.cpp` - Callback implementations (EDIT THIS!)
- `CMakeLists.txt` - Build configuration
- `conanfile.txt` - Package dependencies
## CI/CD
This project includes a GitHub Actions workflow that:
- Builds on Ubuntu and macOS
- Tests Debug and Release configurations
- Validates the build with each commit
## Learn More
- [CAF Documentation](https://actor-framework.readthedocs.io/)
- [CAF GitHub](https://github.com/actor-framework/actor-framework)
- [ActorSimulation DSL](https://github.com/yourusername/gen_server_virtual_time)
## License
Generated code is provided as-is for your use.
"""
end
# Utility functions
defp actor_snake_case(atom) when is_atom(atom) do
atom
|> Atom.to_string()
|> String.replace("-", "_")
end
defp extract_messages_from_pattern(nil), do: []
defp extract_messages_from_pattern({:periodic, _interval, message}), do: [message]
defp extract_messages_from_pattern({:rate, _per_second, message}), do: [message]
defp extract_messages_from_pattern({:burst, _count, _interval, message}) do
[message]
end
defp extract_messages_from_pattern({:self_message, _delay, message}) do
[message]
end
defp message_name(msg) when is_atom(msg) do
Atom.to_string(msg)
end
defp message_name(msg) when is_binary(msg), do: msg
defp message_name(msg) do
inspect(msg) |> String.replace(~r/[^a-z0-9_]/, "_")
end
# CAF 1.0: Generate shared atoms header that all actors include
defp generate_atoms_header(actors) do
# Collect all unique atoms from all actors
all_atoms =
actors
|> Enum.filter(fn {_name, info} -> info.type == :simulated end)
|> Enum.flat_map(fn {_name, info} ->
collect_atoms_from_definition(info.definition)
end)
|> Enum.uniq()
|> Enum.sort()
atom_defs =
Enum.map(all_atoms, fn atom_str ->
" CAF_ADD_ATOM(ActorSimulation, #{atom_str}_atom)"
end)
"""
// Generated from ActorSimulation DSL
// Shared atom definitions for all actors
#pragma once
#include <caf/type_id.hpp>
// CAF 1.0: Define custom type ID block for atoms
CAF_BEGIN_TYPE_ID_BLOCK(ActorSimulation, caf::first_custom_type_id)
#{Enum.join(atom_defs, "\n")}
CAF_END_TYPE_ID_BLOCK(ActorSimulation)
"""
end
defp collect_atoms_from_definition(definition) do
# Always include "event" and "msg" atoms
base_atoms = ["event", "msg"]
# Extract atom from send_pattern if present
pattern_atom = extract_atom_from_send_pattern(definition.send_pattern)
# Combine all atoms and convert to list
atoms = if pattern_atom, do: [pattern_atom | base_atoms], else: base_atoms
Enum.uniq(atoms)
end
defp extract_atom_from_send_pattern({:periodic, _interval, msg}) when is_atom(msg),
do: Atom.to_string(msg)
defp extract_atom_from_send_pattern({:rate, _rate, msg}) when is_atom(msg),
do: Atom.to_string(msg)
defp extract_atom_from_send_pattern({:burst, _count, _interval, msg}) when is_atom(msg),
do: Atom.to_string(msg)
defp extract_atom_from_send_pattern({:self_message, _delay, msg}) when is_atom(msg),
do: Atom.to_string(msg)
defp extract_atom_from_send_pattern(_), do: nil
defp get_atom_name_from_message(msg) when is_atom(msg) do
Atom.to_string(msg)
end
defp get_atom_name_from_message(msg) when is_binary(msg) do
msg
end
defp get_atom_name_from_message(_msg) do
"msg"
end
defp message_to_atom(msg) when is_atom(msg) do
"#{msg}_atom_v"
end
defp message_to_atom(msg) when is_binary(msg) do
"#{msg}_atom_v"
end
defp message_to_atom(_msg) do
"msg_atom_v"
end
end