-
Notifications
You must be signed in to change notification settings - Fork 742
Implement InFlightRequestStrategy and DeDupeInFlightRequestStrategy #2995
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
base: main
Are you sure you want to change the base?
Conversation
| block: (suspend () -> FetchResult), | ||
| ): FetchResult { | ||
| var shouldWait = false | ||
| val request = mutex.withLock { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I suggest to use non-suspend mutex here as critical region is small & non-suspend.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fair enough.
|
|
||
| return request.use { | ||
| block() | ||
| }.also { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Given we launch 2 same requests A, B. A is active, B is waiting for A.
What about A is cancelled? we doesn't remove it from inFlightRequests here.
Moreover, now that B is not cancelled, should we cancel & relaunch network request here? it does harm performance.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If A is cancelled, the request will be removed from the inFlightRequests map and B will proceed...
If there are more requests waiting, on this particular case, all of them will trigger a network request.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If A is cancelled, the request will be removed from the
inFlightRequestsmap and B will proceed...If there are more requests waiting, on this particular case, all of them will trigger a network request.
But we did't remove it, the also block is not getting executed due to CancellationException, it's not wrapped by FetchResult. You should use try finally instead.
Moreover, the suspend Mutex will never work in cancelled CoroutineContext unless you wrap it with withContext(NonCancellable)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If the request is canceled, the Deferrable will be completed, and will not block other requests form going through, but I'll move to a try/finally to remove the request from the map.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If the request is canceled, the Deferrable will be completed, and will not block other requests form going through, but I'll move to a try/finally to remove the request from the map.
I think we should spread the exception(FetchResult) to waiting request when request is failed with network error to avoid meaningless requests.
And resume only one waiting request when inflighting request is cancelled.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wanted to make it really simple, and as less invasive as possible on the current code/functionality while solving the most common use cases...
Lets see what @colinrtwhite has to say about this
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wanted to make it really simple, and as less invasive as possible on the current code/functionality while solving the most common use cases...
Lets see what @colinrtwhite has to say about this
He's on vacation #2959 (comment)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe we could have 2 implementations, the current one proposed on the PR renamed as SimpleInFlightRequestStrategy, a simple strategy that handles the most common cases:
- The first network request succeeds and all other waiters are unblocked to hit the disk cache in parallel
- All queued network requests for the same URL would fail. We unblock all waiting requests to fail in parallel.
And have a second, more complex, implementation
- One request is allowed to go through, all others are waiting on a
Channel<Unit> - The request succeeds:
- The channel is closed, and all waiting coroutines are allowed to run in parallel, hitting the disk cache.
- The request fails:
- Tries to send on the channel, if fails (no one waiting), close the channel and remove the
InFlightRequestfrom the map - If send is successful, this means another coroutine will wake up and run the request, this new coroutine will now be the channel owner/controller and responsible for the cleanup. Rinse and repeat.
- Tries to send on the channel, if fails (no one waiting), close the channel and remove the
|
I suggest to look into our It handles same-url-request debounce well using kotlin flow. Given request order: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@luciofm Thanks for working on this! I left a few comments, but I think these are the main things we should focus on/think about:
InFlightRequestStrategy.applycurrently couples us to the disk cache key. We might not be able to change this especially ifblockmust capture the disk cache reads as well (instead of only the network request).InFlightRequestStrategy.applyblockcapturesOption, which has a reference to the locally-scopeContext. This could leak aFragment/Activityifapplyis cancelled, butblockisn't.- I think we probably only want to ship 2 request strategies - not 3:
InFlightRequestStrategy.DEFAULT(i.e. no deduping) and ideally a version of deduping that uses refcounting and only executes the network request exactly once (instead of passing the baton on a failure). - We'll need to preserve the existing functions/constructors that don't have
InFlightRequestStrategyarguments. This is to avoid breaking binary compatibility.
EDIT: I just realized why we can't do refcounting with the current strategy, as we need to do separate disk cache reads for each request after the network request finishes. Going to think about this a bit... The main thing I'd like to solve for is allowing a network request to keep executing even if NetworkFetcher.fetch is cancelled, but there are still non-cancelled observers.
| class Factory( | ||
| networkClient: () -> NetworkClient, | ||
| cacheStrategy: () -> CacheStrategy = { CacheStrategy.DEFAULT }, | ||
| inFlightRequestStrategy: () -> InFlightRequestStrategy = { InFlightRequestStrategy.DEFAULT }, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This breaks binary compatibility. We'll need to keep old constructors similar to here.
| diskCache = lazyOf(null), | ||
| cacheStrategy = lazyOf(CacheStrategy.DEFAULT), | ||
| connectivityChecker = ConnectivityChecker(context), | ||
| inFlightRequestStrategy = lazyOf(InFlightRequestStrategy.DEFAULT) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: this is ordered after connectivityChecker here, but before connectivityChecker in the constructor
| @JvmName("factory") | ||
| fun KtorNetworkFetcherFactory( | ||
| httpClient: HttpClient, | ||
| inFlightRequestStrategy: InFlightRequestStrategy = InFlightRequestStrategy.DEFAULT |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's create new functions that include inFlightRequestStrategy and mark the old as deprecated hidden similar to here. We need to do this to preserve binary compatibility.
| import okio.Closeable | ||
| import okio.use | ||
|
|
||
| interface InFlightRequestStrategy { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could you add a short doc description to this and mark it as @ExperimentalCoilApi? Thanks
|
|
||
| interface InFlightRequestStrategy { | ||
| suspend fun apply(key: String, block: (suspend () -> FetchResult)): FetchResult { | ||
| return block() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's remove the default implementation and move it to DEFAULT.
| import okio.use | ||
|
|
||
| interface InFlightRequestStrategy { | ||
| suspend fun apply(key: String, block: (suspend () -> FetchResult)): FetchResult { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This couples us to key (the disk cache key), which I was hoping to avoid as it prevents custom implementations where a user can specify a custom "keyer". For example, I'm thinking of a case where a user might want a request's headers to be considered part of the cache key. It'll be possible with the current implementation, but they'll have to set ImageRequest.diskCacheKey manually. We could introduce a diskCacheKeyFactory, though I think that'll introduce a lot of other complexities.
Also, I think block can leak memory depending on the implementation. It'll implicitly hold a reference to Options, which will hold the local Context for that request. If apply is cancelled, but we keep executing block then we would leak the underlying Context. I think we need to figure out a way to avoid capturing the Options and ideally guard against accidentally capturing it in the future.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For now I think we could add a this limitation to the InFlightRequestStrategy documentation, but creating a diskCacheKeyFactory makes sense for these cases...
About the leaking Context, I've added a new safeDiskCacheKey that copies options.diskCacheKey if available, or just use the url.
| } | ||
| } | ||
|
|
||
| data class InFlightRequest( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| data class InFlightRequest( | |
| private class InFlightRequest( |
Removed data to save a few bytes and minimize the public API.
| try { | ||
| return block() | ||
| } catch (ex: Exception) { | ||
| val successfullyPassedBaton = channel.trySend(Unit).isSuccess |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Will this launch a new request if the first request is cancelled and it falls back to a second observer? I'm wondering if we can create an implementation that refcounts the observers and only cancels the request if the refcount drops to 0.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This works like a serial queue until block() succeeds/returns.
If block throws - either cancelation or it fails (404 for example), it will awake exactly one waiter to try again.
If block succeeds, it will close the channel, releasing all waiters to proceed.
|
I think I've fixed all/most your comments, specially the binary compatibility ones.
I've added a new implementation for discussion (we should keep just one in the end). This one has an optional delay in removing the request key from the |
This fixes #1461, to avoid multiple parallel network requests for the same URL.
I've basically created a simple
InFlightRequestStrategyinterface, with aDEFAULTimplementation that does nothing.And a concrete implementation
DeDupeInFlightRequestStrategy, that check for a current in-flight request for the same url and waits for it.Then on
NetworkFetcherI've simply wrapped thefetch()method withInFlightRequestStrategy.apply().With the DEFAULT implementation, it will just invoke the lambda (call doFetch()). And using the DeDupe strategy, it will check for in-flight requests and wait on it.
Note: I haven't update the
.apifiles yet...