Repository: slawlor/ractor
Pull request #306 (2024-12-23 07:45:28)

FromActorRef<TFrom> which allows to send messages convertible to original TMessage
Adding `FromActorRef<TFrom>` - a wrapper over `ActorRef<TMessage>` that allows to send messages of type `TFrom` to the actor if `TFrom: Into<TMessage>`. This allows to create some isolation in the actor system as discussed in #305.

anabyv042: Created a draft PR to validate the initial approach. I will expand it if the approach is correct.

I have some concerns/questions about the current implementation:
1. Naming - I couldn't come up with a better name myself so would be grateful for any suggestions :)
2. `TFrom: Clone` - I needed to enforce the clonability of  the message so that I wouldn't break the `MessagingErr`. Would there be a better way to do this?
3. What API do we want to expose on the wrapper? should it be a subset of `ActorRef` methods or it should be identical?
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/slawlor/ractor/pull/306?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Sean+Lawlor) Report
Attention: Patch coverage is `83.56164%` with `12 lines` in your changes missing coverage. Please review.
> Project coverage is 82.18%. Comparing base [(`3c94f0f`)](https://app.codecov.io/gh/slawlor/ractor/commit/3c94f0f2e26179575a96df28d71b019c532ba0bd?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Sean+Lawlor) to head [(`8479a89`)](https://app.codecov.io/gh/slawlor/ractor/commit/8479a89e1cecff99d123dc8019c4d54a0558ae04?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Sean+Lawlor).

| [Files with missing lines](https://app.codecov.io/gh/slawlor/ractor/pull/306?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Sean+Lawlor) | Patch % | Lines |
|---|---|---|
| [ractor/src/actor/from\_actor\_ref.rs](https://app.codecov.io/gh/slawlor/ractor/pull/306?src=pr&el=tree&filepath=ractor%2Fsrc%2Factor%2Ffrom_actor_ref.rs&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Sean+Lawlor#diff-cmFjdG9yL3NyYy9hY3Rvci9mcm9tX2FjdG9yX3JlZi5ycw==) | 54.16% | [11 Missing :warning: ](https://app.codecov.io/gh/slawlor/ractor/pull/306?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Sean+Lawlor) |
| [ractor/src/actor/tests/mod.rs](https://app.codecov.io/gh/slawlor/ractor/pull/306?src=pr&el=tree&filepath=ractor%2Fsrc%2Factor%2Ftests%2Fmod.rs&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Sean+Lawlor#diff-cmFjdG9yL3NyYy9hY3Rvci90ZXN0cy9tb2QucnM=) | 97.95% | [1 Missing :warning: ](https://app.codecov.io/gh/slawlor/ractor/pull/306?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Sean+Lawlor) |

<details><summary>Additional details and impacted files</summary>


```diff
@@           Coverage Diff           @@
##             main     #306   +/-   ##
=======================================
  Coverage   82.17%   82.18%           
=======================================
  Files          60       61    +1     
  Lines       11032    11105   +73     
=======================================
+ Hits         9066     9127   +61     
- Misses       1966     1978   +12     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/slawlor/ractor/pull/306?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Sean+Lawlor).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Sean+Lawlor).

anabyv042: > This is a great start! I think however we can improve upon the API, and potentially drop the boxed converter, just relying on type constraints for functionality implementation. Let me know if my comments make sense!

Thanks for the review! Regarding the boxed converter, this was added to get rid of `TMessage` on `FromActorRef` definition. As I understand, the suggestion is to parametrize `FromActorRef` by both `TFrom` and `TMessage` and store `ActorRef` internally:
``` rust
pub struct FromActorRef<TFrom, TMessage> {
    inner: ActorRef<TMessage>,
    _phantom: PhantomData<TFrom>,
}
```

then the send message implementation will look like this:
``` rust
impl<TFrom: TryFrom<TMessage>, TMessage: From<TFrom> + Message> FromActorRef<TFrom, TMessage>
where
    <TFrom as TryFrom<TMessage>>::Error: std::fmt::Debug,
{
    /// Casts the message to the target message type of [ActorCell] and sends it
    ///
    /// * `message` - The message to send
    ///
    /// Returns [Ok(())] on successful message send, [Err(MessagingErr)] otherwise
    pub fn send_message(&self, message: TFrom) -> Result<(), MessagingErr<TFrom>> {
        match self.inner.send_message(message.into()) {
            Ok(_) => Ok(()),
            Err(MessagingErr::SendErr(msg)) => Err(MessagingErr::SendErr(
                msg.try_into()
                    .expect("Failed to convert message back to base type"),
            )),
            Err(MessagingErr::ChannelClosed) => Err(MessagingErr::ChannelClosed),
            Err(MessagingErr::InvalidActorType) => Err(MessagingErr::InvalidActorType),
        }
    }
}
``` 

however this will force us to have the original message as part of the `FromActorRef` type which gets in the way of hiding `TMessage`:
``` rust
let actor: ActorRef<AB> = init(); 
let from_a: FromActorRef<A, AB> = actor.clone().from_ref();
```

what do you think? Boxed converter is the only thing I found to hide `TMessage` from `FromActorRef` but let me know if there's a better way :) thanks!

slawlor: @anabyv042 

> however this will force us to have the original message as part of the FromActorRef type which gets in the way of hiding TMessage:

Ahh ok I see your goal. Let me try something, I think I have a workaround.
slawlor: OK I couldn't find a way around the Box, you're right. We need to propagate the original message type, without enforcing the type constraint on the original struct. I did however apply most of the other PR comments. Let me know what you think!

I think this is clean enough at this stage, but we might just need a little more cleanup in spots and maybe an example usage in the doc comments would be good to also be covered by doctests.
anabyv042: sounds good, thanks! added some doctests + added a `SendError` scenario to the ut to test reverse conversion
slawlor: Can you make sure the signals are green? I'm guessing the doc tests are failing 
anabyv042: Fixed the formatting + added import which was failing the test. Hopefully it will pass this time

Patch:
diff --git a/ractor/src/actor/derived_actor.rs b/ractor/src/actor/derived_actor.rs
new file mode 100644
index 0000000..058a64d
--- /dev/null
+++ b/ractor/src/actor/derived_actor.rs
@@ -0,0 +1,216 @@
+// Copyright (c) Sean Lawlor
+//
+// This source code is licensed under both the MIT license found in the
+// LICENSE-MIT file in the root directory of this source tree.
+
+//! [DerivedActorRef] wraps an [ActorCell] to send messages that can be converted
+//! to its accepted type using [From]. It represents a subset of the messages supported
+//! by the original actor.
+
+use crate::{ActorCell, ActorRef, Message, MessagingErr};
+use std::sync::Arc;
+
+/// [DerivedActorRef] wraps an [ActorCell] to send messages that can be converted
+/// into its accepted type using [From]. [DerivedActorRef] allows to create isolation
+/// between actors by hiding the actual message type.
+///
+/// ## Example
+/// ```
+/// // In this example the actor is a ghost kitchen which can accept orders of different types,
+/// // representing multiple virtual restaurants at once. More specifically, it can accept
+/// // pizza or sushi orders.
+/// //
+/// // Derived actor allows to hide the order message accepted by the kitchen actor, therefore
+/// // we can pass the ref to other components without creating a direct dependency on the
+/// // kitchen actor. We can easily replace or split the kitchen actor without affecting
+/// // other components communicating with it.
+/// use ractor::{Actor, ActorProcessingErr, ActorRef, DerivedActorRef, Message};
+///
+/// // First we define order types
+/// struct PizzaOrder {
+///     topping: String,
+/// }
+///
+/// struct SushiOrder {
+///     r#type: String,
+///     quantity: usize,
+/// }
+///
+/// // The order message which is actually sent to the kitchen actor can be either pizza or sushi
+/// enum Order {
+///     Pizza(PizzaOrder),
+///     Sushi(SushiOrder),
+/// }
+///
+/// // Implementing conversion methods from different order types to the actual order
+/// impl From<PizzaOrder> for Order {
+///     fn from(value: PizzaOrder) -> Self {
+///         Order::Pizza(value)
+///     }
+/// }
+///
+/// impl TryFrom<Order> for PizzaOrder {
+///     type Error = String;
+///
+///     fn try_from(value: Order) -> Result<Self, Self::Error> {
+///         match value {
+///             Order::Pizza(order) => Ok(order),
+///             _ => Err("Order has invalid type".to_string()),
+///         }
+///     }
+/// }
+///
+/// impl From<SushiOrder> for Order {
+///     fn from(value: SushiOrder) -> Self {
+///         Order::Sushi(value)
+///     }
+/// }
+///
+/// impl TryFrom<Order> for SushiOrder {
+///     type Error = String;
+///
+///     fn try_from(value: Order) -> Result<Self, Self::Error> {
+///         match value {
+///             Order::Sushi(order) => Ok(order),
+///             _ => Err("Order has invalid type".to_string()),
+///         }
+///     }
+/// }
+///
+/// #[cfg(feature = "cluster")]
+/// impl Message for Order {
+///     fn serializable() -> bool {
+///         false
+///     }
+/// }
+///
+/// struct Kitchen;
+///
+/// #[cfg_attr(feature = "async-trait", ractor::async_trait)]
+/// impl Actor for Kitchen {
+///     type Msg = Order;
+///     type State = ();
+///     type Arguments = ();
+///
+///     async fn pre_start(
+///         &self,
+///         _myself: ActorRef<Self::Msg>,
+///         _: (),
+///     ) -> Result<Self::State, ActorProcessingErr> {
+///         Ok(())
+///     }
+///
+///     async fn handle(
+///         &self,
+///         _myself: ActorRef<Self::Msg>,
+///         message: Self::Msg,
+///         _state: &mut Self::State,
+///     ) -> Result<(), ActorProcessingErr> {
+///         match message {
+///             Order::Pizza(order) => {
+///                 println!("Preparing pizza with topping {}", order.topping);
+///             }
+///             Order::Sushi(order) => {
+///                 println!(
+///                     "Preparing {} sushi of type {}",
+///                     order.quantity, order.r#type
+///                 );
+///             }
+///         }
+///         Ok(())
+///     }
+/// }
+///
+/// async fn example() {
+///     let (kitchen_actor_ref, kitchen_actor_handle) = Actor::spawn(None, Kitchen, ()).await.unwrap();
+///     
+///     // derived actor ref can be passed to the pizza restaurant actor which accepts pizza orders from delivery apps
+///     let pizza_restaurant: DerivedActorRef<PizzaOrder> = kitchen_actor_ref.get_derived();
+///     pizza_restaurant.send_message(PizzaOrder {
+///         topping: String::from("pepperoni"),
+///     }).expect("Failed to order pizza");
+///
+///     // same way, we can also get a derived actor ref which can only accept sushi orders
+///     let sushi_restaurant: DerivedActorRef<SushiOrder> = kitchen_actor_ref.get_derived();
+///     sushi_restaurant.send_message(SushiOrder {
+///         r#type: String::from("sashimi"),
+///         quantity: 3,
+///     }).expect("Failed to order sushi");
+///
+///     kitchen_actor_handle.await.unwrap();
+/// }
+/// ```
+#[derive(Clone)]
+pub struct DerivedActorRef<TFrom> {
+    converter: Arc<dyn Fn(TFrom) -> Result<(), MessagingErr<TFrom>>>,
+    inner: ActorCell,
+}
+
+impl<TFrom> std::fmt::Debug for DerivedActorRef<TFrom> {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        f.debug_struct("DerivedActorRef")
+            .field("cell", &self.inner)
+            .finish()
+    }
+}
+
+// Allows all the functionality of ActorCell on DerivedActorRef
+impl<TMessage> std::ops::Deref for DerivedActorRef<TMessage> {
+    type Target = ActorCell;
+
+    fn deref(&self) -> &Self::Target {
+        &self.inner
+    }
+}
+
+impl<TFrom> DerivedActorRef<TFrom> {
+    /// Casts the message to the target message type of [ActorCell] and sends it
+    ///
+    /// * `message` - The message to send
+    ///
+    /// Returns [Ok(())] on successful message send, [Err(MessagingErr)] otherwise
+    pub fn send_message(&self, message: TFrom) -> Result<(), MessagingErr<TFrom>> {
+        (self.converter)(message)
+    }
+
+    /// Retrieve a cloned [ActorCell] representing this [DerivedActorRef]
+    pub fn get_cell(&self) -> ActorCell {
+        self.inner.clone()
+    }
+}
+
+impl<TMessage: Message> ActorRef<TMessage> {
+    /// Constructs the [DerivedActorRef] for a specific type from [ActorRef]. This allows an
+    /// actor which handles either a subset of the full actor's messages or a convertable type,
+    /// allowing hiding of the original message type through implementation of [From] conversion.
+    ///
+    /// Returns a [DerivedActorRef] where the message type is convertable to the [ActorRef]'s
+    /// original message type via [From]. In order to facilitate [MessagingErr::SendErr] the original
+    /// message type also needs to be reverse convertable via [TryFrom] to the `TFrom` type.
+    ///
+    /// This method will panic if a send error occurs, and the returned message cannot be converted back
+    /// to the `TFrom` type. This should never happen, unless conversions are created incorrectly.
+    pub fn get_derived<TFrom>(&self) -> DerivedActorRef<TFrom>
+    where
+        TMessage: From<TFrom>,
+        TFrom: TryFrom<TMessage>,
+    {
+        let actor_ref = self.clone();
+        let cast_and_send = move |msg: TFrom| {
+            actor_ref.send_message(msg.into()).map_err(|err| match err {
+                MessagingErr::SendErr(returned) => {
+                    let Ok(err) = TFrom::try_from(returned) else {
+                        panic!("Failed to deconvert message to from type");
+                    };
+                    MessagingErr::SendErr(err)
+                }
+                MessagingErr::ChannelClosed => MessagingErr::ChannelClosed,
+                MessagingErr::InvalidActorType => MessagingErr::InvalidActorType,
+            })
+        };
+        DerivedActorRef::<TFrom> {
+            converter: Arc::new(cast_and_send),
+            inner: self.get_cell(),
+        }
+    }
+}
diff --git a/ractor/src/actor/mod.rs b/ractor/src/actor/mod.rs
index 843eece..51cdea3 100644
--- a/ractor/src/actor/mod.rs
+++ b/ractor/src/actor/mod.rs
@@ -69,6 +69,7 @@ pub mod actor_cell;
 pub mod actor_id;
 pub(crate) mod actor_properties;
 pub mod actor_ref;
+pub mod derived_actor;
 mod supervision;
 
 #[cfg(test)]
diff --git a/ractor/src/lib.rs b/ractor/src/lib.rs
index a814a31..164fa44 100644
--- a/ractor/src/lib.rs
+++ b/ractor/src/lib.rs
@@ -199,6 +199,7 @@ use tracing_subscriber as _;
 pub use actor::actor_cell::{ActorCell, ActorStatus, ACTIVE_STATES};
 pub use actor::actor_id::ActorId;
 pub use actor::actor_ref::ActorRef;
+pub use actor::derived_actor::DerivedActorRef;
 pub use actor::messages::{Signal, SupervisionEvent};
 pub use actor::{Actor, ActorRuntime};
 #[cfg(feature = "async-trait")]


Test patch:
diff --git a/ractor/src/actor/tests/mod.rs b/ractor/src/actor/tests/mod.rs
index f97e094..53afa6d 100644
--- a/ractor/src/actor/tests/mod.rs
+++ b/ractor/src/actor/tests/mod.rs
@@ -11,6 +11,7 @@ use std::sync::{
 };
 
 use crate::{
+    actor::derived_actor::DerivedActorRef,
     common_test::periodic_check,
     concurrency::{sleep, Duration},
     MessagingErr, RactorErr,
@@ -1197,3 +1198,88 @@ async fn wait_for_death() {
     actor.stop(None);
     handle.await.unwrap();
 }
+
+#[crate::concurrency::test]
+#[tracing_test::traced_test]
+async fn derived_actor_ref() {
+    let result_counter = Arc::new(AtomicU32::new(0));
+
+    struct TestActor {
+        counter: Arc<AtomicU32>,
+    }
+
+    #[cfg_attr(feature = "async-trait", crate::async_trait)]
+    impl Actor for TestActor {
+        type Msg = u32;
+        type Arguments = ();
+        type State = ();
+
+        async fn pre_start(
+            &self,
+            _this_actor: crate::ActorRef<Self::Msg>,
+            _: (),
+        ) -> Result<Self::State, ActorProcessingErr> {
+            Ok(())
+        }
+
+        async fn handle(
+            &self,
+            _myself: ActorRef<Self::Msg>,
+            message: Self::Msg,
+            _state: &mut Self::State,
+        ) -> Result<(), ActorProcessingErr> {
+            self.counter.fetch_add(message, Ordering::Relaxed);
+            Ok(())
+        }
+    }
+
+    let (actor, handle) = Actor::spawn(
+        None,
+        TestActor {
+            counter: result_counter.clone(),
+        },
+        (),
+    )
+    .await
+    .expect("Actor failed to start");
+
+    let mut sum: u32 = 0;
+
+    let from_u8: DerivedActorRef<u8> = actor.clone().get_derived();
+    let u8_message: u8 = 1;
+    sum += u8_message as u32;
+    from_u8
+        .send_message(u8_message)
+        .expect("Failed to send message to actor");
+
+    periodic_check(
+        || result_counter.load(Ordering::Relaxed) == sum,
+        Duration::from_millis(500),
+    )
+    .await;
+
+    let from_u16: DerivedActorRef<u16> = actor.get_derived();
+    let u16_message: u16 = 2;
+    sum += u16_message as u32;
+    from_u16
+        .send_message(u16_message)
+        .expect("Failed to send message to actor");
+
+    actor
+        .drain_and_wait(None)
+        .await
+        .expect("Failed to drain actor");
+    handle.await.unwrap();
+
+    assert_eq!(result_counter.load(Ordering::Relaxed), sum);
+
+    // trying to send the message to a dead actor to verify reverse conversion in SendError handling
+    let message: u16 = 3;
+    let res = from_u16.send_message(message);
+    assert!(res.is_err());
+    if let Err(MessagingErr::SendErr(failed_message)) = res {
+        assert_eq!(failed_message, message);
+    } else {
+        panic!("Invalid error type");
+    }
+}