tellus, episode 2: an actor is a state machine

In the previous episode I introduced tellus, an actor framework for Rust built on Tokio, and claimed that its actors are state machines rather than mutable objects. In this episode we unpack that claim: what the Actor trait actually asks of us, how we model the state and what we get from that shape when things go wrong.

Three associated types

An actor is defined by implementing Actor. Let’s start with its three associated types:

1
2
3
4
impl Actor for Manager {
type Message = RequestJob;
type State = Managing;
type Error = Infallible;

Message is what this actor accepts. We cannot send it anything else, because sends go through an ActorRef<M>: tell takes an M, and ask takes a function which builds one from a ReplyTo.

State is what it carries between messages. Notice what we do not get: no fields on the actor value itself that change over time. Manager is a unit struct here, and everything mutable lives in Managing.

Error is how it can fail. Both init and receive return Result<_, Self::Error>, so what we get is an ordinary value rather than an escape hatch.

Each of the three has a degenerate case, and each of those is a real type rather than a convention. For a stateless actor we use (), for an infallible one Infallible, and for an actor which receives no messages at all, a pure supervisor for instance, we use Nothing, an uninhabited enum:

1
2
impl Actor for Overseer {
type Message = Nothing;

Why bother with an uninhabited type? Because with Nothing as the message type, Incoming::Message cannot be constructed, so the only incoming such an actor can ever get is a terminated signal from an actor it watches. The compiler knows that, and so do we.

Modeling the state

init creates the initial state and receive maps the current state plus one incoming to the next state. Let’s look at a work-pulling manager which hands out jobs to workers as they ask for them:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
struct Managing {
jobs: Vec<&'static str>,
workers: usize,
}

fn receive(
&self,
_: &ActorContext<Self::Message>,
incoming: Incoming<Self::Message>,
state: Self::State,
) -> Result<Control<Self::State>, Self::Error> {
match incoming {
Incoming::Message(RequestJob(reply_to)) => {
let mut jobs = state.jobs;
match jobs.pop() {
Some(document) => reply_to.reply(Next::Job(document)),
None => reply_to.reply(Next::Drained),
}
Ok(Control::Continue(Managing { jobs, ..state }))
}

Incoming::Terminated(_) => {
let workers = state.workers - 1;

if workers > 0 {
Ok(Control::Continue(Managing { workers, ..state }))
} else {
println!("## All documents processed");
Ok(Control::Stop)
}
}
}
}

Notice that the state arrives by value, so we can simply move state.jobs out and mutate it locally; the struct update syntax then puts the next state together. We get no &mut self and no interior mutability, yet nothing here is expensive: the Vec is moved, not cloned.

Two more things are visible in that snippet. Control is the return: Continue carries the state which will handle the next message; Stop ends the actor, which stops its children first and terminates once all descendants are gone. And Incoming has two variants, so messages and terminated signals arrive through the same receive, in one FIFO order. The manager watches every worker it spawns and stops itself once the last one has terminated; since a worker only stops after the job queue is drained, the last terminated signal also means all the work is done. What we get on top from that shared FIFO order, a terminated signal proving that the watcher has seen every message from that actor it will ever see, is the subject of the next episode.

Failing and restarting

Because Error is an associated type, failing is just returning. Let’s look at a loader whose domain has one toxic value, so type Error = ToxicValue;:

1
2
3
4
5
6
7
8
9
10
11
12
match message {
// The error escalates to supervision, which restarts the loader with backoff.
Message::Load(TOXIC) => Err(ToxicValue(TOXIC)),

Message::Load(value) => {
let count = count + 1;
println!("## Loaded {value}, count is {count}");
Ok(Control::Continue(count))
}

Message::Finish => Ok(Control::Stop),
}

Returning Err hands the failure to the actor’s supervision strategy, and inside receive a ? does the same for anything that converts into Error. If we would rather handle a failure as part of the domain, we write an ordinary match which returns Control::Continue with whatever state we consider correct. So we choose between escalating and handling per call site, in code, with no framework-specific ceremony either way. Panics, incidentally, take the same route: they are caught and fed to supervision exactly like a returned error.

So why separate the actor value from the state at all? Here we get the payoff. Under the Restart strategy, a failure stops the actor’s children, waits out an exponentially growing backoff and re-runs init, until a streak exceeds max_restarts and the actor stops instead. What we rebuild is the state, and only the state. The actor value survives, which is why we put configuration and the refs an actor was constructed with there. The mailbox survives too, so the messages queued behind the failing one are processed by the restarted state, and messages told during the backoff simply queue up. The message which caused the failure is consumed and not redelivered, which is what keeps a toxic message from turning into a restart loop.

We need no rollback mechanism for any of this. The next state only comes into existence when receive returns it, so a receive which fails or panics halfway through has not written anything. Supervision never sees a half-updated state: the failed one is dropped, and init builds a fresh one. In a framework built on &mut self, we would need either a transaction or a convention that nobody violates under deadline pressure. Well, I would rather have the compiler enforce it.

What is next

So far we have seen the state machine from the inside. In the next episode we look at it from the outside: the mailbox, which is one FIFO queue carrying both messages and terminated signals, and the ordering guarantee that falls out of that single queue.

If you want to run these yourself, you can find the full code on GitHub: the manager is work_pulling.rs and the loader is supervision.rs.

Disclosure: the prose of this post was drafted with Claude Code and revised by me.