Cancellable Tasks And Beyond

Cancellable Tasks (IcedTasks)

Introduction

F# has traditionally supported Asynchronous programming with the Async computation expression. However, Tasks in the .NET ecosystem have exploded in usage within the Core library. There has been an interop story by using Async.AwaitTask but for many reasons, F# 6.0 released with native task computation expression support. There are a few differences between Async and Tasks. The big one for me was the loss of implicit CancellationToken passing.

For instance:

    let doSlowWorkAsync (conn : IDbConnection) = async {
        let! ct = Async.CancellationToken
        let cmdDef = CommandDefinition("select pg_sleep(10)",cancellationToken = ct)
        let! _ = conn.QueryAsync(cmdDef) |> Async.AwaitTask
        ()
    }

    // vs

    // The Async.CancellationToken moved to a parameter here
    let doSlowWorkTask (ct : CancellationToken) (conn : IDbConnection) = task {
        let cmdDef = CommandDefinition("select pg_sleep(10)",cancellationToken = ct)
        let! _ = conn.QueryAsync(cmdDef)
        ()
    }

This may not look like much of a difference in this short, but this can explode with complexity as you’re now passing CancellationTokens around to functions that may not even require them. For a more complex example, having to pass CancellationTokens everywhere really pollutes your api example:


module BusinessLogic2 =
    open System.Threading
    open System.Threading.Tasks
    open Microsoft.Extensions.Caching.Distributed

    let preserveEndocrins (logger: ILogger) (ct: CancellationToken) () = 
        task {
            do! Task.Delay(100, ct)
        }

    let monotonectallyImplementErrorFreeConvergence (ct: CancellationToken) (logger: ILogger) id = 
        task {
            let! _ = preserveEndocrins logger ct ()
            return id
        }

    let fungiblyFacilitateTechnicallySoundResults id (ct: CancellationToken) conn = 
        task {
            let! _ = Database.doSlowWork2 ct conn
            ()
        }

    let completelyTransitionBackendRelationships (ct: CancellationToken) () = 
        task {
            //send an email?
            ()
        }

    let continuallyActualizeImperatives (logger: ILogger) (ct: CancellationToken) (conn) = 
        task {
            for i = 0 to 100000 do
                let! r1 = monotonectallyImplementErrorFreeConvergence ct logger i
                let! _ = fungiblyFacilitateTechnicallySoundResults r1 ct conn
                ()
        }

    let reticulatingSplines
        (logger: ILogger)
        (caching: IDistributedCache)
        (ct: CancellationToken)
        (conn)
        =
        task {
            logger.LogDebug("Started reticulatingSplines")
            let! _ = preserveEndocrins logger ct ()
            logger.LogInformation("preserveEndocrins might be doing something strange?")
            let! _ = Database.doSlowWork2 ct conn
            let! _ = continuallyActualizeImperatives logger ct conn
            let! _ = completelyTransitionBackendRelationships ct ()
            ()
        }

There’s got to be a better way

Introducing IcedTasks which provides a few different task based computation expressions. The one we’re going to focus on today is CancellableTask<'T> - Alias for CancellationToken -> Task<'T>. If we look at our first example, we can replace it to be:

    let doSlowWork3 (conn : IDbConnection) = cancellableTask {
        let! ct = CancellableTask.getCancellationToken ()
        let cmdDef = CommandDefinition("select pg_sleep(10)",cancellationToken = ct)
        let! _ = conn.QueryAsync(cmdDef)
        ()
    }

This looks more similar to the Async version! But how does our BusinessLogic look?


module BusinessLogic3 =
    open System.Threading
    open System.Threading.Tasks
    open Microsoft.Extensions.Caching.Distributed
    open IcedTasks

    let preserveEndocrins (logger: ILogger) () = cancellableTask {
        // You can bind against `CancellationToken -> Task<'T>` calls 
        // no need for `CancellableTask.getCancellationToken()`.
        do! fun ct -> Task.Delay(100, ct)
    }

    let monotonectallyImplementErrorFreeConvergence (logger: ILogger) id = 
        cancellableTask {
            let! _ = preserveEndocrins logger ()
            return id
        }

    let fungiblyFacilitateTechnicallySoundResults id conn = 
        cancellableTask {
            let! _ = Database.doSlowWork3 conn
            ()
        }

    let completelyTransitionBackendRelationships () = 
        cancellableTask {
            //send an email?
            ()
        }

    let continuallyActualizeImperatives (logger: ILogger) (conn) = 
        cancellableTask {
            for i = 0 to 100000 do
                let! r1 = monotonectallyImplementErrorFreeConvergence logger i
                let! _ = fungiblyFacilitateTechnicallySoundResults r1 conn
                ()
        }

    let reticulatingSplines (logger: ILogger) (caching: IDistributedCache) (conn) = 
        cancellableTask {
            logger.LogDebug("Started reticulatingSplines")
            let! _ = preserveEndocrins logger ()
            logger.LogInformation("preserveEndocrins might be doing something strange?")
            let! _ = Database.doSlowWork3 conn
            let! _ = continuallyActualizeImperatives logger conn
            let! _ = completelyTransitionBackendRelationships ()
            ()
        }

All the calls have removed the need for CancellationToken! I want to point out are two ways to get at the CancellationToken

  • Binding against CancellationToken -> Task<'T>
  • Binding against let! ct = CancellableTask.getCancellationToken ().

Cool story but why would I want to use CancellationTokens in the first place?

CancellationTokens allow you to stop use of expensive resources. For example, you may have a WebApi that takes to a database. This database query could take a bit of time, however your user may navigate away from the page. Without a CancellationToken being passed along to the database callsite, it could keep processing in the background and waste resources.

An example of this code on github is here.

Ok I’m sold… but I’m already using TaskResult everywhere.

Awesome! FsToolkit.ErrorHandling.IcedTasks is available and provides a CancellableTaskResult<'T> which is an alias CancellationToken -> Task<Result<'T, 'TError>>.

Conclusion

Written on December 14, 2022