rsActor

A Lightweight Rust Actor Framework with Simple Yet Powerful Task Control.
rsActor is a lightweight, Tokio-based actor framework in Rust focused on providing simple yet powerful task control. It prioritizes simplicity and efficiency for local, in-process actor systems while giving developers complete control over their actors' execution lifecycle — define your own on_run, control execution, control the lifecycle.
Note: This project is actively evolving. While core APIs are stable, some features may be refined in future releases.
Core Features
- Minimalist Actor System: Focuses on core actor model primitives.
- Message Passing:
ask/ask_with_timeout: Send a message and asynchronously await a reply.
tell/tell_with_timeout: Send a message without waiting for a reply.
ask_blocking/tell_blocking: Blocking versions for tokio::task::spawn_blocking contexts.
- Actor Lifecycle with Simple Yet Powerful Task Control:
on_start, on_run, and on_stop hooks form the actor's lifecycle. The distinctive on_run feature provides a dedicated task execution environment with simple yet powerful primitives, giving developers complete control over task logic while the framework manages execution.
- Graceful & Immediate Termination: Actors can be stopped gracefully or killed.
ActorResult: Enum representing the outcome of an actor's lifecycle (e.g., completed, failed).
- Macro-Assisted Message Handling:
impl_message_handler! macro simplifies routing messages.
- Tokio-Native: Built for the
tokio asynchronous runtime.
- Only
Send Trait Required: Actor structs only need to implement the Send trait (not Sync), enabling the use of interior mutability types like std::cell::Cell for internal state management without synchronization overhead.
Getting Started
1. Add Dependency
[dependencies]
rsactor = "0.6"
2. Basic Usage Example
A simple counter actor:
use rsactor::{Actor, ActorRef, Message, impl_message_handler, spawn, ActorResult};
use anyhow::Result;
use log::info;
#[derive(Debug)] struct CounterActor {
count: u32,
tick_300ms: tokio::time::Interval,
tick_1s: tokio::time::Interval,
}
impl Actor for CounterActor {
type Args = u32; type Error = anyhow::Error;
async fn on_start(initial_count: Self::Args, actor_ref: ActorRef<Self>) -> Result<Self, Self::Error> {
info!("CounterActor (id: {}) started. Initial count: {}", actor_ref.identity(), initial_count);
Ok(CounterActor {
count: initial_count,
tick_300ms: tokio::time::interval(std::time::Duration::from_millis(300)),
tick_1s: tokio::time::interval(std::time::Duration::from_secs(1)),
})
}
async fn on_run(&mut self, _actor_ref: ActorRef<Self>) -> Result<(), Self::Error> {
tokio::select! {
_ = self.tick_300ms.tick() => {
println!("Tick: 300ms, Count: {}", self.count);
}
_ = self.tick_1s.tick() => {
println!("Tick: 1s, Count: {}", self.count);
}
}
Ok(())
}
async fn on_stop(&mut self, _actor_ref: ActorRef<Self>, killed: bool) -> Result<(), Self::Error> {
info!("CounterActor stopping. Final count: {}. Killed: {}",
self.count, killed);
Ok(())
}
}
struct IncrementMsg(u32);
struct GetCountMsg;
impl Message<IncrementMsg> for CounterActor {
type Reply = u32;
async fn handle(&mut self, msg: IncrementMsg, _actor_ref: ActorRef<Self>) -> Self::Reply {
self.count += msg.0;
self.count
}
}
impl Message<GetCountMsg> for CounterActor {
type Reply = u32;
async fn handle(&mut self, _msg: GetCountMsg, _actor_ref: ActorRef<Self>) -> Self::Reply {
self.count
}
}
impl_message_handler!(CounterActor, [IncrementMsg, GetCountMsg]);
#[tokio::main]
async fn main() -> Result<()> {
env_logger::init();
info!("Creating CounterActor");
let (actor_ref, join_handle) = spawn::<CounterActor>(0u32); info!("CounterActor spawned with ID: {}", actor_ref.identity());
let new_count: u32 = actor_ref.ask(IncrementMsg(5)).await?;
info!("Incremented count: {}", new_count);
let current_count: u32 = actor_ref.ask(GetCountMsg).await?;
info!("Current count: {}", current_count);
let actor_result = join_handle.await?;
info!(
"CounterActor (ID: {}) task completed. Result: {:?}",
actor_ref.identity(),
actor_result
);
match actor_result {
ActorResult::Completed { actor, killed } => {
info!("Actor completed. Final count: {}. Killed: {}", actor.count, killed);
}
ActorResult::Failed { actor, error, phase, killed } => {
info!("Actor failed during phase {:?}: {:?}. Actor state at failure: {:?}. Killed: {}", phase, error, actor, killed);
}
}
info!("Example finished.");
Ok(())
}
Running the Example
Run the example from examples/basic.rs:
cargo run --example basic
Using Blocking Functions with Tokio Tasks
ask_blocking and tell_blocking are for use within Tokio's blocking tasks (tokio::task::spawn_blocking).
When to Use
- Inside a
tokio::task::spawn_blocking task.
Example
use rsactor::{ActorRef, Message, Actor, impl_message_handler}; use tokio::task;
use std::time::Duration;
use anyhow::Result;
struct MyMessage(String);
struct MyQuery;
#[derive(Debug)] struct MyActor;
impl Actor for MyActor {
type Args = (); type Error = anyhow::Error;
async fn on_start(_args: Self::Args, _actor_ref: ActorRef<Self>) -> Result<Self, Self::Error> { Ok(MyActor)
}
}
impl Message<MyMessage> for MyActor {
type Reply = ();
async fn handle(&mut self, _msg: MyMessage, _actor_ref: ActorRef<Self>) -> Self::Reply {}
}
impl Message<MyQuery> for MyActor {
type Reply = String;
async fn handle(&mut self, _msg: MyQuery, _actor_ref: ActorRef<Self>) -> Self::Reply {
"response".to_string()
}
}
rsactor::impl_message_handler!(MyActor, [MyMessage, MyQuery]);
async fn demonstrate_blocking_calls(actor_ref: ActorRef) -> Result<()> {
let actor_ref_clone_tell = actor_ref.clone();
let blocking_task_tell = task::spawn_blocking(move || {
actor_ref_clone_tell.tell_blocking(MyMessage("notification".to_string()), None)
});
let actor_ref_clone_ask = actor_ref.clone();
let blocking_task_ask = task::spawn_blocking(move || {
actor_ref_clone_ask.ask_blocking::<MyQuery, String>(
MyQuery, Some(Duration::from_secs(2))
)
});
match blocking_task_tell.await? {
Ok(_) => println!("Tell blocking successful"),
Err(e) => println!("Tell blocking failed: {:?}", e),
}
match blocking_task_ask.await? {
Ok(response) => println!("Ask blocking successful, response: {}", response),
Err(e) => println!("Ask blocking failed: {:?}", e),
}
Ok(())
}
Important: These functions require an active Tokio runtime.
Type Safety Features
rsActor provides two actor reference types that offer different levels of type safety:
ActorRef<T> - Type-Safe Actor References
ActorRef<T> provides compile-time type safety for message passing:
- Message Type Safety: Only messages that the actor can handle (via
Message<M> trait) are accepted at compile time
- Reply Type Safety: Return types are automatically inferred from the
Message<M> trait implementation
- Zero Runtime Overhead: Type safety is enforced at compile time with no performance cost
use rsactor::{Actor, ActorRef, Message, impl_message_handler, spawn};
#[derive(Debug)]
struct Calculator;
impl Actor for Calculator {
type Args = ();
type Error = anyhow::Error;
async fn on_start(_args: Self::Args, _actor_ref: ActorRef<Self>) -> Result<Self, Self::Error> {
Ok(Calculator)
}
}
struct Add(i32, i32);
struct Multiply(i32, i32);
impl Message<Add> for Calculator {
type Reply = i32;
async fn handle(&mut self, msg: Add, _actor_ref: ActorRef<Self>) -> Self::Reply {
msg.0 + msg.1
}
}
impl Message<Multiply> for Calculator {
type Reply = i32;
async fn handle(&mut self, msg: Multiply, _actor_ref: ActorRef<Self>) -> Self::Reply {
msg.0 * msg.1
}
}
impl_message_handler!(Calculator, [Add, Multiply]);
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let (calc_ref, _handle) = spawn::<Calculator>(());
let sum: i32 = calc_ref.ask(Add(5, 3)).await?;
let product: i32 = calc_ref.ask(Multiply(4, 7)).await?;
println!("Sum: {}, Product: {}", sum, product);
Ok(())
}
UntypedActorRef - Type-Erased Actor References
UntypedActorRef provides runtime type handling for dynamic scenarios:
- Collection Management: Store different actor types in the same collection (Vec, HashMap, etc.)
- Plugin Systems: Handle actors loaded dynamically at runtime
- Heterogeneous Actor Groups: Manage actors of different types uniformly
⚠️ Developer Responsibility: When using UntypedActorRef, you are responsible for ensuring type safety at runtime.
use rsactor::{Actor, ActorRef, UntypedActorRef, Message, impl_message_handler, spawn};
use std::collections::HashMap;
#[derive(Debug)]
struct Logger;
#[derive(Debug)]
struct Counter { count: u32 }
impl Actor for Logger {
type Args = ();
type Error = anyhow::Error;
async fn on_start(_args: Self::Args, _actor_ref: ActorRef<Self>) -> Result<Self, Self::Error> {
Ok(Logger)
}
}
impl Actor for Counter {
type Args = u32;
type Error = anyhow::Error;
async fn on_start(initial: Self::Args, _actor_ref: ActorRef<Self>) -> Result<Self, Self::Error> {
Ok(Counter { count: initial })
}
}
struct LogMessage(String);
struct IncrementMessage;
impl Message<LogMessage> for Logger {
type Reply = ();
async fn handle(&mut self, msg: LogMessage, _actor_ref: ActorRef<Self>) -> Self::Reply {
println!("LOG: {}", msg.0);
}
}
impl Message<IncrementMessage> for Counter {
type Reply = u32;
async fn handle(&mut self, _msg: IncrementMessage, _actor_ref: ActorRef<Self>) -> Self::Reply {
self.count += 1;
self.count
}
}
impl_message_handler!(Logger, [LogMessage]);
impl_message_handler!(Counter, [IncrementMessage]);
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let (logger_ref, _) = spawn::<Logger>(());
let (counter_ref, _) = spawn::<Counter>(0);
let mut actors: HashMap<String, UntypedActorRef> = HashMap::new();
actors.insert("logger".to_string(), logger_ref.untyped_actor_ref().clone());
actors.insert("counter".to_string(), counter_ref.untyped_actor_ref().clone());
if let Some(logger) = actors.get("logger") {
logger.tell(LogMessage("Hello from untyped ref!".to_string())).await?;
}
if let Some(counter) = actors.get("counter") {
let new_count: u32 = counter.ask(IncrementMessage).await?;
println!("Counter: {}", new_count);
}
Ok(())
}
Type Safety Guidelines
-
Use ActorRef<T> by default for type safety and better development experience
-
Use UntypedActorRef only when necessary for:
- Collections of heterogeneous actors
- Dynamic actor loading/plugin systems
- Interfacing with external APIs that require type erasure
-
When using UntypedActorRef:
- Document expected message types clearly
- Consider wrapping in higher-level abstractions
- Use defensive programming (error handling for type mismatches)
- Test thoroughly for runtime type errors
Converting Between Reference Types
You can easily convert between typed and untyped actor references:
let (typed_ref, _) = spawn::<MyActor>(());
let untyped_ref: &UntypedActorRef = typed_ref.untyped_actor_ref();
Further Information
For more detailed questions and answers, please see the FAQ.
License
This project is licensed under the Apache License 2.0. See the LICENSE-APACHE file for details.