beam/actor.rs
1#![allow(clippy::mutable_key_type)] // Addr hashes by id field, not interior-mutable sender
2
3//! Actor framework — a lightweight actor model built on Tokio channels.
4//!
5//! This module provides a minimal actor system inspired by
6//! [Alice Ryhl's "Actors with Tokio"](https://ryhl.io/blog/actors-with-tokio/)
7//! guide. Actors communicate via typed messages over channels and run on the
8//! Tokio async runtime.
9//!
10//! # Architecture
11//!
12//! - [`Actor`] trait — defines the message handling interface
13//! - [`ActorContext`] — per-actor context with peer ID, router address, and
14//! child actor management
15//! - [`Addr`] — a clonable, hashable address for sending messages to an actor
16//!
17//! # Channel Types
18//!
19//! Actors can use either unbounded or bounded channels:
20//!
21//! - **Unbounded** (default) — [`ActorContext::start_actor`] creates an actor
22//! with an unbounded channel. No backpressure; messages are always enqueued.
23//! - **Bounded** — [`ActorContext::start_actor_bounded`] creates an actor with
24//! a bounded channel of the given capacity. When full, [`Addr::send`]
25//! returns `Err(())`, applying backpressure. Used for storage write actors
26//! where unbounded queue growth is undesirable.
27//!
28//! Both channel types are abstracted behind [`AddrSender`]/[`AddrReceiver`]
29//! enums, so callers use the same [`Addr::send`] API regardless of channel
30//! type.
31//!
32//! # Message Flow
33//!
34//! ```text
35//! Sender → Addr.send(msg) → Channel (bounded or unbounded)
36//! ↓
37//! Actor.handle(msg, ctx)
38//! ↓
39//! Actor can:
40//! - spawn child actors
41//! - send to router
42//! - spawn child tasks
43//! ```
44//!
45//! # Shutdown
46//!
47//! Actors are stopped via a stop signal channel. When the context's `stop()`
48//! method is called, all child tasks are aborted and stop signals are sent
49//! to all child actors.
50
51use crate::Node;
52use crate::message::Message;
53use crate::utils::random_string;
54use async_trait::async_trait;
55use futures_util::Future;
56use parking_lot::RwLock;
57use std::collections::HashMap;
58use std::fmt;
59use std::hash::{Hash, Hasher};
60use std::marker::Send;
61use std::sync::Arc;
62use tokio::sync::mpsc::{
63 Receiver, Sender, UnboundedReceiver, UnboundedSender, channel, unbounded_channel,
64};
65use tokio::sync::watch;
66use tokio::task::JoinHandle;
67
68/// Internal enum holding either an unbounded or bounded channel sender.
69///
70/// [`Addr::send`] dispatches over this enum so callers don't need to know
71/// whether the underlying channel has backpressure.
72#[derive(Clone, Debug)]
73enum AddrSender {
74 Unbounded(UnboundedSender<Message>),
75 Bounded(Sender<Message>),
76}
77
78/// Internal enum holding either an unbounded or bounded channel receiver.
79///
80/// [`Actor::run`] consumes this, abstracting over the two receiver types.
81enum AddrReceiver {
82 Unbounded(UnboundedReceiver<Message>),
83 Bounded(Receiver<Message>),
84}
85
86impl AddrReceiver {
87 /// Receives the next message, or `None` when the channel is closed.
88 async fn recv(&mut self) -> Option<Message> {
89 match self {
90 Self::Unbounded(r) => r.recv().await,
91 Self::Bounded(r) => r.recv().await,
92 }
93 }
94}
95
96/// The core actor trait.
97///
98/// Implementors define how to handle [`Message`] values and optionally
99/// configure lifecycle hooks (`pre_start`, `stopping`).
100///
101/// # Lifecycle
102///
103/// 1. `pre_start` — called once before the actor begins processing messages
104/// 2. `handle` — called for each message received
105/// 3. `stopping` — called once after the actor's message loop exits
106///
107/// # Example
108///
109/// ```no_run
110/// use beam::actor::{Actor, ActorContext};
111/// use beam::message::Message;
112/// use async_trait::async_trait;
113///
114/// struct EchoActor;
115///
116/// #[async_trait]
117/// impl Actor for EchoActor {
118/// async fn handle(&mut self, msg: Message, _ctx: &ActorContext) {
119/// // Process message
120/// }
121/// }
122/// ```
123#[async_trait]
124pub trait Actor: Send + Sync + 'static {
125 /// Handle an incoming message.
126 async fn handle(&mut self, message: Message, context: &ActorContext);
127
128 /// Called once before the actor starts processing messages.
129 ///
130 /// Override to initialize state, spawn child actors, or establish
131 /// connections. Defaults to a no-op.
132 async fn pre_start(&mut self, _context: &ActorContext) {}
133
134 /// Called once after the actor's message loop exits.
135 ///
136 /// Override for cleanup logic. Defaults to a no-op.
137 async fn stopping(&mut self, _context: &ActorContext) {}
138
139 /// Whether this actor wants to receive all messages (not just addressed
140 /// to it). Used by the Multicast adapter.
141 ///
142 /// Defaults to `false`.
143 fn subscribe_to_everything(&self) -> bool {
144 false
145 }
146
147 /// Attempts to produce a clone of this actor for storage read/write
148 /// splitting.
149 ///
150 /// Storage adapters override this to return a boxed clone, enabling the
151 /// [`crate::router::Router`] to start separate read and write actors that
152 /// share the same underlying database. Non-storage actors return `None`
153 /// (the default).
154 ///
155 /// When the Router receives `Some`, it starts two actors: one registered
156 /// in `read_adapters` (receives only `Get`), one in `write_adapters`
157 /// (receives `Put`, `BatchPut`, `Flush`). Both share the same underlying
158 /// data store via `Arc`, so reads see committed writes immediately.
159 fn try_clone_storage(&self) -> Option<Box<dyn Actor>> {
160 None
161 }
162}
163
164impl dyn Actor {
165 /// Internal run loop — receives messages until the stop signal fires
166 /// or the channel is closed.
167 ///
168 /// Accepts [`AddrReceiver`] so the run loop works with both unbounded
169 /// and bounded channels. Bounded channels provide backpressure for
170 /// write-heavy actors (e.g. storage write actors).
171 async fn run(
172 &mut self,
173 mut receiver: AddrReceiver,
174 mut stop_receiver: Receiver<()>,
175 mut context: ActorContext,
176 ) {
177 self.pre_start(&context).await;
178 loop {
179 tokio::select! {
180 _v = stop_receiver.recv() => {
181 context.stop();
182 break;
183 },
184 opt_msg = receiver.recv() => {
185 let msg = match opt_msg {
186 Some(msg) => msg,
187 None => break,
188 };
189 self.handle(msg, &context).await
190 }
191 }
192 }
193 self.stopping(&context).await;
194 }
195}
196
197/// Per-actor context providing access to runtime services.
198///
199/// Each actor receives an `ActorContext` in `pre_start`, `handle`, and
200/// `stopping`. The context is clonable — clones share the same underlying
201/// state via `Arc`.
202///
203/// # Key Fields
204///
205/// - `peer_id` — this node's peer identifier (shared across all actors)
206/// - `router` — the router actor's address (for forwarding messages)
207/// - `addr` — this actor's own address
208/// - `node` — optional owned [`Node`] (set for the root actor)
209#[derive(Clone)]
210pub struct ActorContext {
211 /// This node's peer ID, shared across all actors.
212 pub peer_id: Arc<RwLock<String>>,
213 /// The router actor's address for message forwarding.
214 pub router: Addr,
215 /// Stop signals for child actors (keyed by child Addr).
216 stop_signals: Arc<RwLock<HashMap<Addr, Sender<()>>>>,
217 /// Join handles for spawned child tasks.
218 task_handles: Arc<RwLock<Vec<JoinHandle<()>>>>,
219 /// This actor's own address.
220 pub addr: Addr,
221 /// Whether this actor has been stopped.
222 pub is_stopped: Arc<RwLock<bool>>,
223 /// Shutdown signal receiver — set to `true` when graceful shutdown begins.
224 /// Long-running child tasks select on this to break their loops.
225 pub shutdown_rx: watch::Receiver<bool>,
226 /// Optional owned Node (set for the root actor).
227 pub node: Option<Node>,
228}
229
230impl ActorContext {
231 /// Creates a new `ActorContext` with the given peer ID.
232 ///
233 /// The `addr` and `router` fields are initialized to [`Addr::noop()`]
234 /// and should be set before use.
235 pub fn new(peer_id: String) -> Self {
236 Self {
237 addr: Addr::noop(),
238 stop_signals: Arc::new(RwLock::new(HashMap::new())),
239 task_handles: Arc::new(RwLock::new(Vec::new())),
240 peer_id: Arc::new(RwLock::new(peer_id)),
241 router: Addr::noop(),
242 is_stopped: Arc::new(RwLock::new(false)),
243 shutdown_rx: watch::channel(false).1,
244 node: None,
245 }
246 }
247
248 /// Returns the number of child actors spawned by this context.
249 pub fn child_actor_count(&self) -> usize {
250 self.stop_signals.read().len()
251 }
252
253 /// Creates a child context with the given address and stop signal.
254 fn child_context(&self, addr: Addr, stop_signal: Sender<()>) -> Self {
255 let mut stop_signals = HashMap::new();
256 stop_signals.insert(addr.clone(), stop_signal);
257 Self {
258 addr,
259 stop_signals: Arc::new(RwLock::new(stop_signals)),
260 task_handles: Arc::new(RwLock::new(Vec::new())),
261 peer_id: self.peer_id.clone(),
262 router: self.router.clone(),
263 is_stopped: self.is_stopped.clone(),
264 shutdown_rx: self.shutdown_rx.clone(),
265 node: self.node.clone(),
266 }
267 }
268
269 /// Spawns a child actor with an unbounded channel and returns its address.
270 ///
271 /// The actor runs in a tokio task. Its lifecycle is managed by this
272 /// context — calling `stop()` will send a stop signal and abort the task.
273 pub fn start_actor(&self, actor: Box<dyn Actor>) -> Addr {
274 self.start_actor_or_router(actor, false, None)
275 }
276
277 /// Spawns a child actor with a bounded channel and returns its address.
278 ///
279 /// The `bound` parameter sets the channel capacity. When full, `send`
280 /// returns `Err(())`, applying backpressure to senders. Use for
281 /// write-heavy actors where unbounded queue growth is undesirable.
282 pub fn start_actor_bounded(&self, actor: Box<dyn Actor>, bound: usize) -> Addr {
283 self.start_actor_or_router(actor, false, Some(bound))
284 }
285
286 /// Spawns a router actor. The router's context will have its `router`
287 /// field set to its own address (so messages forwarded to `router`
288 /// come back to itself).
289 pub fn start_router(&self, actor: Box<dyn Actor>) -> Addr {
290 self.start_actor_or_router(actor, true, None)
291 }
292
293 /// Spawns a child async task (non-blocking).
294 ///
295 /// The task's `JoinHandle` is tracked so it can be aborted on stop.
296 pub fn child_task<T>(&self, task: T)
297 where
298 T: Future<Output = ()> + Send + 'static,
299 {
300 let handle = tokio::spawn(task);
301 self.task_handles.write().push(handle);
302 }
303
304 /// Spawns a blocking child task via `spawn_blocking`.
305 ///
306 /// Use for CPU-intensive work that should not block the async runtime.
307 pub fn blocking_child_task<F>(&self, task: F)
308 where
309 F: FnOnce() + Send + 'static,
310 {
311 let handle = tokio::task::spawn_blocking(task);
312 self.task_handles.write().push(handle);
313 }
314
315 fn start_actor_or_router(
316 &self,
317 mut actor: Box<dyn Actor>,
318 is_router: bool,
319 bound: Option<usize>,
320 ) -> Addr {
321 let (addr, receiver) = match bound {
322 Some(cap) => {
323 let (sender, receiver) = channel::<Message>(cap);
324 (Addr::new_bounded(sender), AddrReceiver::Bounded(receiver))
325 }
326 None => {
327 let (sender, receiver) = unbounded_channel::<Message>();
328 (Addr::new(sender), AddrReceiver::Unbounded(receiver))
329 }
330 };
331 let (stop_sender, stop_receiver) = channel(1);
332 let mut new_context = self.child_context(addr.clone(), stop_sender.clone());
333 if is_router {
334 new_context.router = addr.clone();
335 }
336 self.stop_signals.write().insert(addr.clone(), stop_sender);
337 let stop_signals = self.stop_signals.clone();
338 let addr_clone = addr.clone();
339 tokio::spawn(async move {
340 actor.run(receiver, stop_receiver, new_context).await;
341 stop_signals.write().remove(&addr_clone);
342 });
343 addr
344 }
345
346 /// Stops this actor and all its children.
347 ///
348 /// Aborts all child tasks and sends stop signals to all child actors.
349 /// Sets `is_stopped` to `true`.
350 pub fn stop(&mut self) {
351 for handle in self.task_handles.read().iter() {
352 handle.abort();
353 }
354 for signal in self.stop_signals.read().values() {
355 let _ = signal.try_send(());
356 }
357 self.node = None;
358 *self.is_stopped.write() = true;
359 }
360}
361
362/// A clonable, hashable address for sending messages to an actor.
363///
364/// `Addr` implements `PartialEq`, `Eq`, and `Hash` based on its `id` field
365/// (a random 32-character string), **not** the underlying channel sender.
366/// This means two `Addr`s are equal iff they refer to the same actor.
367///
368/// # Sending Messages
369///
370/// ```no_run
371/// use beam::actor::Addr;
372/// use beam::message::Message;
373///
374/// // addr.send(msg) returns Result<(), ()>
375/// // Err(()) means the actor's channel is closed (actor stopped)
376/// ```
377#[derive(Clone, Debug)]
378pub struct Addr {
379 id: String,
380 sender: AddrSender,
381}
382
383impl Addr {
384 /// Creates a new address wrapping an unbounded channel sender.
385 pub fn new(sender: UnboundedSender<Message>) -> Self {
386 Self {
387 id: random_string(32),
388 sender: AddrSender::Unbounded(sender),
389 }
390 }
391
392 /// Creates a new address wrapping a bounded channel sender.
393 ///
394 /// Bounded addresses apply backpressure: when the channel is full,
395 /// `send` returns `Err(())` (same error as a closed channel).
396 pub fn new_bounded(sender: Sender<Message>) -> Self {
397 Self {
398 id: random_string(32),
399 sender: AddrSender::Bounded(sender),
400 }
401 }
402
403 /// Sends a message to this actor.
404 ///
405 /// Returns `Ok(())` if the message was enqueued, `Err(())` if the
406 /// actor's channel is closed (actor has stopped) or — for bounded
407 /// channels — if the channel is full (backpressure).
408 ///
409 /// Callers that must not lose messages should retry on `Err`. The
410 /// Router's storage dispatch uses `let _ = addr.send(...)` and accepts
411 /// occasional drops under extreme backpressure, which is the correct
412 /// trade-off for an LWW graph store.
413 #[allow(clippy::result_unit_err)] // channel-closed/full is unrecoverable; no meaningful error payload
414 pub fn send(&self, msg: Message) -> Result<(), ()> {
415 match &self.sender {
416 AddrSender::Unbounded(s) => s.send(msg).map_err(|_| ()),
417 AddrSender::Bounded(s) => s.try_send(msg).map_err(|_| ()),
418 }
419 }
420
421 /// Returns a no-op address with a discarded receiver.
422 ///
423 /// Messages sent to a noop address are silently dropped. Useful as a
424 /// placeholder before a real address is set.
425 pub fn noop() -> Addr {
426 let (sender, _receiver) = unbounded_channel::<Message>();
427 Addr::new(sender)
428 }
429}
430
431impl PartialEq for Addr {
432 fn eq(&self, other: &Addr) -> bool {
433 self.id == other.id
434 }
435}
436
437impl Eq for Addr {}
438
439impl Hash for Addr {
440 fn hash<H: Hasher>(&self, state: &mut H) {
441 self.id.hash(state);
442 }
443}
444
445impl fmt::Display for Addr {
446 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
447 write!(f, "actor:{}", self.id)
448 }
449}
450
451#[cfg(test)]
452mod tests {
453 use super::*;
454
455 #[test]
456 fn test_addr_equality() {
457 let (s1, _r1) = unbounded_channel::<Message>();
458 let (s2, _r2) = unbounded_channel::<Message>();
459 let a1 = Addr::new(s1);
460 let a2 = Addr::new(s2);
461 assert_ne!(a1, a2, "different addrs are not equal");
462 assert_eq!(a1, a1.clone(), "clone is equal");
463 }
464
465 #[test]
466 fn test_addr_hash() {
467 let (s1, _r1) = unbounded_channel::<Message>();
468 let a1 = Addr::new(s1);
469 let a2 = a1.clone();
470 let mut set = std::collections::HashSet::new();
471 set.insert(a1);
472 assert!(set.contains(&a2), "clone should be found in HashSet");
473 }
474
475 #[test]
476 fn test_addr_display() {
477 let (s, _r) = unbounded_channel::<Message>();
478 let addr = Addr::new(s);
479 let display = format!("{}", addr);
480 assert!(display.starts_with("actor:"));
481 assert_eq!(display.len(), "actor:".len() + 32);
482 }
483
484 #[test]
485 fn test_addr_noop_sends_silently() {
486 let addr = Addr::noop();
487 // Sending to noop should not panic
488 // We can't easily send a Message without constructing one,
489 // but noop creates a valid channel with a discarded receiver
490 assert_eq!(addr.id.len(), 32);
491 }
492
493 #[test]
494 fn test_addr_id_length() {
495 let (s, _r) = unbounded_channel::<Message>();
496 let addr = Addr::new(s);
497 assert_eq!(addr.id.len(), 32);
498 assert!(
499 addr.id.chars().all(|c| c.is_ascii_alphanumeric()),
500 "addr id should be alphanumeric"
501 );
502 }
503
504 struct TestActor {
505 received: Arc<RwLock<Vec<Message>>>,
506 }
507
508 #[async_trait]
509 impl Actor for TestActor {
510 async fn handle(&mut self, message: Message, _ctx: &ActorContext) {
511 self.received.write().push(message);
512 }
513 }
514
515 #[tokio::test]
516 async fn test_actor_context_new() {
517 let ctx = ActorContext::new("peer1".to_string());
518 assert_eq!(*ctx.peer_id.read(), "peer1");
519 assert_eq!(ctx.child_actor_count(), 0);
520 assert!(!*ctx.is_stopped.read());
521 }
522
523 #[tokio::test]
524 async fn test_actor_start_and_send() {
525 let mut ctx = ActorContext::new("test".to_string());
526 let received = Arc::new(RwLock::new(Vec::new()));
527 let actor = TestActor {
528 received: received.clone(),
529 };
530 let _addr = ctx.start_actor(Box::new(actor));
531
532 // Give the actor a moment to start
533 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
534
535 assert_eq!(ctx.child_actor_count(), 1);
536
537 // Stop the actor
538 ctx.stop();
539 assert!(*ctx.is_stopped.read());
540 }
541}
542
543#[test]
544fn test_shutdown_signal_default_false() {
545 // A new ActorContext should have shutdown_rx initialized to false.
546 let ctx = ActorContext::new("test-peer".to_string());
547 assert!(
548 !*ctx.shutdown_rx.borrow(),
549 "shutdown_rx should default to false"
550 );
551}
552
553#[test]
554fn test_shutdown_signal_propagates_to_child() {
555 // When the parent sends true on shutdown, child contexts (which
556 // share the same watch channel) should observe the change.
557 let (tx, rx) = watch::channel(false);
558 let mut ctx = ActorContext::new("parent".to_string());
559 ctx.shutdown_rx = rx;
560
561 let (stop_tx, _stop_rx) = channel(1);
562 let child = ctx.child_context(Addr::noop(), stop_tx);
563
564 // Child starts with false
565 assert!(!*child.shutdown_rx.borrow());
566
567 // Parent signals shutdown
568 tx.send(true).unwrap();
569
570 // Child observes the change
571 assert!(
572 *child.shutdown_rx.borrow(),
573 "child should see shutdown signal"
574 );
575}
576
577#[test]
578fn test_shutdown_signal_isolated_per_node() {
579 // Different nodes create independent watch channels —
580 // signaling one should not affect the other.
581 let mut ctx_a = ActorContext::new("node-a".to_string());
582 let ctx_b = ActorContext::new("node-b".to_string());
583
584 // Both start false
585 assert!(!*ctx_a.shutdown_rx.borrow());
586 assert!(!*ctx_b.shutdown_rx.borrow());
587
588 // Replace ctx_a's channel with a controllable one
589 let (tx_a, rx_a) = watch::channel(false);
590 ctx_a.shutdown_rx = rx_a;
591 tx_a.send(true).unwrap();
592
593 // ctx_a sees shutdown, ctx_b does not
594 assert!(*ctx_a.shutdown_rx.borrow());
595 assert!(
596 !*ctx_b.shutdown_rx.borrow(),
597 "unrelated node should not see signal"
598 );
599}