toffor
toffor

Reputation: 1299

CoroutineScope - CompletableDeferred cancellation

I have two questions about this topic. I will use these in android with use case classes and i try to implement an architecture similar to this https://www.youtube.com/watch?v=Sy6ZdgqrQp0 but i need some answers.

1) I have a deferred with async builder and when i cancel job then the other chains cancelled too. This code prints "Call cancelled". But i am not sure that if i am doing correct.

fun main(args: Array<String>) = runBlocking<Unit> {
    val job = GlobalScope.launch {
        println(getUser())
    }
    job.cancelAndJoin()
}

suspend fun getUser() = getUserDeferred().await()


suspend fun getUserDeferred() = coroutineScope {

    val request = Request.Builder()
            .url("https://jsonplaceholder.typicode.com/users")
            .build()

    val call = OkHttpClient().newCall(request)

    val deferred = async(Dispatchers.IO) {
        val body = call.execute()
        body.body()?.string() ?: ""
    }

    deferred.invokeOnCompletion {
        if (deferred.isCancelled) {
            println("Call cancelled")
            call.cancel()
        }
    }
    deferred
}

2) I can't find a way to cancel this one. I want to use this in retrofit2 call adapter, is there any better way to handle this case.

fun main(args: Array<String>) = runBlocking<Unit> {
    val job = GlobalScope.launch {
        println(getUser1())
    }
    job.cancelAndJoin()
}

suspend fun getUser1() = getUser1Deferred().await()


fun getUser1Deferred(): Deferred<String> {
    val request = Request.Builder()
            .url("https://jsonplaceholder.typicode.com/users")
            .build()

    val call = OkHttpClient().newCall(request)

    val deferred = CompletableDeferred<String>()

    call.enqueue(object : Callback {

        override fun onFailure(call: Call, e: IOException) {
            deferred.complete("Error")
        }

        override fun onResponse(call: Call, response: Response) {
            deferred.complete(response.body()?.string() ?: "Error")
        }

    })

    deferred.invokeOnCompletion {
        if (deferred.isCancelled) {
            println("Call cancelled")
            call.cancel()
        }
    }
    return deferred
}

Upvotes: 4

Views: 7427

Answers (2)

mark.kedzierski
mark.kedzierski

Reputation: 663

The reason the 2nd case is not cancelling is because you are using CompletableDeferred. It isn't launched as a coroutine so isn't a child of your parent coroutine. So if you cancel the parent it will not cancel the deferred.

It works in the first case because async starts a new child coroutine which is linked to the parent. when you cancel either one they both get cancelled.

In order to link the Deferred to your parent Job you would need a reference to it and use invokeOnCompletion

var deferred : Deferred<Void>? = null
launch {        
   deferred = retroService.someDeferredCall()
   deferred.await()
}.invokeOnCompletion {
   //job was cancelled.  Probably activity closing.
   if(it is CancellationException) {
      deferred?.let { it.cancel() }
   }
}

Not terribly pretty but should get the job done.

Upvotes: 0

Marko Topolnik
Marko Topolnik

Reputation: 200256

You should avoid the first approach because it blocks a thread in a thread pool. Using the second approach you can propagate cancellation both ways. If you cancel the Deferred it will cancel the call, and if the call fails, it will cancel the Deferred with the exception it got.

fun getUserAsync(): Deferred<String> {
    val call = OkHttpClient().newCall(Request.Builder()
            .url("https://jsonplaceholder.typicode.com/users")
            .build())
    val deferred = CompletableDeferred<String>().apply {
        invokeOnCompletion {
            if (isCancelled) {
                call.cancel()
            }
        }
    }
    call.enqueue(object : Callback {
        override fun onResponse(call: Call, response: Response) {
            deferred.complete(response.body()?.string() ?: "Error")
        }
        override fun onFailure(call: Call, e: IOException) {
            deferred.cancel(e)
        }

    })
    return deferred
}

However, going the Deferred route is probably a red herring. If you are cancelling it, the underlying reason is that you're bailing out of the whole task you're doing. You should instead cancel the whole coroutine it runs in. If you properly implement structured concurrency, everything will happen automatically if your activity gets destroyed.

So my recommendation is to use this code:

suspend fun getUser() = suspendCancellableCoroutine<String> { cont ->
    val call = OkHttpClient().newCall(Request.Builder()
            .url("https://jsonplaceholder.typicode.com/users")
            .build())
    cont.invokeOnCancellation {
        call.cancel()
    }
    call.enqueue(object : Callback {
        override fun onResponse(call: Call, response: Response) {
            cont.resume(response.body()?.string() ?: "Error")
        }
        override fun onFailure(call: Call, e: IOException) {
            cont.resumeWithException(e)
        }

    })
}

If you absolutely need the Deferred because you're running it concurrently in the background, it's easy to do using the above:

val userDeferred = this.async { getUser() }

Where I assume this is your activity, which is also a CoroutineScope.

Upvotes: 5

Related Questions