feat: docs and README

Update the docs and readme with all the changes we've made
This commit is contained in:
Zachery Aaron Shores-Chmielewski 2026-02-07 00:09:23 +07:00
parent 042ccd0db0
commit 9700a6b1a6
5 changed files with 696 additions and 44 deletions

286
README.md
View file

@ -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
View 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
View 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
View 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.

View file

@ -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" },
]