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 [`Mailbox`].
4//!
5//! This module provides a minimal actor system where actors communicate via
6//! [`Arc<Message>`] over pre-allocated ring-buffer mailboxes. The mailbox
7//! replaces tokio's `mpsc` channels, eliminating per-message allocation and
8//! reducing scheduler overhead through batch draining.
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//! # Mailbox
18//!
19//! All actors use [`crate::mailbox::mailbox`] — a bounded `VecDeque` protected
20//! by `parking_lot::Mutex` with `tokio::sync::Notify` for wakeups. Messages
21//! are wrapped in [`Arc<Message>`] so fanout is a refcount bump (~2 ns),
22//! not a deep clone.
23//!
24//! - **Default capacity** (65536) — [`ActorContext::start_actor`] creates an
25//! actor with a generous mailbox. Backpressure only under extreme load.
26//! - **Bounded** — [`ActorContext::start_actor_bounded`] creates an actor with
27//! a smaller capacity. When full, [`Addr::send`] returns `Err(())`,
28//! applying backpressure. Used for storage write actors.
29//!
30//! # Message Flow
31//!
32//! ```text
33//! Sender → Addr.send(msg) → Mailbox (bounded VecDeque<Arc<Message>>)
34//! ↓
35//! Actor.handle(Arc<Message>, ctx)
36//! ↓
37//! Actor can:
38//! - spawn child actors
39//! - send to router
40//! - spawn child tasks
41//! ```
42//!
43//! # `Arc<Message>` Semantics
44//!
45//! [`Addr::send`] accepts `impl Into<Arc<Message>>`, so existing call sites
46//! that pass `Message::Put(put)` still compile — the message is wrapped in
47//! `Arc` inside `send`. For fanout paths (e.g. relay), callers can pass
48//! `Arc::clone(&msg)` to skip the allocation entirely — all subscribers
49//! share the same `Arc`.
50//!
51//! # Shutdown
52//!
53//! Actors are stopped via a stop signal channel. When the context's `stop()`
54//! method is called, all child tasks are aborted and stop signals are sent
55//! to all child actors.
56
57use crate::Node;
58use crate::mailbox::{self, MailboxReceiver, MailboxSender};
59use crate::message::Message;
60use crate::metrics::Metrics;
61use crate::tokio_spawn::JoinHandle;
62use crate::utils::random_string;
63use async_trait::async_trait;
64use futures_util::Future;
65use parking_lot::RwLock;
66use crate::utils::FxHashMap;
67use std::fmt;
68use std::hash::{Hash, Hasher};
69use std::marker::Send;
70use std::sync::Arc;
71use tokio::sync::mpsc::{Receiver, Sender, channel};
72use tokio::sync::watch;
73
74/// Default mailbox capacity for unbounded actors.
75const DEFAULT_MAILBOX_CAPACITY: usize = 65536;
76
77/// The core actor trait.
78///
79/// Implementors define how to handle [`Message`] values and optionally
80/// configure lifecycle hooks (`pre_start`, `stopping`).
81///
82/// # Lifecycle
83///
84/// 1. `pre_start` — called once before the actor begins processing messages
85/// 2. `handle` — called for each message received
86/// 3. `stopping` — called once after the actor's message loop exits
87///
88/// # Example
89///
90/// ```no_run
91/// use beam::actor::{Actor, ActorContext};
92/// use beam::message::Message;
93/// use async_trait::async_trait;
94/// use std::sync::Arc;
95///
96/// struct EchoActor;
97///
98/// #[async_trait]
99/// impl Actor for EchoActor {
100/// async fn handle(&mut self, msg: Arc<Message>, _ctx: &ActorContext) {
101/// // Process message — &*msg gives &Message
102/// }
103/// }
104/// ```
105#[async_trait]
106pub trait Actor: Send + Sync + 'static {
107 /// Handle an incoming message.
108 ///
109 /// Messages are wrapped in [`Arc<Message>`] so that fanout paths
110 /// (e.g. relay) can share a single allocation across all subscribers.
111 /// Use `&*msg` or `msg.as_ref()` to access the inner [`Message`].
112 async fn handle(&mut self, message: Arc<Message>, context: &ActorContext);
113
114 /// Handle a batch of messages drained from the mailbox.
115 ///
116 /// Override to process multiple messages in a single call, enabling
117 /// batch optimizations like coalescing WebSocket writes. The default
118 /// implementation calls [`handle`](Actor::handle) for each message.
119 ///
120 /// Implementors that override this **must** drain all messages from
121 /// `batch` (e.g. via `batch.drain(..)` or `batch.clear()`).
122 async fn handle_batch(&mut self, batch: &mut Vec<Arc<Message>>, context: &ActorContext) {
123 for msg in batch.drain(..) {
124 self.handle(msg, context).await;
125 }
126 }
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 mailbox is closed.
167 ///
168 /// Uses [`MailboxReceiver::recv_batch`] to drain up to 64 messages per
169 /// wakeup, amortizing scheduler overhead across batches. This is the
170 /// key performance difference from tokio's `mpsc::recv` which wakes
171 /// per message.
172 async fn run(
173 &mut self,
174 mut receiver: MailboxReceiver,
175 mut stop_receiver: Receiver<()>,
176 context: ActorContext,
177 ) {
178 self.pre_start(&context).await;
179 let mut batch: Vec<Arc<Message>> = Vec::with_capacity(64);
180 loop {
181 tokio::select! {
182 _v = stop_receiver.recv() => {
183 context.stop();
184 break;
185 },
186 count = receiver.recv_batch(&mut batch, 64) => {
187 if count == 0 {
188 // Mailbox closed.
189 break;
190 }
191 self.handle_batch(&mut batch, &context).await;
192 }
193 }
194 }
195 self.stopping(&context).await;
196 }
197}
198
199/// Per-actor context providing access to runtime services.
200///
201/// Each actor receives an `ActorContext` in `pre_start`, `handle`, and
202/// `stopping`. The context is clonable — clones share the same underlying
203/// state via `Arc`.
204///
205/// # Key Fields
206///
207/// - `peer_id` — this node's peer identifier (shared across all actors)
208/// - `router` — the router actor's address (for forwarding messages)
209/// - `addr` — this actor's own address
210/// - `node` — optional owned [`Node`] (set for the root actor)
211#[derive(Clone)]
212pub struct ActorContext {
213 /// This node's peer ID, shared across all actors.
214 pub peer_id: Arc<RwLock<String>>,
215 /// The router actor's address for message forwarding.
216 /// `Arc<RwLock<…>>` so it can be set after construction (the root node's
217 /// router is created after the node itself).
218 pub router: Arc<RwLock<Addr>>,
219 /// Stop signals for child actors (keyed by child Addr).
220 stop_signals: Arc<RwLock<FxHashMap<Addr, Sender<()>>>>,
221 /// Join handles for spawned child tasks.
222 task_handles: Arc<RwLock<Vec<JoinHandle<()>>>>,
223 /// This actor's own address.
224 pub addr: Addr,
225 /// Whether this actor has been stopped.
226 pub is_stopped: Arc<RwLock<bool>>,
227 /// Shutdown signal receiver — set to `true` when graceful shutdown begins.
228 /// Long-running child tasks select on this to break their loops.
229 pub shutdown_rx: watch::Receiver<bool>,
230 /// Optional owned Node (set for the root actor).
231 pub node: Arc<RwLock<Option<Node>>>,
232 /// Shared metrics handle — lock-free counters for the relay hot path.
233 ///
234 /// Cloned from the root Node's `Metrics` so all actors in the tree
235 /// observe the same atomic counters. Set in `Node::new_with_config`
236 /// and propagated through `child_context`.
237 pub metrics: Arc<Metrics>,
238}
239
240impl ActorContext {
241 /// Creates a new `ActorContext` with the given peer ID.
242 ///
243 /// The `addr` and `router` fields are initialized to [`Addr::noop()`]
244 /// and should be set before use.
245 pub fn new(peer_id: String) -> Self {
246 Self {
247 addr: Addr::noop(),
248 stop_signals: Arc::new(RwLock::new(FxHashMap::default())),
249 task_handles: Arc::new(RwLock::new(Vec::new())),
250 peer_id: Arc::new(RwLock::new(peer_id)),
251 router: Arc::new(RwLock::new(Addr::noop())),
252 is_stopped: Arc::new(RwLock::new(false)),
253 shutdown_rx: watch::channel(false).1,
254 node: Arc::new(RwLock::new(None)),
255 metrics: Arc::new(Metrics::new()),
256 }
257 }
258
259 /// Returns the number of child actors spawned by this context.
260 pub fn child_actor_count(&self) -> usize {
261 self.stop_signals.read().len()
262 }
263
264 /// Creates a child context with the given address and stop signal.
265 fn child_context(&self, addr: Addr, stop_signal: Sender<()>) -> Self {
266 let mut stop_signals = FxHashMap::default();
267 stop_signals.insert(addr.clone(), stop_signal);
268 Self {
269 addr,
270 stop_signals: Arc::new(RwLock::new(stop_signals)),
271 task_handles: Arc::new(RwLock::new(Vec::new())),
272 peer_id: self.peer_id.clone(),
273 router: self.router.clone(),
274 is_stopped: self.is_stopped.clone(),
275 shutdown_rx: self.shutdown_rx.clone(),
276 node: self.node.clone(),
277 metrics: self.metrics.clone(),
278 }
279 }
280
281 /// Spawns a child actor with an unbounded channel and returns its address.
282 ///
283 /// The actor runs in a tokio task. Its lifecycle is managed by this
284 /// context — calling `stop()` will send a stop signal and abort the task.
285 pub fn start_actor(&self, actor: Box<dyn Actor>) -> Addr {
286 self.start_actor_or_router(actor, false, None)
287 }
288
289 /// Spawns a child actor with a bounded channel and returns its address.
290 ///
291 /// The `bound` parameter sets the channel capacity. When full, `send`
292 /// returns `Err(())`, applying backpressure to senders. Use for
293 /// write-heavy actors where unbounded queue growth is undesirable.
294 pub fn start_actor_bounded(&self, actor: Box<dyn Actor>, bound: usize) -> Addr {
295 self.start_actor_or_router(actor, false, Some(bound))
296 }
297
298 /// Spawns a router actor. The router's context will have its `router`
299 /// field set to its own address (so messages forwarded to `router`
300 /// come back to itself).
301 pub fn start_router(&self, actor: Box<dyn Actor>) -> Addr {
302 self.start_actor_or_router(actor, true, None)
303 }
304
305 /// Spawns a router actor with a bounded mailbox.
306 ///
307 /// Same as [`start_router`](Self::start_router) but with an explicit
308 /// backpressure ceiling. Use when the default `DEFAULT_MAILBOX_CAPACITY`
309 /// is not appropriate for the deployment.
310 pub fn start_router_bounded(&self, actor: Box<dyn Actor>, bound: usize) -> Addr {
311 self.start_actor_or_router(actor, true, Some(bound))
312 }
313
314 /// Spawns a child async task (non-blocking).
315 ///
316 /// The task's `JoinHandle` is tracked so it can be aborted on stop.
317 pub fn child_task<T>(&self, task: T)
318 where
319 T: Future<Output = ()> + Send + 'static,
320 {
321 let handle = crate::tokio_spawn::spawn(task);
322 self.task_handles.write().push(handle);
323 }
324
325 /// Spawns a blocking child task via `spawn_blocking`.
326 ///
327 /// Use for CPU-intensive work that should not block the async runtime.
328 #[cfg(not(target_arch = "wasm32"))]
329 pub fn blocking_child_task<F>(&self, task: F)
330 where
331 F: FnOnce() + Send + 'static,
332 {
333 let handle = tokio::task::spawn_blocking(task);
334 self.task_handles.write().push(handle);
335 }
336
337 fn start_actor_or_router(
338 &self,
339 mut actor: Box<dyn Actor>,
340 is_router: bool,
341 bound: Option<usize>,
342 ) -> Addr {
343 let capacity = bound.unwrap_or(DEFAULT_MAILBOX_CAPACITY);
344 let (sender, receiver) = mailbox::mailbox(capacity);
345 let addr = Addr::new(sender);
346 let (stop_sender, stop_receiver) = channel(1);
347 let new_context = self.child_context(addr.clone(), stop_sender.clone());
348 if is_router {
349 *new_context.router.write() = addr.clone();
350 }
351 self.stop_signals.write().insert(addr.clone(), stop_sender);
352 let stop_signals = self.stop_signals.clone();
353 let addr_clone = addr.clone();
354 crate::tokio_spawn::spawn(async move {
355 actor.run(receiver, stop_receiver, new_context).await;
356 stop_signals.write().remove(&addr_clone);
357 });
358 addr
359 }
360
361 /// Stops this actor and all its children.
362 ///
363 /// Aborts all child tasks and sends stop signals to all child actors.
364 /// Sets `is_stopped` to `true`.
365 pub fn stop(&self) {
366 for handle in self.task_handles.read().iter() {
367 handle.abort();
368 }
369 for signal in self.stop_signals.read().values() {
370 let _ = signal.try_send(());
371 }
372 *self.node.write() = None;
373 *self.is_stopped.write() = true;
374 }
375}
376
377/// A clonable, hashable address for sending messages to an actor.
378///
379/// `Addr` implements `PartialEq`, `Eq`, and `Hash` based on its `id` field
380/// (a random 32-character string), **not** the underlying channel sender.
381/// This means two `Addr`s are equal iff they refer to the same actor.
382///
383/// # Sending Messages
384///
385/// ```no_run
386/// use beam::actor::Addr;
387/// use beam::message::Message;
388/// use std::sync::Arc;
389///
390/// // addr.send(Message::Put(put)) — wraps in Arc internally
391/// // addr.send(Arc::clone(&msg)) — refcount bump, no allocation
392/// // Err(()) means the actor's mailbox is closed (actor stopped)
393/// ```
394#[derive(Clone, Debug)]
395pub struct Addr {
396 id: String,
397 sender: MailboxSender,
398}
399
400impl Addr {
401 /// Creates a new address wrapping a [`MailboxSender`].
402 pub fn new(sender: MailboxSender) -> Self {
403 Self {
404 id: random_string(32),
405 sender,
406 }
407 }
408
409 /// Sends a message to this actor.
410 ///
411 /// Accepts `impl Into<Arc<Message>>` so callers can pass either:
412 /// - `Message::Put(put)` — wrapped in `Arc` internally (one allocation)
413 /// - `Arc::clone(&msg)` — refcount bump, zero allocation (for fanout)
414 ///
415 /// Returns `Ok(())` if the message was enqueued, `Err(())` if the
416 /// mailbox is full (backpressure) or closed (actor stopped).
417 ///
418 /// Callers that must not lose messages should retry on `Err`. The
419 /// Router's storage dispatch uses `let _ = addr.send(...)` and accepts
420 /// occasional drops under extreme backpressure, which is the correct
421 /// trade-off for an LWW graph store.
422 #[allow(clippy::result_unit_err)] // mailbox-closed/full is unrecoverable; no meaningful error payload
423 pub fn send(&self, msg: impl Into<Arc<Message>>) -> Result<(), ()> {
424 self.sender.send(msg.into())
425 }
426
427 /// Returns the unique identifier of this actor address.
428 ///
429 /// The id is a random 32-character alphanumeric string, generated at
430 /// address creation. Two `Addr`s are equal iff their ids match.
431 pub fn id(&self) -> &str {
432 &self.id
433 }
434
435 /// Returns a no-op address that silently drops all messages.
436 ///
437 /// Useful as a placeholder before a real address is set.
438 pub fn noop() -> Addr {
439 Addr::new(MailboxSender::noop())
440 }
441}
442
443impl PartialEq for Addr {
444 fn eq(&self, other: &Addr) -> bool {
445 self.id == other.id
446 }
447}
448
449impl Eq for Addr {}
450
451impl Hash for Addr {
452 fn hash<H: Hasher>(&self, state: &mut H) {
453 self.id.hash(state);
454 }
455}
456
457impl fmt::Display for Addr {
458 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
459 write!(f, "actor:{}", self.id)
460 }
461}
462
463#[cfg(test)]
464mod tests {
465 use super::*;
466
467 #[test]
468 fn test_addr_equality() {
469 let (s1, _r1) = crate::mailbox::mailbox(16);
470 let (s2, _r2) = crate::mailbox::mailbox(16);
471 let a1 = Addr::new(s1);
472 let a2 = Addr::new(s2);
473 assert_ne!(a1, a2, "different addrs are not equal");
474 assert_eq!(a1, a1.clone(), "clone is equal");
475 }
476
477 #[test]
478 fn test_addr_hash() {
479 let (s1, _r1) = crate::mailbox::mailbox(16);
480 let a1 = Addr::new(s1);
481 let a2 = a1.clone();
482 let mut set = std::collections::HashSet::new();
483 set.insert(a1);
484 assert!(set.contains(&a2), "clone should be found in HashSet");
485 }
486
487 #[test]
488 fn test_addr_display() {
489 let (s, _r) = crate::mailbox::mailbox(16);
490 let addr = Addr::new(s);
491 let display = format!("{}", addr);
492 assert!(display.starts_with("actor:"));
493 assert_eq!(display.len(), "actor:".len() + 32);
494 }
495
496 #[test]
497 fn test_addr_noop_sends_silently() {
498 let addr = Addr::noop();
499 assert_eq!(addr.id.len(), 32);
500 }
501
502 #[test]
503 fn test_addr_id_accessor() {
504 let (s, _r) = crate::mailbox::mailbox(16);
505 let addr = Addr::new(s);
506 assert_eq!(addr.id().len(), 32);
507 assert!(addr.id().chars().all(|c| c.is_ascii_alphanumeric()));
508 // Display format is "actor:{id}" — id() should return the raw id without prefix
509 assert_ne!(addr.id(), format!("{}", addr));
510 assert!(format!("{}", addr).ends_with(addr.id()));
511 }
512
513 #[test]
514 fn test_addr_id_length() {
515 let (s, _r) = crate::mailbox::mailbox(16);
516 let addr = Addr::new(s);
517 assert_eq!(addr.id.len(), 32);
518 assert!(
519 addr.id.chars().all(|c| c.is_ascii_alphanumeric()),
520 "addr id should be alphanumeric"
521 );
522 }
523
524 struct TestActor {
525 received: Arc<RwLock<Vec<Message>>>,
526 }
527
528 #[async_trait]
529 impl Actor for TestActor {
530 async fn handle(&mut self, message: Arc<Message>, _ctx: &ActorContext) {
531 // Clone the inner Message out of the Arc for the test vector.
532 self.received.write().push((*message).clone());
533 }
534 }
535
536 #[tokio::test]
537 async fn test_actor_context_new() {
538 let ctx = ActorContext::new("peer1".to_string());
539 assert_eq!(*ctx.peer_id.read(), "peer1");
540 assert_eq!(ctx.child_actor_count(), 0);
541 assert!(!*ctx.is_stopped.read());
542 }
543
544 #[tokio::test]
545 async fn test_actor_start_and_send() {
546 let ctx = ActorContext::new("test".to_string());
547 let received = Arc::new(RwLock::new(Vec::new()));
548 let actor = TestActor {
549 received: received.clone(),
550 };
551 let _addr = ctx.start_actor(Box::new(actor));
552
553 // Give the actor a moment to start
554 crate::tokio_time::sleep(web_time::Duration::from_millis(50)).await;
555
556 assert_eq!(ctx.child_actor_count(), 1);
557
558 // Stop the actor
559 ctx.stop();
560 assert!(*ctx.is_stopped.read());
561 }
562}
563
564#[test]
565fn test_shutdown_signal_default_false() {
566 // A new ActorContext should have shutdown_rx initialized to false.
567 let ctx = ActorContext::new("test-peer".to_string());
568 assert!(
569 !*ctx.shutdown_rx.borrow(),
570 "shutdown_rx should default to false"
571 );
572}
573
574#[test]
575fn test_shutdown_signal_propagates_to_child() {
576 // When the parent sends true on shutdown, child contexts (which
577 // share the same watch channel) should observe the change.
578 let (tx, rx) = watch::channel(false);
579 let mut ctx = ActorContext::new("parent".to_string());
580 ctx.shutdown_rx = rx;
581
582 let (stop_tx, _stop_rx) = channel(1);
583 let child = ctx.child_context(Addr::noop(), stop_tx);
584
585 // Child starts with false
586 assert!(!*child.shutdown_rx.borrow());
587
588 // Parent signals shutdown
589 tx.send(true).unwrap();
590
591 // Child observes the change
592 assert!(
593 *child.shutdown_rx.borrow(),
594 "child should see shutdown signal"
595 );
596}
597
598#[test]
599fn test_shutdown_signal_isolated_per_node() {
600 // Different nodes create independent watch channels —
601 // signaling one should not affect the other.
602 let mut ctx_a = ActorContext::new("node-a".to_string());
603 let ctx_b = ActorContext::new("node-b".to_string());
604
605 // Both start false
606 assert!(!*ctx_a.shutdown_rx.borrow());
607 assert!(!*ctx_b.shutdown_rx.borrow());
608
609 // Replace ctx_a's channel with a controllable one
610 let (tx_a, rx_a) = watch::channel(false);
611 ctx_a.shutdown_rx = rx_a;
612 tx_a.send(true).unwrap();
613
614 // ctx_a sees shutdown, ctx_b does not
615 assert!(*ctx_a.shutdown_rx.borrow());
616 assert!(
617 !*ctx_b.shutdown_rx.borrow(),
618 "unrelated node should not see signal"
619 );
620}