tellus 0.2.1

A resilient world of actors for Rust: typed messages, supervision trees, death watch, event sourcing.
Documentation

tellus

Actors for Rust, on solid ground.

Crates.io license build docs benchmarks comparison

An actor framework for Rust, built on Tokio: typed messages, supervision trees, death watch with an ordering guarantee and optional event-sourced persistence. Inspired by Carl Hewitt's Actor Model and strongly influenced by Akka.

tellus is under active development and its API is still settling.

Highlights

  • Typed actors as state machines. An actor implements the Actor trait with associated Message, State and Error types: init creates the initial state, receive consumes the current state and a message and returns the state for the next one via Control::Continue, or stops via Control::Stop. No &mut self, no async in actor code.
  • Supervision tree. Actors form a tree below the root actor of an ActorSystem. Stopping an actor stops its children first; ActorSystem::terminated resolves once the whole tree has terminated.
  • Fire-and-forget messaging. ActorRef::tell never blocks and delivers at most once; undeliverable messages are dropped and logged as dead letters (structured logging via tracing with fields).
  • Request-response. ActorRef::ask awaits a reply from outside the actor tree; ActorContext::reply_to lets actors reply to each other through their ordinary mailboxes, keeping actor code free of futures.
  • Death watch with an ordering guarantee. ActorContext::watch delivers a terminated signal which is ordered behind all messages the terminated actor has delivered to the watcher, hence receiving it proves the watcher has seen every message from that actor it will ever see. ActorContext::unwatch reverts a watch, guaranteed even against an already enqueued signal.
  • Supervision strategies. On an error or panic (panics are caught), the configured strategy decides: Restart re-initializes the actor with a restart limit and exponential backoff, Stop terminates it.
  • Bounded or unbounded mailboxes. Bounded mailboxes drop messages beyond capacity as dead letters, but terminated signals are never dropped.
  • Event-sourced persistence (feature persistence). An actor can persist what happened instead of what its state is: events are appended to a pluggable store and only then applied, the state is recovered by replay, optionally shortcut by snapshots, and conditional appends fence concurrent incarnations.

Getting started

tellus is available on crates.io:

cargo add anyhow
cargo add tokio --features macros,rt-multi-thread
cargo add tellus

A minimal actor system with a single actor which handles one message and stops:

use anyhow::Context;
use std::convert::Infallible;
use tellus::{Actor, ActorContext, ActorSystem, Control, Incoming};

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let system = ActorSystem::new(Greeter);
    system.root().tell(Greet("Tellus".to_string()));
    system
        .terminated()
        .await
        .context("awaiting actor system termination")
}

struct Greeter;

impl Actor for Greeter {
    type Message = Greet;
    type State = ();
    type Error = Infallible;

    fn init(&self, _: &ActorContext<Self::Message>) -> Result<Self::State, Self::Error> {
        Ok(())
    }

    fn receive(
        &self,
        _: &ActorContext<Self::Message>,
        incoming: Incoming<Self::Message>,
        _: Self::State,
    ) -> Result<Control<Self::State>, Self::Error> {
        if let Incoming::Message(Greet(name)) = incoming {
            println!("Hello, {name}!");
        }
        Ok(Control::Stop)
    }
}

struct Greet(String);

ActorSystem::new and ActorContext::spawn use the default ActorConfig; use ActorSystem::with_config and ActorContext::spawn_with_config to choose a mailbox capacity or supervision strategy.

Core concepts

A short tour; for the full picture, top-down with links into the implementation, see docs/actors.md.

Actors and state

An actor is defined by implementing the Actor trait. init creates the initial state, possibly spawning child actors or sending messages. receive gets the current state by value along with an incoming message or signal and designates the state for the next one: a state machine rather than mutation. For stateless actors the state is (); actors which never receive messages (pure supervisors, for example) use the uninhabited Nothing as message type.

Failures are values: Error is the actor's failure type (Infallible for actors that cannot fail). Inside receive, use ? to escalate a failure to supervision and an explicit match to handle it as part of the domain.

receive is synchronous and runs on a Tokio worker: an actor cannot be stopped while receive is running, so a receive which never completes keeps all its ancestors from terminating. For long running or blocking work, spawn a task and send the result back via ActorRef::tell or a ReplyTo.

The actor tree and termination

Creating an ActorSystem spawns the root actor; every actor can spawn children via ActorContext::spawn. When an actor stops (by returning Control::Stop, by failing under the Stop strategy, or because its parent stopped), its children are stopped first; only once all descendants have terminated does it terminate itself. Consequently ActorSystem::terminated resolves exactly when the entire tree has terminated.

Messaging

ActorRef::tell is non-blocking, fire-and-forget and at-most-once. If the actor has terminated, or its bounded mailbox is full, the message is dropped and logged as a dead letter. Delivery does not imply processing: even a delivered message may go unprocessed if the actor stops before getting to it.

Request-response builds on the same delivery: a request message carries a ReplyTo, a single-shot reply destination consumed by reply. From outside the actor tree, ActorRef::ask sends the request and awaits the reply, returning an AskError instead of only logging when the mailbox is full, the actor has terminated or it is detected that no reply can arrive anymore; that detection is best-effort, so every ask carries a timeout which resolves the future at the latest when it elapses. Between actors, ActorContext::reply_to creates a ReplyTo which delivers the reply into the asking actor's own mailbox, converted into its message type, so the reply arrives through receive like any other message.

Watch

ActorContext::watch registers interest in another actor's termination: the watcher receives an Incoming::Terminated signal carrying the terminated actor's ActorId. The signal is ordered behind all messages the terminated actor has delivered to the watcher, hence receiving it proves the watcher has seen every message from that actor it will ever see: each arrived before the signal or was dropped as a dead letter; see examples/scatter_gather.rs for putting this to work. Watching an actor that has already terminated delivers the signal right away, and terminated signals are delivered even when a bounded mailbox is full. ActorContext::unwatch stops watching: after it returns, no terminated signal for that actor is received, even if the signal was already enqueued.

Supervision

Each actor is configured with a SupervisionStrategy deciding what happens when init or receive returns an error or panics: Stop terminates the actor, Restart stops its children and re-runs init for a fresh state, limited and paced by a RestartPolicy: restarts back off exponentially between the backoff's min and max, more than max_restarts failures in a streak stop the actor, and running for reset_after without failure ends the streak. Failures are logged at error level either way.

Configuration

ActorConfig currently holds the mailbox capacity and the supervision strategy:

let config = ActorConfig::default().with_mailbox_capacity(MailboxCapacity::Bounded(NonZeroUsize::MIN));
let child = context.spawn_with_config(actor, config);

With the serde feature the whole configuration is deserializable, so it can be read from a config file, with human readable durations:

mailbox_capacity = { bounded = 100 }

[supervision_strategy.restart]
max_restarts = 3
reset_after  = "30s"
backoff      = { min = "250ms", max = "4s" }
mailbox_capacity:
  bounded: 100

supervision_strategy:
  restart:
    max_restarts: 3
    reset_after: 30s
    backoff:
      min: 250ms
      max: 4s

tellus stays format agnostic and pulls in no parser of its own, so picking the loader is up to the application. config is the recommended one: it normalizes every format into one value tree before deserializing, which is what makes the YAML above plain maps, and it reports the key path along with any error. Note that serde_yaml deserializes the same types differently, expecting a YAML tag (supervision_strategy: !restart) instead.

Anything omitted falls back to its default. Backoff bounds which contradict each other are rejected rather than silently repaired: Backoff::new is fallible and deserialization goes through it, so an invalid pair is unrepresentable whether it comes from code or from a file:

max backoff 1s below min backoff 10s for key `supervision_strategy.backoff`

Event-sourced persistence

With the persistence feature, an actor can be event sourced by implementing EventSourced instead of Actor and spawning it via ActorSystem::event_sourced or ActorContext::spawn_event_sourced: handle validates a command against the current state and returns an Effect naming the events it causes, the events are appended to an EventStore and only then folded into the state by apply, and after a crash or a restart the state is recovered by replaying the events, optionally shortcut by snapshots. The stores are pluggable; tellus-persistence-postgres provides PostgreSQL-backed ones, and the persistence-tests feature adds the contract test suite any store implementation must pass, meant for a backend crate's integration tests. For the guarantees, from replay equals live execution to fencing and schema evolution, see docs/persistence.md.

Examples

Ordered from minimal to real-world-ish, each building on the features of the previous ones. All examples print their results to stdout; those which set up logging log to stderr, with the log level configured via RUST_LOG.

  • hello: the getting started snippet above:

    cargo run --quiet -p tellus --example hello
    
  • counter: a counter actor showing the two send modes: tell fires increments without awaiting anything, ask sends a request carrying a ReplyTo and awaits the reply under a timeout:

    cargo run --quiet -p tellus --example counter
    
  • scatter_gather: a root actor scatters a workload across worker actors and gathers their partial results, requested via ActorContext::reply_to and completed using the watch ordering guarantee to know when all results are in:

    RUST_LOG=tellus=debug cargo run --quiet -p tellus --example scatter_gather
    
  • supervision: a flaky worker under the Restart supervision strategy, showing what a backoff-paced restart rebuilds (the state, via init) and what it retains (the actor value and the mailbox):

    RUST_LOG=tellus=debug cargo run --quiet -p tellus --example supervision
    
  • work_pulling: workers request jobs from a manager whenever they are ready, so a bounded mailbox of capacity one suffices: backpressure by design, without dropping work:

    RUST_LOG=tellus=debug cargo run --quiet -p tellus --example work_pulling
    
  • device_manager: tellus's take on Akka's IoT device manager and the capstone of this list: a dynamic actor hierarchy with watch-based registry pruning, ask at the async boundary, a per-request query child aggregating device replies exactly thanks to the ordering guarantee, and restarting devices:

    RUST_LOG=tellus=debug cargo run --quiet -p tellus --example device_manager
    

The event-sourced examples live with the PostgreSQL stores in tellus-persistence-postgres; both start the postgres service from the root docker-compose.yaml:

just run-examples-event-sourced-counter
just run-examples-event-sourced-supervision

License

This code is open source software licensed under the Apache 2.0 License.