tellus
0.3 actor framework for Rust built on Tokio Apache 2.0

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.

$cargo add tellus
Start the tutorialAPI docs
Message handlers
a dispatcher
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 →
Death watch

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.

How the ordering guarantee works →
A worker delivers two messages to its watcher, a third is dropped as a dead letter, the worker stops, and the Terminated signal arrives last.workerwatcherPartial(325)Partial(950)Partial(1575)mailbox full: dead letterstopsTerminated(worker)always last
Message values from the scatter_gather example, where a gatherer collects the partial sums of its workers.
locally

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.

across nodes

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.

unwatch

Absolute

After unwatch returns, you never receive a terminated signal for that actor, local or remote, even if one was already enqueued.

Supervision

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.

To scale: three failures followed by restarts after 250 ms, 500 ms and 1 s; the fourth consecutive failure stops the actor.0 s0.51.01.52.02.53.0250 ms500 ms1 s✕ failure▬ backoff━ running4th failure: stop
config.toml
mailbox_capacity = { bounded = 100 }

[supervision_strategy.restart]
max_restarts = 3
reset_after  = "30s"
backoff      = { min = "250ms", max = "4s" }
when it's wrong
max backoff 1s below
min backoff 10s for key
`supervision_strategy.backoff`
Event sourcing persistence

Persist 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.

an event-sourced counter
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
}
  1. handleValidates the command against the current state and returns an Effect.
  2. appendtellus appends the events to the EventStore, conditional on the stream's head.
  3. applyOnly durable events are folded into the state, the same way on replay as live.
  4. thenSide effects such as the reply run once the events are durable.
Cluster cluster

Same 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.

config/default.yaml
bootstrap:
  min_peers: 5

endpoint:
  failure_detector:
    phi_accrual: {}
  downing_provider:
    keep_majority:
      after: 10s
config/k8s.yaml
seeds:
  k8s:
    label_selector: app=tellus
    port:
      number: 7878
On the roadmap:cluster singletonsharding
Benchmarks

Measured 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.
See the results on the comparison dashboard →
  1. One run, one machine, back to back. Numbers are only compared within a single run.
  2. Unbounded mailboxes everywhere, so no framework gets backpressure the others do not.
  3. Non-blocking fire-and-forget sends only, no awaited sends and no request-response.
  4. Identical timing boundaries. Spawning happens outside the measurement; sending and termination inside it.
  5. The same Tokio runtime with the same configuration for all three.
  6. Competitors tuned for speed. Their per-message tracing is switched off, which makes kameo 12.5% and ractor 21% faster on flood.
  7. Termination checks correctness. A lost message makes a benchmark hang rather than finish early.
Coming from Akka

You already know most of tellus.

tellus is strongly influenced by Akka Typed. Here is where your concepts live.

Akka Typedtellus
Behaviors.receiveActor::receive, taking the state by value
returning the next behavior, Behaviors.sameControl::Continue(state)
Behaviors.stoppedControl::Stop
ref ! msgactor.tell(msg)
ask, context.askActorRef::ask from outside, context.reply_to between actors
context.watch and the Terminated signalcontext.watch and Incoming::Terminatedplus the ordering guarantee
SupervisorStrategy.restartWithBackoffSupervisionStrategy::Restart with a RestartPolicy
EventSourcedBehaviorEventSourced with Effect
Akka Cluster, Receptionist, distributed pub-subthe cluster feature, receptionist, pubsub
BehaviorTestKittesting::TestContext
Get started

Start digging.

Add tellus and Tokio to your project, then work through the examples. Each one builds on the features of the previous ones.

shell
cargo add anyhow
cargo add tokio --features macros,rt-multi-thread
cargo add tellus
Start the tutorial
  1. helloOne actor handles one message and stops.
  2. counterBoth send modes: tell without waiting, ask with a timeout.
  3. scatter_gatherSpread work across workers and use the watch guarantee to know when all results are in.
  4. supervisionA flaky worker under Restart: what a restart rebuilds and what it keeps.
  5. work_pullingWorkers pull jobs when ready, so a mailbox of capacity one is enough.
  6. device_managerThe capstone, after Akka's IoT example: a dynamic hierarchy, registry pruning by watch, restarting devices.