Eagerly cancel async bindings (#37)

Make suspendible binding function eagerly cancel child jobs by cancelling wrapping scope
This commit is contained in:
Tristan H 2020-11-28 22:50:10 +00:00 committed by GitHub
parent 3c5b432b55
commit c8372a0522
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 98 additions and 3 deletions

View File

@ -4,6 +4,9 @@ import com.github.michaelbull.result.Err
import com.github.michaelbull.result.Ok import com.github.michaelbull.result.Ok
import com.github.michaelbull.result.Result import com.github.michaelbull.result.Result
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.cancel
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlin.contracts.InvocationKind import kotlin.contracts.InvocationKind
@ -11,16 +14,20 @@ import kotlin.contracts.contract
/** /**
* Suspending variant of [binding][com.github.michaelbull.result.binding]. * Suspending variant of [binding][com.github.michaelbull.result.binding].
* Wraps the suspendable block in a new coroutine scope.
* This scope is cancelled once a failing bind is encountered, allowing deferred child jobs to be eagerly cancelled.
*/ */
public suspend inline fun <V, E> binding(crossinline block: suspend SuspendableResultBinding<E>.() -> V): Result<V, E> { public suspend inline fun <V, E> binding(crossinline block: suspend SuspendableResultBinding<E>.() -> V): Result<V, E> {
contract { contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE) callsInPlace(block, InvocationKind.EXACTLY_ONCE)
} }
val receiver = SuspendableResultBindingImpl<E>() val receiver = SuspendableResultBindingImpl<E>()
return try { return try {
with(receiver) { Ok(block()) } coroutineScope {
receiver.coroutineScope = this@coroutineScope
with(receiver) { Ok(block()) }
}
} catch (ex: BindCancellationException) { } catch (ex: BindCancellationException) {
receiver.internalError receiver.internalError
} }
@ -37,6 +44,7 @@ internal class SuspendableResultBindingImpl<E> : SuspendableResultBinding<E> {
private val mutex = Mutex() private val mutex = Mutex()
lateinit var internalError: Err<E> lateinit var internalError: Err<E>
var coroutineScope: CoroutineScope? = null
override suspend fun <V> Result<V, E>.bind(): V { override suspend fun <V> Result<V, E>.bind(): V {
return when (this) { return when (this) {
@ -47,6 +55,7 @@ internal class SuspendableResultBindingImpl<E> : SuspendableResultBinding<E> {
internalError = this internalError = this
} }
} }
coroutineScope?.cancel(BindCancellationException)
throw BindCancellationException throw BindCancellationException
} }
} }

View File

@ -7,6 +7,7 @@ import com.github.michaelbull.result.coroutines.runBlockingTest
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue import kotlin.test.assertTrue
class SuspendableBindingTest { class SuspendableBindingTest {
@ -100,6 +101,48 @@ class SuspendableBindingTest {
} }
} }
@Test
fun returnsStateChangedUntilFirstBindFailed() {
var xStateChange = false
var yStateChange = false
var zStateChange = false
suspend fun provideX(): Result<Int, BindingError> {
delay(1)
xStateChange = true
return Ok(1)
}
suspend fun provideY(): Result<Int, BindingError> {
delay(10)
yStateChange = true
return Err(BindingError)
}
suspend fun provideZ(): Result<Int, BindingError> {
delay(1)
zStateChange = true
return Err(BindingError)
}
runBlockingTest {
val result = binding<Int, BindingError> {
val x = provideX().bind()
val y = provideY().bind()
val z = provideZ().bind()
x + y + z
}
assertTrue(result is Err)
assertEquals(
expected = BindingError,
actual = result.error
)
assertTrue(xStateChange)
assertTrue(yStateChange)
assertFalse(zStateChange)
}
}
@Test @Test
fun returnsFirstErrIfBindingsOfDifferentTypesFailed() { fun returnsFirstErrIfBindingsOfDifferentTypesFailed() {
suspend fun provideX(): Result<Int, BindingError> { suspend fun provideX(): Result<Int, BindingError> {

View File

@ -8,6 +8,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue import kotlin.test.assertTrue
class AsyncSuspendableBindingTest { class AsyncSuspendableBindingTest {
@ -47,7 +48,7 @@ class AsyncSuspendableBindingTest {
@Test @Test
fun returnsFirstErrIfBindingFailed() { fun returnsFirstErrIfBindingFailed() {
suspend fun provideX(): Result<Int, BindingError> { suspend fun provideX(): Result<Int, BindingError> {
delay(1) delay(3)
return Ok(1) return Ok(1)
} }
@ -76,4 +77,46 @@ class AsyncSuspendableBindingTest {
) )
} }
} }
@Test
fun returnsStateChangedForOnlyTheFirstAsyncBindFailWhenEagerlyCancellingBinding() {
var xStateChange = false
var yStateChange = false
var zStateChange = false
suspend fun provideX(): Result<Int, BindingError> {
delay(20)
xStateChange = true
return Ok(1)
}
suspend fun provideY(): Result<Int, BindingError.BindingErrorA> {
delay(10)
yStateChange = true
return Err(BindingError.BindingErrorA)
}
suspend fun provideZ(): Result<Int, BindingError.BindingErrorB> {
delay(1)
zStateChange = true
return Err(BindingError.BindingErrorB)
}
runBlocking {
val result = binding<Int, BindingError> {
val x = async { provideX().bind() }
val y = async { provideY().bind() }
val z = async { provideZ().bind() }
x.await() + y.await() + z.await()
}
assertTrue(result is Err)
assertEquals(
expected = BindingError.BindingErrorB,
actual = result.error
)
assertFalse(xStateChange)
assertFalse(yStateChange)
assertTrue(zStateChange)
}
}
} }