tellus
Actors for Rust, on solid ground.
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
Actortrait with associatedMessage,StateandErrortypes:initcreates the initial state,receiveconsumes the current state and a message and returns the state for the next one viaControl::Continue, or stops viaControl::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::terminatedresolves once the whole tree has terminated. - Fire-and-forget messaging.
ActorRef::tellnever blocks and delivers at most once; undeliverable messages are dropped and logged as dead letters (structured logging viatracingwith fields). - Request-response.
ActorRef::askawaits a reply from outside the actor tree;ActorContext::reply_tolets actors reply to each other through their ordinary mailboxes, keeping actor code free of futures. - Death watch with an ordering guarantee.
ActorContext::watchdelivers 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::unwatchreverts a watch, guaranteed even against an already enqueued signal. - Supervision strategies. On an error or panic (panics are caught), the configured strategy
decides:
Restartre-initializes the actor with a restart limit and exponential backoff,Stopterminates 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:
A minimal actor system with a single actor which handles one message and stops:
use Context;
use Infallible;
use ;
async
;
;
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 = default.with_mailbox_capacity;
let child = context.spawn_with_config;
With the serde feature the whole configuration is deserializable, so it can be read from a config
file, with human readable durations:
= { = 100 }
[]
= 3
= "30s"
= { = "250ms", = "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:tellfires increments without awaiting anything,asksends a request carrying aReplyToand 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 viaActorContext::reply_toand 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 theRestartsupervision strategy, showing what a backoff-paced restart rebuilds (the state, viainit) 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,askat 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.