Pattern: Getting Nice To Have Data In Async Without Crashing Required Data Get (Using supervisorScope)
Pattern for getting nice to have data asynchronously without crashing the scope if the async for nice to have data query throws an exception.
Key-Detail: Add supervisorScope error boundary around the entire function body.
Why do we need this?
There are references online stating that async exception will always go through await (Even official documentation appears to be mis-aligned, issue filed).
So with that we would presume catching await() would give us safe code:
// 🚫 this pseudo-code is unsafe 🚫
func(){
deferredOptional = async(){ /* get optional data that can throw */ }
requiredData = getRequiredData()
try{
deferredOptional.await()
}
catch{
// process optional failure
}
}
BUT, there is a gotcha: ⚠️ Unhandled exception from async can go to parent scope without going through await() ⚠️
Hence, above is not safe and we need to take extra measures, that this pattern aims to address.
✅ - Pseudo code with Fix
func(){
// ✅ By adding supervisor scope we would allow async to fail before await is called
// without causing cascading cancellation upwards.
supervisorScope{
deferredOptional = async(){ /* get optional data that can throw*/ }
requiredData = getRequiredData()
try{
deferredOptional.await()
}
catch{
// process optional failure
}
// finish processing
}
}
Working Implementation Example
Pattern implementation example
Code
package com.glassthought.sandbox
import com.glassthought.sandbox.util.out.impl.out
import gt.sandbox.util.output.Out
import kotlinx.coroutines.*
fun main() = runBlocking {
initialize()
}
private suspend fun initialize() {
try {
// Use supervisor scope so that if async throws an exception prior to getting to await
// we do not fail the entire scope.
supervisorScope {
val niceToHaveData = async(CoroutineName("queryNiceToHaveData")) {
out.actionWithMsg("niceToHaveData", {
throw FailureToGetNiceToHaveData("Failed to fetch niceToHaveData")
})
"nice-to-have-data-val"
}
val parseNotesDeferred = async(CoroutineName("required-data-2")) {
out.actionWithMsg("required-data", {
delay(100)
"required-data-1-val"
})
}
val niceToHaveResult = niceToHaveData.awaitOrNull(out)
val requiredData1 = parseNotesDeferred.await()
out.actionWithMsg("required-data-2", {
out.info("niceToHaveDataResult:$niceToHaveResult")
out.info("requiredDataResult:" + requiredData1)
})
}
} catch (e: CancellationException) {
out.cancelled("initialize", e)
} catch (e: Throwable) {
out.error("Initialize caught ERROR $e")
}
}
private suspend fun <T> Deferred<T>.awaitOrNull(out: Out): T? {
try {
return this.await()
} catch (e: CancellationException) {
out.cancelled("awaitOrNull", e)
throw e
} catch (e: Exception) {
out.warn("awaitOrNull caught [${e::class.simpleName}/${e.message}], returning null")
return null
}
}
class FailureToGetNiceToHaveData(msg: String) : RuntimeException(msg)
Command to reproduce:
gt.sandbox.checkout.commit 8b69f4ce79c906ff14c1 \
&& cd "${GT_SANDBOX_REPO}" \
&& cmd.run.announce "./gradlew run --quiet"
Recorded output of command:
Picked up JAVA_TOOL_OPTIONS: -Dkotlinx.coroutines.debug
Picked up JAVA_TOOL_OPTIONS: -Dkotlinx.coroutines.debug
[INFO][elapsed: 28ms][①][coroutname:@queryNiceToHaveData#2] [->] action=[niceToHaveData] is starting.
[WARN][elapsed: 71ms][①][coroutname:@queryNiceToHaveData#2] [<-][💥] Finished action=[niceToHaveData], it THREW exception of type=[FailureToGetNiceToHaveData] we are rethrowing it.
[INFO][elapsed: 85ms][⓶][coroutname:@required-data-2#3] [->] action=[required-data] is starting.
[WARN][elapsed: 86ms][⓷][coroutname:@coroutine#1] awaitOrNull caught [FailureToGetNiceToHaveData/Failed to fetch niceToHaveData], returning null
[INFO][elapsed: 186ms][⓶][coroutname:@required-data-2#3] [<-] Finished action=[required-data].
[INFO][elapsed: 187ms][⓷][coroutname:@coroutine#1] [->] action=[required-data-2] is starting.
[INFO][elapsed: 187ms][⓷][coroutname:@coroutine#1] niceToHaveDataResult:null
[INFO][elapsed: 187ms][⓷][coroutname:@coroutine#1] requiredDataResult:required-data-1-val
[INFO][elapsed: 187ms][⓷][coroutname:@coroutine#1] [<-] Finished action=[required-data-2].
Backlinks