Description
Use case
A very common pattern I see very often in codebases is having a bunch of independent read-only operations that are pre-requisites to one write operation. Since the read-only operations are independent, we want them to execute concurrently. However, we don't want to execute the write operations if the read-only operations fail.
Without coroutines, this is typically written:
fun foo(u: UserId, b: BarId) {
val user = userService.load(u)
val bar = barService.load(b)
doFoo(user, bar)
}
private doFoo(user: User, bar: Bar) { … }
Of course, in the real world, we see examples with more than 10 of such prior read-only operations.
The goal of this issue is to find a canonical way of implementing this pattern with coroutines. The following are a few patterns I considered.
coroutineScope {}
Mentally, launching the read-only operations in a specific coroutineScope {}
seems to be exactly what I'm searching for: they all execute concurrently before the write operation can start, and the Coroutines library takes care of cancellation and exceptions. However, since coroutineScope {}
introduces a lexical scope, it isn't possible to easily extract the data into local variables, and tricks are necessary.
suspend fun foo(u: UserId, b: BarId) {
var user: User by notNull()
var bar: Bar by notNull()
coroutineScope {
launch {
user = userService.load(u)
}
launch {
bar = barService.load(b)
}
}
doFoo(user, bar)
}
This works, but is particularly not fun to write, and the necessity to declare variables as var
is not great.
joinAll
Another option is to start a bunch of jobs and use joinAll
followed by getCompleted
to access the values:
suspend fun foo(u: UserId, b: BarId) = coroutineScope {
val user = async { userService.load(u) }
val bar = async { barService.load(b) }
joinAll(user, bar)
doFoo(user.getCompleted(), bar.getCompleted())
}
To me, using getCompleted()
is application code is a code smell, and in fact it is common to forget to add one of the deferred to the joinAll
code, leading to runtime errors.
await
A variation of the previous example, using await()
instead of getCompleted()
to avoid the need for joinAll()
:
suspend fun foo(u: UserId, b: BarId) = coroutineScope {
val user = async { userService.load(u) }
val bar = async { barService.load(b) }
doFoo(user.await(), bar.await())
}
This is definitely the easiest version to read and write, but it has a few downsides:
- In the real world, the write operation is often inside some kind of loop, so we end up
.await()
'ing the same value many times. Looking at the code, it seems.await()
spin-locks, so probably not a great idea? - The nature of "read-only operations that must finish before the write operation starts" is lost. If the write operation is written in multiple function calls, we risk starting it without awaiting all values.
Arrow parZip
Using Arrow Fx Coroutines, we can use parZip
:
suspend fun foo(u: UserId, b: BarId) {
parZip(
{ userService.load(u) },
{ barService.load(b) }
) { user, bar ->
doFoo(user, bar)
}
}
I guess this is exactly what I'm asking for, but it doesn't really feel like idiomatic Kotlin code. In particular, the usage of non-trailing lambdas, the declaration of the names in a different place than their contents, and the added indentation in the primary function code.
Another idea?
I don't really feel satisfied with any of the above options (though I have seen them all in production code), but I can't really come up with a satisfying design either. My best idea is something like:
suspend fun foo(u: UserId, b: BarId) {
val fence = prerequisites()
val user by fence { userService.load(u) }
val bar by fence { barService.load(b) }
fence.close() // suspends
doFoo(user, bar)
}
where prerequisites()
creates a CoroutineScope
similar to the one created by coroutineScope {}
, which is joined by the call of close()
, and the delegates use .getCompleted()
under the hood.
Still, this solution will compile even if fence.close()
isn't called, breaking at runtime. Also, this solution breaks at run-time if any of the read operations attempts to use the results of another read operation (which only the .await()
example above can handle).
What do you think? Is there another pattern I missed? Could this be improved somehow?