andrewMacmurray/elm-concurrent-task/1.0.0

NOTE: the latest version is 2.0.0

ConcurrentTask

A Task similar to elm/core's Task but:

  • Allows concurrent execution of map2, map3, ...
  • Can safely call external JavaScript and chain the results (also known as Task Ports).

Built-in Tasks

Because elm-concurrent-task uses a different type to elm/core Task it's unfortunately not compatible with elm/core Tasks.

However, there are a number of tasks built into the JavaScript runner and supporting modules that should cover a large amount of the existing functionality of elm/core Tasks.

Check out the built-ins for more details:

Concurrent Tasks

type alias ConcurrentTask x a =
ConcurrentTask x a

A ConcurrentTask represents an asynchronous unit of work with the possibility of failure.

Underneath, each task represents a call to a JavaScript function and the runner handles batching and sequencing the calls.

{ function : String
, expect : Expect a
, errors : Errors x
, args : Value
}
-> ConcurrentTask x a

Define a ConcurrentTask from an external JavaScript function with:

  • The name of the registered function you want to call.
  • What you expect to come back from the function when it returns.
  • How to interpret errors coming from the function (exceptions or explicitly returned errors).
  • The encoded args to pass to the function.

Say you wanted to interact with the node filesystem:

Define your task in Elm:

import ConcurrentTask exposing (ConcurrentTask)
import Json.Encode as Encode


type Error
    = Error String


readFile : String -> ConcurrentTask Error String
readFile path =
    ConcurrentTask.define
        { function = "fs:readFile"
        , expect = ConcurrentTask.expectString
        , errors = ConcurrentTask.expectThrows Error
        , args = Encode.object [ ( "path", Encode.string path ) ]
        }

And in your JavaScript runner:

import * as fs from "node:fs/promises"
import * as ConcurrentTask from "@andrewmacmurray/elm-concurrent-task";




const app = Elm.Main.init({});


ConcurrentTask.register({
  tasks: {
    "fs:readFile": (args) => fs.readFile(args.path),
  },
  ports: {
    send: app.ports.send,
    receive: app.ports.receive,
  },
});

A note on Errors:

The example fs:readFile Task has very simple error handling (turn any thrown exceptions into the Error type). This can be a great way to start, but what if you want more detailed errors?

The Errors section goes into more detail on different error handling strategies, including:

  • how to define and handle more meaningful error types.
  • bypassing all errors for Tasks which never fail (e.g. get the current time, log to the console).
  • handling unexpected return data (e.g. the function returns an Int when you were expecting a String).

Expectations

type alias Expect a =
Expect a

Decode the response of a JS function into an Elm value.

expectJson : Decoder a -> Expect a

Run a JSON decoder on the response of a Task

expectString : Expect String

Expect the response of a Task to be a String

expectWhatever : Expect ()

Ignore the response of a Task

Error Handling

type alias Errors x =
Errors x

The Errors type provides different ways to capture errors for a ConcurrentTask.

Understanding Errors

ConcurrentTask has two main kinds of Errors:

Error

This is the x in the ConcurrentTask x a and represents an expected error as part of your task flow. You can handle these with mapError and onError.

UnexpectedError

You can think of these as unhandled errors that are not a normal part of your task flow.

The idea behind UnexpectedError is to keep your task flow types ConcurrentTask x a clean and meaningful, and optionally lift some of them into regular task flow where it makes sense. Two hooks you can use for this are onResponseDecoderFailure and onJsException.

See the section on UnexpectedErrors for more details.

expectThrows : (String -> x) -> Errors x

The simplest Error handler. If a JS function throws an Exception, it will be wrapped in the provided Error type.

Maybe your JS function throws an AccessError:

import ConcurrentTask exposing (ConcurrentTask)


type Error
    = MyError String


example : ConcurrentTask Error String
example =
    ConcurrentTask.define
        { function = "functionThatThrows"
        , expect = ConcurrentTask.expectString
        , errors = ConcurrentTask.expectThrows MyError
        , args = Encode.null
        }

When the task is run it will complete with Task.Error (MyError "AccessError: access denied"). This can be transformed and chained using Task.mapError and Task.onError.

NOTE:

This kind of error handling can be useful to get started quickly, but it's often much more expressive and useful if you catch and explicitly return error data in your JS function that can be decoded with the expectError handler.

expectErrors : Decoder x -> Errors x

Decode explicit errors returned by a ConcurrentTask. Use this when you want more meaningful errors in your task.

This will decode the value from an error key returned by a JS function, e.g.:

return {
  error: {
    code: "MY_ERROR_CODE",
    message: "Something Went Wrong",
  }
}

IMPORTANT NOTES:

  • If your function doesn't return an "error" key it will be interpreted as a success response.
  • If your JS function throws an exception it will surface an UnhandledJsException - make sure to either:
    • catch these in your JS function and return them as structured errors.
    • catch them with the onJsException hook.
  • If your error decoder fails the task will surface an ExpectErrorFailure.
    • This error is uncatchable, make sure to return data that matches your error decoder.

Maybe you want to handle different kinds of errors when writing to localStorage:

import ConcurrentTask exposing (ConcurrentTask)
import Json.Decode as Decode
import Json.Encode as Encode


type WriteError
    = QuotaExceeded
    | WriteBlocked


set : String -> String -> Task WriteError ()
set key value =
    ConcurrentTask.define
        { function = "storage:set"
        , expect = ConcurrentTask.expectWhatever
        , errors = ConcurrentTask.expectErrors decodeWriteError
        , args =
            Encode.object
                [ ( "key", Encode.string key )
                , ( "value", Encode.string value )
                ]
        }


decodeWriteError : Decode.Decoder WriteError
decodeWriteError =
    Decode.string
        |> Decode.andThen
            (\reason ->
                case reason of
                    "QUOTA_EXCEEDED" ->
                        Decode.succeed QuotaExceeded


                    "WRITE_BLOCKED" ->
                        Decode.succeed WriteBlocked


                    _ ->
                        Decode.fail ("Unknown WriteError Reason: " ++ reason)
            )

And on the JS side:

ConcurrentTask.register({
  tasks: {
    "storage:set": (args) => setItem(args),
  },
  ports: {
    send: app.ports.send,
    receive: app.ports.receive,
  },
});




function setItem(args) {
  try {
    localStorage.setItem(args.key, args.value);
  } catch (e) {
    if (e.name === "QuotaExceededError") {
      return {
        error: "QUOTA_EXCEEDED",
      };
    } else {
      return {
        error: "WRITE_BLOCKED",
      };
    }
  }
}
expectNoErrors : Errors x

Only use this handler for functions that you don't expect to fail.

NOTE: If the decoder fails or the function throws an exception, these will be surfaced as UnexpectedErrors.

e.g. logging to the console:

import ConcurrentTask exposing (ConcurrentTask)


log : String -> ConcurrentTask x ()
log msg =
    ConcurrentTask.define
        { function = "console:log"
        , expect = ConcurrentTask.expectWhatever ()
        , errors = ConcurrentTask.expectNoErrors
        , args = Encode.string msg
        }

On the JS side:

ConcurrentTask.register({
  tasks: {
    "console:log": (msg) => console.log(msg),
  },
  ports: {
    send: app.ports.send,
    receive: app.ports.receive,
  },
});

Error Hooks

Lift UnexpectedErrors into regular task flow.

(Error -> ConcurrentTask x a)
-> ConcurrentTask x a
-> ConcurrentTask x a

Use this alongside other error handlers to lift a ResponseDecoderFailure's Json.Decode error into regular task flow.

Maybe you want to represent an unexpected response as a BadBody error for a http request:

import ConcurrentTask exposing (ConcurrentTask)


type Error
    = Timeout
    | NetworkError
    | BadStatus Int
    | BadUrl String
    | BadBody Decode.Error


request : Request a -> ConcurrentTask Error a
request options =
    ConcurrentTask.define
        { function = "http:request"
        , expect = ConcurrentTask.expectJson options.expect
        , errors = ConcurrentTask.expectErrors decodeHttpErrors
        , args = encodeArgs options
        }
        |> ConcurrentTask.onResponseDecoderFailure (BadBody >> Task.fail)
( { message : String
, raw : Value
}
-> ConcurrentTask x a
)
-> ConcurrentTask x a
-> ConcurrentTask x a

Use this to capture a raw JsException to lift it into the task flow.

NOTE: Tasks defined with expectThrows will never trigger this hook, make sure to only use it with expectErrors and expectNoErrors.

Transforming Errors

mapError : (x -> y) -> ConcurrentTask x a -> ConcurrentTask y a

Transform the value of an Error (like map but for errors).

onError : (x -> ConcurrentTask y a) -> ConcurrentTask x a -> ConcurrentTask y a

If the previous Task fails, catch that error and return a new Task (like andThen but for errors).

Chaining Tasks

succeed : a -> ConcurrentTask x a

A Task that succeeds immediately when it's run.

fail : x -> ConcurrentTask x a

A Task that fails immediately when it's run.

andThen : (a -> ConcurrentTask x b) -> ConcurrentTask x a -> ConcurrentTask x b

Chain the successful result of the previous Task into another one.

Maybe you want to do a timestamped Http request

import ConcurrentTask exposing (ConcurrentTask)
import ConcurrentTask.Http as Http
import ConcurrentTask.Time
import Time


task : ConcurrentTask Http.Error String
task =
    ConcurrentTask.Time.now
        |> Task.andThen (createArticle "my article")


createArticle : String -> Time.Posix -> ConcurrentTask Http.Error String
createArticle title time =
    Http.request
        { url = "http://blog.com/articles"
        , method = "POST"
        , headers = []
        , expect = Http.expectString
        , body = Http.jsonBody (encodeArticle title time)
        }

Convenience Helpers

These are some general helpers that can make chaining and combining tasks more convenient.

fromResult : Result x a -> ConcurrentTask x a

Create a Task from a Result error value. The task will either immediately succeed or fail when run.

Maybe you want to chain together tasks with CSV parsing:

import ConcurrentTask exposing (ConcurrentTask)
import Csv


task : ConcurrentTask Error CsvData
task =
    readFile |> ConcurrentTask.andThen parseCsv


parseCsv : String -> ConcurrentTask Error CsvData
parseCsv raw =
    Csv.decode decoder raw
        |> ConcurrentTask.fromResult
        |> ConcurrentTask.mapError CsvError
andThenDo : ConcurrentTask x b -> ConcurrentTask x a -> ConcurrentTask x b

Similar to andThen but ignores the successful result of the previous Task.

Maybe you want to save a file then log a message to the console:

import ConcurrentTask exposing (ConcurrentTask)


task : ConcurrentTask Error ()
task =
    saveFile |> ConcurrentTask.andThenDo (log "file saved")
return : b -> ConcurrentTask x a -> ConcurrentTask x b

Succeed with a hardcoded value after the previous Task

Maybe you want to do some Tasks on a User but allow it to be chained onwards:

import ConcurrentTask exposing (ConcurrentTask)


saveUser : User -> ConcurrentTask Error User
saveUser user =
    saveToDatabase user
        |> ConcurrentTask.andThenDo (log "user saved")
        |> ConcurrentTask.return user

Batch Helpers

When you need to combine many tasks together.

Stack Safety

These helpers are carefully written to be stack safe. Use them if you're handling large lists of tasks (> 2000).

batch : List (ConcurrentTask x a) -> ConcurrentTask x (List a)

Perform a List of tasks concurrently (similar to Promise.all() in JavaScript) and return the results in a List.

If any of the subtasks fail the whole ConcurrentTask will fail.

sequence : List (ConcurrentTask x a) -> ConcurrentTask x (List a)

Perform a List of tasks one after the other and return the results in a List.

If any of the subtasks fail the whole ConcurrentTask will fail.

Maps

Transform values returned from tasks.

map : (a -> b) -> ConcurrentTask x a -> ConcurrentTask x b

Transform the value from a task.

Maybe you want to find what time it is in one hour.

import ConcurrentTask as exposing (ConcurrentTask)
import ConcurrentTask.Time
import Time


timeInOneHour : ConcurrentTask x Time.Posix
timeInOneHour =
    ConcurrentTask.map addOneHour ConcurrentTask.Time.now


addOneHour : Time.Posix -> Time.Posix
addOneHour time =
    Time.millisToPosix (Time.posixToMillis time + 60 * 60 * 1000)
andMap : ConcurrentTask x a -> ConcurrentTask x (a -> b) -> ConcurrentTask x b

Combine an arbitrary number of tasks together concurrently.

Maybe you want to load multiple pieces of config into a record:

import ConcurrentTask exposing (ConcurrentTask)


type alias Config =
    { dbConfig : DbConfig
    , yamlConfig : YamlConfig
    , envFile : EnvFile
    }


 loadConfig : ConcurrentTask Error Config
 loadConfig =
    ConcurrentTask.succeed Config
        |> ConcurrentTask.andMap loadDbConfig
        |> ConcurrentTask.andMap loadYamlConfig
        |> ConcurrentTask.andMap loadEnvFile
(a -> b -> c)
-> ConcurrentTask x a
-> ConcurrentTask x b
-> ConcurrentTask x c

Run two tasks concurrently and combine their results.

import ConcurrentTask exposing (ConcurrentTask)
import ConcurrentTask.Time
import Time


loadUserAndTime : ConcurrentTask Error ( User, Time.Posix )
loadUserAndTime =
    ConcurrentTask.map2 Tuple.pair loadUser ConcurrentTask.Time.now
(a -> b -> c -> d)
-> ConcurrentTask x a
-> ConcurrentTask x b
-> ConcurrentTask x c
-> ConcurrentTask x d

Run three tasks concurrently and combine their results.

(a -> b -> c -> d -> e)
-> ConcurrentTask x a
-> ConcurrentTask x b
-> ConcurrentTask x c
-> ConcurrentTask x d
-> ConcurrentTask x e

Run four tasks concurrently and combine their results.

(a -> b -> c -> d -> e -> f)
-> ConcurrentTask x a
-> ConcurrentTask x b
-> ConcurrentTask x c
-> ConcurrentTask x d
-> ConcurrentTask x e
-> ConcurrentTask x f

Run five tasks concurrently and combine their results.

Run a Task

Once you've constructed a Task it needs to be passed to the runner to perform all of the effects.

Here's a minimal complete example:

A task to fetch 3 resources concurrently:

type alias Titles =
    { todo : String
    , post : String
    , album : String
    }


getAllTitles : ConcurrentTask Http.Error Titles
getAllTitles =
    Task.map3 Titles
        (getTitle "/todos/1")
        (getTitle "/posts/1")
        (getTitle "/albums/1")


getTitle : String -> ConcurrentTask Http.Error String
getTitle path =
    Http.request
        { url = "https://jsonplaceholder.typicode.com" ++ path
        , method = "GET"
        , headers = []
        , body = Http.emptyBody
        , expect = Http.expectJson (Decode.field "title" Decode.string)
        }

A program to run the task:

port module Example exposing (main)


import ConcurrentTask exposing (ConcurrentTask)
import ConcurrentTask.Http as Http
import Json.Decode as Decode


type alias Model =
    { tasks : ConcurrentTask.Pool Msg Http.Error Titles
    }


type Msg
    = OnProgress ( ConcurrentTask.Pool Msg Http.Error Titles, Cmd Msg )
    | OnComplete (ConcurrentTask.Response Http.Error Titles)


init : ( Model, Cmd Msg )
init =
    let
        ( tasks, cmd ) =
            ConcurrentTask.attempt
                { send = send
                , pool = Task.pool
                , onComplete = OnComplete
                }
                getAllTitles
    in
    ( { tasks = tasks }, cmd )


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        OnComplete response ->
            let
                _ =
                    Debug.log "response" response
            in
            ( model, Cmd.none )


        OnProgress ( tasks, cmd ) ->
            ( { model | tasks = tasks }, cmd )


subscriptions : Model -> Sub Msg
subscriptions model =
    ConcurrentTask.onProgress
        { send = send
        , receive = receive
        , onProgress = OnProgress
        }
        model.tasks


port send : Decode.Value -> Cmd msg


port receive : (Decode.Value -> msg) -> Sub msg


main : Program {} Model Msg
main =
    Platform.worker
        { init = always init
        , update = update
        , subscriptions = subscriptions
        }
{ pool : Pool msg x a
, send : Value -> Cmd msg
, onComplete : Response x a -> msg
}
-> ConcurrentTask x a
-> ( Pool msg x a, Cmd msg )

Start a ConcurrentTask. This needs:

  • A task Pool (The internal model to keep track of task progress).
  • The send port.
  • The Msg to be called when the task completes.
  • Your ConcurrentTask to be run.

Make sure to update your Model and pass in the Cmd returned from attempt. e.g. in a branch of update:

let
    ( tasks, cmd ) =
        ConcurrentTask.attempt
            { send = send
            , pool = model.pool
            , onComplete = OnComplete
            }
            myTask
in
( { model | tasks = tasks }, cmd )
type Response x a
= Success a
| Error x
| UnexpectedError UnexpectedError

The value returned from a task when it completes (returned in the OnComplete msg).

Can be either:

  • Success a - the task succeeded with no errors, woo!
  • Error x - the task failed with an expected error.
  • UnexpectedError - the task failed with an unexpected error (see the section on Error Handling for more details).
= UnhandledJsException
{ function : String
, message : String
, raw : Value
}
| ResponseDecoderFailure
{ function : String
, error : Error
}
| ErrorsDecoderFailure
{ function : String
, error : Error
}
| MissingFunction String
| InternalError String

This error will surface if something unexpected has happened during the task flow.

Catchable Errors

These errors will be surfaced if not handled during task flow:

  • UnhandledJsException - a task threw an exception and was not caught with an error handler (can be caught with expectThrows and onJsException).
  • ResponseDecoderFailure - a task returned an unexpected response (can be caught with onResponseDecoderFailure).

Uncatchable Errors

These errors will always surface, as they are assumed to have no meaningful way to recover from during regular task flow:

  • ErrorsDecoderFailure - a task returned error data in an unexpected format when using an expectErrors handler.
  • MissingFunction - a task tried to call a function in the JS runner which was not registered.
  • InternalError - something went wrong with the runner internals - this should not happen, but if you see this error please leave details and an issue.
{ send : Value -> Cmd msg
, receive : (Value -> msg) -> Sub msg
, onProgress : ( Pool msg x a, Cmd msg ) -> msg
}
-> Pool msg x a
-> Sub msg

Subscribe to updates from the JavaScript task runner.

This needs:

  • The send port.
  • The receive port.
  • The Msg to be called with the updated progress.
  • The task Pool stored in your model.

You can wire this in like so:

subscriptions : Model -> Sub Msg
subscriptions model =
    ConcurrentTask.onProgress
        { send = send
        , receive = receive
        , onProgress = OnProgress
        }
        model.tasks

Make sure to update your Model and pass in the Cmd in your OnProgress branch in update:

OnProgress ( tasks, cmd ) ->
    ( { model | tasks = tasks }, cmd )
type alias Pool msg x a =
Pool msg x a

A Pool keeps track of each task's progress, and allows multiple Task attempts to be in-flight at the same time.

pool : Pool msg x a

Create an empty ConcurrentTask Pool.

Right now it doesn't expose any functionality, but it could be used in the future to do things like:

  • Buffer the number of in-flight tasks (e.g. a server request queue, or database connection pool).
  • Handle graceful process termination (e.g. abort or cleanup all in-flight tasks).
  • Expose metrics on previous or running tasks.