diff --git a/docs/async-await-proposal.md b/docs/async-await-proposal.md new file mode 100644 index 000000000..55fb2abcf --- /dev/null +++ b/docs/async-await-proposal.md @@ -0,0 +1,442 @@ +# Proposal: Async/await support + +## Introduction + +With the introduction of [async/await][SE-0296] in Swift 5.5, it is +now possible to write asynchronous code without the need for callbacks. +Language support for [`AsyncSequence`][SE-0298] also allows for writing +functions that return values over time. + +We would like to explore how we could offer APIs that make use of these new +language features to allow users to implement and call gRPC services using +these new idioms. + +This proposal describes what these APIs could look like and explores some of +the potential usability concerns. + +## Motivation + +Consider the familiar example Echo service which exposes all four types of +call: unary, client-streaming, server-streaming, and bidirectional-streaming. +It is defined as follows: + +### Example Echo service + +```proto +service Echo { + // Immediately returns an echo of a request. + rpc Get(EchoRequest) returns (EchoResponse) {} + + // Splits a request into words and returns each word in a stream of messages. + rpc Expand(EchoRequest) returns (stream EchoResponse) {} + + // Collects a stream of messages and returns them concatenated when the caller closes. + rpc Collect(stream EchoRequest) returns (EchoResponse) {} + + // Streams back messages as they are received in an input stream. + rpc Update(stream EchoRequest) returns (stream EchoResponse) {} +} + +message EchoRequest { + // The text of a message to be echoed. + string text = 1; +} + +message EchoResponse { + // The text of an echo response. + string text = 1; +} +``` + +### Existing server API + +To implement the Echo server, the user must implement a type that conforms to +the following generated protocol: + +```swift +/// To build a server, implement a class that conforms to this protocol. +public protocol Echo_EchoProvider: CallHandlerProvider { + var interceptors: Echo_EchoServerInterceptorFactoryProtocol? { get } + + /// Immediately returns an echo of a request. + func get(request: Echo_EchoRequest, context: StatusOnlyCallContext) -> EventLoopFuture + + /// Splits a request into words and returns each word in a stream of messages. + func expand(request: Echo_EchoRequest, context: StreamingResponseCallContext) -> EventLoopFuture + + /// Collects a stream of messages and returns them concatenated when the caller closes. + func collect(context: UnaryResponseCallContext) -> EventLoopFuture<(StreamEvent) -> Void> + + /// Streams back messages as they are received in an input stream. + func update(context: StreamingResponseCallContext) -> EventLoopFuture<(StreamEvent) -> Void> +} +``` + +### Existing example server implementation + +Here is an example implementation of the bidirectional streaming handler for `update`: + +```swift +public func update( + context: StreamingResponseCallContext +) -> EventLoopFuture<(StreamEvent) -> Void> { + var count = 0 + return context.eventLoop.makeSucceededFuture({ event in + switch event { + case let .message(message): + let response = Echo_EchoResponse.with { + $0.text = "Swift echo update (\(count)): \(message.text)" + } + count += 1 + context.sendResponse(response, promise: nil) + + case .end: + context.statusPromise.succeed(.ok) + } + }) +} +``` + +This API exposes a number incidental types and patterns that the user need +concern themselves with that are not specific to their application: + +1. The fact that gRPC is implemented on top of NIO is not hidden from the user + and they need to implement an API in terms of an `EventLoopFuture` and access + an `EventLoop` from the call context. +2. There is a different context type passed to the user function for each + different type of call and this context is generic over the response type. +3. In the server- and bidirectional-streaming call handlers, an added layer of + asynchrony is exposed. That is, the user must return a _future_ for + a closure that will handle incoming events. +4. The user _must_ fulfil the `statusPromise` when it receives `.end`, but there +is nothing that enforces this. + +### Existing client API + +Turning our attention to the client API, in order to make calls to the Echo server, the user must instantiate the generated `Echo_EchoClient` which provides the following API: + +```swift +public protocol Echo_EchoClientProtocol: GRPCClient { + var serviceName: String { get } + var interceptors: Echo_EchoClientInterceptorFactoryProtocol? { get } + + func get( + _ request: Echo_EchoRequest, + callOptions: CallOptions? + ) -> UnaryCall + + func expand( + _ request: Echo_EchoRequest, + callOptions: CallOptions?, + handler: @escaping (Echo_EchoResponse) -> Void + ) -> ServerStreamingCall + + func collect( + callOptions: CallOptions? + ) -> ClientStreamingCall + + func update( + callOptions: CallOptions?, + handler: @escaping (Echo_EchoResponse) -> Void + ) -> BidirectionalStreamingCall +} +``` + +### Existing example client usage + +Here is an example use of the client, making a bidirectional streaming call to +`update`: + +```swift +// Update is a bidirectional streaming call; provide a response handler. +let update = client.update { response in + print("update received: \(response.text)") +} + +// Send a bunch of messages to the service. +for word in ["boyle", "jeffers", "holt"] { + let request = Echo_EchoRequest.with { $0.text = word } + update.sendMessage(request, promise: nil) +} + +// Close the request stream. +update.sendEnd(promise: nil) + +// wait() for the call to terminate +let status = try update.status.wait() +print("update completed with status: \(status.code)") +``` + +This API also exposes a number incidental types and patterns that the user need +concern themselves with that are not specific to their application: + +1. It exposes the NIO types to the user, allowing the provision of an + `EventLoopPromise` when sending messages and requiring the use of + `EventLoopFuture` to obtain the `status` of the call. +2. Code does not read in a straight line due to the need to provide a completion + handler when making the call. + +## Proposed solution + +### Proposed server API + +We propose generating the following new protocol which the user must conform to in +order to implement the server: + +```swift +/// To build a server, implement a class that conforms to this protocol. +public protocol Echo_AsyncEchoProvider: CallHandlerProvider { + var interceptors: Echo_EchoServerInterceptorFactoryProtocol? { get } + + /// Immediately returns an echo of a request. + func get( + request: Echo_EchoRequest, + context: AsyncServerCallContext + ) async throws -> Echo_EchoResponse + + /// Splits a request into words and returns each word in a stream of messages. + func expand( + request: Echo_EchoRequest, + responseStreamWriter: AsyncResponseStreamWriter, + context: AsyncServerCallContext + ) async throws + + /// Collects a stream of messages and returns them concatenated when the caller closes. + func collect( + requests: GRPCAsyncStream, + context: AsyncServerCallContext + ) async throws -> Echo_EchoResponse + + /// Streams back messages as they are received in an input stream. + func update( + requests: GRPCAsyncStream, + responseStreamWriter: AsyncResponseStreamWriter, + context: AsyncServerCallContext + ) async throws +} +``` + +Here is an example implementation of the bidirectional streaming `update` +handler using this new API: + +```swift +public func update( + requests: GRPCAsyncStream, + responseStreamWriter: AsyncResponseStreamWriter, + context: AsyncServerCallContext +) async throws { + var count = 0 + for try await request in requests { + let response = Echo_EchoResponse.with { + $0.text = "Swift echo update (\(count)): \(request.text)" + } + count += 1 + try await responseStreamWriter.sendResponse(response) + } +} +``` + +This API addresses the previously noted drawbacks the existing API: + +> 1. The fact that gRPC is implemented on top of NIO is not hidden from the user +> and they need to implement an API in terms of an `EventLoopFuture` and needs +> to access an `EventLoop` from the call context. + +There is no longer a need for the adopter to `import NIO` nor implement anything +in terms of NIO types. Instead they now implement an `async` function. + +> 2. There is a different context type passed to the user function for each +> different type of call and this context is generic over the response type. + +The same non-generic `AsyncServerCallContext` is passed to the user function +regardless of the type of RPC. + +> 3. In the server- and bidirectional-streaming call handlers, an added layer of +> asynchrony is exposed. That is, the user must return a _future_ for +> a closure that will handle incoming events. + +The user function consumes requests from an `AsyncSequence`, using the new +language idioms. + +> 4. The user _must_ fulfil the `statusPromise` when it receives `.end` but there +> is nothing that enforces this. + +The user need simply return from the function or throw an error. The closing of +the call is handled by the library. + +If the user function throws a `GRPCStatus` (which already conforms to `Error`) +or a value of a type that conforms to `GRPCStatusTransformable` then the library +will take care of setting the RPC status appropriately. If the user throws +anything else then the library will still take care of setting the status +appropriately, but in this case it will use `internalError` for the RPC status. + +### Proposed client API + +We propose generating a client which conforms to this new protocol: + +```swift +public protocol Echo_AsyncEchoClientProtocol: GRPCClient { + var serviceName: String { get } + var interceptors: Echo_EchoClientInterceptorFactoryProtocol? { get } + + func makeGetCall( + _ request: Echo_EchoRequest, + callOptions: CallOptions? + ) -> AsyncUnaryCall + + func makeExpandCall( + _ request: Echo_EchoRequest, + callOptions: CallOptions? + ) -> AsyncServerStreamingCall + + func makeCollectCall( + callOptions: CallOptions? + ) -> AsyncClientStreamingCall + + func makeUpdateCall( + callOptions: CallOptions? + ) -> AsyncBidirectionalStreamingCall +} +``` + +Here is an example use of the new client API, making a bidirectional streaming +call to `update`: + +```swift +// No longer provide a response handler when making the call. +let update = client.makeUpdateCall() + +Task { + // Send requests as before but using `await` instead of a `promise`. + for word in ["foo", "bar", "baz"] { + try await update.sendMessage(.with { $0.text = word }) + } + // Close the request stream, again using `await` instead of a `promise`. + try await update.sendEnd() +} + +// Consume responses as an AsyncSequence. +for try await response in update.responseStream { + print("update received: \(response.text)") +} + +// Wait for the call to terminate, but using `await` rather than a future. +let status = await update.status +print("update completed with status: \(status.code)") +``` + +As highlighted in the code comments above, it allows the user to write +staight-line code, using the new async/await language support, and for +consuming responses from an `AsyncSequence` using the new `for try await ... in +{ ... }` idiom. + +Specifically, this API addresses the previously noted drawbacks the existing +client API [anchor link]: + +> 1. It exposes the NIO types to the user, allowing for the provision of an +> `EventLoopPromise` when sending messages and requiring the use of +> `EventLoopFuture` to obtain the `status` of the call. + +NIO types are not exposed. Asynchronous functions and properties are marked as +`async` and the user makes use of the `await` keyword when using them. + +> 2. Code does not read in a straight line due to the need to provide a completion +> handler when making the call. + +While the above example is reasonably artificial, the response handling code can +be placed after the code that is sending requests. + +#### Simple/safe wrappers + +The client API above provides maximum expressibility but has a notable drawback +that was not present in the existing callback-based API. Specifically, in the +server- and bidirectional-streaming cases, if the user does not consume the +responses then waiting on the status will block indefinitely. This can be +considered the converse of the drawback with the _existing_ server API that this +proposal addresses. + +It is for this reason that the above proposed client APIs have slightly obscured +names (e.g. `makeUpdateCall` instead of `update`) and we propose also generating +additional, less expressive, but safer APIs. These APIs will not expose the RPC +metadata (e.g. the status, initial metadata, trailing metadata), but will +instead either simply return the response(s) or throw an error. + +In addition to avoiding the pitfall of the expressive counterparts, the client- +and bidirectional-streaming calls provide the ability to pass an `AsyncSequence` +of requests. In this way, the underlying library takes care of ensuring that no +part of the RPC goes unterminated (both the request and response streams). It +also offers an opportunity for users who have an `AsyncSequence` from which they +are generating requests to make use of the combinators of `AsyncSequence` to not +have to introduce unnecessary synchrony. + +We expect these will be sufficient for a lot of client use cases and, because +they do not have the same pitfalls as their more expressive counterparts, we +propose they be generated with the "plain names" of the RPC calls (e.g. +`update`). + +For example, these are the additional APIs we propose to generate: + +```swift +extension Echo_AsyncEchoClientProtocol { + public func get( + _ request: Echo_EchoRequest, + callOptions: CallOptions? = nil + ) async throws -> Echo_EchoResponse { ... } + + public func collect( + requests: RequestStream, + callOptions: CallOptions? = nil + ) async throws -> Echo_EchoResponse + where RequestStream: AsyncSequence, RequestStream.Element == Echo_EchoRequest { ... } + + public func expand( + _ request: Echo_EchoRequest, + callOptions: CallOptions? = nil + ) -> GRPCAsyncStream { ... } + + public func update( + requests: RequestStream, + callOptions: CallOptions? = nil + ) -> GRPCAsyncStream + where RequestStream: AsyncSequence, RequestStream.Element == Echo_EchoRequest { ... } +``` + +Here is an example use of the safer client API, making a bidirectional streaming +call to `update` using an `AsyncSequence` of requests: + +```swift +let requestStream: AsyncStream = ... // constructed elsewhere + +for try await response in client.update(requests: requestStream) { + print("update received: \(response.text)") +} +``` + +Note how there is no call handler that the user needs to hold onto and use in a +safe way, they just pass in a stream of requests and consume a stream of +responses. + +## Alternatives considered + +### Using throwing effectful read-only properties + +[Effectful read-only properties][SE-0310] were also recently added to the Swift +language. These allow for a read-only property to be marked with effects (e.g. +`async` and/or `throws`). + +We considered making the status and trailing metadata properties that could +throw an error if they are awaited before the call was in the final state. The +drawback here is that you may _actually_ want to wait on the completion of the +call if for example your responses were being consumed in a concurrent task. + +### Adding a throwing function to access the status + +When looking at the C# implementation (which is of interest because C# also has +async/await language constructs), they provide throwing APIs to access the final +metadata for the RPC. We could consider doing the same and have not ruled it +out. + +[SE-0296]: https://github.com/apple/swift-evolution/blob/main/proposals/0296-async-await.md +[SE-0298]: https://github.com/apple/swift-evolution/blob/main/proposals/0298-asyncsequence.md +[SE-0310]: https://github.com/apple/swift-evolution/blob/main/proposals/0310-effectful-readonly-properties.md +[SE-0314]: https://github.com/apple/swift-evolution/blob/main/proposals/0314-async-stream.md