feat: docs and README
Update the docs and readme with all the changes we've made
This commit is contained in:
parent
042ccd0db0
commit
9700a6b1a6
5 changed files with 696 additions and 44 deletions
286
README.md
286
README.md
|
|
@ -1,67 +1,265 @@
|
||||||
# swactor
|
# swactor
|
||||||
(S)mall (W)ASM-compatible (actor) library
|
|
||||||
|
|
||||||
## Useful
|
Small, WASM-compatible actor runtime for Rust, with Python and WebAssembly
|
||||||
|
bindings.
|
||||||
|
|
||||||
View code dependency DAG
|
## Quick Start (Rust)
|
||||||
|
|
||||||
```bash
|
|
||||||
cargo run --manifest-path tools/depgraph/Cargo.toml -- --src-dir src/ --output deps
|
|
||||||
```
|
|
||||||
|
|
||||||
## Quick example
|
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
use swactor::{
|
use swactor::actor::{ActorAddress, ActorInterface};
|
||||||
Ctx,
|
use swactor::runtime::{Ctx, Runtime, RuntimeConfig};
|
||||||
actor::{ActorAddress, ActorInterface},
|
|
||||||
runtime::{Runtime, RuntimeConfig},
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
#[derive(Clone)]
|
||||||
struct Greeter { num_greeted: usize }
|
struct Greet { name: String, reply_to: ActorAddress }
|
||||||
|
|
||||||
#[derive(Debug, Default, Clone)]
|
#[derive(Clone)]
|
||||||
struct GreetMessage { who: String, return_addr: ActorAddress }
|
struct Greeting(String);
|
||||||
|
|
||||||
#[derive(Debug, Default, Clone)]
|
struct Greeter;
|
||||||
struct GreetResponse(String);
|
|
||||||
|
|
||||||
impl ActorInterface for Greeter {
|
impl ActorInterface for Greeter {
|
||||||
type Incoming = GreetMessage;
|
type Incoming = Greet;
|
||||||
type Response = GreetResponse;
|
type Response = Greeting;
|
||||||
|
|
||||||
fn handle(&mut self, ctx: &Ctx, msg: GreetMessage) {
|
fn handle(&mut self, ctx: &Ctx, msg: Greet) {
|
||||||
let res = GreetResponse(format!("Hello, {}!", msg.who));
|
let _ = ctx.send(msg.reply_to, Greeting(format!("Hello, {}!", msg.name)));
|
||||||
self.num_greeted += 1;
|
|
||||||
if let Err(_) = ctx.send(msg.return_addr, res) {
|
|
||||||
self.num_greeted -= 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
let rt = Runtime::new(RuntimeConfig::default());
|
let rt = Runtime::new(RuntimeConfig::default());
|
||||||
let addr = rt.spawn(Greeter::default()).expect("failed to spawn");
|
let addr = rt.spawn(Greeter).unwrap();
|
||||||
|
let inbox = rt.new_inbox::<Greeting>().unwrap();
|
||||||
|
|
||||||
let inbox = rt.new_inbox::<GreetResponse>().unwrap();
|
rt.send_to(addr, Greet { name: "world".into(), reply_to: *inbox.addr() }).unwrap();
|
||||||
rt.send_to(addr, GreetMessage {
|
rt.tick();
|
||||||
who: "world".into(),
|
rt.tick();
|
||||||
return_addr: *inbox.addr(),
|
|
||||||
}).unwrap();
|
|
||||||
|
|
||||||
for _ in 0..3 { rt.tick(); }
|
println!("{}", inbox.try_recv().unwrap().0); // "Hello, world!"
|
||||||
let resp = inbox.try_recv().expect("should have response");
|
|
||||||
println!("{}", resp.0); // "Hello, world!"
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Build & test
|
## Quick Start (Python)
|
||||||
|
|
||||||
```sh
|
```bash
|
||||||
cargo build
|
uv pip install . # builds the Rust extension automatically
|
||||||
cargo test
|
|
||||||
cargo test --features stress # stress tests
|
|
||||||
cargo run --bin bench --release # benchmarks
|
|
||||||
cargo run --example hello
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Single-threaded — caller drives each tick:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from swactor import Runtime
|
||||||
|
|
||||||
|
def echo(ctx, msg):
|
||||||
|
ctx.send(msg["reply_to"], f"hello, {msg['name']}!")
|
||||||
|
|
||||||
|
rt = Runtime()
|
||||||
|
addr = rt.spawn(echo)
|
||||||
|
inbox = rt.inbox()
|
||||||
|
rt.send(addr, {"name": "world", "reply_to": inbox.addr})
|
||||||
|
rt.tick()
|
||||||
|
print(inbox.try_recv()) # "hello, world!"
|
||||||
|
```
|
||||||
|
|
||||||
|
Multi-threaded — workers run on background threads:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import asyncio
|
||||||
|
from swactor import Runtime, RuntimeConfig
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
rt = Runtime(RuntimeConfig(num_threads=2))
|
||||||
|
|
||||||
|
def echo(ctx, msg):
|
||||||
|
ctx.send(msg["reply_to"], f"hello, {msg['name']}!")
|
||||||
|
|
||||||
|
addr = rt.spawn(echo)
|
||||||
|
inbox = rt.inbox()
|
||||||
|
handle = rt.run() # spawns worker threads, consumes rt
|
||||||
|
|
||||||
|
for name in ["alice", "bob", "charlie"]:
|
||||||
|
handle.send(addr, {"name": name, "reply_to": inbox.addr})
|
||||||
|
while (reply := inbox.try_recv()) is None:
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
print(reply)
|
||||||
|
|
||||||
|
handle.shutdown()
|
||||||
|
handle.join()
|
||||||
|
|
||||||
|
asyncio.run(main())
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Start (WASM)
|
||||||
|
|
||||||
|
The `wasm/` crate wraps swactor for use from JavaScript via `wasm-bindgen`.
|
||||||
|
It runs single-threaded with the caller driving `tick()` — a natural fit
|
||||||
|
for game loops, simulations, or any frame-based update cycle.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd wasm && wasm-pack build --target nodejs # or --target web
|
||||||
|
```
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { SwactorRuntime } from "./wasm/pkg/swactor_wasm.js";
|
||||||
|
|
||||||
|
const rt = new SwactorRuntime();
|
||||||
|
|
||||||
|
// spawn a counter actor — accumulates values sent to it
|
||||||
|
const counter = rt.spawn_counter();
|
||||||
|
|
||||||
|
// spawn a relay that forwards messages to the counter
|
||||||
|
const relay = rt.spawn_relay(counter);
|
||||||
|
|
||||||
|
// send through the relay
|
||||||
|
rt.send(relay, 5);
|
||||||
|
rt.send(relay, 7);
|
||||||
|
|
||||||
|
rt.tick(); // relay receives and forwards
|
||||||
|
rt.tick(); // counter receives forwarded messages
|
||||||
|
|
||||||
|
// drain results from the inbox
|
||||||
|
let v;
|
||||||
|
while ((v = rt.try_recv()) !== undefined) {
|
||||||
|
console.log(v); // 5, then 12
|
||||||
|
}
|
||||||
|
|
||||||
|
rt.free();
|
||||||
|
```
|
||||||
|
|
||||||
|
The WASM crate uses the `no_random` feature (deterministic address
|
||||||
|
generation) so there's no dependency on system RNG.
|
||||||
|
|
||||||
|
## Running the Examples
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo run --example hello # single actor, request/response
|
||||||
|
cargo run --example ring # 500 actors in a ring topology
|
||||||
|
```
|
||||||
|
|
||||||
|
## Multi-threaded Mode
|
||||||
|
|
||||||
|
Pass `num_threads` in the config. The runtime spawns OS threads and runs
|
||||||
|
workers autonomously — no `tick()` calls needed.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let mut config = RuntimeConfig::default();
|
||||||
|
config.num_threads = 4;
|
||||||
|
let rt = Runtime::new(config);
|
||||||
|
|
||||||
|
let addr = rt.spawn(MyActor::default()).unwrap();
|
||||||
|
let handle = rt.run().unwrap(); // consumes rt, spawns 4 threads
|
||||||
|
|
||||||
|
// use handle.runtime to spawn/send while workers run
|
||||||
|
handle.runtime.send_to(addr, MyMsg).unwrap();
|
||||||
|
|
||||||
|
handle.shutdown();
|
||||||
|
handle.join();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The runtime is layered: **Runtime** → **Workers** → **ActorPool** → **Actors**.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ Runtime (Arc, shared) ──────────────────────────────────────┐
|
||||||
|
│ │
|
||||||
|
│ AddressMap Placement InboxRegistry is_running │
|
||||||
|
│ (addr→worker) (round-robin) (external inboxes) (AtomicBool) │
|
||||||
|
│ │
|
||||||
|
│ transfer_txs[] spawn_txs[] │
|
||||||
|
│ (one Sender per worker) (one Sender per worker) │
|
||||||
|
│ │
|
||||||
|
└───────┬───────────────┬───────────────┬───────────────────────┘
|
||||||
|
│ │ │
|
||||||
|
v v v
|
||||||
|
┌─ Worker 0 ──┐ ┌─ Worker 1 ──┐ ┌─ Worker 2 ──┐
|
||||||
|
│ ActorPool │ │ ActorPool │ │ ActorPool │
|
||||||
|
│ ┌────────┐ │ │ ┌────────┐ │ │ ┌────────┐ │
|
||||||
|
│ │mailbox │ │ │ │mailbox │ │ │ │mailbox │ │
|
||||||
|
│ │ actor │ │ │ │ actor │ │ │ │ actor │ │
|
||||||
|
│ └────────┘ │ │ └────────┘ │ │ └────────┘ │
|
||||||
|
│ ┌────────┐ │ │ ┌────────┐ │ │ │
|
||||||
|
│ │mailbox │ │ │ │mailbox │ │ └──────────────┘
|
||||||
|
│ │ actor │ │ │ │ actor │ │
|
||||||
|
│ └────────┘ │ │ └────────┘ │
|
||||||
|
└──────────────┘ └──────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Each worker runs a **four-phase tick loop**:
|
||||||
|
|
||||||
|
1. **Drain spawn queue** — add newly spawned actors to the pool
|
||||||
|
2. **Drain transfer queue** — deliver cross-worker messages to mailboxes
|
||||||
|
3. **Tick all actors** — pop messages, call handlers, buffer outgoing sends
|
||||||
|
4. **Drain pending local** — deliver same-worker messages for the next tick
|
||||||
|
|
||||||
|
Messages are type-erased (`Box<dyn Any + Send>`) in transit and downcast
|
||||||
|
back to the concrete type at delivery. Mismatched types are silently dropped.
|
||||||
|
|
||||||
|
Detailed architecture docs live in `docs/`:
|
||||||
|
|
||||||
|
| Document | Covers |
|
||||||
|
|----------|--------|
|
||||||
|
| [Worker Thread](docs/worker-thread.md) | Tick phases, backoff, message routing, full system topology |
|
||||||
|
| [Runtime](docs/runtime.md) | Runtime, Ctx, Inbox, RuntimeHandle, stats |
|
||||||
|
| [Actor Model](docs/actor-model.md) | Traits, type erasure, addresses |
|
||||||
|
| [Channels & Shared State](docs/channels.md) | HybridChannel, AddressMap, Placement |
|
||||||
|
|
||||||
|
## Source Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── lib.rs module root, feature gates, get_random()
|
||||||
|
├── actor.rs Message, ActorInterface, ActorAddress, type erasure
|
||||||
|
├── runtime.rs Runtime, Ctx, Inbox, RuntimeHandle, InboxRegistry
|
||||||
|
├── worker/
|
||||||
|
│ ├── mod.rs Worker, WorkerContext, ActorPool, tick loop
|
||||||
|
│ └── tests.rs worker unit tests with step-based DSL
|
||||||
|
├── channel.rs HybridChannel (ArrayQueue + SegQueue), Sender/Receiver
|
||||||
|
├── config.rs RuntimeConfig, BackoffPolicy
|
||||||
|
├── address_map.rs AddressMap (RwLock<HashMap>), Placement (round-robin)
|
||||||
|
├── error.rs Error type
|
||||||
|
└── python.rs PyO3 bindings (feature = "python")
|
||||||
|
|
||||||
|
wasm/
|
||||||
|
├── Cargo.toml separate crate, depends on swactor with no_random
|
||||||
|
├── src/lib.rs wasm-bindgen wrapper (SwactorRuntime)
|
||||||
|
└── test.mjs Node.js test suite
|
||||||
|
|
||||||
|
examples/
|
||||||
|
├── hello.rs echo actor
|
||||||
|
├── ring.rs ring topology
|
||||||
|
└── python/
|
||||||
|
├── hello_single_thread.py minimal Python example
|
||||||
|
├── hello_async.py multi-threaded + asyncio
|
||||||
|
└── getting_started.ipynb Jupyter notebook
|
||||||
|
|
||||||
|
tests/
|
||||||
|
├── runtime_api.rs single + multi-thread integration tests
|
||||||
|
├── stats_demo.rs stats snapshot tests
|
||||||
|
└── test_python.py Python binding tests
|
||||||
|
```
|
||||||
|
|
||||||
|
## Building & Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Rust
|
||||||
|
cargo test # run all tests
|
||||||
|
cargo run --example hello # run an example
|
||||||
|
cargo bench # benchmarks (criterion)
|
||||||
|
|
||||||
|
# Python bindings (requires Rust toolchain on PATH)
|
||||||
|
uv pip install . # build + install
|
||||||
|
uv run python3 tests/test_python.py # run Python tests
|
||||||
|
|
||||||
|
# WASM bindings
|
||||||
|
cd wasm && wasm-pack build --target nodejs
|
||||||
|
node test.mjs # run WASM tests
|
||||||
|
```
|
||||||
|
|
||||||
|
## Feature Flags
|
||||||
|
|
||||||
|
| Flag | Default | What it does |
|
||||||
|
|------|---------|--------------|
|
||||||
|
| `getrandom` | yes | System RNG for actor addresses |
|
||||||
|
| `no_random` | no | Deterministic counter (for WASM / reproducible tests) |
|
||||||
|
| `python` | no | PyO3 bindings, builds cdylib wheel |
|
||||||
|
|
|
||||||
138
docs/actor-model.md
Normal file
138
docs/actor-model.md
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
# 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
|
||||||
|
|
||||||
|
```rust
|
||||||
|
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 |
|
||||||
124
docs/channels.md
Normal file
124
docs/channels.md
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
# Channels & Shared State
|
||||||
|
|
||||||
|
All communication between workers (and between the `Runtime` and workers)
|
||||||
|
goes through lock-free channels. There are no mutexes in the hot path.
|
||||||
|
|
||||||
|
## HybridChannel
|
||||||
|
|
||||||
|
The core primitive. A lock-free MPSC queue with bounded fast path and
|
||||||
|
unbounded overflow.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ HybridChannel<T> ───────────────────────────────────────────────────────┐
|
||||||
|
│ │
|
||||||
|
│ ┌─ ring: ArrayQueue<T> (crossbeam) ─────────────────────────────────┐ │
|
||||||
|
│ │ Pre-allocated, fixed capacity, lock-free CAS │ │
|
||||||
|
│ │ ┌───┬───┬───┬───┬───┬───┬───┬───┐ │ │
|
||||||
|
│ │ │ │ │ │ │ │ │ │ │ │ │
|
||||||
|
│ │ └───┴───┴───┴───┴───┴───┴───┴───┘ │ │
|
||||||
|
│ └────────────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌─ overflow: SegQueue<T> (crossbeam) ───────────────────────────────┐ │
|
||||||
|
│ │ Unbounded linked-list queue, lock-free │ │
|
||||||
|
│ │ Only used when ring is full │ │
|
||||||
|
│ └────────────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ push(v): │
|
||||||
|
│ ring.push(v) → Ok: done │
|
||||||
|
│ ring.push(v) → Err: overflow.push(v) │
|
||||||
|
│ │
|
||||||
|
│ pop(): │
|
||||||
|
│ ring.pop() → Some: return it │
|
||||||
|
│ overflow.pop() → Some: return it │
|
||||||
|
│ otherwise → None │
|
||||||
|
│ │
|
||||||
|
└───────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
The fast path (ring) avoids allocation. The overflow (SegQueue) acts as a
|
||||||
|
safety net — the system never drops messages due to capacity, but
|
||||||
|
performance degrades under sustained overflow.
|
||||||
|
|
||||||
|
## Sender and Receiver
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ Sender<T> ──────────┐ ┌─ Receiver<T> ────────────┐
|
||||||
|
│ │ │ │
|
||||||
|
│ queue: Arc<Hybrid> │──same──▶│ queue: Arc<Hybrid> │
|
||||||
|
│ │ Arc │ │
|
||||||
|
│ try_send(v) → push │ │ try_recv() → pop │
|
||||||
|
│ │ │ │
|
||||||
|
│ Clone: new_sender() │ │ Single consumer │
|
||||||
|
│ (clones the Arc) │ │ (not Clone) │
|
||||||
|
│ │ │ │
|
||||||
|
└───────────────────────┘ └───────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
`Sender` is `Clone` — multiple producers can send into the same channel.
|
||||||
|
`Receiver` is not `Clone` — exactly one consumer drains it.
|
||||||
|
|
||||||
|
## What Channels Exist
|
||||||
|
|
||||||
|
Each worker gets two inbound channels, created at `Runtime::new()` time:
|
||||||
|
|
||||||
|
```
|
||||||
|
Per Worker:
|
||||||
|
|
||||||
|
transfer channel: carries Envelope (messages to actors)
|
||||||
|
Senders: Runtime, other workers (via TickContext)
|
||||||
|
Receiver: this Worker
|
||||||
|
|
||||||
|
spawn channel: carries (ActorAddress, Box<dyn AnyActor>)
|
||||||
|
Senders: Runtime, other workers (via TickContext)
|
||||||
|
Receiver: this Worker
|
||||||
|
```
|
||||||
|
|
||||||
|
For N workers, the runtime holds N transfer senders and N spawn senders.
|
||||||
|
Every worker can reach every other worker's queues through `TickContext`.
|
||||||
|
|
||||||
|
## AddressMap
|
||||||
|
|
||||||
|
Global directory mapping actor addresses to the worker that owns them.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ AddressMap ──────────────────────────────────────────────────────────────┐
|
||||||
|
│ │
|
||||||
|
│ RwLock< HashMap<ActorAddress, WorkerId> > │
|
||||||
|
│ │
|
||||||
|
│ Read path (very frequent): │
|
||||||
|
│ Every ctx.send() and Runtime.send_to() does a lookup. │
|
||||||
|
│ RwLock allows concurrent readers — no contention. │
|
||||||
|
│ │
|
||||||
|
│ Write path (rare): │
|
||||||
|
│ Only on spawn. Takes exclusive lock briefly. │
|
||||||
|
│ │
|
||||||
|
└───────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Placement
|
||||||
|
|
||||||
|
Decides which worker gets a newly spawned actor.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ Placement ───────────────────────────────────────────────────────────────┐
|
||||||
|
│ │
|
||||||
|
│ next: AtomicUsize │
|
||||||
|
│ num_workers: usize │
|
||||||
|
│ │
|
||||||
|
│ next_worker() → WorkerId( next.fetch_add(1) % num_workers ) │
|
||||||
|
│ │
|
||||||
|
│ Simple round-robin. No load balancing, no affinity. │
|
||||||
|
│ Actors stay on their assigned worker for life. │
|
||||||
|
│ │
|
||||||
|
└───────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Where Things Live in the Code
|
||||||
|
|
||||||
|
| Concept | File |
|
||||||
|
|---------|------|
|
||||||
|
| `HybridChannel`, `Sender`, `Receiver` | `src/channel.rs` |
|
||||||
|
| `AddressMap`, `WorkerId`, `Placement` | `src/address_map.rs` |
|
||||||
|
| `Envelope` | `src/runtime.rs` |
|
||||||
|
| `InboxRegistry` | `src/runtime.rs` |
|
||||||
|
| `RuntimeConfig`, `BackoffPolicy` | `src/config.rs` |
|
||||||
185
docs/runtime.md
Normal file
185
docs/runtime.md
Normal file
|
|
@ -0,0 +1,185 @@
|
||||||
|
# Runtime Architecture
|
||||||
|
|
||||||
|
The `Runtime` is the main entry point. It creates workers, owns the shared
|
||||||
|
infrastructure, and provides the public API for spawning actors and sending
|
||||||
|
messages.
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ Runtime ─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ │
|
||||||
|
│ config: RuntimeConfig -- tunable knobs (see config.rs) │
|
||||||
|
│ is_running: AtomicBool -- shutdown flag, read by all workers │
|
||||||
|
│ │
|
||||||
|
│ ┌─ Shared State (lives on Arc<Runtime>) ──────────────────────────────┐ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ address_map: Arc<AddressMap> -- actor -> worker lookup │ │
|
||||||
|
│ │ inbox_registry: Arc<InboxRegistry> -- external inbox delivery │ │
|
||||||
|
│ │ placement: Placement -- round-robin worker picker │ │
|
||||||
|
│ │ worker_stats: Vec<Arc<WorkerStats>> -- atomic stat counters │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌─ Channel Endpoints ─────────────────────────────────────────────────┐ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ transfer_txs: Vec<Sender<Envelope>> -- one per worker (messages) │ │
|
||||||
|
│ │ spawn_txs: Vec<Sender<(Addr,Box)>> -- one per worker (spawns) │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌─ Mode ──────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ SINGLE-THREADED: single_worker: Some(RefCell<Worker>) │ │
|
||||||
|
│ │ MULTI-THREADED: pending_workers: Some(Vec<Worker>) │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ After run() is called, both are None — workers move to threads. │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
└───────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Two Modes of Operation
|
||||||
|
|
||||||
|
```
|
||||||
|
SINGLE-THREADED MULTI-THREADED
|
||||||
|
────────────── ──────────────
|
||||||
|
|
||||||
|
let rt = Runtime::new(config); let mut config = RuntimeConfig::default();
|
||||||
|
config.num_threads = 4;
|
||||||
|
let rt = Runtime::new(config);
|
||||||
|
|
||||||
|
rt.spawn(my_actor)?; rt.spawn(my_actor)?;
|
||||||
|
rt.send_to(addr, msg)?; rt.send_to(addr, msg)?;
|
||||||
|
|
||||||
|
loop { rt.tick(); } let handle = rt.run()?;
|
||||||
|
^ ^
|
||||||
|
| |
|
||||||
|
caller drives each tick workers run on their own threads
|
||||||
|
handle.join() blocks until shutdown
|
||||||
|
```
|
||||||
|
|
||||||
|
Single-threaded mode keeps the `Worker` inline and requires the caller to
|
||||||
|
call `rt.tick()` to advance the simulation. This is useful for deterministic
|
||||||
|
testing, WASM, or game loops where you want frame-level control.
|
||||||
|
|
||||||
|
Multi-threaded mode consumes the `Runtime` via `run()`, wraps it in an
|
||||||
|
`Arc`, and spawns one OS thread per worker. Returns a `RuntimeHandle`.
|
||||||
|
|
||||||
|
## Ctx — the Actor Syscall Interface
|
||||||
|
|
||||||
|
When an actor's `handle()` method runs, it receives a `&Ctx`. This is the
|
||||||
|
only way for actors to interact with the outside world.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ Ctx<'a> ─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ │
|
||||||
|
│ inner: &dyn ContextInner -- polymorphic dispatch │
|
||||||
|
│ self_addr: ActorAddress -- address of the current actor │
|
||||||
|
│ │
|
||||||
|
│ ┌─ Public API ────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ ctx.self_addr() -> ActorAddress │ │
|
||||||
|
│ │ ctx.send(addr, msg) -> Result<(), Error> │ │
|
||||||
|
│ │ ctx.spawn(actor) -> Result<ActorAddress, Error> │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌─ ContextInner dispatch ─────────────────────────────────────────────┐ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ In single-threaded mode: inner = &Runtime │ │
|
||||||
|
│ │ send → transfer_txs[wid], spawn → spawn_txs[wid] │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ In multi-threaded mode: inner = &WorkerContext │ │
|
||||||
|
│ │ send → pending_local (same worker) or transfer_txs (cross) │ │
|
||||||
|
│ │ spawn → spawn_txs[target_wid] │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
└───────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
The `ContextInner` trait is the object-safe bridge. It's not public — actors
|
||||||
|
interact only through the typed `Ctx` wrapper.
|
||||||
|
|
||||||
|
## Inbox — Receiving Messages Outside the Runtime
|
||||||
|
|
||||||
|
`Inbox<M>` lets external code (the "main" thread, a game loop, an HTTP
|
||||||
|
handler, etc.) receive typed messages from actors.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ Creation ─────────────────────────────────────────────────────────────┐
|
||||||
|
│ │
|
||||||
|
│ let inbox = rt.new_inbox::<MyResponse>()?; │
|
||||||
|
│ │
|
||||||
|
│ Under the hood: │
|
||||||
|
│ addr = ActorAddress::new_random() │
|
||||||
|
│ receiver = Receiver::<M>::new(capacity) │
|
||||||
|
│ sender = receiver.new_sender() │
|
||||||
|
│ inbox_registry.register(addr, Arc::new(sender)) │
|
||||||
|
│ │
|
||||||
|
└────────────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌─ Usage ────────────────────────────────────────────────────────────────┐
|
||||||
|
│ │
|
||||||
|
│ // give inbox.addr() to actors so they know where to reply │
|
||||||
|
│ rt.send_to(greeter, GreetMsg { return_addr: *inbox.addr() })?; │
|
||||||
|
│ │
|
||||||
|
│ // poll for responses │
|
||||||
|
│ if let Some(msg) = inbox.try_recv() { ... } │
|
||||||
|
│ │
|
||||||
|
└────────────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌─ Delivery Path ────────────────────────────────────────────────────────┐
|
||||||
|
│ │
|
||||||
|
│ actor calls ctx.send(inbox_addr, response) │
|
||||||
|
│ │ │
|
||||||
|
│ v │
|
||||||
|
│ address_map.lookup(inbox_addr) → None (inboxes aren't actors) │
|
||||||
|
│ │ │
|
||||||
|
│ v │
|
||||||
|
│ inbox_registry.try_deliver(addr, msg) │
|
||||||
|
│ │ │
|
||||||
|
│ v │
|
||||||
|
│ downcast Box<Any> → M, push into Receiver<M> │
|
||||||
|
│ │
|
||||||
|
└────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## RuntimeHandle
|
||||||
|
|
||||||
|
Returned by `run()`. Holds `Arc<Runtime>` and the thread `JoinHandle`s.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ RuntimeHandle ───────────────────────────────────────────────────────────┐
|
||||||
|
│ │
|
||||||
|
│ runtime: Arc<Runtime> -- still usable for spawn/send/stats │
|
||||||
|
│ threads: Vec<JoinHandle<()>> -- one per worker │
|
||||||
|
│ │
|
||||||
|
│ handle.shutdown() → runtime.is_running.store(false) │
|
||||||
|
│ handle.join() → waits for all worker threads to exit │
|
||||||
|
│ │
|
||||||
|
└───────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## RuntimeStats
|
||||||
|
|
||||||
|
`rt.stats()` (or `handle.runtime.stats()`) returns a snapshot:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ RuntimeStats ────────────────────────────────────────────────────────────┐
|
||||||
|
│ │
|
||||||
|
│ num_workers: usize │
|
||||||
|
│ actors: Vec<(ActorAddress, worker_id)> -- from AddressMap snapshot │
|
||||||
|
│ workers: Vec<WorkerInfo> │
|
||||||
|
│ ├─ id: usize │
|
||||||
|
│ ├─ num_actors: usize -- from atomic counter │
|
||||||
|
│ ├─ mailbox_depth: usize -- total queued messages │
|
||||||
|
│ └─ messages_processed: u64 -- cumulative count │
|
||||||
|
│ │
|
||||||
|
└───────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Stats are published by workers via atomic stores at the end of each tick,
|
||||||
|
so they're always slightly stale but never block.
|
||||||
|
|
@ -12,3 +12,10 @@ dev = ["jupyter", "ipykernel"]
|
||||||
|
|
||||||
[tool.maturin]
|
[tool.maturin]
|
||||||
features = ["python"]
|
features = ["python"]
|
||||||
|
|
||||||
|
[tool.uv]
|
||||||
|
cache-keys = [
|
||||||
|
{ file = "pyproject.toml" },
|
||||||
|
{ file = "Cargo.toml" },
|
||||||
|
{ file = "src/**/*.rs" },
|
||||||
|
]
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue