diff --git a/docs/API_REDESIGN.md b/docs/API_REDESIGN.md new file mode 100644 index 0000000..86f1842 --- /dev/null +++ b/docs/API_REDESIGN.md @@ -0,0 +1,498 @@ +# Plan: API Redesign for v0.5 - Issues #144, #145, and Phase 3 + +## Decisions Made + +| Issue | Decision | Rationale | +|-------|----------|-----------| +| **#145** (Circular deps) | Recipient\ pattern | Type-safe, no exposed Pid | +| **#144** (Type safety) | Per-message types (Handler\) | Leverage Rust's type system, clean API | +| **Breaking changes** | Accepted | v0.5 is the right time for API improvements | +| **Pattern support** | Per-message only | Clean break, one way to do things | +| **Actor identity** | Internal ID (hidden) | Links/monitors work via traits, no public Pid | +| **Supervision** | Trait-based (`Supervisable`) | Type-safe child management | + +## Overview + +This is a significant API redesign that: +1. Adds Handler pattern for per-message type safety (#144) +2. Adds Recipient for type-erased message sending (#145) +3. Uses internal identity (not exposed as Pid) for links/monitors +4. Uses traits for supervision (Supervisable, Linkable) + +--- + +## Issue #145: Circular Dependency with Bidirectional Actors + +### The Problem + +```rust +// actor_a.rs +use crate::actor_b::ActorB; +struct ActorA { peer: ActorRef } // Needs ActorB type + +// actor_b.rs +use crate::actor_a::ActorA; +struct ActorB { peer: ActorRef } // CIRCULAR! +``` + +### Solution: Recipient\ + +```rust +/// Trait for anything that can receive messages of type M. +/// Object-safe: all methods return concrete types (no async/impl Future). +/// Async waiting happens outside the trait via oneshot channels (Actix pattern). +trait Receiver: Send + Sync { + fn send(&self, msg: M) -> Result<(), ActorError>; + fn request(&self, msg: M) -> Result, ActorError>; +} + +// ActorRef implements Receiver for all M where A: Handler +// Type-erased handle (ergonomic alias) +type Recipient = Arc>; + +// Ergonomic async wrapper on the concrete Recipient type (not on the trait) +impl Recipient { + pub async fn send_request(&self, msg: M) -> Result { + let rx = self.request(msg)?; + rx.await.map_err(|_| ActorError::ActorStopped) + } +} + +// Usage - no circular dependency! +struct ActorA { peer: Recipient } +struct ActorB { peer: Recipient } +``` + +--- + +## Issue #144: Type Safety for Request/Reply + +### The Problem + +```rust +enum Reply { Name(String), Age(u32), NotFound } + +// GetName can only return Name or NotFound, but must match Age too +match actor.request(Request::GetName).await? { + Reply::Name(n) => println!("{}", n), + Reply::NotFound => println!("not found"), + Reply::Age(_) => unreachable!(), // Required but impossible +} +``` + +### Solution: Per-Message Types with Handler\ + +```rust +struct GetName(String); +impl Message for GetName { + type Result = Option; +} + +impl Handler for NameServer { + fn handle(&mut self, msg: GetName) -> Option { ... } +} + +// Clean caller code - exact type! +let name: Option = actor.request(GetName("joe")).await?; +``` + +--- + +# Implementation Plan + +## Phase 3.1: Receiver\ Trait and Recipient\ Alias + +**New file:** `concurrency/src/recipient.rs` + +```rust +/// Trait for anything that can receive messages of type M. +/// +/// Object-safe by design: all methods return concrete types, no async/impl Future. +/// This follows the Actix pattern where async waiting happens outside the trait +/// boundary via oneshot channels, keeping the trait compatible with `dyn`. +/// +/// This is implemented by ActorRef for all message types the actor handles. +/// Use `Recipient` for type-erased storage. +pub trait Receiver: Send + Sync { + /// Fire-and-forget send (enqueue message, don't wait for reply) + fn send(&self, msg: M) -> Result<(), ActorError>; + + /// Enqueue message and return a oneshot channel to await the reply. + /// This is synchronous — it only does channel plumbing. + /// The async waiting happens on the returned receiver. + fn request(&self, msg: M) -> Result, ActorError>; +} + +/// Type-erased handle (ergonomic alias). Object-safe because Receiver is. +pub type Recipient = Arc>; + +/// Ergonomic async wrapper — lives on the concrete type, not the trait. +impl Recipient { + pub async fn send_request(&self, msg: M) -> Result { + let rx = Receiver::request(&**self, msg)?; + rx.await.map_err(|_| ActorError::ActorStopped) + } +} + +// ActorRef implements Receiver for all M where A: Handler +impl Receiver for ActorRef +where + A: Actor + Handler, + M: Message, +{ + fn send(&self, msg: M) -> Result<(), ActorError> { + // Pack message into envelope, push to actor's mailbox channel + ... + } + + fn request(&self, msg: M) -> Result, ActorError> { + // Create oneshot channel, pack (msg, tx) into envelope, + // push to actor's mailbox, return rx + ... + } +} + +// Convert ActorRef to Recipient +impl ActorRef { + pub fn recipient(&self) -> Recipient + where + A: Handler, + M: Message, + { + Arc::new(self.clone()) + } +} +``` + +## Phase 3.2: Internal Identity (Hidden) + +**New file:** `concurrency/src/identity.rs` + +```rust +/// Internal process identifier (not public API) +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub(crate) struct ActorId(u64); + +/// Exit reason for actors +pub enum ExitReason { + Normal, + Shutdown, + Error(String), + Linked, // Linked actor died +} +``` + +## Phase 3.3: Traits for Supervision and Links + +**New file:** `concurrency/src/traits.rs` + +```rust +/// Trait for actors that can be supervised. +/// Provides actor_id() for identity comparison with trait objects. +pub trait Supervisable: Send + Sync { + fn actor_id(&self) -> ActorId; + fn stop(&self); + fn is_alive(&self) -> bool; + fn on_exit(&self, callback: Box); +} + +/// Trait for actors that can be linked. +/// Uses actor_id() (from Supervisable) to track links internally. +pub trait Linkable: Supervisable { + fn link(&self, other: &dyn Linkable); + fn unlink(&self, other: &dyn Linkable); +} + +/// Trait for actors that can be monitored +pub trait Monitorable: Supervisable { + fn monitor(&self) -> MonitorRef; + fn demonitor(&self, monitor_ref: MonitorRef); +} + +// ActorRef implements all these traits +impl Supervisable for ActorRef { ... } +impl Linkable for ActorRef { ... } +impl Monitorable for ActorRef { ... } +``` + +## Phase 3.4: Registry (Named Actors) + +**New file:** `concurrency/src/registry.rs` + +```rust +/// Register a recipient under a name +pub fn register(name: &str, recipient: Recipient) -> Result<(), RegistryError>; + +/// Look up a recipient by name (must know message type) +pub fn whereis(name: &str) -> Option>; + +/// Unregister a name +pub fn unregister(name: &str); + +/// List all registered names +pub fn registered() -> Vec; +``` + +## Phase 4: Handler Pattern (#144) + +**Redesigned Actor API:** + +```rust +/// Marker trait for messages +pub trait Message: Send + 'static { + type Result: Send; +} + +/// Handler for a specific message type. +/// Uses RPITIT (Rust 1.75+) — this is fine since Handler is never used as dyn. +/// &mut self is safe: actors process messages sequentially (one at a time), +/// so there is no concurrent access to self. +pub trait Handler: Actor { + fn handle(&mut self, msg: M, ctx: &Context) -> impl Future + Send; +} + +/// Actor context (replaces ActorRef in handlers) +pub struct Context { + // ... internal fields +} + +impl Context { + pub fn stop(&self); + pub fn recipient(&self) -> Recipient where A: Handler; +} + +/// Base actor trait (simplified) +pub trait Actor: Send + Sized + 'static { + fn started(&mut self, ctx: &Context) -> impl Future + Send { async {} } + fn stopped(&mut self, ctx: &Context) -> impl Future + Send { async {} } +} +``` + +**ActorRef changes:** + +```rust +/// Typed handle to an actor. +/// +/// Internally uses an envelope pattern (like Actix) for the mailbox: +/// messages of different types are packed into `Box>` so +/// the actor's single mpsc channel can carry any message type the actor handles. +pub struct ActorRef { + id: ActorId, // Internal identity (not public) + sender: mpsc::Sender + Send>>, + _marker: PhantomData, +} + +/// Type-erased envelope that the actor loop can dispatch. +/// Each concrete envelope captures the message and an optional oneshot sender. +trait Envelope: Send { + fn handle(self: Box, actor: &mut A, ctx: &Context); +} + +impl ActorRef +where + A: Actor + Handler, + M: Message, +{ + /// Fire-and-forget send (returns error if actor dead) + pub fn send(&self, msg: M) -> Result<(), ActorError>; + + /// Enqueue message and return a oneshot receiver for the reply. + /// Synchronous — only does channel plumbing (Actix pattern). + pub fn request(&self, msg: M) -> Result, ActorError>; + + /// Ergonomic async request: enqueue + await reply. + pub async fn send_request(&self, msg: M) -> Result { + let rx = self.request(msg)?; + rx.await.map_err(|_| ActorError::ActorStopped) + } + + /// Get type-erased Recipient for this message type + pub fn recipient(&self) -> Recipient; +} + +// Implements supervision/linking traits +impl Supervisable for ActorRef { ... } +impl Linkable for ActorRef { ... } +impl Monitorable for ActorRef { ... } +``` + +--- + +## Example: Bank Actor (New API) + +```rust +// messages.rs +pub struct CreateAccount { pub name: String } +pub struct Deposit { pub account: String, pub amount: u64 } +pub struct GetBalance { pub account: String } + +impl Message for CreateAccount { type Result = Result<(), BankError>; } +impl Message for Deposit { type Result = Result; } +impl Message for GetBalance { type Result = Result; } + +// bank.rs +pub struct Bank { + accounts: HashMap, +} + +impl Actor for Bank { + async fn started(&mut self, ctx: &Context) { + tracing::info!("Bank started"); + } +} + +impl Handler for Bank { + async fn handle(&mut self, msg: CreateAccount, _ctx: &Context) -> Result<(), BankError> { + self.accounts.insert(msg.name, 0); + Ok(()) + } +} + +impl Handler for Bank { + async fn handle(&mut self, msg: Deposit, _ctx: &Context) -> Result { + let balance = self.accounts.get_mut(&msg.account).ok_or(BankError::NotFound)?; + *balance += msg.amount; + Ok(*balance) + } +} + +impl Handler for Bank { + async fn handle(&mut self, msg: GetBalance, _ctx: &Context) -> Result { + self.accounts.get(&msg.account).copied().ok_or(BankError::NotFound) + } +} + +// main.rs +let bank: ActorRef = Bank::new().start(); + +// Type-safe request (async convenience wrapper: enqueue + await oneshot) +let balance: Result = bank.send_request(GetBalance { account: "alice".into() }).await?; + +// Fire-and-forget send +bank.send(Deposit { account: "alice".into(), amount: 50 })?; + +// Low-level: get oneshot receiver directly (useful for select!, timeouts, etc.) +let rx = bank.request(GetBalance { account: "alice".into() })?; +let balance = tokio::time::timeout(Duration::from_secs(5), rx).await??; + +// Get type-erased Recipient for storage/passing to other actors +let recipient: Recipient = bank.recipient(); + +// Supervision uses trait objects +let children: Vec> = vec![ + Arc::new(bank.clone()), + Arc::new(logger.clone()), +]; +``` + +--- + +## Example: Solving #145 (Circular Deps) with Recipient + +```rust +// shared_messages.rs - NO circular dependency +pub struct OrderUpdate { pub order_id: u64, pub status: String } +pub struct InventoryReserve { pub item: String, pub quantity: u32, pub reply_to: Recipient } + +impl Message for OrderUpdate { type Result = (); } +impl Message for InventoryReserve { type Result = Result<(), InventoryError>; } + +// order_service.rs - imports InventoryReserve, NOT InventoryService +use crate::shared_messages::{OrderUpdate, InventoryReserve}; + +pub struct OrderService { + inventory: Recipient, // Type-erased, no circular dep! +} + +impl Handler for OrderService { + async fn handle(&mut self, msg: PlaceOrder, ctx: &Context) -> Result<(), OrderError> { + let reply_to: Recipient = ctx.recipient(); + self.inventory.send_request(InventoryReserve { + item: msg.item, + quantity: msg.quantity, + reply_to, + }).await?; + Ok(()) + } +} + +// inventory_service.rs - imports OrderUpdate, NOT OrderService +use crate::shared_messages::{OrderUpdate, InventoryReserve}; + +pub struct InventoryService { ... } + +impl Handler for InventoryService { + async fn handle(&mut self, msg: InventoryReserve, _ctx: &Context) -> Result<(), InventoryError> { + // Reserve inventory... + msg.reply_to.send(OrderUpdate { order_id: 123, status: "reserved".into() })?; + Ok(()) + } +} + +// main.rs - wire them together +let inventory: ActorRef = InventoryService::new().start(); +let inventory_recipient: Recipient = inventory.recipient(); + +let order_service = OrderService::new(inventory_recipient).start(); +``` + +--- + +## Files to Create/Modify + +| File | Action | Description | +|------|--------|-------------| +| `concurrency/src/recipient.rs` | Create | Receiver trait and Recipient alias | +| `concurrency/src/identity.rs` | Create | Internal ActorId (not public) | +| `concurrency/src/traits.rs` | Create | Supervisable, Linkable, Monitorable | +| `concurrency/src/registry.rs` | Create | Named actor registry | +| `concurrency/src/message.rs` | Create | Message and Handler traits | +| `concurrency/src/context.rs` | Create | Context type | +| `concurrency/src/tasks/actor.rs` | Rewrite | New Actor/Handler API | +| `concurrency/src/threads/actor.rs` | Rewrite | Same changes for threads | +| `concurrency/src/lib.rs` | Update | Export new types | +| `examples/*` | Update | Migrate to new API | + +--- + +## Migration Path + +1. **Step 1:** Add Message trait and Handler pattern alongside current API +2. **Step 2:** Add Recipient for type-erased sending +3. **Step 3:** Add Supervisable/Linkable/Monitorable traits +4. **Step 4:** Add Registry with Recipient +5. **Deprecation:** Mark old Request/Reply/Message associated types as deprecated +6. **Removal:** Remove old API in subsequent release + +--- + +## v0.6+ Considerations + +| Feature | Approach | +|---------|----------| +| **Clustering** | Add `RemoteRecipient` that serializes ActorId + message | +| **State machines** | gen_statem equivalent using Handler pattern | +| **Persistence** | Event sourcing via Handler | + +--- + +## Verification + +1. `cargo build --workspace` - All crates compile +2. `cargo test --workspace` - All tests pass +3. Update examples to new API +4. Test bidirectional actor communication without circular deps +5. Test Supervisable/Linkable traits work correctly + +--- + +## Final Decisions + +| Item | Decision | +|------|----------| +| Method naming | `send()` = fire-forget, `request()` = wait for reply | +| Dead actor handling | Returns `Err(ActorStopped)` (type-safe feedback) | +| Pattern support | Per-message types only (no enum fallback) | +| Type erasure | `Recipient` for message-type-safe erasure | +| Actor identity | Internal `ActorId` (not exposed as Pid) | +| Supervision | `Supervisable` / `Linkable` / `Monitorable` traits |