|
| 1 | +# Getting Started with joerl |
| 2 | + |
| 3 | +**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. |
| 4 | + |
| 5 | +## Why joerl? |
| 6 | + |
| 7 | +- **Familiar for Erlang developers**: Same concepts, same terminology, seamless mental model transfer |
| 8 | +- **Production-ready**: Built-in telemetry, health monitoring, and distributed messaging |
| 9 | +- **Well-tested**: Extensive property-based testing ensures correctness |
| 10 | +- **Zero-cost abstractions**: Built on tokio for maximum performance |
| 11 | + |
| 12 | +## A Simple Example: Counter Actor |
| 13 | + |
| 14 | +Let's build a simple counter actor to demonstrate the basics: |
| 15 | + |
| 16 | +```rust |
| 17 | +use joerl::{Actor, ActorContext, ActorSystem, Message}; |
| 18 | +use async_trait::async_trait; |
| 19 | + |
| 20 | +// Define your actor |
| 21 | +struct Counter { |
| 22 | + count: i32, |
| 23 | +} |
| 24 | + |
| 25 | +#[async_trait] |
| 26 | +impl Actor for Counter { |
| 27 | + async fn started(&mut self, ctx: &mut ActorContext) { |
| 28 | + println!("Counter started with pid {}", ctx.pid()); |
| 29 | + } |
| 30 | + |
| 31 | + async fn handle_message(&mut self, msg: Message, ctx: &mut ActorContext) { |
| 32 | + if let Some(cmd) = msg.downcast_ref::<&str>() { |
| 33 | + match *cmd { |
| 34 | + "increment" => { |
| 35 | + self.count += 1; |
| 36 | + println!("[{}] Count: {}", ctx.pid(), self.count); |
| 37 | + }, |
| 38 | + "get" => { |
| 39 | + println!("[{}] Current count: {}", ctx.pid(), self.count); |
| 40 | + }, |
| 41 | + "stop" => { |
| 42 | + ctx.stop(joerl::ExitReason::Normal); |
| 43 | + }, |
| 44 | + _ => {} |
| 45 | + } |
| 46 | + } |
| 47 | + } |
| 48 | + |
| 49 | + async fn stopped(&mut self, reason: &joerl::ExitReason, ctx: &mut ActorContext) { |
| 50 | + println!("[{}] Counter stopped: {}", ctx.pid(), reason); |
| 51 | + } |
| 52 | +} |
| 53 | + |
| 54 | +#[tokio::main] |
| 55 | +async fn main() { |
| 56 | + let system = ActorSystem::new(); |
| 57 | + let counter = system.spawn(Counter { count: 0 }); |
| 58 | + |
| 59 | + counter.send(Box::new("increment")).await.unwrap(); |
| 60 | + counter.send(Box::new("increment")).await.unwrap(); |
| 61 | + counter.send(Box::new("get")).await.unwrap(); |
| 62 | + counter.send(Box::new("stop")).await.unwrap(); |
| 63 | + |
| 64 | + // Wait for messages to process |
| 65 | + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; |
| 66 | +} |
| 67 | +``` |
| 68 | + |
| 69 | +**Key concepts:** |
| 70 | +- **Actor**: Encapsulates state (`count`) and behavior (`handle_message`) |
| 71 | +- **ActorSystem**: Runtime that manages all actors |
| 72 | +- **Message passing**: Type-erased messages allow any type to be sent |
| 73 | +- **Lifecycle hooks**: `started()` and `stopped()` for initialization/cleanup |
| 74 | + |
| 75 | +## Telemetry and Observability |
| 76 | + |
| 77 | +One of joerl's strengths is built-in production monitoring. Enable the `telemetry` feature: |
| 78 | + |
| 79 | +```toml |
| 80 | +[dependencies] |
| 81 | +joerl = { version = "0.5", features = ["telemetry"] } |
| 82 | +metrics-exporter-prometheus = "0.15" |
| 83 | +``` |
| 84 | + |
| 85 | +Add telemetry to your application: |
| 86 | + |
| 87 | +```rust |
| 88 | +use joerl::telemetry; |
| 89 | +use metrics_exporter_prometheus::PrometheusBuilder; |
| 90 | + |
| 91 | +#[tokio::main] |
| 92 | +async fn main() -> Result<(), Box<dyn std::error::Error>> { |
| 93 | + // Start Prometheus exporter |
| 94 | + PrometheusBuilder::new() |
| 95 | + .with_http_listener(([127, 0, 0, 1], 9090)) |
| 96 | + .install()?; |
| 97 | + |
| 98 | + telemetry::init(); |
| 99 | + |
| 100 | + let system = ActorSystem::new(); |
| 101 | + // ... your actors |
| 102 | + |
| 103 | + Ok(()) |
| 104 | +} |
| 105 | +``` |
| 106 | + |
| 107 | +Now visit `http://localhost:9090/metrics` to see: |
| 108 | +- **Actor lifecycle**: spawns, stops, panics |
| 109 | +- **Message throughput**: messages sent/processed per second |
| 110 | +- **Mailbox depth**: backpressure indicators |
| 111 | +- **Supervisor restarts**: fault recovery statistics |
| 112 | + |
| 113 | +**Why it matters:** |
| 114 | +- Zero-configuration monitoring out of the box |
| 115 | +- Production debugging without guesswork |
| 116 | +- Grafana/Prometheus integration ready |
| 117 | +- OpenTelemetry support for distributed tracing |
| 118 | + |
| 119 | +## Transparent Distribution |
| 120 | + |
| 121 | +joerl provides location-transparent messaging: the same API works for local and remote actors. |
| 122 | + |
| 123 | +**Start two nodes:** |
| 124 | + |
| 125 | +```bash |
| 126 | +# Terminal 1: Start EPMD server |
| 127 | +cargo run --example epmd_server |
| 128 | + |
| 129 | +# Terminal 2: Start node A |
| 130 | +cargo run --example distributed_cluster -- node_a 5001 |
| 131 | + |
| 132 | +# Terminal 3: Start node B |
| 133 | +cargo run --example distributed_cluster -- node_b 5002 |
| 134 | +``` |
| 135 | + |
| 136 | +Nodes automatically discover each other via EPMD (Erlang Port Mapper Daemon). |
| 137 | + |
| 138 | +**Code example:** |
| 139 | + |
| 140 | +```rust |
| 141 | +use joerl::ActorSystem; |
| 142 | + |
| 143 | +#[tokio::main] |
| 144 | +async fn main() { |
| 145 | + // Create distributed system |
| 146 | + let system = ActorSystem::new_distributed( |
| 147 | + "mynode@localhost", |
| 148 | + 5000, |
| 149 | + "127.0.0.1:4369" // EPMD address |
| 150 | + ).await.unwrap(); |
| 151 | + |
| 152 | + // Spawn actor - works exactly like local |
| 153 | + let actor = system.spawn(MyActor::new()); |
| 154 | + |
| 155 | + // Send message - works for local AND remote actors |
| 156 | + actor.send(Box::new("hello")).await.unwrap(); |
| 157 | + |
| 158 | + // Connect to another node |
| 159 | + system.connect_to_node("othernode@localhost").await.unwrap(); |
| 160 | + |
| 161 | + // Get remote actor pid and send messages transparently |
| 162 | + // ... same API, zero code changes! |
| 163 | +} |
| 164 | +``` |
| 165 | + |
| 166 | +**Key features:** |
| 167 | +- **Same API**: `spawn()`, `send()`, `link()` work identically |
| 168 | +- **Automatic discovery**: EPMD handles node registration |
| 169 | +- **Bidirectional links**: Full Erlang-style connection semantics |
| 170 | +- **Serialization**: Trait-based message serialization with global registry |
| 171 | + |
| 172 | +This is **exactly** how Erlang works: write once, deploy anywhere. |
| 173 | + |
| 174 | +## For Erlang/OTP Developers |
| 175 | + |
| 176 | +If you know Erlang, you already know joerl. Here's the mapping: |
| 177 | + |
| 178 | +| Erlang | joerl | Notes | |
| 179 | +|--------|-------|-------| |
| 180 | +| `spawn/1` | `system.spawn(actor)` | Spawn new process | |
| 181 | +| `Pid ! Msg` | `actor.send(msg).await` | Send message | |
| 182 | +| `gen_server:call/2` | `server.call(request).await` | Synchronous RPC | |
| 183 | +| `gen_server:cast/2` | `server.cast(msg).await` | Async message | |
| 184 | +| `link/1` | `system.link(pid1, pid2)` | Bidirectional link | |
| 185 | +| `monitor/2` | `actor.monitor(from)` | Unidirectional monitor | |
| 186 | +| `process_flag(trap_exit, true)` | `ctx.trap_exit(true)` | Handle failures | |
| 187 | +| `{'EXIT', Pid, Reason}` | `Signal::Exit { from, reason }` | Exit signal | |
| 188 | +| `supervisor:start_link/2` | `spawn_supervisor(&system, spec)` | Supervision tree | |
| 189 | + |
| 190 | +**Example: Converting Erlang gen_server to joerl:** |
| 191 | + |
| 192 | +Erlang: |
| 193 | +```erlang |
| 194 | +-module(counter). |
| 195 | +-behaviour(gen_server). |
| 196 | + |
| 197 | +init([]) -> {ok, 0}. |
| 198 | + |
| 199 | +handle_call(get, _From, State) -> |
| 200 | + {reply, State, State}; |
| 201 | +handle_call({add, N}, _From, State) -> |
| 202 | + {reply, State + N, State + N}. |
| 203 | + |
| 204 | +handle_cast(increment, State) -> |
| 205 | + {noreply, State + 1}. |
| 206 | +``` |
| 207 | + |
| 208 | +joerl: |
| 209 | +```rust |
| 210 | +use joerl::gen_server::{GenServer, GenServerContext}; |
| 211 | + |
| 212 | +struct Counter; |
| 213 | + |
| 214 | +#[async_trait] |
| 215 | +impl GenServer for Counter { |
| 216 | + type State = i32; |
| 217 | + type Call = CounterCall; |
| 218 | + type Cast = CounterCast; |
| 219 | + type CallReply = i32; |
| 220 | + |
| 221 | + async fn init(&mut self, _ctx: &mut GenServerContext<'_, Self>) -> Self::State { |
| 222 | + 0 |
| 223 | + } |
| 224 | + |
| 225 | + async fn handle_call( |
| 226 | + &mut self, |
| 227 | + call: Self::Call, |
| 228 | + state: &mut Self::State, |
| 229 | + _ctx: &mut GenServerContext<'_, Self>, |
| 230 | + ) -> Self::CallReply { |
| 231 | + match call { |
| 232 | + CounterCall::Get => *state, |
| 233 | + CounterCall::Add(n) => { |
| 234 | + *state += n; |
| 235 | + *state |
| 236 | + } |
| 237 | + } |
| 238 | + } |
| 239 | + |
| 240 | + async fn handle_cast( |
| 241 | + &mut self, |
| 242 | + cast: Self::Cast, |
| 243 | + state: &mut Self::State, |
| 244 | + _ctx: &mut GenServerContext<'_, Self>, |
| 245 | + ) { |
| 246 | + match cast { |
| 247 | + CounterCast::Increment => *state += 1, |
| 248 | + } |
| 249 | + } |
| 250 | +} |
| 251 | +``` |
| 252 | + |
| 253 | +**Migration is straightforward:** |
| 254 | +1. Map your gen_server callbacks to trait methods |
| 255 | +2. Use `async`/`await` where Erlang would block |
| 256 | +3. Keep your mental model: actors, supervision, links, monitors |
| 257 | +4. Gain Rust's type safety and performance |
| 258 | + |
| 259 | +## Property-Based Testing: Proof of Correctness |
| 260 | + |
| 261 | +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. |
| 262 | + |
| 263 | +**What's tested:** |
| 264 | + |
| 265 | +1. **Pid properties**: Serialization roundtrips, node semantics, equality |
| 266 | +2. **Message serialization**: Lossless encoding, determinism, edge cases |
| 267 | +3. **EPMD protocol**: All protocol messages, NodeInfo properties |
| 268 | +4. **Actor lifecycle**: Spawn, message handling, termination |
| 269 | +5. **Supervision**: Restart strategies, failure propagation |
| 270 | + |
| 271 | +**Example property test:** |
| 272 | + |
| 273 | +```rust |
| 274 | +use quickcheck_macros::quickcheck; |
| 275 | + |
| 276 | +/// Property: Pid serialization must be lossless |
| 277 | +#[quickcheck] |
| 278 | +fn prop_pid_serialization_roundtrip(pid: Pid) -> bool { |
| 279 | + let serialized = serde_json::to_string(&pid).unwrap(); |
| 280 | + let deserialized: Pid = serde_json::from_str(&serialized).unwrap(); |
| 281 | + pid == deserialized |
| 282 | +} |
| 283 | +``` |
| 284 | + |
| 285 | +QuickCheck generates random `Pid` values and verifies the property holds for all of them. |
| 286 | + |
| 287 | +**Running property tests:** |
| 288 | + |
| 289 | +```bash |
| 290 | +# Run all property tests |
| 291 | +cargo test --tests proptest |
| 292 | + |
| 293 | +# Run 1000 random cases per property |
| 294 | +QUICKCHECK_TESTS=1000 cargo test --test proptest_pid |
| 295 | + |
| 296 | +# Run specific test |
| 297 | +cargo test --test proptest_serialization prop_message_roundtrip |
| 298 | +``` |
| 299 | + |
| 300 | +**Why this matters:** |
| 301 | +- **Confidence**: Tests cover cases you'd never think of manually |
| 302 | +- **Edge cases**: Finds corner cases (empty strings, max values, etc.) |
| 303 | +- **Regression prevention**: Failed test cases can be saved and replayed |
| 304 | +- **Living documentation**: Properties describe what the code guarantees |
| 305 | + |
| 306 | +For full details, see [PROPERTY_TESTING.md](PROPERTY_TESTING.md). |
| 307 | + |
| 308 | +## Next Steps |
| 309 | + |
| 310 | +1. **Learn by example**: Check the [`examples/`](joerl/examples/) directory |
| 311 | + - `counter.rs` - Basic actor |
| 312 | + - `gen_server_counter.rs` - GenServer pattern |
| 313 | + - `supervision_tree.rs` - Fault tolerance |
| 314 | + - `telemetry_demo.rs` - Monitoring |
| 315 | + - `distributed_cluster.rs` - Multi-node systems |
| 316 | + |
| 317 | +2. **Read the docs**: Comprehensive API documentation at [docs.rs/joerl](https://docs.rs/joerl) |
| 318 | + |
| 319 | +3. **Understand supervision**: Erlang's "let it crash" philosophy is core to joerl |
| 320 | + |
| 321 | +4. **Explore distribution**: See [DISTRIBUTED.md](DISTRIBUTED.md) for clustering details |
| 322 | + |
| 323 | +5. **Monitor in production**: See [TELEMETRY.md](TELEMETRY.md) for observability setup |
| 324 | + |
| 325 | +## Summary |
| 326 | + |
| 327 | +joerl brings Erlang/OTP's proven concurrent programming model to Rust: |
| 328 | + |
| 329 | +- **Simple**: Start with basic actors, add complexity as needed |
| 330 | +- **Observable**: Built-in telemetry for production debugging |
| 331 | +- **Distributed**: Write once, deploy across multiple nodes |
| 332 | +- **Familiar**: Direct mapping from Erlang concepts |
| 333 | +- **Verified**: Property-based testing ensures correctness |
| 334 | + |
| 335 | +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. |
0 commit comments