SquireCore
SquireCore copied to clipboard
[Dev]: Actor Generalization
Unmet Need:
While functional and generally applicable, our current actor model is mostly designed for actors that are:
- Meant to live forever
- Always able to receive and process inbound messages
- Use a channel as their primary inbound stream
As evidenced by the Gathering
actor, this is not always the case. As such, we should look into expanding the design space for actors.
Design Considerations:
I believe there are two primary design considerations to consider: "lifetime" and "role".
Here, "lifetime" is not a Rust lifetime but rather if the actor might ever terminate. An actor with a finite lifetime is one that we expect to terminate at some point. Conservely, an actor with an infinite lifetime should never terminate, save for an expected panic. We want to distinguish between these two because that would help improve the ergonomics and stability of all actors. When sending a message to an infinite actor, we want to unwrap the Result
that sending a message produces since an error means that the infinite actor is dead, so we ought to "bubble up" that error (in the same way that you bubble up errors from poisoned mutexes).
The idea of an actor's "role" is a bit more complicated, but it can be roughly modeled by the Stream
and Sink
traits. All actors that we've worked with up until now do some combination of consuming, responding to, and forwarding messages. Such actors act like sinks. The GatheringHall
, for example, does all of these. However, we have not seen an actor that only/primarily emits messages. These actors would act like streams.
The motivating example for a stream-like actor is WebSockets. The management of WS connections (in both the client and backend) is a bit messy. This is due to a lack of separation of concerns. For example, a Gathering
is responsible for:
- Processing inbound sync requests
- Forwarding successful syncs
- Managing the sync and forwarding message chains for all clients
- Managing persistence of the tournament
Additionally, it also needs to "know" about changes to a client's session and when to ignore a WS message because of a closed connection or invalid session. A better separation of concerns can be achieved by having an actor manage the WS connection. However, the client for this actor would transmit the messages originating from the actor, rather than sending a message to the actor. The key difference between these actor types has far more to do with the infrastructure around the actors than the actors themselves. After all, all actors share the same baseline functionality of processing messages from some inbound stream.
Possible Solutions:
To me, the most promising solutions for these two characteristics are very different.
To mark a trait as (in)finite, we can use an associate constant/type on the actor trait. This would allow us to distinguish between them in a mutually exclusive manner. This is important for knowing when to unwrap send errors in the client and when to allow the scheduler to offer the terminate
method.
To mark a trait as a sink, stream, or both, we can use extension traits, something like StreamActor
and SinkActor
. These traits would help the builder determine what kinds of clients and methods. They would also help govern the behavior of the Schedule as needed.
Challenges:
The largest issue we have to contend with is the clients. Because we want to ascribe semantic meaning to the lifetime of an actor and whether or not the actor is a sink and/or stream, the client needs to be aware of this as well. This means that we might need up to 6 different client types. This will require some forethought to avoid code duplication and too much cognitive load.
Thanks for the detailed description of the problems. Here are my initial thoughts and questions
-
Calling the persistence of the actors
lifetime
might get confusing in the long run. Should we consider an alternative? I proposetransient
andpermanent
actors andpermanence
of actors. -
For the API, could we allow the users to define what kind of an actor they want while building the actor (as a method on
ActorBuilder
)? The following seems like a good, intuitive syntax that might reduce cognitive load.
ActorBuilder::new().transient().launch();
ActorBuilder::new().persistent().launch();
// or we could get rid of `new` constructor in these cases
ActorBuilder::persistent().launch();
ActorBuilder::transient().launch();
ActorBuilder::new().launch(); // defaults to `persistent`
ActorBuilder::new().sink().launch();
ActorBuilder::new().stream().launch();
// as before, briefer constructors
ActorBuilder::stream().launch();
ActorBuilder::sink().launch();
// we could combine:
ActorBuilder::transient().sink().launch();
I haven't heard of extension traits before. I will check into them.
I like the phrasing of "transient" and "permanent" actors a lot. That's a great idea.
As for API, the actor state is the part that "knows" if the state will ever terminate or not. This will allow us to restrict the scheduler accordingly. But more importantly, you should not be able to create a client that thinks it's talking to a permanent actor when it's talking to a transient actor (or, somewhat less importantly, visa versa). This is certainly possible. My larger concern is code duplication between different client types.
As for extension traits, see the Itertools
trait in the itertools crate or the FutureEtx
, StreamEtx
, and SinkExt
traits in the futures crate.