Expand description
zestors is an actor framework with Erlang/OTP-style supervision.
This crate is a facade: it re-exports the workspace crates as modules and
collects the commonly used items in prelude.
§Reading Order
| Module | What it provides |
|---|---|
interface | The vocabulary: derive Message for each message type and Interface for the set of messages an actor accepts. |
runtime | The delivery machinery: actors receive through Inbox, everyone else sends through Address/StrongAddress, and actors are looked up by Pid in Registry. |
actor | The actors: implement Handler (per-message handlers) or Actor (the full event loop), then package either in an Blueprint. |
supervision | The supervisor’s vocabulary: ChildSpec pairs a blueprint with its ChildConfig, and RestartIntensity bounds how often a child may restart. |
supervisor | The supervision actors: Supervisor starts, watches, and restarts children per a SupervisionStrategy; Node runs a root supervisor as a whole program. |
api_server | HTTP introspection: ApiServer exposes /processes, /snapshots, and /health for a running tree. |
zestors_inspector | The zestors-inspector GUI — a separate workspace crate, not re-exported here — visualizes the same tree data that api_server exposes. |
§Guide: writing your first actor
This walks through building a small actor step by step, then covers the lifecycle behavior that governs it, a declarative alternative for defining the actor body, and the supervision layer. Some of the lifecycle behavior is easy to get wrong without knowing about it in advance, so it’s called out explicitly rather than left to be discovered from a bug report.
§1. Define your messages
A message is a plain struct deriving Message.
Messages come in two flavors: fire-and-forget, and request/reply.
use zestors::interface::Message;
// Fire-and-forget: nothing is returned to the sender.
#[derive(Message, Debug)]
struct Increment;
// Request/reply: `reply = u32` means calling this message returns a `u32`.
#[derive(Message, Debug)]
#[msg(reply = u32)]
struct GetCount;§2. Group them into an Interface
An actor doesn’t accept a single message type - it accepts a set of
them, described by an Interface. Each variant
wraps one message type in an Envelope:
use zestors::interface::{Envelope, Interface, Message};
#[derive(Interface, Debug)]
enum CounterInterface {
Increment(Envelope<Increment>),
GetCount(Envelope<GetCount>),
}Only tuple variants with exactly one Envelope<T> field are allowed -
the derive macro needs that shape to generate the conversions that let a
channel accept and dispatch each message type.
§3. Write the actor, spawn it, and send it messages
The actor body is an async function (or closure) that owns an
Inbox and loops over incoming messages until it
decides to stop. spawn_rand starts it on a
fresh, randomly-generated Pid, returning a
Child that’s both a handle for sending messages and
a future that resolves to the actor’s own return value once it exits.
use zestors::interface::{Envelope, Interface, Message};
use zestors::runtime::prelude::*;
use zestors::runtime::spawn_rand;
#[derive(Message, Debug)]
struct Increment;
#[derive(Message, Debug)]
#[msg(reply = u32)]
struct GetCount;
#[derive(Interface, Debug)]
enum CounterInterface {
Increment(Envelope<Increment>),
GetCount(Envelope<GetCount>),
}
let child = spawn_rand(|mut inbox: Inbox<CounterInterface>| async move {
let mut count = 0;
while let Some(msg) = inbox.recv().await {
match msg {
CounterInterface::Increment(_) => count += 1,
CounterInterface::GetCount(envelope) => {
let _ = envelope.reply(count);
}
}
}
Ok(())
});
// `cast` sends a message without waiting for it to be handled.
for _ in 0..5 {
child.cast(Increment).await.unwrap();
}
// `call` sends a message and waits for its reply. Every message to one
// actor goes through the same queue, in order, so this only resolves
// once the 5 `Increment`s above have already been processed.
assert_eq!(child.call(GetCount).await.unwrap(), 5);
child.signal_shutdown();envelope.reply(value) answers a request/reply message; a
fire-and-forget message like Increment has nothing to reply to, so its
envelope is just dropped once handled.
Dropping a Child aborts the actor it belongs to, unless .detach() has
been called on it first (or it’s been consumed via .into_handle()).
This applies even if the Child is bound to _ or simply goes out of
scope.
§4. The actor lifecycle
Every actor moves through a small set of states, tracked as
ActorStatus: Initializing, then Running,
optionally toggling between Running and Suspended, then Exiting,
and finally Exited (with a reason: normal exit, panic, abort, or an
error returned from the actor’s own code).
An actor reaches Initializing the moment it’s spawned, before its task
has run even once, and only becomes Running once it calls a receiving
method for the first time. Code that runs immediately after spawning
should not assume the actor is already Running; either wait for it
with child.watch_init().await, or accept Initializing as well.
Two behaviors around signals (shutdown, suspend, resume) depart from what the method names alone would suggest:
- Sending a signal does not take effect immediately. Calling
child.signal_shutdown()adds “shutdown” to the actor’s queue and returns without waiting for it to be processed. Checkingchild.status()on the next line can still show the previous status. To observe the effect, await something that actually waits for it, such aschild.watch_exit().await. - Signals take priority over messages, except when the queue is
empty. Signals are checked before regular messages, so a shutdown
request does not wait behind a backlog of messages. But once an
actor’s message queue is empty, processing a shutdown signal makes it
exit immediately, without checking whether another signal is queued
behind it. This matters only in specific timing situations (for
example, pinging an actor in the same breath as shutting it down). An
actor that needs to keep responding to signals after
Shutdownshould loop withrecv_event_alwaysand decide for itself when to stop, rather than relying onrecv/recv_eventto do it.
§5. Declarative actors with Handler
Writing the event loop by hand, as in step 3, gives full control over
how messages, signals, and other futures are read and interleaved.
Handler is a higher-level alternative for actors that
don’t need that control: it provides the event loop, and asks only for
lifecycle hooks and one Handle<M> implementation per
message type.
use zestors::interface::{Envelope, Interface, Message, Request};
use zestors::prelude::*;
#[derive(Message, Debug)]
struct Increment;
#[derive(Message, Debug)]
#[msg(reply = u32)]
struct GetCount;
#[derive(Interface, HandlerInterface, Debug)]
enum CounterInterface {
Increment(Envelope<Increment>),
GetCount(Envelope<GetCount>),
}
#[derive(Debug, Clone)]
struct Counter {
count: u32,
}
impl Handler for Counter {
type Interface = CounterInterface;
}
impl Handle<Increment> for Counter {
async fn handle(
&mut self,
_ctx: HandlerContext<'_, Self>,
_msg: Increment,
_req: (),
) -> Result<(), rootcause::Report> {
self.count += 1;
Ok(())
}
}
impl Handle<GetCount> for Counter {
async fn handle(
&mut self,
_ctx: HandlerContext<'_, Self>,
_msg: GetCount,
req: Request<u32>,
) -> Result<(), rootcause::Report> {
let _ = req.reply(self.count);
Ok(())
}
}
// `ActorExt::spawn_rand` (from the `Actor` implementation `Handler`
// provides automatically) plays the same role as `spawn_rand` did in
// step 3.
let child = Counter { count: 0 }.spawn_rand();
for _ in 0..5 {
child.cast(Increment).await.unwrap();
}
assert_eq!(child.call(GetCount).await.unwrap(), 5);
child.signal_shutdown();#[derive(Interface, HandlerInterface)] is what connects each message
variant to its Handle<M> implementation; both derives read the same
enum. A type implementing Handler implements Actor
automatically, so it’s spawned, sent messages, and awaited the same way
as any other actor.
§6. Supervision
Everything above is sufficient for actors that talk to each other
directly, without any restart policy. supervision/supervisor add
an optional layer for Erlang/OTP-style restart trees:
ChildSpec pairs a blueprint with the
configuration a supervisor applies to it (restart mode, timeouts), and a
Supervisor actor starts, watches, and
restarts a set of them.
Any actor that is Clone + Debug - a Handler
usually is - implements Blueprint
automatically, so it can go directly into a ChildSpec, and several of
them into a SupervisorBlueprint:
Node covers the common case of running one root
supervisor as an entire program: starting it, restarting it on failure up
to a configured budget, and shutting it down gracefully on
Ctrl+C/SIGTERM.
use zestors::interface::{Envelope, Interface, Message};
use zestors::prelude::*;
use zestors::supervision::messages::GetChildren;
use zestors::supervisor::{Node, Supervisor};
#[derive(Message, Debug)]
struct Ping;
#[derive(Interface, HandlerInterface, Debug)]
enum WorkerInterface {
Ping(Envelope<Ping>),
}
#[derive(Debug, Clone)]
struct Worker;
impl Handler for Worker {
type Interface = WorkerInterface;
}
impl Handle<Ping> for Worker {
async fn handle(
&mut self,
_ctx: HandlerContext<'_, Self>,
_msg: Ping,
_req: (),
) -> Result<(), rootcause::Report> {
Ok(())
}
}
let node = Node::new(
Supervisor::blueprint()
.child(Worker.pid("worker").unwrap())
.rand_pid(),
);
// In a real program, `node.run()` is usually just awaited directly from
// `main`. It's spawned onto its own task here only so the rest of this
// example can also demonstrate signaling and observing its shutdown.
let root = node.root_supervisor().address().clone();
let node_task = tokio::spawn(node.run());
root.watch_running().await;
let children = root.call(GetChildren).await.unwrap();
assert_eq!(children.len(), 1);
// The root supervisor exiting on its own is a normal, successful stop
// for the whole node - triggered here with an ordinary shutdown signal,
// in place of the Ctrl+C/SIGTERM a real deployment would send.
root.signal_shutdown();
assert!(node_task.await.unwrap().is_ok());§Where to go next
The runtime, interface, actor, supervision, and
supervisor crate docs each have further worked examples - a bare
Inbox<()> actor, looking an actor up by Pid from elsewhere in the
process, the lower-level Envelope/Interface machinery this guide
builds on, and more. Every example in this guide and in those crate docs
compiles and runs as part of this workspace’s test suite, so they stay
in sync with the framework’s actual behavior rather than drifting from
it over time.
Modules§
- actor
- Declarative actor implementation on top of
zestors-runtime. - api_
server - An HTTP API server actor for
zestors: exposes introspection endpoints over a running supervision tree. - interface
- Defines the core interface/messaging traits for
zestors. - prelude
- The items you usually need, re-exported from each sub-crate plus the codegen macros.
- runtime
- The actor runtime underlying
zestors. - supervision
- Shared building blocks for
zestorssupervision trees, in the OTP sense. - supervisor
- An OTP-style
Supervisoractor forzestors: it starts and watches a set of children — each described by aChildSpec— and restarts them according to aSupervisionStrategyand aRestartIntensitypolicy when they exit.