Write tests for docker integration, as docker is how we will initially handle distribution on heterogeneous hardware.
6.7 KiB
6.7 KiB
Actor Model
Swactor's actor model is intentionally minimal. An actor is a struct that
implements one trait, receives one message type, and communicates only
through Ctx.
Defining an Actor
use swactor::actor::ActorInterface;
use swactor::runtime::Ctx;
#[derive(Debug, Default, Clone)]
struct Ping { return_addr: ActorAddress }
#[derive(Debug, Default, Clone)]
struct Pong;
struct MyActor {
count: usize,
}
impl ActorInterface for MyActor {
type Incoming = Ping;
type Response = Pong; // not enforced at runtime — a documentation hint
fn handle(&mut self, ctx: &Ctx, msg: Ping) {
self.count += 1;
let _ = ctx.send(msg.return_addr, Pong);
}
}
That's it. No lifecycle hooks, no supervision trees, no async. Just a
handle method.
The Traits
┌─ Message ─────────────────────────────────────────────────────────────────┐
│ │
│ trait Message: 'static + Sized + Clone + Send + Sync {} │
│ │
│ Blanket-implemented for any type that meets the bounds. │
│ You never implement this manually. │
│ │
│ Why Clone + Send + Sync? │
│ Clone — messages may be duplicated (Python bindings, stats, etc.) │
│ Send — messages cross thread boundaries │
│ Sync — required by the type-erased Any + Send path │
│ │
└───────────────────────────────────────────────────────────────────────────┘
┌─ ActorInterface ──────────────────────────────────────────────────────────┐
│ │
│ trait ActorInterface: 'static + Send { │
│ type Incoming: Message; │
│ type Response: Message; │
│ fn handle(&mut self, ctx: &Ctx, msg: Self::Incoming); │
│ } │
│ │
│ This is what you implement. The actor owns mutable state (&mut self) │
│ and receives typed messages. │
│ │
│ Actors are Send but NOT Sync — only one worker thread ever touches │
│ a given actor. │
│ │
└───────────────────────────────────────────────────────────────────────────┘
Type Erasure
Actors in the runtime are stored as Box<dyn AnyActor>, which erases the
concrete type. Messages are stored as Box<dyn Any + Send>. Type checking
happens at delivery time via downcast:
compile time runtime
─────────── ───────
ctx.send(addr, msg)
│
v
Box::new(msg) as Box<dyn Any + Send> -- type erased here
│
v
enqueued in mailbox (VecDeque<Box<dyn Any + Send>>)
│
v
actor.handle_any(ctx, msg)
│
v
msg.downcast::<A::Incoming>() -- type recovered here
│
┌────┴────┐
│ │
ok err
│ │
v v
A.handle silently dropped
(ctx,msg)
Why silent drop? In a dynamic system (especially with Python bindings), type mismatches aren't crashes — they're routing errors. The actor simply ignores messages it doesn't understand.
ActorAddress
┌─ ActorAddress ────────────────────────────────────────────────────────────┐
│ │
│ pub struct ActorAddress(pub [u8; 32]); │
│ │
│ 32 random bytes — globally unique, no coordination needed. │
│ Generated via get_random() (system RNG or deterministic counter │
│ for WASM builds). │
│ │
│ Derives: Debug, Default, Clone, Copy, PartialEq, Eq, Hash │
│ │
│ Used as keys in: │
│ AddressMap (actor → worker lookup) │
│ ActorPool (actor → mailbox + state) │
│ InboxRegistry (external inbox lookup) │
│ │
└───────────────────────────────────────────────────────────────────────────┘
Where Things Live in the Code
| Concept | File | Key lines |
|---|---|---|
Message trait |
src/actor.rs |
blanket impl |
ActorInterface trait |
src/actor.rs |
user-facing trait |
ActorAddress |
src/actor.rs |
32-byte random ID |
Actor<A> wrapper |
src/actor.rs |
wraps user state |
AnyActor trait |
src/actor.rs |
type-erased handler |
ActorPool |
src/worker/mod.rs |
per-worker storage |
ActorSlot |
src/worker/mod.rs |
mailbox + actor pair |