[Pitch] Distributed Actors

This is a suggestion as well. We can make that a “should”. Nothing breaks or doesn’t work if this would be the case and a transport violated this recommendation.

“Underlining” here means things like network errors - if an actor transport encounters network issues and fails a call because of that, it’s good for users to be able to know that was such kind of error vs. an error thrown by the alive and actually replying remote actor.

1 Like

I agree completely, but I think that knowledge is impossible without typed throws. That’s more or less the entire point of typed throws.

I have encountered this precise issue repeatedly in my own code, and I’ve had to settle for throwing methods that return a Result. It’s all rather tedious, and I look forward to a day where I can use a distributed actor that employs associated types to throw specific errors for a given combination of transport and method.

I have two requests to make, if its possible of course:

  1. If possible reuse some well know binary encoding like protobuf, even if not using proto's IDL
    and going all the way with Swift for IDL (which is a good idea anyway), having a well-known
    widespread binary payload is pretty good for interfacing with other systems even outside of the Swift world like gRPC.

  2. If 1 is not the path taken, please make a documentation of the custom binary protocol that is going on the wire and the interface that is being used to generate the binary protocol available for us to call and generate the binary payload "by hand" if needed, given with this we will be able to interface and reuse it, playing well with other technologies that might want to interface with it.

In short, the payload that is going on the wire should be well-known or well documented if its a custom protocol, so that interfaces with other tech and with other languages are possible.

2 Likes

There is no protocol, it’s just Codable and a transport. I’m pretty sure you could have a transport act as an adapter between the two quite easily.

I have a question about a common use case: caching. Should that be a transport-level concern or an actor-level concern?

For instance, would it be appropriate to use a distributed actor with a REST client, and cache state locally if it is known to not change? Could you have both local and remote implementations, and use the local implementation as a cache for remote implementations?

Yet for this to work the team behind this will have to choose a 'flavor' of Codable interface to implement the default encoding idiom to be used as the wire encoding.

Anything that needs to be transported to another machine or that needs to get persisted and later deserialized, as the current proposal implies, needs to care about things like endianess or packing integers.

That's why i'm making a sugestion of an encoding that would automatically play well with others, and if that's not the path taken, lets say the current inner encoding used to encode swift objects (which is Swift only) are chosen instead (which is what you are implying here), that this have a well documented format (this might already be true, idk), so it can be recovered in other runtime's.

Go for instance have a custom go encoding which is well documented called 'gob' gob package - encoding/gob - Go Packages which in case the custom encoding route is taken might server as an example of something which is custom to Go but yet have enough information so other solutions outside of Go platform are able to create codecs for it.

I think you are misunderstanding the intent of this pitch. There is no attempt to make a universal serialization system beyond the paradigm of Codable, and transports are not expected to be compatible with every actor.

What you are describing, a specific wire encoding and decisions about serialization and such, is up to a specific transport. Consider counterparts in other languages (Go’s gob, Python’s pickle, etc.) as specific transports. There are no transports in this proposal, so there are no counterparts. This is very much in keeping with the versatile design of Swift: there aren’t any Codable implementations in the standard library either.

1 Like

I think the hard requirement for Codable must be dropped. The allowed types clearly are depending on the transport. I can imagine a default transport that requires Codable, but other transports may have completely different requirements regarding possible types.

This leads to a problem - how is the user going to find out that their actor cannot work with their chosen transport? Looks like this will only manifest at runtime with a fatalError. The code generator could produce an error if it encounters types it can’t support, but how does it know which actor should use its transport? Or is it unsupported to use multiple transports in the same program?

Maybe the whole thing already is too complex. Why do we need customizable transports if interoperability with other languages/environments is not a goal? If it is only about Swift-to-Swift communication we could skip all this and let the compiler generate all messages based on Codable. The user still would have to provide a transport, but this could be limited to managing identities and sending messages (which are just raw byte buffers) to a given identity.

5 Likes

Thanks for the pitch document! It explains a lot of questions I had regarding the new DistributedActor popping up in main Swift tree :slight_smile:

For one use case, I am interested in DistributedActor not from cluster perspective, but more from cross-process management perspective such that I can run actors in isolated processes with more restricted privileges and having a "supervisor" to babysitting these actors (restart if needed) or having explicit "message broker" actor in between.

For that use case, it seems that deep customization for transport (no serialization even, just shared memory, or the "serialization" is just copying bytes because there is no versioning or other compatibility concern) is needed, and would be interesting to know if that's possible beyond Codable.

On the other extreme, and this arguably is longer term need for me, is to potentially use DistributedActor as the communication mechanism from client (iOS) to server. On that extreme, some visibility into the pure-Swift side, i.e., having proxies in between (Envoy?) to handle load-balancing and authentication would require some understanding to what's going on in the wire. Would that also be handled at ActorTransport?

6 Likes

Looks great in general, thanks for working on it. I know a couple of people have mentioned this already but I have two specific issues with the properties section:

func example1(p: Player) async throws -> (String, Int) {
  try await (p.name, p.score) // ❌ might make two slow round-trips to `p`
}

Firstly, this example is no better if it is p.getName() and p.getScore() and I don't really think making it slightly inconvenient is necessarily going to make people think carefully about this on both sides (callee and caller).

Secondly, and more importantly, it implies that the compiler is not going to optimise even simple examples like this in order to reduce the number of round trips. There is a comment about batching access there, but it seems to be talking about the callee and caller doing this manually (e.g. by writing a getNameAndScore function). If I wrote the code in that example then I would be very surprised to find it was doing two round trips rather than submitting a single batched request to the distributed actor that would get the name and the score and return both simultaneously.

If this kind of optimisation isn't accounted for in the proposal (and from the section about Actor Transports and their outbound/inbound messaging I'm not sure it has been) then I would suggest that it is reconsidered, because aggressively reducing round trips is likely to be the major performance factor. It is also another great argument for doing this in the compiler rather than in a library.

5 Likes

I don’t see how the compiler could possibly optimize round trips.

1 Like

You're going to have to be a lot more explicit here. Is there something that stops the compiler from doing the same kind of optimisations it already does locally in order to group together calls to the same distributed actor (or, more ambitiously, to the same remote process or server), send it a single message with the batch of calls, and it then replies with a single message containing multiple results?

Yes: batching is a feature that will not always be available. It’s an implementation detail, and is therefore implementation-specific.

Such optimization would have to be controlled by the developer. The question is whether it would be the role of the actor or the transport.

If a specific transport can't batch messages for whatever reason then why can't it internally just send the messages one-by-one?

Concurrency isn’t batching. The remote implementation would still need to handle separate requests, incurring all the overhead and energy usage that entails.

I’m no expert on the proposal, though I’ve messed with the implementation in main a bit. I’d appreciate a recommended pattern for caching and batching, language feature or not, as that is obviously critical for any sane usage. One thing I can be confident about is that there’s no automatic way to do it for any arbitrary application.

I don't know who said concurrency was batching or what that means here. The remote implementation would simply be making separate calls into the (now local) actor as normal. As I said, reducing round trips is likely to be the major performance factor here.

What local actor? I feel like we’re talking at cross-purposes here.

When I say batching, I mean making one request rather than multiple. When I say round trip, I mean a single request, even if they are performed concurrently.

The compiler should absolutely stay out of any attempts at magically changing what messages shall be sent.

One distributed call should be one message. How a transport handles it on the wire is its problem, it may batch up messages or not. The compiler does not have enough knowledge to do the “right thing” here.

This is exactly why the, while “arbitrary”, useful push towards only functions. Distribution requires explicit thinking about the communication protocols you’re designing. There’s things like message sizes, order and even message loss that one has to consider.

We may lift this restriction, to Doug’s point, however there will and not cannot be any compiler magic to suddenly make one message out of two accesses. If anything, transports can try to batch writes, but this is the same as pipelining requests in any other network client library, nothing more.

8 Likes

My problem with the emphasis on functions is that you should be treating properties with that amount of care anyway. In fact, you should treat all suspension points carefully. Swift already mandates that sort of thinking by requiring try and async/await.

The compiler wouldn't “magically” be changing which messages were being sent, and wouldn't need to try to “do the right thing”. However, couldn't it theoretically present multiple messages simultaneously to the transport and have the transport itself decide whether to make multiple round trips or not? It would still be entirely up to the transport how to handle it on the wire. Without such a facility, I'm not sure there's any way that you could ever avoid making two round trips here, because you're awaiting the result of the first call before the transport even knows about the second.

e.g. If all outgoing messages are represented by a single type (e.g. the Message enum you mention in distributed func internals) then you could have separate functions produce a Message instance and then a send function that takes [Message]. The transport is then free to either send a request for each Message or batch them up for transport to avoid multiple round trips.

Edit: I suppose things get complicated in that you need to break the responses back out in some way. Anyway, I'm not convinced it's worthwhile but also not convinced the other way.

2 Likes