Actors for Rust, on solid ground.
tellus gives you typed actors as state machines, supervision trees and a death watch you can reason about. Add event sourcing and a cluster when you need them, through the same API.
fn receive(
&self,
context: &ActorContext<Self::Message>,
incoming: Incoming<Self::Message>,
count: Self::State,
) -> Result<Control<Self::State>, Self::Error> {
if let Incoming::Message(Job(job)) = incoming {
let worker = context.spawn(Worker);
worker.tell(Work(job));
}
Ok(Control::Continue(count + 1))
}A message handler is a plain function.
Carl Hewitt defined an actor by what it may do in response to a message. A tellus receive does exactly that and nothing more. It runs to completion, so an actor is never halfway through a message with its state in limbo.
When an actor needs to wait for something, the result arrives as another message, so actor code never awaits. Even event sourcing works this way: the handler returns the events as data, and tellus does the I/O.
Why tellus handlers are synchronous →Know when an actor is gone, and know you have heard everything it said.
Receiving Terminated proves you have seen every message that actor will ever deliver to you.
Watch any actor with context.watch(&actor). When it terminates, you get a signal in your ordinary mailbox, ordered behind every message the actor delivered to you. Each of its messages arrived before the signal or was dropped as a dead letter.
So you can gather results from workers without counting replies, timeouts or extra acknowledgements: when the last worker's signal arrives, you have every result. And unwatch means what it says, even if the signal is already waiting in your mailbox.
scatter_gather example, where a gatherer collects the partial sums of its workers.Ordered and exact
The signal is ordered behind every delivered message, it also proves the actor's destructors have run, and it is delivered even when a bounded mailbox is full.
Same API, two tiers
A real termination keeps the full local contract over the wire. If a node is downed instead, the synthesized signal guarantees that no message from that actor is ever delivered through this endpoint again.
Absolute
After unwatch returns, you never receive a terminated signal for that actor, local or remote, even if one was already enqueued.
Let it crash, then restart it calmly.
Actors form a tree. When init or receive returns an error or panics, the actor's supervision strategy decides: Stop terminates it, Restart stops its children and runs init again for a fresh state, keeping the actor's address and mailbox.
Restarts back off exponentially between min and max. More than max_restarts consecutive failures stop the actor, and running for reset_after without failure starts the count afresh. Stopping always goes bottom-up: children first, then the parent.
The whole configuration deserializes from your config files, with readable durations, and contradicting bounds are rejected rather than silently repaired.
mailbox_capacity = { bounded = 100 }
[supervision_strategy.restart]
max_restarts = 3
reset_after = "30s"
backoff = { min = "250ms", max = "4s" }max backoff 1s below
min backoff 10s for key
`supervision_strategy.backoff`persistencePersist the events, replay the state.
Implement EventSourced instead of Actor. Your handler validates a command against the state and returns an Effect naming the events it causes. tellus appends them to the store, and only then folds them into the state. After a crash, replay rebuilds exactly the same state, optionally shortcut by a snapshot.
Conditional appends fence concurrent incarnations, manifests version your event schemas, and a contract test suite checks any store backend. PostgreSQL ships today; an in-memory store and TestContext let you test without a database.
fn handle(
&self,
_: &ActorContext<Self::Command>,
incoming: Incoming<Self::Command>,
_: &Self::State,
) -> Result<Effect<Self>, Self::Error> {
match incoming {
Incoming::Message(Command::Increase(reply_to)) => {
Ok(Effect::persist(Increased).then(move |count| reply_to.reply(*count)))
}
_ => Ok(Effect::none()),
}
}
fn apply(&self, count: Self::State, _: Self::Event) -> Self::State {
count + 1
}- handleValidates the command against the current state and returns an
Effect. - appendtellus appends the events to the
EventStore, conditional on the stream's head. - applyOnly durable events are folded into the state, the same way on replay as live.
- thenSide effects such as the reply run once the events are durable.
clusterSame API, many nodes.
ActorRef is serializable, so a message can carry a reference to an actor on any node. You tell, ask and watch remote actors exactly like local ones. Nodes find each other through seed discovery, gossip their membership, and agree on who is gone.
QUIC with TLS
Transport over quinn with TLS included, and per-sender FIFO across the wire.
Seed discovery
Fixed seeds, DNS SRV or A/AAAA records, or Kubernetes pods by label selector.
Gossiped membership
The member list is a state-based CRDT, so duplicated, reordered or crossed gossip cannot corrupt it.
Failure detection and downing
Phi accrual detection and a pluggable downing provider; keep_majority by default.
Receptionist and pub-sub
Register actors under a key, look them up across the cluster, and publish to every subscriber of a topic.
Observable cluster state
A versioned value on every change; tellus-cluster-inspect serves it to you as server-sent events.
bootstrap:
min_peers: 5
endpoint:
failure_detector:
phi_accrual: {}
downing_provider:
keep_majority:
after: 10sseeds:
k8s:
label_selector: app=tellus
port:
number: 7878Measured fairly, published openly.
We benchmark tellus against kameo and ractor, two actively maintained actor frameworks that also run on a plain Tokio runtime. Every framework does the same work, and the competitors run in their fastest configuration, not their defaults.
flood100,000 messages to a single counting actor.ping_pong1,000 rounds between pairs of actors, one pair and eight pairs.fan_out100,000 messages round-robin to 8 and to 32 workers.- One run, one machine, back to back. Numbers are only compared within a single run.
- Unbounded mailboxes everywhere, so no framework gets backpressure the others do not.
- Non-blocking fire-and-forget sends only, no awaited sends and no request-response.
- Identical timing boundaries. Spawning happens outside the measurement; sending and termination inside it.
- The same Tokio runtime with the same configuration for all three.
- Competitors tuned for speed. Their per-message tracing is switched off, which makes kameo 12.5% and ractor 21% faster on
flood. - Termination checks correctness. A lost message makes a benchmark hang rather than finish early.
You already know most of tellus.
tellus is strongly influenced by Akka Typed. Here is where your concepts live.
| Akka Typed | tellus |
|---|---|
Behaviors.receive | Actor::receive, taking the state by value |
returning the next behavior, Behaviors.same | Control::Continue(state) |
Behaviors.stopped | Control::Stop |
ref ! msg | actor.tell(msg) |
ask, context.ask | ActorRef::ask from outside, context.reply_to between actors |
context.watch and the Terminated signal | context.watch and Incoming::Terminatedplus the ordering guarantee |
SupervisorStrategy.restartWithBackoff | SupervisionStrategy::Restart with a RestartPolicy |
EventSourcedBehavior | EventSourced with Effect |
Akka Cluster, Receptionist, distributed pub-sub | the cluster feature, receptionist, pubsub |
BehaviorTestKit | testing::TestContext |
Start digging.
Add tellus and Tokio to your project, then work through the examples. Each one builds on the features of the previous ones.
cargo add anyhow
cargo add tokio --features macros,rt-multi-thread
cargo add tellus- helloOne actor handles one message and stops.
- counterBoth send modes:
tellwithout waiting,askwith a timeout. - scatter_gatherSpread work across workers and use the watch guarantee to know when all results are in.
- supervisionA flaky worker under
Restart: what a restart rebuilds and what it keeps. - work_pullingWorkers pull jobs when ready, so a mailbox of capacity one is enough.
- device_managerThe capstone, after Akka's IoT example: a dynamic hierarchy, registry pruning by watch, restarting devices.