joerl is an Erlang-inspired actor model library for Rust, designed to make building concurrent, fault-tolerant systems as natural in Rust as it is in Erlang/OTP.
- Familiar for Erlang developers: Same concepts, same terminology, seamless mental model transfer
- Production-ready: Built-in telemetry, health monitoring, and distributed messaging
- Well-tested: Extensive property-based testing ensures correctness
- Zero-cost abstractions: Built on tokio for maximum performance
Let's build a simple counter actor to demonstrate the basics:
use joerl::{Actor, ActorContext, ActorSystem, Message};
use async_trait::async_trait;
// Define your actor
struct Counter {
count: i32,
}
#[async_trait]
impl Actor for Counter {
async fn started(&mut self, ctx: &mut ActorContext) {
println!("Counter started with pid {}", ctx.pid());
}
async fn handle_message(&mut self, msg: Message, ctx: &mut ActorContext) {
if let Some(cmd) = msg.downcast_ref::<&str>() {
match *cmd {
"increment" => {
self.count += 1;
println!("[{}] Count: {}", ctx.pid(), self.count);
},
"get" => {
println!("[{}] Current count: {}", ctx.pid(), self.count);
},
"stop" => {
ctx.stop(joerl::ExitReason::Normal);
},
_ => {}
}
}
}
async fn stopped(&mut self, reason: &joerl::ExitReason, ctx: &mut ActorContext) {
println!("[{}] Counter stopped: {}", ctx.pid(), reason);
}
}
#[tokio::main]
async fn main() {
let system = ActorSystem::new();
let counter = system.spawn(Counter { count: 0 });
counter.send(Box::new("increment")).await.unwrap();
counter.send(Box::new("increment")).await.unwrap();
counter.send(Box::new("get")).await.unwrap();
counter.send(Box::new("stop")).await.unwrap();
// Wait for messages to process
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
}Key concepts:
- Actor: Encapsulates state (
count) and behavior (handle_message) - ActorSystem: Runtime that manages all actors
- Message passing: Type-erased messages allow any type to be sent
- Lifecycle hooks:
started()andstopped()for initialization/cleanup
One of joerl's strengths is built-in production monitoring. Enable the telemetry feature:
[dependencies]
joerl = { version = "0.5", features = ["telemetry"] }
metrics-exporter-prometheus = "0.15"Add telemetry to your application:
use joerl::telemetry;
use metrics_exporter_prometheus::PrometheusBuilder;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Start Prometheus exporter
PrometheusBuilder::new()
.with_http_listener(([127, 0, 0, 1], 9090))
.install()?;
telemetry::init();
let system = ActorSystem::new();
// ... your actors
Ok(())
}Now visit http://localhost:9090/metrics to see:
- Actor lifecycle: spawns, stops, panics
- Message throughput: messages sent/processed per second
- Mailbox depth: backpressure indicators
- Supervisor restarts: fault recovery statistics
Why it matters:
- Zero-configuration monitoring out of the box
- Production debugging without guesswork
- Grafana/Prometheus integration ready
- OpenTelemetry support for distributed tracing
joerl provides location-transparent messaging: the same API works for local and remote actors.
Start two nodes:
# Terminal 1: Start EPMD server
cargo run --example epmd_server
# Terminal 2: Start node A
cargo run --example distributed_cluster -- node_a 5001
# Terminal 3: Start node B
cargo run --example distributed_cluster -- node_b 5002Nodes automatically discover each other via EPMD (Erlang Port Mapper Daemon).
Code example:
use joerl::ActorSystem;
#[tokio::main]
async fn main() {
// Create distributed system
let system = ActorSystem::new_distributed(
"mynode@localhost",
5000,
"127.0.0.1:4369" // EPMD address
).await.unwrap();
// Spawn actor - works exactly like local
let actor = system.spawn(MyActor::new());
// Send message - works for local AND remote actors
actor.send(Box::new("hello")).await.unwrap();
// Connect to another node
system.connect_to_node("othernode@localhost").await.unwrap();
// Get remote actor pid and send messages transparently
// ... same API, zero code changes!
}Key features:
- Same API:
spawn(),send(),link()work identically - Automatic discovery: EPMD handles node registration
- Bidirectional links: Full Erlang-style connection semantics
- Serialization: Trait-based message serialization with global registry
This is exactly how Erlang works: write once, deploy anywhere.
If you know Erlang, you already know joerl. Here's the mapping:
| Erlang | joerl | Notes |
|---|---|---|
spawn/1 |
system.spawn(actor) |
Spawn new process |
Pid ! Msg |
actor.send(msg).await |
Send message |
gen_server:call/2 |
server.call(request).await |
Synchronous RPC |
gen_server:cast/2 |
server.cast(msg).await |
Async message |
link/1 |
system.link(pid1, pid2) |
Bidirectional link |
monitor/2 |
actor.monitor(from) |
Unidirectional monitor |
process_flag(trap_exit, true) |
ctx.trap_exit(true) |
Handle failures |
{'EXIT', Pid, Reason} |
Signal::Exit { from, reason } |
Exit signal |
supervisor:start_link/2 |
spawn_supervisor(&system, spec) |
Supervision tree |
Example: Converting Erlang gen_server to joerl:
Erlang:
-module(counter).
-behaviour(gen_server).
init([]) -> {ok, 0}.
handle_call(get, _From, State) ->
{reply, State, State};
handle_call({add, N}, _From, State) ->
{reply, State + N, State + N}.
handle_cast(increment, State) ->
{noreply, State + 1}.joerl:
use joerl::gen_server::{GenServer, GenServerContext};
struct Counter;
#[async_trait]
impl GenServer for Counter {
type State = i32;
type Call = CounterCall;
type Cast = CounterCast;
type CallReply = i32;
async fn init(&mut self, _ctx: &mut GenServerContext<'_, Self>) -> Self::State {
0
}
async fn handle_call(
&mut self,
call: Self::Call,
state: &mut Self::State,
_ctx: &mut GenServerContext<'_, Self>,
) -> Self::CallReply {
match call {
CounterCall::Get => *state,
CounterCall::Add(n) => {
*state += n;
*state
}
}
}
async fn handle_cast(
&mut self,
cast: Self::Cast,
state: &mut Self::State,
_ctx: &mut GenServerContext<'_, Self>,
) {
match cast {
CounterCast::Increment => *state += 1,
}
}
}Migration is straightforward:
- Map your gen_server callbacks to trait methods
- Use
async/awaitwhere Erlang would block - Keep your mental model: actors, supervision, links, monitors
- Gain Rust's type safety and performance
joerl supports Erlang's deferred reply pattern with {noreply, State} and gen_server:reply/2. This allows GenServers to accept requests, return control to process other messages, and reply later when work completes:
use joerl::gen_server::{CallResponse, GenServer, GenServerContext, ReplyHandle};
#[async_trait]
impl GenServer for JobProcessor {
async fn handle_call(
&mut self,
job: Self::Call,
state: &mut Self::State,
ctx: &mut GenServerContext<'_, Self>,
) -> CallResponse<Self::CallReply> {
match job {
Job::Quick(data) => {
// Immediate reply
CallResponse::Reply(process_quick(data))
}
Job::Slow(data) => {
// Defer reply for async work
let reply_handle = ctx.reply_handle();
tokio::spawn(async move {
let result = expensive_computation(data).await;
reply_handle.reply(result).expect("reply failed");
});
// Return immediately without replying
CallResponse::NoReply
}
}
}
}Key benefits:
- Server remains responsive during long-running operations
- Multiple concurrent deferred calls supported
- Caller awaits transparently (doesn't know if reply is deferred)
- Matches Erlang semantics exactly
See examples/gen_server_deferred_reply.rs for a complete example.
Like Erlang's receive with pattern matching, joerl provides selective receive to wait for specific messages while leaving others in the mailbox:
use joerl::{Actor, ActorContext, Message};
#[async_trait]
impl Actor for RpcClient {
async fn handle_message(&mut self, msg: Message, ctx: &mut ActorContext) {
if let Some(request_id) = msg.downcast_ref::<u64>() {
let id = *request_id;
// Send request to server
ctx.send(server, Box::new(Request { id, ... })).await;
// Wait for specific response with matching ID
let response = ctx.receive_timeout(
|msg| {
msg.downcast_ref::<Response>()
.filter(|r| r.correlation_id == id)
.cloned()
},
Duration::from_secs(5)
).await;
match response {
Some(resp) => println!("Got response: {:?}", resp),
None => println!("Timeout waiting for response"),
}
}
}
}Available methods:
ctx.receive(predicate)- Wait indefinitely for matching messagectx.receive_timeout(predicate, timeout)- Wait with timeoutctx.try_receive(predicate)- Non-blocking check
How it works:
- Non-matching messages saved to pending queue
- Pending queue checked first on subsequent receives
- Maintains message ordering through VecDeque
- Perfect for RPC, correlation IDs, state machines
See examples/actor_selective_receive.rs for RPC implementation with correlation IDs.
Erlang mapping:
| Erlang | joerl |
|---|---|
receive Pattern -> Body end |
ctx.receive(predicate).await |
receive Pattern -> Body after Timeout -> TimeoutBody end |
ctx.receive_timeout(predicate, timeout).await |
| Messages stay in mailbox when not matched | Same - pending queue preserves them |
joerl uses extensive property-based testing with QuickCheck to verify correctness. Instead of hand-written test cases, properties are defined that should hold for all inputs, then hundreds of random test cases are generated.
What's tested:
- Pid properties: Serialization roundtrips, node semantics, equality
- Message serialization: Lossless encoding, determinism, edge cases
- EPMD protocol: All protocol messages, NodeInfo properties
- Actor lifecycle: Spawn, message handling, termination
- Supervision: Restart strategies, failure propagation
Example property test:
use quickcheck_macros::quickcheck;
/// Property: Pid serialization must be lossless
#[quickcheck]
fn prop_pid_serialization_roundtrip(pid: Pid) -> bool {
let serialized = serde_json::to_string(&pid).unwrap();
let deserialized: Pid = serde_json::from_str(&serialized).unwrap();
pid == deserialized
}QuickCheck generates random Pid values and verifies the property holds for all of them.
Running property tests:
# Run all property tests
cargo test --tests proptest
# Run 1000 random cases per property
QUICKCHECK_TESTS=1000 cargo test --test proptest_pid
# Run specific test
cargo test --test proptest_serialization prop_message_roundtripWhy this matters:
- Confidence: Tests cover cases you'd never think of manually
- Edge cases: Finds corner cases (empty strings, max values, etc.)
- Regression prevention: Failed test cases can be saved and replayed
- Living documentation: Properties describe what the code guarantees
For full details, see PROPERTY_TESTING.md.
-
Learn by example: Check the
examples/directorycounter.rs- Basic actorgen_server_counter.rs- GenServer patterngen_server_deferred_reply.rs- Deferred reply with async workactor_selective_receive.rs- RPC with correlation IDssupervision_tree.rs- Fault tolerancetelemetry_demo.rs- Monitoringdistributed_cluster.rs- Multi-node systems
-
Read the docs: Comprehensive API documentation at docs.rs/joerl
-
Understand supervision: Erlang's "let it crash" philosophy is core to joerl
-
Explore distribution: See DISTRIBUTED.md for clustering details
-
Monitor in production: See TELEMETRY.md for observability setup
joerl brings Erlang/OTP's proven concurrent programming model to Rust:
- Simple: Start with basic actors, add complexity as needed
- Observable: Built-in telemetry for production debugging
- Distributed: Write once, deploy across multiple nodes
- Familiar: Direct mapping from Erlang concepts
- Verified: Property-based testing ensures correctness
Whether you're coming from Erlang or new to the actor model, joerl provides a robust foundation for building fault-tolerant, distributed systems in Rust.