Asynchronous Effects

Chapter Goals

This chapter focuses on the Aff monad, which is similar to the Effect monad, but represents asynchronous side-effects. We'll demonstrate examples of asynchronously interacting with the filesystem and making HTTP requests. We'll also cover managing sequential and parallel execution of asynchronous effects.

Project Setup

New PureScript libraries introduced in this chapter are:

  • aff - defines the Aff monad.
  • node-fs-aff - asynchronous filesystem operations with Aff.
  • affjax - HTTP requests with AJAX and Aff.
  • parallel - parallel execution of Aff.

When running outside of the browser (such as in our Node.js environment), the affjax library requires the xhr2 NPM module, which is listed as a dependency in the package.json of this chapter. Install that by running:

$ npm install

Asynchronous JavaScript

A convenient way to work with asynchronous code in JavaScript is with async and await. See this article on asynchronous JavaScript for more background information.

Here is an example of using this technique to copy the contents of one file to another file:

import { promises as fsPromises } from 'fs'

async function copyFile(file1, file2) {
  let data = await fsPromises.readFile(file1, { encoding: 'utf-8' });
  fsPromises.writeFile(file2, data, { encoding: 'utf-8' });
}

copyFile('file1.txt', 'file2.txt')
.catch(e => {
  console.log('There was a problem with copyFile: ' + e.message);
});

It is also possible to use callbacks or synchronous functions, but those are less desirable because:

  • Callbacks lead to excessive nesting, known as "Callback Hell" or the "Pyramid of Doom".
  • Synchronous functions block execution of the other code in your app.

Asynchronous PureScript

The Aff monad in PureScript offers similar ergonomics of JavaScript's async/await syntax. Here is the same copyFile example from before, but rewritten in PureScript using Aff:

import Prelude
import Data.Either (Either(..))
import Effect.Aff (Aff, attempt, message, launchAff_)
import Effect (Effect)
import Effect.Class.Console (log)
import Node.Encoding (Encoding(..))
import Node.FS.Aff (readTextFile, writeTextFile)
import Node.Path (FilePath)

main :: Effect Unit
main = launchAff_ program

program :: Aff Unit
program = do
  result <- attempt $ copyFile "file1.txt" "file2.txt"
  case result of
    Left e -> log $ "There was a problem with copyFile: " <> message e
    _ -> pure unit

copyFile :: FilePath -> FilePath -> Aff Unit
copyFile file1 file2 = do
  my_data <- readTextFile UTF8 file1
  writeTextFile UTF8 file2 my_data

Note that we have to use launchAff_ to convert the Aff to Effect because main must be Effect Unit.

It is also possible to re-write the above snippet using callbacks or synchronous functions (for example, with Node.FS.Async and Node.FS.Sync, respectively), but those share the same downsides as discussed earlier with JavaScript, so that coding style is not recommended.

The syntax for working with Aff is very similar to working with Effect. They are both monads and can therefore be written with do notation.

For example, if we look at the signature of readTextFile, we see that it returns the file contents as a String wrapped in Aff:

readTextFile :: Encoding -> FilePath -> Aff String

We can "unwrap" the returned string with a bind arrow (<-) in do notation:

my_data <- readTextFile UTF8 file1

Then pass it as the string argument to writeTextFile:

writeTextFile :: Encoding -> FilePath -> String -> Aff Unit

The only other notable feature unique to Aff in the above example is attempt, which captures errors or exceptions encountered while running Aff code and stores them in an Either:

attempt :: forall a. Aff a -> Aff (Either Error a)

You should hopefully be able to draw on your knowledge of concepts from previous chapters and combine this with the new Aff patterns learned in the above copyFile example to tackle the following exercises:

Exercises

  1. (Easy) Write a concatenateFiles function that concatenates two text files.

  2. (Medium) Write a function concatenateMany to concatenate multiple text files, given an array of input and output file names. Hint: use traverse.

  3. (Medium) Write a function countCharacters :: FilePath -> Aff (Either Error Int) that returns the number of characters in a file, or an error if one is encountered.

Additional Aff Resources

If you haven't already looked at the official Aff guide, skim through that now. It's not a direct prerequisite for completing the remaining exercises in this chapter, but you may find it helpful to lookup some functions on Pursuit.

You're also welcome to consult these supplemental resources too, but again, the exercises in this chapter don't depend on them:

A HTTP Client

The affjax library offers a convenient way to make asynchronous AJAX HTTP requests with Aff. Depending on what environment you are targeting, you need to use either the purescript-affjax-web or the purescript-affjax-node library.

In the rest of this chapter, we will be targeting node and thus using purescript-affjax-node. Consult the Affjax docs for more usage information. Here is an example that makes HTTP GET requests at a provided URL and returns the response body or an error message:

import Prelude
import Affjax.Node as AN
import Affjax.ResponseFormat as ResponseFormat
import Data.Either (Either(..))
import Effect.Aff (Aff)

getUrl :: String -> Aff String
getUrl url = do
  result <- AN.get ResponseFormat.string url
  pure case result of
    Left err -> "GET /api response failed to decode: " <> AN.printError err
    Right response -> response.body

When calling this in the repl, launchAff_ is required to convert the Aff to a repl-compatible Effect:

$ spago repl

> :pa
… import Prelude
… import Effect.Aff (launchAff_)
… import Effect.Class.Console (log)
… import Test.HTTP (getUrl)
…
… launchAff_ do
…   str <- getUrl "https://reqres.in/api/users/1"
…   log str
…
unit
{"data":{"id":1,"email":"george.bluth@reqres.in","first_name":"George","last_name":"Bluth", ...}}

Exercises

  1. (Easy) Write a function writeGet which makes an HTTP GET request to a provided url, and writes the response body to a file.

Parallel Computations

We've seen how to use the Aff monad and do notation to compose asynchronous computations in sequence. It would also be useful to be able to compose asynchronous computations in parallel. With Aff, we can compute in parallel simply by initiating our two computations one after the other.

The parallel package defines a type class Parallel for monads like Aff, which support parallel execution. When we met applicative functors earlier in the book, we observed how applicative functors can be useful for combining parallel computations. In fact, an instance for Parallel defines a correspondence between a monad m (such as Aff) and an applicative functor f that can be used to combine computations in parallel:

class (Monad m, Applicative f) <= Parallel f m | m -> f, f -> m where
  sequential :: forall a. f a -> m a
  parallel :: forall a. m a -> f a

The class defines two functions:

  • parallel, which takes computations in the monad m and turns them into computations in the applicative functor f, and
  • sequential, which performs a conversion in the opposite direction.

The aff library provides a Parallel instance for the Aff monad. It uses mutable references to combine Aff actions in parallel by keeping track of which of the two continuations has been called. When both results have been returned, we can compute the final result and pass it to the main continuation.

Because applicative functors support lifting of functions of arbitrary arity, we can perform more computations in parallel by using the applicative combinators. We can also benefit from all of the standard library functions which work with applicative functors, such as traverse and sequence!

We can also combine parallel computations with sequential portions of code by using applicative combinators in a do notation block, or vice versa, using parallel and sequential to change type constructors where appropriate.

To demonstrate the difference between sequential and parallel execution, we'll create an array of 100 10-millisecond delays, then execute those delays with both techniques. You'll notice in the repl that seqDelay is much slower than parDelay. Note that parallel execution is enabled by simply replacing sequence_ with parSequence_.

import Prelude

import Control.Parallel (parSequence_)
import Data.Array (replicate)
import Data.Foldable (sequence_)
import Effect (Effect)
import Effect.Aff (Aff, Milliseconds(..), delay, launchAff_)

delayArray :: Array (Aff Unit)
delayArray = replicate 100 $ delay $ Milliseconds 10.0

seqDelay :: Effect Unit
seqDelay = launchAff_ $ sequence_ delayArray

parDelay :: Effect Unit
parDelay = launchAff_ $ parSequence_ delayArray
$ spago repl

> import Test.ParallelDelay

> seqDelay -- This is slow
unit

> parDelay -- This is fast
unit

Here's a more real-world example of making multiple HTTP requests in parallel. We're reusing our getUrl function to fetch information from two users in parallel. Note that parTraverse (the parallel version of traverse) is used in this case. This example would also work fine with traverse instead, but it will be slower.

import Prelude

import Control.Parallel (parTraverse)
import Effect (Effect)
import Effect.Aff (launchAff_)
import Effect.Class.Console (logShow)
import Test.HTTP (getUrl)

fetchPar :: Effect Unit
fetchPar =
  launchAff_ do
    let
      urls = map (\n -> "https://reqres.in/api/users/" <> show n) [ 1, 2 ]
    res <- parTraverse getUrl urls
    logShow res
$ spago repl

> import Test.ParallelFetch

> fetchPar
unit
["{\"data\":{\"id\":1,\"email\":\"george.bluth@reqres.in\", ... }"
,"{\"data\":{\"id\":2,\"email\":\"janet.weaver@reqres.in\", ... }"
]

A full listing of available parallel functions can be found in the parallel docs on Pursuit. The aff docs section on parallel also contains more examples.

Exercises

  1. (Easy) Write a concatenateManyParallel function with the same signature as the earlier concatenateMany function but reads all input files in parallel.

  2. (Medium) Write a getWithTimeout :: Number -> String -> Aff (Maybe String) function which makes an HTTP GET request at the provided URL and returns either:

    • Nothing: if the request takes longer than the provided timeout (in milliseconds).
    • The string response: if the request succeeds before the timeout elapses.
  3. (Difficult) Write a recurseFiles function that takes a "root" file and returns an array of all paths listed in that file (and listed in the listed files too). Read listed files in parallel. Paths are relative to the directory of the file they appear in. Hint: The node-path module has some helpful functions for negotiating directories.

For example, if starting from the following root.txt file:

$ cat root.txt
a.txt
b/a.txt
c/a/a.txt

$ cat a.txt
b/b.txt

$ cat b/b.txt
c/a.txt

$ cat b/c/a.txt

$ cat b/a.txt

$ cat c/a/a.txt

The expected output is:

["root.txt","a.txt","b/a.txt","b/b.txt","b/c/a.txt","c/a/a.txt"]

Conclusion

In this chapter, we covered asynchronous effects and learned how to:

  • Run asynchronous code in the Aff monad with the aff library.
  • Make HTTP requests asynchronously with the affjax library.
  • Run asynchronous code in parallel with the parallel library.