Skip to content

Conversation

@luciofm
Copy link

@luciofm luciofm commented May 22, 2025

This fixes #1461, to avoid multiple parallel network requests for the same URL.

I've basically created a simple InFlightRequestStrategy interface, with a DEFAULT implementation 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 NetworkFetcher I've simply wrapped the fetch() method with InFlightRequestStrategy.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 .api files yet...

block: (suspend () -> FetchResult),
): FetchResult {
var shouldWait = false
val request = mutex.withLock {
Copy link
Contributor

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.

Copy link
Author

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 {
Copy link
Contributor

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.

Copy link
Author

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.

Copy link
Contributor

@revonateB0T revonateB0T May 22, 2025

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.

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)

Copy link
Author

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.

Copy link
Contributor

@revonateB0T revonateB0T May 22, 2025

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.

Copy link
Author

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

Copy link
Contributor

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)

Copy link
Author

@luciofm luciofm May 26, 2025

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 InFlightRequest from 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.

https://github.com/coil-kt/coil/pull/2995/files#diff-d957f14d6fa8fb2c0110fd58003e7d65e4550a2cd3f2fb7bf2b2de8eb2fa234eR88-R178

@revonateB0T
Copy link
Contributor

I suggest to look into our MergeInteceptor https://github.com/FooIbar/EhViewer/blob/1548e16fbe7250785d5d13e55615131777ac11b6/app/src/main/kotlin/com/hippo/ehviewer/coil/MergeInterceptor.kt#L34

It handles same-url-request debounce well using kotlin flow.
Given request order:
Launch A -> Launch B -> Cancel A
where A, B use same URL, launched network request is executed as is.

Given request order:
Launch A -> Cancel A -> Launch B
where A, B use same URL, delay 100ms on each operation, launched network request is executed as is, we avoid relaunch request with a 100ms cancel-delay trade-off.

Copy link
Member

@colinrtwhite colinrtwhite left a 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.apply currently couples us to the disk cache key. We might not be able to change this especially if block must capture the disk cache reads as well (instead of only the network request).
  • InFlightRequestStrategy.apply block captures Option, which has a reference to the locally-scope Context. This could leak a Fragment/Activity if apply is cancelled, but block isn'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 InFlightRequestStrategy arguments. 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 },
Copy link
Member

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)
Copy link
Member

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
Copy link
Member

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 {
Copy link
Member

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()
Copy link
Member

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 {
Copy link
Member

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.

Copy link
Author

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(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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
Copy link
Member

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.

Copy link
Author

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.

@luciofm
Copy link
Author

luciofm commented May 31, 2025

I think I've fixed all/most your comments, specially the binary compatibility ones.

  • 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).
    Agree with having just a DEFAULT that does nothing and one implementation... I've moved the SimpleInFlightRequestStrategy as a sample on InFlightRequestStrategy doc.

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 inFlightRequests map, to avoid creating new channels on scrolling for example. If you don't think this is a good idea, we can just remove the delay, but I think this implementation is cleaner than the first one... It works basically the same.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Avoid multiple parallel network requests for the same URL

3 participants