xtra

repository·master·Indexed 18 days ago

https://github.com/restioson/xtra

A lightweight, safe, and fast actor framework for Rust, modeled after Actix. It is runtime-agnostic and provides convenience wrappers for executors such as Tokio, async-std, and smol. xtra features an asynchronous Handler interface supporting async/await with &mut self access and includes a custom Actor derive macro. It supports strong and weak addresses, priority-based mailboxes (Default, Priority, and Broadcast), and integration with wasm-bindgen.

Tokens
10.7K
Snippets
40
Records
47
Agent score
63%

What's inside xtra

  1. What is xtra?

    master
    xtra is a tiny, fast, and safe actor framework for Rust, modeled after Actix. It is designed to be lightweight (~2000 LoC) and does not depend on a specific runtime, allowing it to run on any futures executor. It features an asynchronous Handler interface that supports async/await syntax with &mut self access.
  2. How to access xtra macros

    master

    When using xtra, you should access all macros through the main xtra crate.

    While the macros are implemented in the xtra-macros crate, that crate is an internal implementation detail. The macros-test crate exists solely to verify that the public macro API exposed via the main xtra crate behaves as expected and remains consistent for end-users.

  3. Spawn an actor on Tokio

    master

    To run an actor using the Tokio runtime, enable the tokio and macros features in your Cargo.toml. You can then use xtra::spawn_tokio to spawn the actor with a specified Mailbox type (e.g., Mailbox::unbounded()).

    [dependencies]
    extra = { version = "0.6.0", features = ["tokio", "macros"] }
    // Spawning the actor
    let addr = xtra::spawn_tokio(Printer::default(), Mailbox::unbounded());
    
    // Sending a message
    addr.send(Print("hello".to_string())).await.expect("Printer should not be dropped");
  4. Implement an Actor with xtra

    master

    To create an actor, define a struct and implement the Actor derive macro. To handle messages, implement the Handler<Message> trait for your actor. The handle method is asynchronous, allowing you to use async/await while maintaining mutable access to the actor's state via &mut self.

    use xtra::prelude::*;
    
    #[derive(Default, xtra::Actor)]
    struct Printer {
        times: usize,
    }
    
    struct Print(String);
    
    impl Handler<Print> for Printer {
        type Return = ();
    
        async fn handle(&mut self, print: Print, _ctx: &mut Context<Self>) {
            self.times += 1;
            println!("Printing {}. Printed {} times so far.", print.0, self.times);
        }
    }
  5. What is a MessageChannel and how to use it

    master

    A MessageChannel<M, R, Rc> is a communication primitive used to send a specific message type M to any actor capable of handling it, returning a value of type R.

    Unlike an Address, which is tied to a specific actor type, a MessageChannel is associated with the message type. This allows you to decouple the sender from the specific identity of the actor, as long as the actor implements Handler<M, Return = R>.

    Key Capabilities

    • Send messages: Use .send(message) to dispatch a message. It returns a SendFuture that resolves to the handler's return value or an error if the actor is disconnected.
    • Check status: Use .is_connected() to see if the actor is running, .len() for mailbox size, and .is_empty() to check if the mailbox is empty.
    • Lifecycle: Use .join() to get an ActorJoinHandle that resolves when the actor stops.
    • Reference Management: You can .downgrade() a strong channel to a Weak reference or use .as_either() to convert to an Either reference count type.

    Example

    # use xtra::prelude::*;
    struct WhatsYourName;
    
    struct Alice;
    struct Bob;
    
    impl Actor for Alice {
        type Stop = ();
        async fn stopped(self) {}
    }
    
    impl Actor for Bob {
        type Stop = ();
        async fn stopped(self) {}
    }
    
    impl Handler<WhatsYourName> for Alice {
        type Return = &'static str;
    
        async fn handle(&mut self, _: WhatsYourName, _ctx: &mut Context<Self>) -> Self::Return {
            "Alice"
        }
    }
    
    impl Handler<WhatsYourName> for Bob {
        type Return = &'static str;
    
        async fn handle(&mut self, _: WhatsYourName, _ctx: &mut Context<Self>) -> Self::Return {
            "Bob"
        }
    }
    
    fn main() {
    # #[cfg(feature = "smol")]
        smol::block_on(async {
            let alice = xtra::spawn_smol(Alice, Mailbox::unbounded());
            let bob = xtra::spawn_smol(Bob, Mailbox::unbounded());
    
            let channels = [
                MessageChannel::new(alice),
                MessageChannel::new(bob)
            ];
            let name = ["Alice", "Bob"];
    
            for (channel, name) in channels.iter().zip(&name) {
                assert_eq!(*name, channel.send(WhatsYourName).await.unwrap());
            }
        })
    }
    # use xtra::prelude::*;
    // ... (see full example in content) ...
    let channels = [
        MessageChannel::new(alice),
        MessageChannel::new(bob)
    ];
    assert_eq!(*name, channel.send(WhatsYourName).await.unwrap());
  6. How Address and WeakAddress work together

    master

    An Address<A, Rc> is a reference to an actor used to send messages. By default, addresses are Strong, meaning they prevent the actor from being dropped as long as at least one strong address exists.

    If you want to hold a reference to an actor without preventing it from being shut down and dropped, use a WeakAddress<A>. You can create one by calling Address::downgrade() on a strong address.

    To use a WeakAddress, you must attempt to upgrade it back to a strong Address using try_upgrade(). This returns None if the actor has already been dropped.

    // Creating a weak address from a strong one
    let weak_addr = strong_addr.downgrade();
    
    // Attempting to use it later
    if let Some(strong_addr) = weak_addr.try_upgrade() {
        strong_addr.send(Message).await;
    }
  7. How actors and handlers work together

    master

    The xtra actor system follows a pattern where an Actor holds state and lifecycle logic, while Handler implementations define the behavior for specific message types.

    1. Define State: Create a struct to hold your actor's data.
    2. Implement Actor: Define lifecycle hooks (started, stopped).
    3. Implement Handler: Define how to process specific messages using handle.
    4. Spawn: Use a spawn function (like spawn_smol) to start the actor and receive an Address.
    # use xtra::prelude::*;
    # struct MyActor;
    # impl Actor for MyActor { type Stop = (); async fn stopped(self) {} }
    # struct Msg;
    
    impl Handler<Msg> for MyActor {
        type Return = u32;
        async fn handle(&mut self, _message: Msg, _ctx: &mut Context<Self>) -> u32 {
            20
        }
    }
    
    // Usage pattern:
    // let addr = xtra::spawn_smol(MyActor, Mailbox::unbounded());
    // let result = addr.send(Msg).await;
  8. How SendFuture works for sending messages to actors

    master

    A SendFuture represents the state of sending a message to an actor. It handles the lifecycle of queuing a message into an actor's mailbox and managing the response.

    Default Behavior

    By default, awaiting a SendFuture resolves directly to the return value of the actor's handler (Handler::Return).

    Backpressure

    If the actor's mailbox is bounded, the SendFuture will yield Pending until the message is successfully queued. This allows the actor to exercise backpressure on its users.

    Detaching for Concurrency

    You can call .detach() on a SendFuture to change its behavior. A detached future resolves once the message is successfully queued, returning a Receiver<R>. This Receiver is itself a future that resolves to the handler's return value. This pattern allows your current task to continue immediately after the message is queued, while the actor processes the message asynchronously.

    Setting Message Priority

    You can set the priority of a message using .priority(new_priority: u32) before the future is polled.

    Note: Calling .priority() after the future has already been polled will cause a panic.

    // Example of detaching a SendFuture to handle the response separately
    let receiver = send_future.detach().await?;
    // ... do other work ...
    let result = receiver.await?;
  9. How Mailbox and Address work together

    master

    In the xtra actor model, an Address<A> and a Mailbox<A> function as a Multi-Producer Multi-Consumer (MPMC) channel.

    • Address<A>: The sending end. Messages sent to an Address are queued for delivery.
    • Mailbox<A>: The receiving end. It is the counterpart to the Address and is used by an actor to retrieve incoming messages.

    You can create these pairs using either Mailbox::bounded(capacity) for back-pressure or Mailbox::unbounded() for unlimited capacity (use with caution due to potential memory growth).

    // Example of creating a bounded mailbox/address pair
    let capacity = 100;
    let (address, mailbox) = Mailbox::<MyMessage>::bounded(capacity);
    
    // Sending a message
    address.send(MyMessage::Hello);
    
    // Receiving a message
    let msg = mailbox.next().await;
  10. How `DispatchFuture` works

    master

    A DispatchFuture is a state machine that manages the transition from a raw Message to the execution of an actor's handler.

    1. New State: The future is initialized with the ActorMessage, a mutable reference to the actor, and the Mailbox.
    2. Running State: Upon the first poll, the future transitions to the Running state by invoking the appropriate handler logic (e.g., ToOneActor, ToAllActors, or Shutdown). It wraps the resulting handler future in a BoxFuture.
    3. Execution: While polling, the future executes the handler within the scope of its Span (if instrumentation is enabled).
    4. Completion: The future returns Poll::Ready(ControlFlow<()>) once the handler completes.
  11. How actor mailboxes and priority work

    master

    Internally, an actor's mailbox is divided into three distinct queues, each with its own capacity and backpressure behavior:

    1. Default Priority (Ordered): The most common queue. Messages are handled in the order they are sent. This queue is only serviced if the other two mailboxes are empty.
    2. Priority: Used for critical tasks (like pings). Messages are handled in order of their priority. This queue is serviced before the default priority queue.
    3. Broadcast: Used for messages that must be handled by every actor on the address. Backpressure from Address::broadcast will wait for the slowest actor to process the message.

    Note that the actor's mailbox capacity applies to each queue individually. An actor can have cap messages in every mailbox before it is considered full.