3

I am trying to create a Flow that emits a value after a timeout, without cancelling the underlying coroutine. The idea is that the network call has X time to complete and emit a value and after that timeout has been reached, emit some initial value without cancelling the underlying work (eventually emitting the value from the network call, assuming it succeeds).

Something like this seems like it might work, but it would cancel the underlying coroutine when the timeout is reached. It also doesn't handle emitting some default value on timeout.

val someFlow = MutableStateFlow("someInitialValue")

val deferred = async {
    val networkCallValue = someNetworkCall()
    someFlow.emit(networkCallValue)
}

withTimeout(SOME_NUMBER_MILLIS) {
    deferred.await()
}

I'd like to be able to emit the value returned by the network call at any point, and if the timeout is reached just emit some default value. How would I accomplish this with Flow/Coroutines?

Sergio
  • 27,326
  • 8
  • 128
  • 149
Citut
  • 847
  • 2
  • 10
  • 25

4 Answers4

1

One way to do this is with a simple select clause:

import kotlinx.coroutines.selects.*

val someFlow = MutableStateFlow("someInitialValue")

val deferred = async {
    someFlow.value = someNetworkCall()
}

// await the first of the 2 things, without cancelling anything
select<Unit> {
    deferred.onAwait {}
    onTimeout(SOME_NUMBER_MILLIS) {
        someFlow.value = someDefaultValue
    }
}

You would have to watch out for race conditions though, if this runs on a multi-threaded dispatcher. If the async finished just after the timeout, there is a chance the default value overwrites the network response.

One way to prevent that, if you know the network can't return the same value as the initial value (and if no other coroutine is changing the state) is with the atomic update method:

val deferred = async {
    val networkCallValue = someNetworkCall()
    someFlow.update { networkCallValue }
}

// await the first of the 2 things, without cancelling anything
val initialValue = someFlow.value
select<Unit> {
    deferred.onAwait {}
    onTimeout(300) {
        someFlow.update { current ->
            if (current == initialValue) {
                "someDefaultValue"
            } else {
                current // don't overwrite the network result
            }
        }
    }
}

If you can't rely on comparisons of the state, you can protect access to the flow with a Mutex and a boolean:

val someFlow = MutableStateFlow("someInitialValue")
val mutex = Mutex()
var networkCallDone = false

val deferred = async {
    val networkCallValue = someNetworkCall()
    mutex.withLock {
        someFlow.value = networkCallValue
        networkCallDone = true
    }
}

// await the first of the 2 things, without cancelling anything
select<Unit> {
    deferred.onAwait {}
    onTimeout(300) {
        mutex.withLock {
            if (!networkCallDone) {
                someFlow.value = "someDefaultValue"
            }
        }
    }
}
Joffrey
  • 32,348
  • 6
  • 68
  • 100
  • To avoid the race condition could we use a mutex/semaphore? It seems there are pros and cons with the approach you and Arpit mentioned. Your approach doesn't wait for delay, but might have a race condition. Arpit's approach doesn't seem to have concurrency issues, but the delay would occur even if the network call returns "instantly". – Citut Jan 12 '22 at 18:29
  • @Citut Arpit's approach has the same concurrency issue here – Joffrey Jan 12 '22 at 18:31
  • Yes, it could be prevented with a coroutine `Mutex`. For instance, if you know the initial value, and if the flow is not modified by other coroutines you can wrap the flow accesses with `mutex.withLock {}` and in `onTimeout`, make sure the state flow still has the initial value (within the mutex lock) before actually setting the value – Joffrey Jan 12 '22 at 18:35
  • Interestingly, my long awaited prayer has been answered, and there is an atomic `MutableStateFlow.update()` now, so no need for `Mutex` if you're ok with your state being compared multiple times: https://github.com/Kotlin/kotlinx.coroutines/issues/2392 – Joffrey Jan 12 '22 at 19:00
  • Why do we need a `Mutex` here? – IgorGanapolsky Jun 08 '23 at 17:12
1

You can launch two coroutines simultaneously and cancel the Job of the first one, which responsible for emitting default value, in the second one:

val someFlow = MutableStateFlow("someInitialValue")

val firstJob = launch {
    delay(SOME_NUMBER_MILLIS)
    ensureActive() // Ensures that current Job is active.
    someFlow.update {"DefaultValue"}
}
launch {
    val networkCallValue = someNetworkCall()
    firstJob.cancelAndJoin()
    someFlow.update { networkCallValue }
}
Sergio
  • 27,326
  • 8
  • 128
  • 149
  • You don't really need the second coroutine, though, to stay closer to OP's initial code. Then this answer is pretty much the same as Arpit's, although you're using `update` in a way that still allows a race on a multi-threaded dispatcher here: there is no condition in the timeout update – Joffrey Jan 13 '22 at 09:17
  • I think we don't need condition on the timeout because `ensureActive` won't allow to update `someFlow` when this timeout's coroutine job is canceled. – Sergio Jan 13 '22 at 09:40
  • What if this job is canceled between `ensureActive()` and `someFlow.update`, and the update from the second coroutine goes first? – Joffrey Jan 13 '22 at 09:41
  • 1
    hm, it seems you are right, then `cancelAndJoin` should help. – Sergio Jan 13 '22 at 09:44
1

Probably the easiest way to solve the race condition is to use select() as in @Joffrey's answer. select() guarantees to execute only a single branch.

However, I believe mutating a shared flow concurrently complicates the situation and introduces another race condition that we need to solve. Instead, we can do it really very easily:

flow {
    val network = async { someNetworkCall() }
    select {
        network.onAwait{ emit(it) }
        onTimeout(1000) {
            emit("initial")
            emit(network.await())
        }
    }
}

There are no race conditions to handle. We have just two simple execution branches, depending on what happened first.

If we need a StateFlow then we can use stateIn() to convert a regular flow. Or we can use a MutableStateFlow as in the question, but mutate it only inside select(), similarly to above:

select {
    network.onAwait{ someFlow.value = it }
    onTimeout(1000) {
        someFlow.value = "initial"
        someFlow.value = network.await()
    }
}
broot
  • 21,588
  • 3
  • 30
  • 35
  • OMG... I didn't think of awaiting the network call in `onTimeout`. That's much better! – Joffrey Jan 13 '22 at 09:14
  • Nice this is awesome! Does the initial network call get cancelled if the timeout gets hit here? Or does it get suspended? I'm investigating if this restarts the entire network call when the timeout happens and we call `network.await()` – Citut Jan 13 '22 at 18:09
  • @Joffrey I guess you just locked yourself to the initial idea suggested in the question that we emit inside `async()` :-) It's much easier to handle mutable state if we only return from `async()` and emit elsewhere. – broot Jan 13 '22 at 18:36
  • @Citut No, the network call is not cancelled and restarted. `onTimeout()` does not timeout the network call, it timeouts `select()`. So this works like this: start executing network call in the background. Wait for it to finish, but if it doesn't in a specified time then execute the code inside `onTimeout { }`. Network is not at all affected in this case. Also, `onAwait()` is guaranteed to be not executed if `onTimeout()` is and vice versa. – broot Jan 13 '22 at 18:42
0

You can send the network request and start the timeout delay simultaneously. When the network call succeeds, update the StateFlow with the response. And, when the timeout finishes and we haven't received the response, update the StateFlow with the default value.

val someFlow = MutableStateFlow(initialValue)

suspend fun getData() {
    launch {
        someFlow.value = someNetworkCall()
    }
    delay(TIMEOUT_MILLIS)
    if(someFlow.value == initialValue)
        someFlow.value = defaultValue
}

If the response of the network call can be same as the initialValue, you can create a new Boolean to check the completion of network request. Another option can be to store a reference of the Job returned by launch and check if job.isActive after the timeout.

Edit: In case you want to cancel delay when the network request completes, you can do something like:

val someFlow = MutableStateFlow(initialValue)

suspend fun getData() {
    val job = launch {    
        delay(TIMEOUT_MILLIS)
        someFlow.value = defaultValue
    } 
    someFlow.value = someNetworkCall()
    job.cancel()
}

And to solve the possible concurrency issue, you can use MutableStateFlow.update for atomic updates.

Arpit Shukla
  • 9,612
  • 1
  • 14
  • 40
  • The problem with this approach is that the overall code here always takes as much time as the timeout, even if the network call is fast. I don't think that's what the OP is looking for – Joffrey Jan 12 '22 at 18:19
  • That shouldn't be the problem if the `getData` function does nothing else. The work will automatically be cancelled when the scope is cancelled. In case the `getData` function does more work than this, I guess we can wrap the `delay` and `if` in another `launch` and cancel this job when network call completes. – Arpit Shukla Jan 12 '22 at 18:23
  • Also, I think both `if` statements here are reversed, btw – Joffrey Jan 12 '22 at 19:12
  • *That shouldn't be the problem if the getData function does nothing else* - I disagree, the suspend function should resume after doing its work. Even if `getData` does nothing else, the code that follows it will unnecessarily wait. In any case, your new code solves the problem ;) – Joffrey Jan 12 '22 at 19:15
  • Thanks for pointing out the reversed `if`. And yeah, I guess you are right about the second point also. Hadn't thought that the caller would have to wait unnecessarily in my first code. Thanks again :) – Arpit Shukla Jan 12 '22 at 19:24