Skip to main content

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::FxHashMap;
63use crate::utils::random_string;
64use async_trait::async_trait;
65use futures_util::Future;
66use parking_lot::RwLock;
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    /// Whether this actor is a relay server (WsServer) that accepts
148    /// incoming WebSocket connections and fans out to individual WsConn
149    /// clients.
150    ///
151    /// The Router uses this to distinguish WsServer (which handles
152    /// per-connection echo-back via `msg.is_from(conn)`) from
153    /// OutgoingWebsocketManager (which sends to a single remote relay
154    /// and must be skipped on echo-back).
155    ///
156    /// Defaults to `false`.
157    fn is_relay_server(&self) -> bool {
158        false
159    }
160
161    /// Attempts to produce a clone of this actor for storage read/write
162    /// splitting.
163    ///
164    /// Storage adapters override this to return a boxed clone, enabling the
165    /// [`crate::router::Router`] to start separate read and write actors that
166    /// share the same underlying database. Non-storage actors return `None`
167    /// (the default).
168    ///
169    /// When the Router receives `Some`, it starts two actors: one registered
170    /// in `read_adapters` (receives only `Get`), one in `write_adapters`
171    /// (receives `Put`, `BatchPut`, `Flush`). Both share the same underlying
172    /// data store via `Arc`, so reads see committed writes immediately.
173    fn try_clone_storage(&self) -> Option<Box<dyn Actor>> {
174        None
175    }
176}
177
178impl dyn Actor {
179    /// Internal run loop — receives messages until the stop signal fires
180    /// or the mailbox is closed.
181    ///
182    /// Uses [`MailboxReceiver::recv_batch`] to drain up to 64 messages per
183    /// wakeup, amortizing scheduler overhead across batches. This is the
184    /// key performance difference from tokio's `mpsc::recv` which wakes
185    /// per message.
186    async fn run(
187        &mut self,
188        mut receiver: MailboxReceiver,
189        mut stop_receiver: Receiver<()>,
190        context: ActorContext,
191    ) {
192        self.pre_start(&context).await;
193        let mut batch: Vec<Arc<Message>> = Vec::with_capacity(64);
194        loop {
195            tokio::select! {
196                _v = stop_receiver.recv() => {
197                    context.stop();
198                    break;
199                },
200                count = receiver.recv_batch(&mut batch, 64) => {
201                    if count == 0 {
202                        // Mailbox closed.
203                        break;
204                    }
205                    self.handle_batch(&mut batch, &context).await;
206                }
207            }
208        }
209        self.stopping(&context).await;
210    }
211}
212
213/// Per-actor context providing access to runtime services.
214///
215/// Each actor receives an `ActorContext` in `pre_start`, `handle`, and
216/// `stopping`. The context is clonable — clones share the same underlying
217/// state via `Arc`.
218///
219/// # Key Fields
220///
221/// - `peer_id` — this node's peer identifier (shared across all actors)
222/// - `router` — the router actor's address (for forwarding messages)
223/// - `addr` — this actor's own address
224/// - `node` — optional owned [`Node`] (set for the root actor)
225#[derive(Clone)]
226pub struct ActorContext {
227    /// This node's peer ID, shared across all actors.
228    pub peer_id: Arc<RwLock<String>>,
229    /// The router actor's address for message forwarding.
230    /// `Arc<RwLock<…>>` so it can be set after construction (the root node's
231    /// router is created after the node itself).
232    pub router: Arc<RwLock<Addr>>,
233    /// Stop signals for child actors (keyed by child Addr).
234    stop_signals: Arc<RwLock<FxHashMap<Addr, Sender<()>>>>,
235    /// Join handles for spawned child tasks.
236    task_handles: Arc<RwLock<Vec<JoinHandle<()>>>>,
237    /// This actor's own address.
238    pub addr: Addr,
239    /// Whether this actor has been stopped.
240    pub is_stopped: Arc<RwLock<bool>>,
241    /// Shutdown signal receiver — set to `true` when graceful shutdown begins.
242    /// Long-running child tasks select on this to break their loops.
243    pub shutdown_rx: watch::Receiver<bool>,
244    /// Optional owned Node (set for the root actor).
245    pub node: Arc<RwLock<Option<Node>>>,
246    /// Shared metrics handle — lock-free counters for the relay hot path.
247    ///
248    /// Cloned from the root Node's `Metrics` so all actors in the tree
249    /// observe the same atomic counters. Set in `Node::new_with_config`
250    /// and propagated through `child_context`.
251    pub metrics: Arc<Metrics>,
252}
253
254impl ActorContext {
255    /// Creates a new `ActorContext` with the given peer ID.
256    ///
257    /// The `addr` and `router` fields are initialized to [`Addr::noop()`]
258    /// and should be set before use.
259    pub fn new(peer_id: String) -> Self {
260        Self {
261            addr: Addr::noop(),
262            stop_signals: Arc::new(RwLock::new(FxHashMap::default())),
263            task_handles: Arc::new(RwLock::new(Vec::new())),
264            peer_id: Arc::new(RwLock::new(peer_id)),
265            router: Arc::new(RwLock::new(Addr::noop())),
266            is_stopped: Arc::new(RwLock::new(false)),
267            shutdown_rx: watch::channel(false).1,
268            node: Arc::new(RwLock::new(None)),
269            metrics: Arc::new(Metrics::new()),
270        }
271    }
272
273    /// Returns the number of child actors spawned by this context.
274    pub fn child_actor_count(&self) -> usize {
275        self.stop_signals.read().len()
276    }
277
278    /// Creates a child context with the given address and stop signal.
279    fn child_context(&self, addr: Addr, stop_signal: Sender<()>) -> Self {
280        let mut stop_signals = FxHashMap::default();
281        stop_signals.insert(addr.clone(), stop_signal);
282        Self {
283            addr,
284            stop_signals: Arc::new(RwLock::new(stop_signals)),
285            task_handles: Arc::new(RwLock::new(Vec::new())),
286            peer_id: self.peer_id.clone(),
287            router: self.router.clone(),
288            is_stopped: self.is_stopped.clone(),
289            shutdown_rx: self.shutdown_rx.clone(),
290            node: self.node.clone(),
291            metrics: self.metrics.clone(),
292        }
293    }
294
295    /// Spawns a child actor with an unbounded channel and returns its address.
296    ///
297    /// The actor runs in a tokio task. Its lifecycle is managed by this
298    /// context — calling `stop()` will send a stop signal and abort the task.
299    pub fn start_actor(&self, actor: Box<dyn Actor>) -> Addr {
300        self.start_actor_or_router(actor, false, None)
301    }
302
303    /// Spawns a child actor with a bounded channel and returns its address.
304    ///
305    /// The `bound` parameter sets the channel capacity. When full, `send`
306    /// returns `Err(())`, applying backpressure to senders. Use for
307    /// write-heavy actors where unbounded queue growth is undesirable.
308    pub fn start_actor_bounded(&self, actor: Box<dyn Actor>, bound: usize) -> Addr {
309        self.start_actor_or_router(actor, false, Some(bound))
310    }
311
312    /// Spawns a router actor. The router's context will have its `router`
313    /// field set to its own address (so messages forwarded to `router`
314    /// come back to itself).
315    pub fn start_router(&self, actor: Box<dyn Actor>) -> Addr {
316        self.start_actor_or_router(actor, true, None)
317    }
318
319    /// Spawns a router actor with a bounded mailbox.
320    ///
321    /// Same as [`start_router`](Self::start_router) but with an explicit
322    /// backpressure ceiling. Use when the default `DEFAULT_MAILBOX_CAPACITY`
323    /// is not appropriate for the deployment.
324    pub fn start_router_bounded(&self, actor: Box<dyn Actor>, bound: usize) -> Addr {
325        self.start_actor_or_router(actor, true, Some(bound))
326    }
327
328    /// Spawns a child async task (non-blocking).
329    ///
330    /// The task's `JoinHandle` is tracked so it can be aborted on stop.
331    pub fn child_task<T>(&self, task: T)
332    where
333        T: Future<Output = ()> + Send + 'static,
334    {
335        let handle = crate::tokio_spawn::spawn(task);
336        self.task_handles.write().push(handle);
337    }
338
339    /// Spawns a blocking child task via `spawn_blocking`.
340    ///
341    /// Use for CPU-intensive work that should not block the async runtime.
342    #[cfg(not(target_arch = "wasm32"))]
343    pub fn blocking_child_task<F>(&self, task: F)
344    where
345        F: FnOnce() + Send + 'static,
346    {
347        let handle = tokio::task::spawn_blocking(task);
348        self.task_handles.write().push(handle);
349    }
350
351    fn start_actor_or_router(
352        &self,
353        mut actor: Box<dyn Actor>,
354        is_router: bool,
355        bound: Option<usize>,
356    ) -> Addr {
357        let capacity = bound.unwrap_or(DEFAULT_MAILBOX_CAPACITY);
358        let (sender, receiver) = mailbox::mailbox(capacity);
359        let addr = Addr::new(sender);
360        let (stop_sender, stop_receiver) = channel(1);
361        let new_context = self.child_context(addr.clone(), stop_sender.clone());
362        if is_router {
363            *new_context.router.write() = addr.clone();
364        }
365        self.stop_signals.write().insert(addr.clone(), stop_sender);
366        let stop_signals = self.stop_signals.clone();
367        let addr_clone = addr.clone();
368        crate::tokio_spawn::spawn(async move {
369            actor.run(receiver, stop_receiver, new_context).await;
370            stop_signals.write().remove(&addr_clone);
371        });
372        addr
373    }
374
375    /// Stops this actor and all its children.
376    ///
377    /// Aborts all child tasks and sends stop signals to all child actors.
378    /// Sets `is_stopped` to `true`.
379    pub fn stop(&self) {
380        for handle in self.task_handles.read().iter() {
381            handle.abort();
382        }
383        for signal in self.stop_signals.read().values() {
384            let _ = signal.try_send(());
385        }
386        *self.node.write() = None;
387        *self.is_stopped.write() = true;
388    }
389}
390
391/// A clonable, hashable address for sending messages to an actor.
392///
393/// `Addr` implements `PartialEq`, `Eq`, and `Hash` based on its `id` field
394/// (a random 32-character string), **not** the underlying channel sender.
395/// This means two `Addr`s are equal iff they refer to the same actor.
396///
397/// # Sending Messages
398///
399/// ```no_run
400/// use beam::actor::Addr;
401/// use beam::message::Message;
402/// use std::sync::Arc;
403///
404/// // addr.send(Message::Put(put)) — wraps in Arc internally
405/// // addr.send(Arc::clone(&msg)) — refcount bump, no allocation
406/// // Err(()) means the actor's mailbox is closed (actor stopped)
407/// ```
408#[derive(Clone, Debug)]
409pub struct Addr {
410    id: String,
411    sender: MailboxSender,
412}
413
414impl Addr {
415    /// Creates a new address wrapping a [`MailboxSender`].
416    pub fn new(sender: MailboxSender) -> Self {
417        Self {
418            id: random_string(32),
419            sender,
420        }
421    }
422
423    /// Sends a message to this actor.
424    ///
425    /// Accepts `impl Into<Arc<Message>>` so callers can pass either:
426    /// - `Message::Put(put)` — wrapped in `Arc` internally (one allocation)
427    /// - `Arc::clone(&msg)` — refcount bump, zero allocation (for fanout)
428    ///
429    /// Returns `Ok(())` if the message was enqueued, `Err(())` if the
430    /// mailbox is full (backpressure) or closed (actor stopped).
431    ///
432    /// Callers that must not lose messages should retry on `Err`. The
433    /// Router's storage dispatch uses `let _ = addr.send(...)` and accepts
434    /// occasional drops under extreme backpressure, which is the correct
435    /// trade-off for an LWW graph store.
436    #[allow(clippy::result_unit_err)] // mailbox-closed/full is unrecoverable; no meaningful error payload
437    pub fn send(&self, msg: impl Into<Arc<Message>>) -> Result<(), ()> {
438        self.sender.send(msg.into())
439    }
440
441    /// Returns the unique identifier of this actor address.
442    ///
443    /// The id is a random 32-character alphanumeric string, generated at
444    /// address creation. Two `Addr`s are equal iff their ids match.
445    pub fn id(&self) -> &str {
446        &self.id
447    }
448
449    /// Returns a no-op address that silently drops all messages.
450    ///
451    /// Useful as a placeholder before a real address is set.
452    pub fn noop() -> Addr {
453        Addr::new(MailboxSender::noop())
454    }
455}
456
457impl PartialEq for Addr {
458    fn eq(&self, other: &Addr) -> bool {
459        self.id == other.id
460    }
461}
462
463impl Eq for Addr {}
464
465impl Hash for Addr {
466    fn hash<H: Hasher>(&self, state: &mut H) {
467        self.id.hash(state);
468    }
469}
470
471impl fmt::Display for Addr {
472    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
473        write!(f, "actor:{}", self.id)
474    }
475}
476
477#[cfg(test)]
478mod tests {
479    use super::*;
480
481    #[test]
482    fn test_addr_equality() {
483        let (s1, _r1) = crate::mailbox::mailbox(16);
484        let (s2, _r2) = crate::mailbox::mailbox(16);
485        let a1 = Addr::new(s1);
486        let a2 = Addr::new(s2);
487        assert_ne!(a1, a2, "different addrs are not equal");
488        assert_eq!(a1, a1.clone(), "clone is equal");
489    }
490
491    #[test]
492    fn test_addr_hash() {
493        let (s1, _r1) = crate::mailbox::mailbox(16);
494        let a1 = Addr::new(s1);
495        let a2 = a1.clone();
496        let mut set = std::collections::HashSet::new();
497        set.insert(a1);
498        assert!(set.contains(&a2), "clone should be found in HashSet");
499    }
500
501    #[test]
502    fn test_addr_display() {
503        let (s, _r) = crate::mailbox::mailbox(16);
504        let addr = Addr::new(s);
505        let display = format!("{}", addr);
506        assert!(display.starts_with("actor:"));
507        assert_eq!(display.len(), "actor:".len() + 32);
508    }
509
510    #[test]
511    fn test_addr_noop_sends_silently() {
512        let addr = Addr::noop();
513        assert_eq!(addr.id.len(), 32);
514    }
515
516    #[test]
517    fn test_addr_id_accessor() {
518        let (s, _r) = crate::mailbox::mailbox(16);
519        let addr = Addr::new(s);
520        assert_eq!(addr.id().len(), 32);
521        assert!(addr.id().chars().all(|c| c.is_ascii_alphanumeric()));
522        // Display format is "actor:{id}" — id() should return the raw id without prefix
523        assert_ne!(addr.id(), format!("{}", addr));
524        assert!(format!("{}", addr).ends_with(addr.id()));
525    }
526
527    #[test]
528    fn test_addr_id_length() {
529        let (s, _r) = crate::mailbox::mailbox(16);
530        let addr = Addr::new(s);
531        assert_eq!(addr.id.len(), 32);
532        assert!(
533            addr.id.chars().all(|c| c.is_ascii_alphanumeric()),
534            "addr id should be alphanumeric"
535        );
536    }
537
538    struct TestActor {
539        received: Arc<RwLock<Vec<Message>>>,
540    }
541
542    #[async_trait]
543    impl Actor for TestActor {
544        async fn handle(&mut self, message: Arc<Message>, _ctx: &ActorContext) {
545            // Clone the inner Message out of the Arc for the test vector.
546            self.received.write().push((*message).clone());
547        }
548    }
549
550    #[tokio::test]
551    async fn test_actor_context_new() {
552        let ctx = ActorContext::new("peer1".to_string());
553        assert_eq!(*ctx.peer_id.read(), "peer1");
554        assert_eq!(ctx.child_actor_count(), 0);
555        assert!(!*ctx.is_stopped.read());
556    }
557
558    #[tokio::test]
559    async fn test_actor_start_and_send() {
560        let ctx = ActorContext::new("test".to_string());
561        let received = Arc::new(RwLock::new(Vec::new()));
562        let actor = TestActor {
563            received: received.clone(),
564        };
565        let _addr = ctx.start_actor(Box::new(actor));
566
567        // Give the actor a moment to start
568        crate::tokio_time::sleep(web_time::Duration::from_millis(50)).await;
569
570        assert_eq!(ctx.child_actor_count(), 1);
571
572        // Stop the actor
573        ctx.stop();
574        assert!(*ctx.is_stopped.read());
575    }
576}
577
578#[test]
579fn test_shutdown_signal_default_false() {
580    // A new ActorContext should have shutdown_rx initialized to false.
581    let ctx = ActorContext::new("test-peer".to_string());
582    assert!(
583        !*ctx.shutdown_rx.borrow(),
584        "shutdown_rx should default to false"
585    );
586}
587
588#[test]
589fn test_shutdown_signal_propagates_to_child() {
590    // When the parent sends true on shutdown, child contexts (which
591    // share the same watch channel) should observe the change.
592    let (tx, rx) = watch::channel(false);
593    let mut ctx = ActorContext::new("parent".to_string());
594    ctx.shutdown_rx = rx;
595
596    let (stop_tx, _stop_rx) = channel(1);
597    let child = ctx.child_context(Addr::noop(), stop_tx);
598
599    // Child starts with false
600    assert!(!*child.shutdown_rx.borrow());
601
602    // Parent signals shutdown
603    tx.send(true).unwrap();
604
605    // Child observes the change
606    assert!(
607        *child.shutdown_rx.borrow(),
608        "child should see shutdown signal"
609    );
610}
611
612#[test]
613fn test_shutdown_signal_isolated_per_node() {
614    // Different nodes create independent watch channels —
615    // signaling one should not affect the other.
616    let mut ctx_a = ActorContext::new("node-a".to_string());
617    let ctx_b = ActorContext::new("node-b".to_string());
618
619    // Both start false
620    assert!(!*ctx_a.shutdown_rx.borrow());
621    assert!(!*ctx_b.shutdown_rx.borrow());
622
623    // Replace ctx_a's channel with a controllable one
624    let (tx_a, rx_a) = watch::channel(false);
625    ctx_a.shutdown_rx = rx_a;
626    tx_a.send(true).unwrap();
627
628    // ctx_a sees shutdown, ctx_b does not
629    assert!(*ctx_a.shutdown_rx.borrow());
630    assert!(
631        !*ctx_b.shutdown_rx.borrow(),
632        "unrelated node should not see signal"
633    );
634}