-
Notifications
You must be signed in to change notification settings - Fork 120
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
HTTPClient.execute(...) async throws
API violates Structured Concurrency
#752
Comments
Yes, I brought this up as well. The async APIs are not structured concurrency "conform". We spawn an unstructured Task inside them and that task is "driving" the request. This leads to exactly this problem. We need to provide a |
Exactly, they should be called |
I'm not sure I agree here. In structured concurrency multiple ways can lead to Rome! The question is how do we structure the concurrency: In AHC the I can totally see that an additional with style API can make sense for streaming request and response bodies at the same time. But I'm not convinced we should make this the default. |
So I think we have two orthogonal problems here. Currently AHC spawns an unstructured task in the async APIs and that leads to http request to continue running in the background even though you might assume it is done. The other problem is being able to strictly tell when an http request is done. When using a Now we can make
Fundamentally, AHC is offering a bi-directional streaming API. This itself requires a
Now we can offer convenience APIs on top of this. One that just takes a fixed outbound body in terms of |
Not quite. Structured Concurrency doesn't mean you get to pick the structure, the structure of your code (the Quotes from Wikipedia on Structured Concurrency
So the one thing that mustn't happen is that you pass your scope's
It may have a structure but it's not the structure that Structured Programming and Structured Concurrency mandate. All the bad stuff that's supposed to be impossible with Structured Concurrency can happen with For example:
For example if you do
And yes, my program is stupid and wrong. But the thing about structured concurrency is that this shouldn't matter. The compelling thing is that I'm meant to get a 'guarantee' that when I returned I will have cleaned up (or still waiting for it), If you will, you can liken this to structured programming. If you write
then you know that it'll print And this is despite the fact that the loop condition ( The loop continuing after you hit the enclosing |
I am not disagreeing just want to add to this. One of the intrinsic problems that we have here is the fact that we have the EL that is capable doing work outside of our task. This in combination with tying the request lifetime to the lifetime of the Now what I don't want to say is that it has to be fully structured all the way to the syscall. That's not achievable IMO and also not something we should aim for. The way I have been thinking about clients in Concurrency lately is the following. Each client has a pool of connections (might be 1). Each connection lives in a separate child task originating from a I really like this documentation from Tokio and think we should have something similar for Swift https://tokio.rs/tokio/tutorial/channels |
I don't see the problem. The EL, dispatch, any thread or any Swift Concurrency executor is able to enqueue work. The job of an
We have a solution and it's
Exactly right. This is impossible and undesirable. We do also need escape hatches but much like
I disagree. I dislike lateral communication between sibling tasks. Yes, technically it fulfils the requirements of structured concurrency because the In my opinion, passing messages laterally is awkward, skating on the edge of what the Structured Concurrency model 'allows' and will be slower. Of course, I understand that connection pools aren't implementable sensibly unless you keep stuff around for longer than strictly necessary but I think this is okay because it's unobservable that a sibling or parent tasks takes ownership of a pre-existing task. Instead, I think the right model is an explicit ownership transfer from connection pool to child task when a connection is required. And from child task back to connection pool when the connection is no longer required and can be reused later. So yes, there is still lateral communication but strictly this way:
Yes, Tokio has pretty decent docs and it'd be lovely to have something comparable for all things Swift. |
I have been thinking about the lateral communication vs ownership transfer a lot recently and I am not sure if ownership transfer is going to be working in the future due to us wanting to adopt
Now our connection pool would create those connections inside a child task originating from the
Now if we want to send or receive data we need to communicate into that child task and we cannot transfer the ownership as far as I understand. |
Wait, wait, who would want to adopt non-escaping (probably) makes sense for stuff like iterators. Stuff that's quick to create/discard, usually wrapping something else. But doing lateral communication in order to save a few ARCs feels backwards. You'd be trading something super fast (ARC, maybe 2ns per retain/release pair) for something awkard, complex and much slower that's also borderline at odds with the structured concurrency model (lateral comms). |
IMO anything that we currently provide in a with style scope makes sense to adopt
I agree that we can transfer ownership for the period of a request for performance reasons; however, lateral communication will always exist in any system. Different tasks have to communicate with each other in larger applications. Doesn't mean we have to use for a connection pool but I still expect it to be a common pattern between tasks and not violate structured concurrency. |
Exactly, superficially, this looks possible but is hypothetical. And it may be possible with little wrapper types around the actual, underlying types. As in, there might be APIs that wrap their regular
Agree, it will exist but it's awkward, error-prone (and slow). So let's strive to use it as little as possible treating it almost like a half-violation of Structured Concurrency. To me a good litmus test is if it's observable that we did lateral comms. If yes: bad, if no: okay. Take a connection pool: Yes, it needs lateral communication but a connection pool is an optimisation. We could (of course wouldn't) not have a connection pool and then we could manage connections without lateral comms. This to me is acceptable because the model itself is sound, we just bend the rules a little to get a massive perf boost.
Yes, we have also made that observation. And in my experience that's where the bugs come in. I'm pretty sure that 9 out of 10 use cases for lateral comms can be dealt with with a handful of helpers. One of the examples that we commonly have: One task accepts a connection and handles another resource through the connection. Where the problem comes in: What if the connection disappears? For many resources just tearing it down is the correct and easy to implement answer. But for other resources you want a client to be able to re-connect within a grace period (maybe 1 minute) such that network glitches don't immediately require you to redo everything. I'm thinking this can be handled the following way: The original task notices the client going away. It now moves the open connection into some special 'connection parking lot' type (this is lateral communication) which makes sure that any connection parked is either picked up within 1 minute or it'll tear it down. At least in my experience there are only so many (good) reasons to communicate laterally and I'm confident that we'll build a few helper types that take the awkwardness away. (very) Pseudo code with the parking lot could look like this: func newConnectionHandlerFactory(...) async throws { ... }
ParkingLot(gracePeriod: 1min).withParkingLot { connectionParkingLog in
for newConnection in try await server.connections {
try await connectionParkingLog.withParkedOrNewConnectionHandler(newConnFactory: newConnectionHandlerFactory) { handler, connection in
try await handler.attachAndHandle(connection)
}
}
} Key thing here is that Of course, there's some lateral comms going on here but it's entirely handled by the "parking lot" thing. That is easily testable and a re-usable component. And the first implementation of the parking lot could just be to always pop out a new handler. That also fulfils the litmus test of the model working without lateral comms too (just slower and more wasteful). |
I thought about this some more on the weekend and remembered why so far I decided against scoped ownership transfer. It was due to how cancellation works. The problem that I encountered was that the connection's inbound and outbound both implemented cancellation by basically closing the connection or rather making the connection unusable. Especially the inbound async sequences are terminating when the consuming task got cancelled. Now that's something we can change but it is an important caveat when sharing ownership. It might also not always be the case that you can modify the underlying asynchronous interfaces that are shared in a way that cancellation of the task doesn't close the underlying resource. |
That's a valid concern but that's an implementation issue that's easily fixed. Either by changing it or by wrapping the transferred thing with something that creates its own cancellation scope. Remember, the connection pool is an optimisation, the model should be consistent with how it'd work if we created (and closed) a connection per scope. And ofc, lateral comms make things slower than necessary and we're already in a bit of a pickle (#756). |
I agree that this is something the underlying async sequence can change; however, I don't think you can wrap it. The problem is if the user's task gets cancelled and you are currently suspended on a |
I think it's possible by wrapping (such that you get to make the cancel in But don't get me wrong, I'm not saying it's easy. I think it's pretty difficult and we'll need a bunch of rounds of experimentation before getting it right |
Yes I agree. I think generally if a task gets cancelled while using a borrowed resource such as an H1 connection. The default is that we have close and throw away that resource. There might be some optimisations where we can keep of the state to determine that even in the event of cancellation the resource was left in a reusable state. |
100% yes, as an optimisation. And I believe all of this should be possible. But I do acknowledge that if the framework adds zero extra code to compensate, we'll kill the resource. And that's a feature IMHO: If the framework didn't think about it we must not reuse. Imagine a stupid HTTP framework accidentally returning a HTTP connection that's currently request/response body streaming into the connection pool. That would potentially be a security vulnerability. tl;dr: default: kill all resources, optimisation: potentially return resources is good |
Structured Concurrency mandates that upon the return of a function any background work it may have started has completed.
With
HTTPClient.execute(...) async throws
that's clearly not the case as it returns a half-finishedHTTPClientResponse
where theresponse.body
yet has to arrive.The text was updated successfully, but these errors were encountered: