From 41e4b62f4ea3f5dca49d667f791ce3ae672bbb55 Mon Sep 17 00:00:00 2001 From: Esteban Dimitroff Hodi Date: Wed, 4 Feb 2026 12:26:45 -0300 Subject: [PATCH 1/4] docs: add API redesign plan for v0.5 --- docs/API_REDESIGN.md | 431 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 431 insertions(+) create mode 100644 docs/API_REDESIGN.md diff --git a/docs/API_REDESIGN.md b/docs/API_REDESIGN.md new file mode 100644 index 0000000..91ca23f --- /dev/null +++ b/docs/API_REDESIGN.md @@ -0,0 +1,431 @@ +# 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 MessageSender: Send + Sync { + fn send(&self, msg: M) -> Result<(), ActorError>; +} + +// ActorRef implements MessageSender +// Type-erased handle for specific message type +type Recipient = Arc>; + +// 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: Recipient (Type-Erased Sender) + +**New file:** `concurrency/src/recipient.rs` + +```rust +/// Trait for sending a specific message type +pub trait MessageSender: Send + Sync { + fn send(&self, msg: M) -> Result<(), ActorError>; + fn request(&self, msg: M) -> impl Future> + Send; +} + +/// Type-erased handle that can send messages of type M +pub type Recipient = Arc>; + +// ActorRef implements MessageSender for all M where A: Handler +impl MessageSender for ActorRef +where + A: Actor + Handler, + M: Message, +{ + fn send(&self, msg: M) -> Result<(), ActorError> { ... } + fn request(&self, msg: M) -> impl Future> + Send { ... } +} + +// 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 +pub trait Supervisable: Send + Sync { + fn stop(&self); + fn is_alive(&self) -> bool; + fn on_exit(&self, callback: Box); +} + +/// Trait for actors that can be linked +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 +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 +pub struct ActorRef { + id: ActorId, // Internal identity (not public) + sender: mpsc::Sender>, + _marker: PhantomData, +} + +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>; + + /// Send and wait for typed response + pub async fn request(&self, msg: M) -> Result; + + /// 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 (wait for reply) +let balance: Result = bank.request(GetBalance { account: "alice".into() }).await?; + +// Fire-and-forget send +bank.send(Deposit { account: "alice".into(), amount: 50 })?; + +// 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.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 | Recipient and MessageSender trait | +| `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 | From a39ce76619ac4888781cfc1ddc423b0c73f3f4db Mon Sep 17 00:00:00 2001 From: Esteban Dimitroff Hodi Date: Thu, 5 Feb 2026 15:48:06 -0300 Subject: [PATCH 2/4] fix: use Receiver trait with Recipient alias for consistent naming --- docs/API_REDESIGN.md | 31 +++++++++++++++++++------------ 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/docs/API_REDESIGN.md b/docs/API_REDESIGN.md index 91ca23f..1c011b2 100644 --- a/docs/API_REDESIGN.md +++ b/docs/API_REDESIGN.md @@ -38,13 +38,14 @@ struct ActorB { peer: ActorRef } // CIRCULAR! ### Solution: Recipient\ ```rust -trait MessageSender: Send + Sync { +/// Trait for anything that can receive messages of type M +trait Receiver: Send + Sync { fn send(&self, msg: M) -> Result<(), ActorError>; } -// ActorRef implements MessageSender -// Type-erased handle for specific message type -type Recipient = Arc>; +// ActorRef implements Receiver for all M where A: Handler +// Type-erased handle (ergonomic alias) +type Recipient = Arc>; // Usage - no circular dependency! struct ActorA { peer: Recipient } @@ -88,22 +89,28 @@ let name: Option = actor.request(GetName("joe")).await?; # Implementation Plan -## Phase 3.1: Recipient (Type-Erased Sender) +## Phase 3.1: Receiver\ Trait and Recipient\ Alias **New file:** `concurrency/src/recipient.rs` ```rust -/// Trait for sending a specific message type -pub trait MessageSender: Send + Sync { +/// Trait for anything that can receive messages of type M +/// +/// 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 fn send(&self, msg: M) -> Result<(), ActorError>; + + /// Send and wait for reply fn request(&self, msg: M) -> impl Future> + Send; } -/// Type-erased handle that can send messages of type M -pub type Recipient = Arc>; +/// Type-erased handle (ergonomic alias) +pub type Recipient = Arc>; -// ActorRef implements MessageSender for all M where A: Handler -impl MessageSender for ActorRef +// ActorRef implements Receiver for all M where A: Handler +impl Receiver for ActorRef where A: Actor + Handler, M: Message, @@ -375,7 +382,7 @@ let order_service = OrderService::new(inventory_recipient).start(); | File | Action | Description | |------|--------|-------------| -| `concurrency/src/recipient.rs` | Create | Recipient and MessageSender trait | +| `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 | From fa724c8688bdd1dfbca96de9f95db14671808066 Mon Sep 17 00:00:00 2001 From: Esteban Dimitroff Hodi Date: Thu, 5 Feb 2026 18:19:13 -0300 Subject: [PATCH 3/4] docs: use Actix-style object-safe Receiver trait in API plan --- docs/API_REDESIGN.md | 84 ++++++++++++++++++++++++++++++++++++-------- 1 file changed, 69 insertions(+), 15 deletions(-) diff --git a/docs/API_REDESIGN.md b/docs/API_REDESIGN.md index 1c011b2..b6c8523 100644 --- a/docs/API_REDESIGN.md +++ b/docs/API_REDESIGN.md @@ -38,15 +38,26 @@ struct ActorB { peer: ActorRef } // CIRCULAR! ### Solution: Recipient\ ```rust -/// Trait for anything that can receive messages of type M +/// 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 } @@ -94,29 +105,51 @@ let name: Option = actor.request(GetName("joe")).await?; **New file:** `concurrency/src/recipient.rs` ```rust -/// Trait for anything that can receive messages of type M +/// 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 + /// Fire-and-forget send (enqueue message, don't wait for reply) fn send(&self, msg: M) -> Result<(), ActorError>; - /// Send and wait for reply - fn request(&self, msg: M) -> impl Future> + Send; + /// 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) +/// 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> { ... } - fn request(&self, msg: M) -> impl Future> + Send { ... } + 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 @@ -232,13 +265,23 @@ pub trait Actor: Send + Sized + 'static { **ActorRef changes:** ```rust -/// Typed handle to an actor +/// 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>, + 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, @@ -247,8 +290,15 @@ where /// Fire-and-forget send (returns error if actor dead) pub fn send(&self, msg: M) -> Result<(), ActorError>; - /// Send and wait for typed response - pub async fn request(&self, msg: M) -> Result; + /// 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; @@ -309,12 +359,16 @@ impl Handler for Bank { // main.rs let bank: ActorRef = Bank::new().start(); -// Type-safe request (wait for reply) -let balance: Result = bank.request(GetBalance { account: "alice".into() }).await?; +// 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(); @@ -347,7 +401,7 @@ pub struct OrderService { impl Handler for OrderService { async fn handle(&mut self, msg: PlaceOrder, ctx: &Context) -> Result<(), OrderError> { let reply_to: Recipient = ctx.recipient(); - self.inventory.request(InventoryReserve { + self.inventory.send_request(InventoryReserve { item: msg.item, quantity: msg.quantity, reply_to, From 1ef33bf0c463543dca379463c554ccc5914c86ff Mon Sep 17 00:00:00 2001 From: Esteban Dimitroff Hodi Date: Fri, 6 Feb 2026 15:29:01 -0300 Subject: [PATCH 4/4] docs: address PR review comments on Supervisable and Handler traits --- docs/API_REDESIGN.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/docs/API_REDESIGN.md b/docs/API_REDESIGN.md index b6c8523..86f1842 100644 --- a/docs/API_REDESIGN.md +++ b/docs/API_REDESIGN.md @@ -187,14 +187,17 @@ pub enum ExitReason { **New file:** `concurrency/src/traits.rs` ```rust -/// Trait for actors that can be supervised +/// 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 +/// 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); @@ -240,7 +243,10 @@ pub trait Message: Send + 'static { type Result: Send; } -/// Handler for a specific message type +/// 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; }