Asynchronous programming in late 2018: Cats-Effect

Piotr Gawryś | twitter.com/p_gawrys

Agenda

  • Cats-Effect introduction
  • Problem description
  • Connect and register
  • Process passwords
  • Work parallelization
  • Error handling
  • Optimizations

Referential Transparency

Replacing an expression by its bound value doesn't alter the behavior of your program. Why?

  • Local reasoning
  • Fearless refactoring
  • Explicit behavior
  • Inversion of control
  • Compositionality

See this excellent description if you wish to know more! 

Cats-Effect IO

 

  • Referentially transparent
  • Manages both synchronous and asynchronous effects
  • Resource Safety (bracket)
  • Error Handling
  • Concurrency, Parallelism
  • Building blocks for sharing state and synchronization (Ref, MVar, Deferred)

Cats-Effect IO - Basic Usage

import cats.effect.IO

val ioa = IO { println("hi KSUG!") }

// nothing happens yet, it's just a description 
// of evaluation that will produce `Unit` or 
// end with error
val program: IO[Unit] =
  for {
     _ <- ioa
     _ <- ioa
  } yield ()

// prints "hi KSUG!" twice
program.unsafeRunSync() 
import cats.effect.IO
import cats.syntax.apply._

val program: IO[Unit] = 
  ioa.flatMap(_ => ioa)

val program: IO[Unit] = 
  ioa *> ioa
import cats.effect.IO
import scala.concurrent.duration._


// do `fa` followed by `fb`,
// discard the first result
def *>[A, B](fa: F[A])(fb: F[B]): F[B] =
  map2(fa, fb)((_, b) => b)

// waits 100 millis (without blocking!) 
// then prints "woke up"
IO.sleep(100.millis) *> IO(println("woke up"))

Cats-Effect IO - Error Handling I

import cats.effect.IO

val ioa = IO(println("A"))
val iob = IO.raiseError(new Exception("B"))
val ioc = IO(println("C"))

val program: IO[Int] = 
  for {
    _ <- ioa
    _ <- iob // short circuits IO with error
    _ <- ioc // ioc will never happen!
  } yield 42

// A
// Exception in thread "main" java.lang.Exception: B
program.unsafeRunSync()

// A
// Process finished with exit code 0
val safeProgram: IO[Either[Throwable, Int]] = 
  program.attempt
safeProgram.unsafeRunSync()
// will print A, B and C
val program: IO[Int] =
  for {
    _ <- ioa
    // in case of error executes IO in argument
    _ <- iob.handleErrorWith(_ => IO(println("B")))
    _ <- ioc
  } yield 42

Cats-Effect IO - Error Handling II


import cats.syntax.applicativeError._
import cats.effect.IO

val ioa = IO(println("A"))
val iob = IO.raiseError(new Exception("B"))
val ioc = IO(println("C"))

def handleError: PartialFunction[Throwable, IO[Unit]] = {
  case _ => IO(println("errB"))
}

// prints "A", "errB" and ends with error
val program =
  for {
    _ <- ioa
    // executes PartialFunction and rethrows error
    _ <- iob.onError(handleError)
    _ <- ioc
  } yield 42

Good place to look for methods are MonadError and ApplicativeError

Cats-Effect IO - Parallelism

import cats.effect.IO
import cats.instances.list._
import cats.syntax.all._

val ioa = IO(println("A"))
val iob = IO(println("B"))

val listOfIO: List[IO[Unit]] = List(ioa, iob)

val ioOfList: IO[List[Unit]] = listOfIO.parSequence

// using Parallel typeclass instance
val parIO: IO[Unit] = 
  (ioa, iob).parMapN((_, _) => ())
val ioInParallel: IO[Unit] =
  for {
    // run in "background"
    fibA <- ioa.start 
    fibB <- iob.start
    // wait for ioa result
    _ <- fibA.join 
    _ <- fibB.join
  } yield ()


// run both in parallel 
// and cancel the loser
val raceIO: IO[Either[Unit, Unit]] = 
  IO.race(ioa, iob)

See documentation and scala docs for more information!

Cats-Effect IO - Ref I

Purely functional wrapper for AtomicRef. Allows for safe concurrent access and mutation but preserves referential transparency.

abstract class Ref[F[_], A] {
  def get: F[A]
  def set(a: A): F[Unit]
  def modify[B](f: A => (A, B)): F[B]
  // ... and more
}

changes Ref to A but can return something else to the caller

Cats-Effect IO - Ref II

import cats.effect.IO 
import cats.effect.concurrent.Ref

// Creating `Ref` returns `IO[Ref[...]]`
// To use it we need to flatMap over it.
// Keep in mind that IO is just description
// of operation - running it several times
// will repeat everything, so running it twice
// will produce two different Refs
val stringsRef: IO[Ref[IO, List[String]]] =
  Ref.of[IO, List[String]](List())

def take(ref: Ref[IO, List[String]]): IO[Option[String]] =
  ref.modify(list => (list.drop(1), list.headOption))

def put(ref: Ref[IO, List[String]], s: String): IO[Unit] =
  ref.modify(list => (s :: list, ()))

val program: IO[Unit] =
  for {
    _ <- stringsRef.flatMap(put(_, "KSUG"))
    element <- stringsRef.flatMap(take)
  } yield println(element)

program.unsafeRunSync() // prints "None"
val program: IO[Unit] =
  for {
    ref <- stringsRef
    _ <- put(ref, "KSUG")
    element <- take(ref)
  } yield println(element)

program.unsafeRunSync() // prints "Some(KSUG)"

Cats-Effect IO - Ref III

Confusing? On the contrary!

It forces us to share state in only one possible way - passing it as a parameter.

 

The regions of sharing are exactly the same of your call graph - no more wondering which class share this state.

 

Want to go more in-depth regarding sharing state in FP? Check out this blog post

Problem to solve

Request passwords from the server and decrypt it using provided Decrypter class.

 

It can process up to 4 calls in parallel, decrypts in three stages (prepare, decode, decrypt), randomly fails with Exception and silently corrupts other running instances unless you restart it.

 

The task is to have as high throughput and correctness as you can achieve

Problem to solve

Decrypter has three methods that we need to invoke in order:

class Decrypter {
  ...
   
  def prepare(password: String): PasswordPrepared

  def decode(state: PasswordPrepared): PasswordDecoded

  def decrypt(state: PasswordDecoded): String
}
  • There can be only a certain amount of decrypter instances in one JVM.
  • When one decrypter instance fails then all instances silently get corrupted and produce wrong results.

Connect and Register

PasswordClient which handles HTTP requests is already implemented.

If you with to try it locally:

git clone https://github.com/VirtusLab/akka-workshop-client.git
cd akka-workshop-client

git checkout cats-effect-master-step1
git clone https://github.com/VirtusLab/akka-workshop.git
cd akka-workshop/server

sbt run

Connect and Register

In Main.scala you'll see run method which starts HTTP Client and proceeds to invoke methods responsible for decrypting passwords.

override def run(args: List[String]): IO[ExitCode] =
  Http1Client[IO]()
    .bracket { httpClient =>
      val client = PasswordClient.create(httpClient)
      for {
        token <- client.requestToken("Piotrek")
        _ <- decryptForever(client, token)(timer)
      } yield ExitCode.Success
    }(_.shutdown)

change it to your name

LEADERBOARD

http://async-in-2018.herokuapp.com/?mode=remote

Process passwords

For the first step we have to use existing PasswordClient to request, decrypt and validate few passwords! For now we don't care about Decrypter failures.

Requesting Passwords

abstract class PasswordClient[F[_]](httpClient: Client[F]) {
  def requestToken(userName: String): F[Token]

  def requestPassword(token: Token): F[EncryptedPassword]

  def validatePassword(token: Token, encryptedPassword: String, 
                       decryptedPassword: String): F[Status]
}

Methods should be self-explanatory - feel free to modify their implementation if you want to add logging - I recommend .flatTap(a => IO(println(a)) defined in cats!

 

If you don't recognize F[_] don't worry - it just means that we could write implementation for something other than IO

Work parallelization

Go up on the leaderboard!

http://async-in-2018.herokuapp.com/?mode=parallel

If you follow provided skeleton the structure is just IO running forever so now try to do the same for N IO running in parallel.  

 

One of the earlier slides should give you a hint!

Error Handling

If you see full leaderboard you're probably notice awful correctness after parallelization.

 

This is because one of the parallel IO can corrupt the decrypter and it won't restart until all of them fail.

 

Let's think about the mechanism that could stop other tasks as soon as one of them fails.

Error Handling

Unlike Akka, Cats-Effect doesn't have any supervision mechanism included.

 

The point of Cats-Effect and IO model in general is to have simple, composable building blocks to easily build more complicated structures.

 

 

Error Handling

If you share state between parallel IOs you can use it to signal failure. I'd suggest refreshing Ref

 

Most straightforward way is to introduce checkpoints in the decryption code to determine whether we should stop our recursive calls or not.

 

On failure we should set signal and terminate.

Optimizations

With previous step done it's possible to have 100% correctness without sacrificing parallelism.

 

It doesn't mean we can't improve our throughput.

Saving partially decrypted passwords

So far we restart as soon as possible if error occurs in any task but we don't have to discard our progress.

 

If we save our last step we could improve performance quite a bit!

Saving partially decrypted passwords

To do that I'd suggest sharing state once again. This time we could use something like Ref[IO, List[Password]].

 

All that has to be done is to introduce new parameter and save the password before terminating.

Recovering partially decrypted passwords

def getPassword(client: PasswordClient[IO], token: Token): IO[Password] = {
  client.requestPassword(token)
}

This looks like a good place to take saved passwords instead of requesting them each time.

Cleaning up

Now that everything is working fast and correct we could take a closer look at how the code is organized.

fullDecryption functions is probably cluttered with two Refs and a lot of Ref specific logic like modify.

Hiding Ref

We could get inspiration from existing PasswordClient and define algebra that exposes clean interface, e.g.:

trait PasswordQueue[F[_]] {
  def take: F[Option[Password]]

  def put(password: Password): F[Unit]
}

Hiding Ref

And the implementation:

object PasswordQueue {
  def create[F[_] : Sync]: F[PasswordQueue[F]] =
    Ref.of[F, List[Password]](List.empty).map { state =>
      new PasswordQueue[F] {
        def take: F[Option[Password]] = ???

        def put(password: Password): F[Unit] = ???
      }
    }
}

This trick completely separates Ref behind interface closer to domain logic.

Feel free to contact in case of any questions and feedback!

Piotr Gawryś 

twitter.com/p_gawrys

github.com/Avasil

pgawrys2@gmail.com

Asynchronous programming in late 2018: Cats-Effect

By Piotr Gawryś

Asynchronous programming in late 2018: Cats-Effect

  • 1,016