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 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}