I've thought about this a bit more, and I no longer think this is a good idea. The distributed tag indicates what the surface area of the distributed actor that cannot be local, and I think it's key to Resolving DistributedActor bound protocols.
I've also been thinking about source generation or, rather, how to remove it.
ActorTransport-based remote calls
We can extend the ActorTransport protocol with the ability to perform a remote call, such that each distributed function can be compiled into a remote call through the transport, eliminating the need for code generation on the sender side. For the receiver side, each distributed function will be recorded in compiled metadata that can be queried to match a remote call to its type information and the actual function to call.
ActorTransport can be extended with a remote call with a signature like the following:
protocol ActorTransport {
// ... existing requirements ...
/// Perform a remote call to the function.
///
/// - parameters:
/// - function: the identifier of the function, assigned by the implementation and used as a lookup key for the
/// runtime lookup of distributed functions.
/// - actor: identity for the actor that we're calling.
/// - arguments: the set of arguments provided to the call.
/// - resultType: the type of the result
///
/// - returns: the result of the remote call
func remoteCall<Result: Codable>(to function: String, on actor: DistributedActor, arguments: [Codable], returning resultType: Result.Type) async throws -> Result
}
When a distributed function is compiled, the compiler creates a distributed "thunk" that will either call the actual function (if the actor is local) or form a remote call invocation (if the actor is remote). For example, given:
distributed actor Greeter {
distributed func greet(name: String) -> String { ... }
}
the compiler will produce a distributed thunk that looks like this:
extension Greeter {
nonisolated func greet_$thunk(name: String) async throws -> String {
if isRemoteActor(self) {
// Remote actor, perform a remote call
return try await actorTransport.remoteCall(to: "unique string for 'greet'", on: self, arguments: [name], returning: String.self)
} else {
// Local actor, just perform the call locally
return await greet(name: name)
}
}
}
The transport will have to implement remoteCall(to:on:arguments:returning:), and is responsible for forming the message envelope containing the function identifier and arguments and sending it to the remote host, then suspending until a reply is received or some failure has been encountered.
Responding to remote calls
When a message envelope for a remote call is received, it will need to be matched to a local operation that can be called. The function identifier is the key to accessing this information, which will be provided by the distributed actor runtime through the following API:
struct DistributedFunction {
/// The type of the distributed actor on which this function can be invoked.
let actorType: DistributedActor.Type
/// The types of the parameters to the function.
let parameterTypes: [Codable.Type]
/// The result type of the function.
let resultType: Codable.Type
/// The error type of the function, which will currently be either `Error` or `Never`.
let errorType: Error.Type
/// The actual (local) function that can be invoked given the distributed actor (whose type is `actorType`), arguments
/// (whose types correspond to those in `parameterTypes`) and producing either a result (of type `resultType`) or throwing
/// an error (of type `errorType`).
let function: (DistributedActor, [Codable]) async throws -> Codable
/// Try to resolve the given function name into a distributed function, or produce `nil` if the name cannot be resolved.
init?(resolving function: String)
}
The function property of DistributedFunction is most interesting, because it is a type-erased value that allows calling the local function without any specific knowledge of the types that the function types. The other properties (actorType, parameterTypes, etc.) describe the types that function expects to work with. Together, they provide enough information to decode a message.
The initializer will perform a lookup into runtime metadata that maps from the string to these operations. The compiler will be responsible for emitting suitable metadata, which includes a type-erased "thunk" to call the local function. For the greet operation in the prior section, that would look something like this:
func Greeter_greet_$erased(actor: DistributedActor, arguments: [Codable]) async throws -> Codable
let concreteActor = actor as! Greeter // known to be local
let concreteName = arguments[0] as! String
return await concreteActor.greet(name: concreteName)
}
The design here is not unlike what we do with protocol conformances: the compiler emits information about the conformances declared within your module into a special section in the binary, and the runtime looks into that section when it needs to determine whether a given type conforms to a protocol dynamically (e.g., to evaluate value as? SomeProtocol). The approach allows, for example, a module different from the one that defined a distributed actor to provide a distributed function on that distributed actor, and we'll still be able to find it.
Doug