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 crate::tokio_spawn::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 = crate::tokio_spawn::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 #[cfg(not(target_arch = "wasm32"))]
308 pub fn blocking_child_task<F>(&self, task: F)
309 where
310 F: FnOnce() + Send + 'static,
311 {
312 let handle = tokio::task::spawn_blocking(task);
313 self.task_handles.write().push(handle);
314 }
315
316 fn start_actor_or_router(
317 &self,
318 mut actor: Box<dyn Actor>,
319 is_router: bool,
320 bound: Option<usize>,
321 ) -> Addr {
322 let (addr, receiver) = match bound {
323 Some(cap) => {
324 let (sender, receiver) = channel::<Message>(cap);
325 (Addr::new_bounded(sender), AddrReceiver::Bounded(receiver))
326 }
327 None => {
328 let (sender, receiver) = unbounded_channel::<Message>();
329 (Addr::new(sender), AddrReceiver::Unbounded(receiver))
330 }
331 };
332 let (stop_sender, stop_receiver) = channel(1);
333 let mut new_context = self.child_context(addr.clone(), stop_sender.clone());
334 if is_router {
335 new_context.router = addr.clone();
336 }
337 self.stop_signals.write().insert(addr.clone(), stop_sender);
338 let stop_signals = self.stop_signals.clone();
339 let addr_clone = addr.clone();
340 crate::tokio_spawn::spawn(async move {
341 actor.run(receiver, stop_receiver, new_context).await;
342 stop_signals.write().remove(&addr_clone);
343 });
344 addr
345 }
346
347 /// Stops this actor and all its children.
348 ///
349 /// Aborts all child tasks and sends stop signals to all child actors.
350 /// Sets `is_stopped` to `true`.
351 pub fn stop(&mut self) {
352 for handle in self.task_handles.read().iter() {
353 handle.abort();
354 }
355 for signal in self.stop_signals.read().values() {
356 let _ = signal.try_send(());
357 }
358 self.node = None;
359 *self.is_stopped.write() = true;
360 }
361}
362
363/// A clonable, hashable address for sending messages to an actor.
364///
365/// `Addr` implements `PartialEq`, `Eq`, and `Hash` based on its `id` field
366/// (a random 32-character string), **not** the underlying channel sender.
367/// This means two `Addr`s are equal iff they refer to the same actor.
368///
369/// # Sending Messages
370///
371/// ```no_run
372/// use beam::actor::Addr;
373/// use beam::message::Message;
374///
375/// // addr.send(msg) returns Result<(), ()>
376/// // Err(()) means the actor's channel is closed (actor stopped)
377/// ```
378#[derive(Clone, Debug)]
379pub struct Addr {
380 id: String,
381 sender: AddrSender,
382}
383
384impl Addr {
385 /// Creates a new address wrapping an unbounded channel sender.
386 pub fn new(sender: UnboundedSender<Message>) -> Self {
387 Self {
388 id: random_string(32),
389 sender: AddrSender::Unbounded(sender),
390 }
391 }
392
393 /// Creates a new address wrapping a bounded channel sender.
394 ///
395 /// Bounded addresses apply backpressure: when the channel is full,
396 /// `send` returns `Err(())` (same error as a closed channel).
397 pub fn new_bounded(sender: Sender<Message>) -> Self {
398 Self {
399 id: random_string(32),
400 sender: AddrSender::Bounded(sender),
401 }
402 }
403
404 /// Sends a message to this actor.
405 ///
406 /// Returns `Ok(())` if the message was enqueued, `Err(())` if the
407 /// actor's channel is closed (actor has stopped) or — for bounded
408 /// channels — if the channel is full (backpressure).
409 ///
410 /// Callers that must not lose messages should retry on `Err`. The
411 /// Router's storage dispatch uses `let _ = addr.send(...)` and accepts
412 /// occasional drops under extreme backpressure, which is the correct
413 /// trade-off for an LWW graph store.
414 #[allow(clippy::result_unit_err)] // channel-closed/full is unrecoverable; no meaningful error payload
415 pub fn send(&self, msg: Message) -> Result<(), ()> {
416 match &self.sender {
417 AddrSender::Unbounded(s) => s.send(msg).map_err(|_| ()),
418 AddrSender::Bounded(s) => s.try_send(msg).map_err(|_| ()),
419 }
420 }
421
422 /// Returns a no-op address with a discarded receiver.
423 ///
424 /// Messages sent to a noop address are silently dropped. Useful as a
425 /// placeholder before a real address is set.
426 pub fn noop() -> Addr {
427 let (sender, _receiver) = unbounded_channel::<Message>();
428 Addr::new(sender)
429 }
430}
431
432impl PartialEq for Addr {
433 fn eq(&self, other: &Addr) -> bool {
434 self.id == other.id
435 }
436}
437
438impl Eq for Addr {}
439
440impl Hash for Addr {
441 fn hash<H: Hasher>(&self, state: &mut H) {
442 self.id.hash(state);
443 }
444}
445
446impl fmt::Display for Addr {
447 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
448 write!(f, "actor:{}", self.id)
449 }
450}
451
452#[cfg(test)]
453mod tests {
454 use super::*;
455
456 #[test]
457 fn test_addr_equality() {
458 let (s1, _r1) = unbounded_channel::<Message>();
459 let (s2, _r2) = unbounded_channel::<Message>();
460 let a1 = Addr::new(s1);
461 let a2 = Addr::new(s2);
462 assert_ne!(a1, a2, "different addrs are not equal");
463 assert_eq!(a1, a1.clone(), "clone is equal");
464 }
465
466 #[test]
467 fn test_addr_hash() {
468 let (s1, _r1) = unbounded_channel::<Message>();
469 let a1 = Addr::new(s1);
470 let a2 = a1.clone();
471 let mut set = std::collections::HashSet::new();
472 set.insert(a1);
473 assert!(set.contains(&a2), "clone should be found in HashSet");
474 }
475
476 #[test]
477 fn test_addr_display() {
478 let (s, _r) = unbounded_channel::<Message>();
479 let addr = Addr::new(s);
480 let display = format!("{}", addr);
481 assert!(display.starts_with("actor:"));
482 assert_eq!(display.len(), "actor:".len() + 32);
483 }
484
485 #[test]
486 fn test_addr_noop_sends_silently() {
487 let addr = Addr::noop();
488 // Sending to noop should not panic
489 // We can't easily send a Message without constructing one,
490 // but noop creates a valid channel with a discarded receiver
491 assert_eq!(addr.id.len(), 32);
492 }
493
494 #[test]
495 fn test_addr_id_length() {
496 let (s, _r) = unbounded_channel::<Message>();
497 let addr = Addr::new(s);
498 assert_eq!(addr.id.len(), 32);
499 assert!(
500 addr.id.chars().all(|c| c.is_ascii_alphanumeric()),
501 "addr id should be alphanumeric"
502 );
503 }
504
505 struct TestActor {
506 received: Arc<RwLock<Vec<Message>>>,
507 }
508
509 #[async_trait]
510 impl Actor for TestActor {
511 async fn handle(&mut self, message: Message, _ctx: &ActorContext) {
512 self.received.write().push(message);
513 }
514 }
515
516 #[tokio::test]
517 async fn test_actor_context_new() {
518 let ctx = ActorContext::new("peer1".to_string());
519 assert_eq!(*ctx.peer_id.read(), "peer1");
520 assert_eq!(ctx.child_actor_count(), 0);
521 assert!(!*ctx.is_stopped.read());
522 }
523
524 #[tokio::test]
525 async fn test_actor_start_and_send() {
526 let mut ctx = ActorContext::new("test".to_string());
527 let received = Arc::new(RwLock::new(Vec::new()));
528 let actor = TestActor {
529 received: received.clone(),
530 };
531 let _addr = ctx.start_actor(Box::new(actor));
532
533 // Give the actor a moment to start
534 crate::tokio_time::sleep(web_time::Duration::from_millis(50)).await;
535
536 assert_eq!(ctx.child_actor_count(), 1);
537
538 // Stop the actor
539 ctx.stop();
540 assert!(*ctx.is_stopped.read());
541 }
542}
543
544#[test]
545fn test_shutdown_signal_default_false() {
546 // A new ActorContext should have shutdown_rx initialized to false.
547 let ctx = ActorContext::new("test-peer".to_string());
548 assert!(
549 !*ctx.shutdown_rx.borrow(),
550 "shutdown_rx should default to false"
551 );
552}
553
554#[test]
555fn test_shutdown_signal_propagates_to_child() {
556 // When the parent sends true on shutdown, child contexts (which
557 // share the same watch channel) should observe the change.
558 let (tx, rx) = watch::channel(false);
559 let mut ctx = ActorContext::new("parent".to_string());
560 ctx.shutdown_rx = rx;
561
562 let (stop_tx, _stop_rx) = channel(1);
563 let child = ctx.child_context(Addr::noop(), stop_tx);
564
565 // Child starts with false
566 assert!(!*child.shutdown_rx.borrow());
567
568 // Parent signals shutdown
569 tx.send(true).unwrap();
570
571 // Child observes the change
572 assert!(
573 *child.shutdown_rx.borrow(),
574 "child should see shutdown signal"
575 );
576}
577
578#[test]
579fn test_shutdown_signal_isolated_per_node() {
580 // Different nodes create independent watch channels —
581 // signaling one should not affect the other.
582 let mut ctx_a = ActorContext::new("node-a".to_string());
583 let ctx_b = ActorContext::new("node-b".to_string());
584
585 // Both start false
586 assert!(!*ctx_a.shutdown_rx.borrow());
587 assert!(!*ctx_b.shutdown_rx.borrow());
588
589 // Replace ctx_a's channel with a controllable one
590 let (tx_a, rx_a) = watch::channel(false);
591 ctx_a.shutdown_rx = rx_a;
592 tx_a.send(true).unwrap();
593
594 // ctx_a sees shutdown, ctx_b does not
595 assert!(*ctx_a.shutdown_rx.borrow());
596 assert!(
597 !*ctx_b.shutdown_rx.borrow(),
598 "unrelated node should not see signal"
599 );
600}