slawlor / ractor

Rust actor framework
MIT License
1.52k stars 73 forks source link

Uses Handler trait instead of Actor trait to define possible messages #54

Closed hardliner66 closed 1 year ago

hardliner66 commented 1 year ago

One of the things I like about actix, is the possibility to define handlers for multiple messages per actor, which is something that erlang, caf and akka (iirc) allow as well. I know that this can be emulated with enums, but having it work without would be a good quality of life improvement.

Actix Example:

use actix::prelude::*;

// this is our Message
// we have to define the response type (rtype)
#[derive(Message)]
#[rtype(result = "usize")]
struct Sum(usize, usize);

// Actor definition
struct Calculator;

impl Actor for Calculator {
    type Context = Context<Self>;
}

// now we need to implement `Handler` on `Calculator` for the `Sum` message.
impl Handler<Sum> for Calculator {
    type Result = usize; // <- Message response type

    fn handle(&mut self, msg: Sum, ctx: &mut Context<Self>) -> Self::Result {
        msg.0 + msg.1
    }
}

#[actix::main] // <- starts the system and block until future resolves
async fn main() {
    let addr = Calculator.start();
    let res = addr.send(Sum(10, 5)).await; // <- send message and get future for result

    match res {
        Ok(result) => println!("SUM: {}", result),
        _ => println!("Communication to the actor has failed"),
    }
}
slawlor commented 1 year ago

So I'll have to look more into how actix handles this under the hood, but do they support multiple Handler definitions for different message types?

Also does a Handler have to have a reply? Generally that's not true of an actor pattern, since there are both 1-way sends which should be non-blocking and 2-way sends with replies, which seems to be the only thing actix has implemented?

I guess you could just not await the future, but that seems wrong than explicitly sending a 1-way message.... And if you await the reply always, you can (1) get deadlocks without realizing pretty easily (A triggers B, and waits on B's reply which requires pinging A. If A waits on the initial send to B, deadlock) and (2) If the actor has a long message queue, that await might take a long time to return when you don't need to process a result.

hardliner66 commented 1 year ago

Yeah, they do. You just add a handler per message type.

I have a few ideas for "no reply". The simplest would be to make the response a (). But I'm not sure if that can be optimized away properly. Another would be to return an Option<T>, but this would be unnecessary overhead when you always or never send a response.

Maybe two handlers, one with response, one without?

I think it might even be better to make handlers return nothing. If you need a response, you could then send a channel or something similar, which the handler uses to send something back. Then you could probably build convenience wrappers that work with return types, but use the primitives that already exist.

Maybe there is some inspiration to be had from CAF.

slawlor commented 1 year ago

Yeah, they do. You just add a handler per message type.

Hmm this might be tricky for the cluster scenario. You'd need to know each message type and still be able to multiplex around them in order to send the right message over the link. Even from the actix team they seem to hit a wall on this and give up (before giving up in-general on actix actors) https://github.com/actix/actix-remote

It looks like CAF has network communication support, but I'll need to read more to see what the implications might be doing this.

slawlor commented 1 year ago

Ah so I played around a tiny bit and it's more difficult than I originally thought for more basic reasons.

You have the basic Actor trait which has a message type today (in ractor). Moving to Handler implementations means that you have an actor that may have 0..inf message types to deal with. Unfortunately in the case of 0, there's not going to be any handle(..) function to call, and the actor supports 0 message types. It looks like that's OK in actix today... which is very strange. This unfortunately be a massive rewrite in our realization to make the message loop flexible enough to somehow support an actor with 0 handler implementations.

Either that, or the base actor trait would need to know all the message types it supports, which imo defeats the purpose of having the Handler trait in this case. It looks like the way they achieved it was by having the message itself know the type and which handler to call which is probably why every send operation is a future I'm guessing. https://github.com/actix/actix/blob/5d447fcd0a1ded1be1e189d57c6f1950d9d2ef6b/actix/src/contextitems.rs#L114

I'll keep this idea in mind, but I'm not sure (right now) if it'll fit into how we structured things. Thanks for the suggestion though!

hardliner66 commented 1 year ago

Depending on how strongly typed everything is, it might be possible to send a message to an actor that doesn't support it. In this case, the actor would probably answer with something along the lines of MessageTypeNotSupported. Having 0 handlers is the same. You try to send a message to an actor that doesn't support it, so it should also respond with that error.

I'm not too into the details of ractor, so I can't speak on whats possible now and what should/will be possible in the future, so this might never come up anyway, but being able to send untyped messages to actors might still be something to consider for building generic fan-out actors or similar.

I'm currently a bit sick, but I might have a look at the internals of ractor to see if it could be incorporated somehow.

hardliner66 commented 1 year ago

CAF has network support, because every message must be serializable. So if you cross that border, it gets serialized and deserialized on the other side. But because it's C++, you can probably do some crazy tricks to see which messages have appropriate handlers.

slawlor commented 1 year ago

CAF has network support, because every message must be serializable. So if you cross that border, it gets serialized and deserialized on the other side. But because it's C++, you can probably do some crazy tricks to see which messages have appropriate handlers.

Yeah we do a similar thing, but you can opt-in to serializability in ractor when using it in a distributed cluster. Not every actor needs to implement the serializable traits if you're not going to send messages over a link. Anyways I look forward to any suggestions or contributions you have :)

hardliner66 commented 1 year ago

I think having serializability as requirement would still be a big benefit, as you get location transparency for free. If every message is serializable, you don't need to think about which actor is on the same platform and which isn't. And you can move actors from local to remote without changing anything.

slawlor commented 1 year ago

The way it's designed here, you don't need to think about which actor is where. If a actor is remote or local is completely transparent to the actor. The point of making it opt-in, is serializability isn't a trivial definition in some Rust structs (like discriminated union/enums). Additionally it allows you to be specific about some things, like serializing a usize, that shouldn't be done at all due to the risk of varying architectures across a network of hosts.

However, once defined, if the actor is local or remote doesn't matter to all of the APIs and it's completely seemless. Actors which don't support remote-calling would just not be represented across the remote link in the remote node(). If you make all actors have serializable message structures, they'll all appear on the remote host as available.