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 }}
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.sandboximport com.glassthought.sandbox.util.out.impl.outimport gt.sandbox.util.output.Outimport 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.debugPicked 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].
It is good practice to use yield in suspending functions between
blocks of non-suspended CPU-intensive or time-intensive
operations. This function suspends and immediately resumes
the coroutine, thus it supports cancellation. Calling yield also
allows redispatching, thanks to which one process will not starve
other processes. - kotlin-coroutines-deep-dive