diff --git a/README.md b/README.md index 78c4ddc..81cc453 100644 --- a/README.md +++ b/README.md @@ -1,67 +1,265 @@ # 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 - -```bash -cargo run --manifest-path tools/depgraph/Cargo.toml -- --src-dir src/ --output deps -``` - -## Quick example +## Quick Start (Rust) ```rust -use swactor::{ - Ctx, - actor::{ActorAddress, ActorInterface}, - runtime::{Runtime, RuntimeConfig}, -}; +use swactor::actor::{ActorAddress, ActorInterface}; +use swactor::runtime::{Ctx, Runtime, RuntimeConfig}; -#[derive(Debug, Default)] -struct Greeter { num_greeted: usize } +#[derive(Clone)] +struct Greet { name: String, reply_to: ActorAddress } -#[derive(Debug, Default, Clone)] -struct GreetMessage { who: String, return_addr: ActorAddress } +#[derive(Clone)] +struct Greeting(String); -#[derive(Debug, Default, Clone)] -struct GreetResponse(String); +struct Greeter; impl ActorInterface for Greeter { - type Incoming = GreetMessage; - type Response = GreetResponse; + type Incoming = Greet; + type Response = Greeting; - fn handle(&mut self, ctx: &Ctx, msg: GreetMessage) { - let res = GreetResponse(format!("Hello, {}!", msg.who)); - self.num_greeted += 1; - if let Err(_) = ctx.send(msg.return_addr, res) { - self.num_greeted -= 1; - } + fn handle(&mut self, ctx: &Ctx, msg: Greet) { + let _ = ctx.send(msg.reply_to, Greeting(format!("Hello, {}!", msg.name))); } } fn main() { 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::().unwrap(); - let inbox = rt.new_inbox::().unwrap(); - rt.send_to(addr, GreetMessage { - who: "world".into(), - return_addr: *inbox.addr(), - }).unwrap(); + rt.send_to(addr, Greet { name: "world".into(), reply_to: *inbox.addr() }).unwrap(); + rt.tick(); + rt.tick(); - for _ in 0..3 { rt.tick(); } - let resp = inbox.try_recv().expect("should have response"); - println!("{}", resp.0); // "Hello, world!" + println!("{}", inbox.try_recv().unwrap().0); // "Hello, world!" } ``` -## Build & test +## Quick Start (Python) -```sh -cargo build -cargo test -cargo test --features stress # stress tests -cargo run --bin bench --release # benchmarks -cargo run --example hello +```bash +uv pip install . # builds the Rust extension automatically ``` + +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`) 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), 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 | diff --git a/docs/actor-model.md b/docs/actor-model.md new file mode 100644 index 0000000..8c37dc5 --- /dev/null +++ b/docs/actor-model.md @@ -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`, which erases the +concrete type. Messages are stored as `Box`. Type checking +happens at delivery time via `downcast`: + +``` + compile time runtime + ─────────── ─────── + ctx.send(addr, msg) + │ + v + Box::new(msg) as Box -- type erased here + │ + v + enqueued in mailbox (VecDeque>) + │ + v + actor.handle_any(ctx, msg) + │ + v + msg.downcast::() -- 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` 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 | diff --git a/docs/channels.md b/docs/channels.md new file mode 100644 index 0000000..e5de864 --- /dev/null +++ b/docs/channels.md @@ -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 ───────────────────────────────────────────────────────┐ +│ │ +│ ┌─ ring: ArrayQueue (crossbeam) ─────────────────────────────────┐ │ +│ │ Pre-allocated, fixed capacity, lock-free CAS │ │ +│ │ ┌───┬───┬───┬───┬───┬───┬───┬───┐ │ │ +│ │ │ │ │ │ │ │ │ │ │ │ │ +│ │ └───┴───┴───┴───┴───┴───┴───┴───┘ │ │ +│ └────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─ overflow: SegQueue (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 ──────────┐ ┌─ Receiver ────────────┐ +│ │ │ │ +│ queue: Arc │──same──▶│ queue: Arc │ +│ │ 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) + 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 > │ +│ │ +│ 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` | diff --git a/docs/runtime.md b/docs/runtime.md new file mode 100644 index 0000000..9ca1c13 --- /dev/null +++ b/docs/runtime.md @@ -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) ──────────────────────────────┐ │ +│ │ │ │ +│ │ address_map: Arc -- actor -> worker lookup │ │ +│ │ inbox_registry: Arc -- external inbox delivery │ │ +│ │ placement: Placement -- round-robin worker picker │ │ +│ │ worker_stats: Vec> -- atomic stat counters │ │ +│ │ │ │ +│ └─────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─ Channel Endpoints ─────────────────────────────────────────────────┐ │ +│ │ │ │ +│ │ transfer_txs: Vec> -- one per worker (messages) │ │ +│ │ spawn_txs: Vec> -- one per worker (spawns) │ │ +│ │ │ │ +│ └─────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─ Mode ──────────────────────────────────────────────────────────────┐ │ +│ │ │ │ +│ │ SINGLE-THREADED: single_worker: Some(RefCell) │ │ +│ │ MULTI-THREADED: pending_workers: Some(Vec) │ │ +│ │ │ │ +│ │ 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 │ │ +│ │ │ │ +│ └─────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─ 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` lets external code (the "main" thread, a game loop, an HTTP +handler, etc.) receive typed messages from actors. + +``` + ┌─ Creation ─────────────────────────────────────────────────────────────┐ + │ │ + │ let inbox = rt.new_inbox::()?; │ + │ │ + │ Under the hood: │ + │ addr = ActorAddress::new_random() │ + │ receiver = Receiver::::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 → M, push into Receiver │ + │ │ + └────────────────────────────────────────────────────────────────────────┘ +``` + +## RuntimeHandle + +Returned by `run()`. Holds `Arc` and the thread `JoinHandle`s. + +``` +┌─ RuntimeHandle ───────────────────────────────────────────────────────────┐ +│ │ +│ runtime: Arc -- still usable for spawn/send/stats │ +│ threads: Vec> -- 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 │ +│ ├─ 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. diff --git a/pyproject.toml b/pyproject.toml index cc4cfe9..2e6a774 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,3 +12,10 @@ dev = ["jupyter", "ipykernel"] [tool.maturin] features = ["python"] + +[tool.uv] +cache-keys = [ + { file = "pyproject.toml" }, + { file = "Cargo.toml" }, + { file = "src/**/*.rs" }, +]