ZIO
A ZIO[R, E, A]
value is an immutable value that lazily describes a workflow or job. The workflow requires some environment R
, and may fail with an error of type E
, or succeed with a value of type A
.
A value of type ZIO[R, E, A]
is like an effectful version of the following function type:
R => Either[E, A]
This function, which requires an R
, might produce either an E
, representing failure, or an A
, representing success. ZIO effects are not actually functions, of course, because they model complex effects, like asynchronous and concurrent effects.
ZIO effects model resourceful interaction with the outside world, including synchronous, asynchronous, concurrent, and parallel interaction.
ZIO effects use a fiber-based concurrency model, with built-in support for scheduling, fine-grained interruption, structured concurrency, and high scalability.
The ZIO[R, E, A]
data type has three type parameters:
R
- Environment Type. The effect requires an environment of typeR
. If this type parameter isAny
, it means the effect has no requirements, because we can run the effect with any value (for example, the unit value()
).E
- Failure Type. The effect may fail with a value of typeE
. Some applications will useThrowable
. If this type parameter isNothing
, it means the effect cannot fail, because there are no values of typeNothing
.A
- Success Type. The effect may succeed with a value of typeA
. If this type parameter isUnit
, it means the effect produces no useful information, while if it isNothing
, it means the effect runs forever (or until failure).
In the following example, the getStrLn
function requires the Console
service, it may fail with value of type IOException
, or may succeed with a value of type String
:
val getStrLn: ZIO[Console, IOException, String] =
ZIO.accessM(_.get.getStrLn)
ZIO
values are immutable, and all ZIO
functions produce new ZIO
values, enabling ZIO
to be reasoned about and used like any ordinary Scala immutable data structure.
ZIO
values do not actually do anything; they are just values that model or describe effectful interactions.
ZIO
can be interpreted by the ZIO runtime system into effectful interactions with the external world. Ideally, this occurs at a single time, in our application's main
function. The App
class provides this functionality automatically.
Table of Content
Creation
In this section we explore some of the common ways to create ZIO effects from values, from common Scala types, and from both synchronous and asynchronous side-effects. Here is the summary list of them:
Success Values
Function | Input Type | Output Type |
---|---|---|
succeed | A | UIO[A] |
Using the ZIO.succeed
method, we can create an effect that succeeds with the specified value:
val s1 = ZIO.succeed(42)
We can also use methods in the companion objects of the ZIO
type aliases:
val s2: Task[Int] = Task.succeed(42)
Note:
succeed
vs.effectTotal
The
succeed
is nothing different thaneffectTotal
they are the same but for different purposes for clarity. Thesucceed
method takes a by-name parameter to make sure that any accidental side effects from constructing the value can be properly managed by the ZIO Runtime. However,succeed
is intended for values which do not have any side effects. If we know that our value does have side effects, we should consider usingZIO.effectTotal
for clarity.
val now = ZIO.effectTotal(System.currentTimeMillis())
The value inside a successful effect constructed with ZIO.effectTotal
will only be constructed if absolutely required.
Failure Values
Function | Input Type | Output Type |
---|---|---|
fail | E | IO[E, Nothing] |
Using the ZIO.fail
method, we can create an effect that models failure:
val f1 = ZIO.fail("Uh oh!")
For the ZIO
data type, there is no restriction on the error type. We may use strings, exceptions, or custom data types appropriate for our application.
Many applications will model failures with classes that extend Throwable
or Exception
:
val f2 = Task.fail(new Exception("Uh oh!"))
Note that unlike the other effect companion objects, the UIO
companion object does not have UIO.fail
, because UIO
values cannot fail.
From Values
ZIO contains several constructors which help us to convert various data types into the ZIO
effect.
Option
Function | Input Type | Output Type |
---|---|---|
fromOption | Option[A] | IO[Option[Nothing], A] |
some | A | UIO[Option[A]] |
none | UIO[Option[Nothing]] | |
getOrFail | Option[A] | Task[A] |
getOrFailUnit | Option[A] | IO[Unit, A] |
getOrFailWith | e:=> E, v:=> Option[A] | IO[E, A] |
An Option
can be converted into a ZIO effect using ZIO.fromOption
:
val zoption: IO[Option[Nothing], Int] = ZIO.fromOption(Some(2))
The error type of the resulting effect is Option[Nothing]
, which provides no information on why the value is not there. We can change the Option[Nothing]
into a more specific error type using ZIO#mapError
:
val zoption2: IO[String, Int] = zoption.mapError(_ => "It wasn't there!")
We can also readily compose it with other operators while preserving the optional nature of the result (similar to an OptionT
)
val maybeId: IO[Option[Nothing], String] = ZIO.fromOption(Some("abc123"))
def getUser(userId: String): IO[Throwable, Option[User]] = ???
def getTeam(teamId: String): IO[Throwable, Team] = ???
val result: IO[Throwable, Option[(User, Team)]] = (for {
id <- maybeId
user <- getUser(id).some
team <- getTeam(user.teamId).asSomeError
} yield (user, team)).optional
Either
Function | Input Type | Output Type |
---|---|---|
fromEither | Either[E, A] | IO[E, A] |
left | A | UIO[Either[A, Nothing]] |
right | A | UIO[Either[Nothing, B]] |
An Either
can be converted into a ZIO effect using ZIO.fromEither
:
val zeither = ZIO.fromEither(Right("Success!"))
The error type of the resulting effect will be whatever type the Left
case has, while the success type will be whatever type the Right
case has.
Try
Function | Input Type | Output Type |
---|---|---|
fromTry | scala.util.Try[A] | Task[A] |
A Try
value can be converted into a ZIO effect using ZIO.fromTry
:
import scala.util.Try
val ztry = ZIO.fromTry(Try(42 / 0))
The error type of the resulting effect will always be Throwable
, because Try
can only fail with values of type Throwable
.
Function
Function | Input Type | Output Type |
---|---|---|
fromFunction | R => A | URIO[R, A] |
fromFunctionM | R => IO[E, A] | ZIO[R, E, A] |
A function A => B
can be converted into a ZIO effect with ZIO.fromFunction
:
val zfun: URIO[Int, Int] =
ZIO.fromFunction((i: Int) => i * i)
The environment type of the effect is A
(the input type of the function), because in order to run the effect, it must be supplied with a value of this type.
Future
Function | Input Type | Output Type |
---|---|---|
fromFuture | ExecutionContext => scala.concurrent.Future[A] | Task[A] |
fromFutureJava | java.util.concurrent.Future[A] | RIO[Blocking, A] |
fromFunctionFuture | R => scala.concurrent.Future[A] | RIO[R, A] |
fromFutureInterrupt | ExecutionContext => scala.concurrent.Future[A] | Task[A] |
A Future
can be converted into a ZIO effect using ZIO.fromFuture
:
import scala.concurrent.Future
lazy val future = Future.successful("Hello!")
val zfuture: Task[String] =
ZIO.fromFuture { implicit ec =>
future.map(_ => "Goodbye!")
}
The function passed to fromFuture
is passed an ExecutionContext
, which allows ZIO to manage where the Future
runs (of course, we can ignore this ExecutionContext
).
The error type of the resulting effect will always be Throwable
, because Future
can only fail with values of type Throwable
.
Promise
Function | Input Type | Output Type |
---|---|---|
fromPromiseScala | scala.concurrent.Promise[A] | Task[A] |
A Promise
can be converted into a ZIO effect using ZIO.fromPromiseScala
:
val func: String => String = s => s.toUpperCase
for {
promise <- ZIO.succeed(scala.concurrent.Promise[String]())
_ <- ZIO.effect {
Try(func("hello world from future")) match {
case Success(value) => promise.success(value)
case Failure(exception) => promise.failure(exception)
}
}.fork
value <- ZIO.fromPromiseScala(promise)
_ <- putStrLn(s"Hello World in UpperCase: $value")
} yield ()
Fiber
Function | Input Type | Output Type |
---|---|---|
fromFiber | Fiber[E, A] | IO[E, A] |
fromFiberM | IO[E, Fiber[E, A]] | IO[E, A] |
A Fiber
can be converted into a ZIO effect using ZIO.fromFiber
:
val io: IO[Nothing, String] = ZIO.fromFiber(Fiber.succeed("Hello From Fiber!"))
From Side-Effects
ZIO can convert both synchronous and asynchronous side-effects into ZIO effects (pure values).
These functions can be used to wrap procedural code, allowing us to seamlessly use all features of ZIO with legacy Scala and Java code, as well as third-party libraries.
Synchronous
Function | Input Type | Output Type | Note |
---|---|---|---|
effectTotal | A | UIO[A] | Imports a total synchronous effect |
effect | A | Task[A] | Imports a (partial) synchronous side-effect |
A synchronous side-effect can be converted into a ZIO effect using ZIO.effect
:
import scala.io.StdIn
val getStrLine: Task[String] =
ZIO.effect(StdIn.readLine())
The error type of the resulting effect will always be Throwable
, because side-effects may throw exceptions with any value of type Throwable
.
If a given side-effect is known to not throw any exceptions, then the side-effect can be converted into a ZIO effect using ZIO.effectTotal
:
def putStrLine(line: String): UIO[Unit] =
ZIO.effectTotal(println(line))
val effectTotalTask: UIO[Long] =
ZIO.effectTotal(System.nanoTime())
We should be careful when using ZIO.effectTotal
—when in doubt about whether or not a side-effect is total, prefer ZIO.effect
to convert the effect.
If this is too broad, the refineOrDie
method of ZIO
may be used to retain only certain types of exceptions, and to die on any other types of exceptions:
import java.io.IOException
val getStrLn2: IO[IOException, String] =
ZIO.effect(StdIn.readLine()).refineToOrDie[IOException]
Blocking Synchronous Side-Effects
Function | Input Type | Output Type |
---|---|---|
blocking | ZIO[R, E, A] | ZIO[R, E, A] |
effectBlocking | A | RIO[Blocking, A] |
effectBlockingCancelable | effect: => A , cancel: UIO[Unit] | RIO[Blocking, A] |
effectBlockingInterrupt | A | RIO[Blocking, A] |
effectBlockingIO | A | ZIO[Blocking, IOException, A] |
Some side-effects use blocking IO or otherwise put a thread into a waiting state. If not carefully managed, these side-effects can deplete threads from our application's main thread pool, resulting in work starvation.
ZIO provides the zio.blocking
package, which can be used to safely convert such blocking side-effects into ZIO effects.
A blocking side-effect can be converted directly into a ZIO effect blocking with the effectBlocking
method:
import zio.blocking._
val sleeping =
effectBlocking(Thread.sleep(Long.MaxValue))
The resulting effect will be executed on a separate thread pool designed specifically for blocking effects.
Blocking side-effects can be interrupted by invoking Thread.interrupt
using the effectBlockingInterrupt
method.
Some blocking side-effects can only be interrupted by invoking a cancellation effect. We can convert these side-effects using the effectBlockingCancelable
method:
import java.net.ServerSocket
import zio.UIO
def accept(l: ServerSocket) =
effectBlockingCancelable(l.accept())(UIO.effectTotal(l.close()))
If a side-effect has already been converted into a ZIO effect, then instead of effectBlocking
, the blocking
method can be used to ensure the effect will be executed on the blocking thread pool:
import scala.io.{ Codec, Source }
def download(url: String) =
Task.effect {
Source.fromURL(url)(Codec.UTF8).mkString
}
def safeDownload(url: String) =
blocking(download(url))
Asynchronous
Function | Input Type | Output Type |
---|---|---|
effectAsync | (ZIO[R, E, A] => Unit) => Any | ZIO[R, E, A] |
effectAsyncM | (ZIO[R, E, A] => Unit) => ZIO[R, E, Any] | ZIO[R, E, A] |
effectAsyncMaybe | (ZIO[R, E, A] => Unit) => Option[ZIO[R, E, A]] | ZIO[R, E, A] |
effectAsyncInterrupt | (ZIO[R, E, A] => Unit) => Either[Canceler[R], ZIO[R, E, A]] | ZIO[R, E, A] |
An asynchronous side-effect with a callback-based API can be converted into a ZIO effect using ZIO.effectAsync
:
object legacy {
def login(
onSuccess: User => Unit,
onFailure: AuthError => Unit): Unit = ???
}
val login: IO[AuthError, User] =
IO.effectAsync[AuthError, User] { callback =>
legacy.login(
user => callback(IO.succeed(user)),
err => callback(IO.fail(err))
)
}
Asynchronous ZIO effects are much easier to use than callback-based APIs, and they benefit from ZIO features like interruption, resource-safety, and superior error handling.
Creating Suspended Effects
Function | Input Type | Output Type |
---|---|---|
effectSuspend | RIO[R, A] | RIO[R, A] |
effectSuspendTotal | ZIO[R, E, A] | ZIO[R, E, A] |
effectSuspendTotalWith | (Platform, Fiber.Id) => ZIO[R, E, A] | ZIO[R, E, A] |
effectSuspendWith | (Platform, Fiber.Id) => RIO[R, A] | RIO[R, A] |
A RIO[R, A]
effect can be suspended using effectSuspend
function:
val suspendedEffect: RIO[Any, ZIO[Console, IOException, Unit]] =
ZIO.effectSuspend(ZIO.effect(putStrLn("Suspended Hello World!")))
Mapping
map
We can change an IO[E, A]
to an IO[E, B]
by calling the map
method with a function A => B
. This lets us transform values produced by actions into other values.
import zio.{ UIO, IO }
val mappedValue: UIO[Int] = IO.succeed(21).map(_ * 2)
mapError
We can transform an IO[E, A]
into an IO[E2, A]
by calling the mapError
method with a function E => E2
. This lets us transform the failure values of effects:
val mappedError: IO[Exception, String] =
IO.fail("No no!").mapError(msg => new Exception(msg))
Note:
Note that mapping over an effect's success or error channel does not change the success or failure of the effect, in the same way that mapping over an
Either
does not change whether theEither
isLeft
orRight
.
mapEffect
mapEffect
returns an effect whose success is mapped by the specified side-effecting f
function, translating any thrown exceptions into typed failed effects.
Converting literal "Five" String to Int by calling toInt
is a side effecting because it will throws NumberFormatException
exception:
val task: RIO[Any, Int] = ZIO.succeed("hello").mapEffect(_.toInt)
mapEffect
converts an unchecked exception to a checked one by returning the RIO
effect.
Chaining
We can execute two actions in sequence with the flatMap
method. The second action may depend on the value produced by the first action.
val chainedActionsValue: UIO[List[Int]] = IO.succeed(List(1, 2, 3)).flatMap { list =>
IO.succeed(list.map(_ + 1))
}
If the first effect fails, the callback passed to flatMap
will never be invoked, and the composed effect returned by flatMap
will also fail.
In any chain of effects, the first failure will short-circuit the whole chain, just like throwing an exception will prematurely exit a sequence of statements.
Because the ZIO
data type supports both flatMap
and map
, we can use Scala's for comprehensions to build sequential effects:
val program =
for {
_ <- putStrLn("Hello! What is your name?")
name <- getStrLn
_ <- putStrLn(s"Hello, ${name}, welcome to ZIO!")
} yield ()
For comprehensions provide a more procedural syntax for composing chains of effects.
Zipping
We can combine two effects into a single effect with the zip
method. The resulting effect succeeds with a tuple that contains the success values of both effects:
val zipped: UIO[(String, Int)] =
ZIO.succeed("4").zip(ZIO.succeed(2))
Note that zip
operates sequentially: the effect on the left side is executed before the effect on the right side.
In any zip
operation, if either the left or right-hand sides fail, then the composed effect will fail, because both values are required to construct the tuple.
zipLeft and zipRight
Sometimes, when the success value of an effect is not useful (or example, it is Unit
), it can be more convenient to use the zipLeft
or zipRight
functions, which first perform a zip
, and then map over the tuple to discard one side or the other:
val zipRight1 =
putStrLn("What is your name?").zipRight(getStrLn)
The zipRight
and zipLeft
functions have symbolic aliases, known as *>
and <*
, respectively. Some developers find these operators easier to read:
val zipRight2 =
putStrLn("What is your name?") *>
getStrLn
Parallelism
ZIO provides many operations for performing effects in parallel. These methods are all named with a Par
suffix that helps us identify opportunities to parallelize our code.
For example, the ordinary ZIO#zip
method zips two effects together, sequentially. But there is also a ZIO#zipPar
method, which zips two effects together in parallel.
The following table summarizes some of the sequential operations and their corresponding parallel versions:
Description | Sequential | Parallel |
---|---|---|
Zips two effects into one | ZIO#zip | ZIO#zipPar |
Zips two effects into one | ZIO#zipWith | ZIO#zipWithPar |
Collects from many effects | ZIO.collectAll | ZIO.collectAllPar |
Effectfully loop over values | ZIO.foreach | ZIO.foreachPar |
Reduces many values | ZIO.reduceAll | ZIO.reduceAllPar |
Merges many values | ZIO.mergeAll | ZIO.mergeAllPar |
For all the parallel operations, if one effect fails, then others will be interrupted, to minimize unnecessary computation.
If the fail-fast behavior is not desired, potentially failing effects can be first converted into infallible effects using the ZIO#either
or ZIO#option
methods.
Racing
ZIO lets us race multiple effects in parallel, returning the first successful result:
for {
winner <- IO.succeed("Hello").race(IO.succeed("Goodbye"))
} yield winner
If we want the first success or failure, rather than the first success, then we can use left.either race right.either
, for any effects left
and right
.
Timeout
ZIO lets us timeout any effect using the ZIO#timeout
method, which returns a new effect that succeeds with an Option
. A value of None
indicates the timeout elapsed before the effect completed.
import zio.duration._
IO.succeed("Hello").timeout(10.seconds)
If an effect times out, then instead of continuing to execute in the background, it will be interrupted so no resources will be wasted.
Error Management
Either
Function | Input Type | Output Type |
---|---|---|
ZIO#either | URIO[R, Either[E, A]] | |
ZIO.absolve | ZIO[R, E, Either[E, A]] | ZIO[R, E, A] |
We can surface failures with ZIO#either
, which takes an ZIO[R, E, A]
and produces an ZIO[R, Nothing, Either[E, A]]
.
val zeither: UIO[Either[String, Int]] =
IO.fail("Uh oh!").either
We can submerge failures with ZIO.absolve
, which is the opposite of either
and turns an ZIO[R, Nothing, Either[E, A]]
into a ZIO[R, E, A]
:
def sqrt(io: UIO[Double]): IO[String, Double] =
ZIO.absolve(
io.map(value =>
if (value < 0.0) Left("Value must be >= 0.0")
else Right(Math.sqrt(value))
)
)
Catching
Function | Input Type | Output Type |
---|---|---|
ZIO#catchAll | E => ZIO[R1, E2, A1] | ZIO[R1, E2, A1] |
ZIO#catchAllCause | Cause[E] => ZIO[R1, E2, A1] | ZIO[R1, E2, A1] |
ZIO#catchAllDefect | Throwable => ZIO[R1, E1, A1] | ZIO[R1, E1, A1] |
ZIO#catchAllTrace | ((E, Option[ZTrace])) => ZIO[R1, E2, A1] | ZIO[R1, E2, A1] |
ZIO#catchSome | PartialFunction[E, ZIO[R1, E1, A1]] | ZIO[R1, E1, A1] |
ZIO#catchSomeCause | PartialFunction[Cause[E], ZIO[R1, E1, A1]] | ZIO[R1, E1, A1] |
ZIO#catchSomeDefect | PartialFunction[Throwable, ZIO[R1, E1, A1]] | ZIO[R1, E1, A1] |
ZIO#catchSomeTrace | PartialFunction[(E, Option[ZTrace]), ZIO[R1, E1, A1]] | ZIO[R1, E1, A1] |
Catching All Errors
If we want to catch and recover from all types of errors and effectfully attempt recovery, we can use the catchAll
method:
val z: IO[IOException, Array[Byte]] =
readFile("primary.json").catchAll(_ =>
readFile("backup.json"))
In the callback passed to catchAll
, we may return an effect with a different error type (or perhaps Nothing
), which will be reflected in the type of effect returned by catchAll
.
Catching Some Errors
If we want to catch and recover from only some types of exceptions and effectfully attempt recovery, we can use the catchSome
method:
val data: IO[IOException, Array[Byte]] =
readFile("primary.data").catchSome {
case _ : FileNotFoundException =>
readFile("backup.data")
}
Unlike catchAll
, catchSome
cannot reduce or eliminate the error type, although it can widen the error type to a broader class of errors.
Fallback
Function | Input Type | Output Type |
---|---|---|
orElse | ZIO[R1, E2, A1] | ZIO[R1, E2, A1] |
orElseEither | ZIO[R1, E2, B] | ZIO[R1, E2, Either[A, B]] |
orElseFail | E1 | ZIO[R, E1, A] |
orElseOptional | ZIO[R1, Option[E1], A1] | ZIO[R1, Option[E1], A1] |
orElseSucceed | A1 | URIO[R, A1] |
We can try one effect, or, if it fails, try another effect, with the orElse
combinator:
val primaryOrBackupData: IO[IOException, Array[Byte]] =
readFile("primary.data").orElse(readFile("backup.data"))
Folding
Function | Input Type | Output Type |
---|---|---|
fold | failure: E => B, success: A => B | URIO[R, B] |
foldCause | failure: Cause[E] => B, success: A => B | URIO[R, B] |
foldM | failure: E => ZIO[R1, E2, B], success: A => ZIO[R1, E2, B] | ZIO[R1, E2, B] |
foldCauseM | failure: Cause[E] => ZIO[R1, E2, B], success: A => ZIO[R1, E2, B] | ZIO[R1, E2, B] |
foldTraceM | failure: ((E, Option[ZTrace])) => ZIO[R1, E2, B], success: A => ZIO[R1, E2, B] | ZIO[R1, E2, B] |
Scala's Option
and Either
data types have fold
, which let us handle both failure and success at the same time. In a similar fashion, ZIO
effects also have several methods that allow us to handle both failure and success.
The first fold method, fold
, lets us non-effectfully handle both failure and success, by supplying a non-effectful handler for each case:
lazy val DefaultData: Array[Byte] = Array(0, 0)
val primaryOrDefaultData: UIO[Array[Byte]] =
readFile("primary.data").fold(
_ => DefaultData,
data => data)
The second fold method, foldM
, lets us effectfully handle both failure and success, by supplying an effectful (but still pure) handler for each case:
val primaryOrSecondaryData: IO[IOException, Array[Byte]] =
readFile("primary.data").foldM(
_ => readFile("secondary.data"),
data => ZIO.succeed(data))
Nearly all error handling methods are defined in terms of foldM
, because it is both powerful and fast.
In the following example, foldM
is used to handle both failure and success of the readUrls
method:
val urls: UIO[Content] =
readUrls("urls.json").foldM(
error => IO.succeed(NoContent(error)),
success => fetchContent(success)
)
Retrying
Function | Input Type | Output Type |
---|---|---|
retry | Schedule[R1, E, S] | ZIO[R1 with Clock, E, A] |
retryN | n: Int | ZIO[R, E, A] |
retryOrElse | policy: Schedule[R1, E, S], orElse: (E, S) => ZIO[R1, E1, A1] | ZIO[R1 with Clock, E1, A1] |
retryOrElseEither | schedule: Schedule[R1, E, Out], orElse: (E, Out) => ZIO[R1, E1, B] | ZIO[R1 with Clock, E1, Either[B, A]] |
retryUntil | E => Boolean | ZIO[R, E, A] |
retryUntilEquals | E1 | ZIO[R, E1, A] |
retryUntilM | E => URIO[R1, Boolean] | ZIO[R1, E, A] |
retryWhile | E => Boolean | ZIO[R, E, A] |
retryWhileEquals | E1 | ZIO[R, E1, A] |
retryWhileM | E => URIO[R1, Boolean] | ZIO[R1, E, A] |
When we are building applications we want to be resilient in the face of a transient failure. This is where we need to retry to overcome these failures.
There are a number of useful methods on the ZIO data type for retrying failed effects.
The most basic of these is ZIO#retry
, which takes a Schedule
and returns a new effect that will retry the first effect if it fails, according to the specified policy:
import zio.clock._
val retriedOpenFile: ZIO[Clock, IOException, Array[Byte]] =
readFile("primary.data").retry(Schedule.recurs(5))
The next most powerful function is ZIO#retryOrElse
, which allows specification of a fallback to use, if the effect does not succeed with the specified policy:
readFile("primary.data").retryOrElse(
Schedule.recurs(5),
(_, _:Long) => ZIO.succeed(DefaultData)
)
The final method, ZIO#retryOrElseEither
, allows returning a different type for the fallback.
Resource Management
ZIO's resource management features work across synchronous, asynchronous, concurrent, and other effect types, and provide strong guarantees even in the presence of failure, interruption, or defects in the application.
Finalizing
Scala has a try
/ finally
construct which helps us to make sure we don't leak resources because no matter what happens in the try, the finally
block will be executed. So we can open files in the try block, and then we can close them in the finally
block, and that gives us the guarantee that we will not leak resources.
Asynchronous Try / Finally
The problem with the try
/ finally
construct is that it only applies with synchronous code, they don't work for asynchronous code. ZIO gives us a method called ensuring
that works with either synchronous or asynchronous actions. So we have a functional try/finally but across the async region of our code, also our finalizer could have async regions.
Like try
/ finally
, the ensuring
operation guarantees that if an effect begins executing and then terminates (for whatever reason), then the finalizer will begin executing:
val finalizer =
UIO.effectTotal(println("Finalizing!"))
// finalizer: UIO[Unit] = zio.ZIO$EffectTotal@438627be
val finalized: IO[String, Unit] =
IO.fail("Failed!").ensuring(finalizer)
// finalized: IO[String, Unit] = zio.ZIO$CheckInterrupt@54924fe9
The finalizer is not allowed to fail, which means that it must handle any errors internally.
Like try
/ finally
, finalizers can be nested, and the failure of any inner finalizer will not affect outer finalizers. Nested finalizers will be executed in reverse order, and linearly (not in parallel).
Unlike try
/ finally
, ensuring
works across all types of effects, including asynchronous and concurrent effects.
Here is another example of ensuring that our clean-up action called before our effect is done:
import zio.Task
var i: Int = 0
val action: Task[String] =
Task.effectTotal(i += 1) *>
Task.fail(new Throwable("Boom!"))
val cleanupAction: UIO[Unit] = UIO.effectTotal(i -= 1)
val composite = action.ensuring(cleanupAction)
_Note: Finalizers offer very powerful guarantees, but they are low-level, and should generally not be used for releasing resources. For higher-level logic built on
ensuring
, seeZIO#bracket
on the bracket section.
Unstoppable Finalizers
In Scala when we nest try
/ finally
finalizers, they cannot be stopped. If we have nested finalizers and one of them fails for some sort of catastrophic reason the ones on the outside will still be run and in the correct order.
try {
try {
try {
...
} finally f1
} finally f2
} finally f3
Also in ZIO like try
/ finally
, the finalizers are unstoppable. This means if we have a buggy finalizer, and it is going to leak some resources that unfortunately happens, we will leak the minimum amount of resources because all other finalizers will be run in the correct order.
val io = ???
io.ensuring(f1)
.ensuring(f2)
.ensuring(f3)
Brackets
In Scala the try
/ finally
is often used to manage resources. A common use for try
/ finally
is safely acquiring and releasing resources, such as new socket connections or opened files:
val handle = openFile(name)
try {
processFile(handle)
} finally closeFile(handle)
ZIO encapsulates this common pattern with ZIO#bracket
, which allows us to specify an acquire effect, which acquires a resource; a release effect, which releases it; and a use effect, which uses the resource. Bracket lets us open a file and close the file and no matter what happens when we are using that resource.
The release action is guaranteed to be executed by the runtime system, even if the utilize action throws an exception or the executing fiber is interrupted.
Brackets are a built-in primitive that let us safely acquire and release resources. They are used for a similar purpose as try/catch/finally
, only brackets work with synchronous and asynchronous actions, work seamlessly with fiber interruption, and are built on a different error model that ensures no errors are ever swallowed.
Brackets consist of an acquire action, a utilize action (which uses the acquired resource), and a release action.
import zio.{ UIO, IO }
val groupedFileData: IO[IOException, Unit] = openFile("data.json").bracket(closeFile(_)) { file =>
for {
data <- decodeData(file)
grouped <- groupData(data)
} yield grouped
}
Brackets have compositional semantics, so if a bracket is nested inside another bracket, and the outer bracket acquires a resource, then the outer bracket's release will always be called, even if, for example, the inner bracket's release fails.
Let's look at a full working example on using brackets:
import zio.{ ExitCode, Task, UIO }
import java.io.{ File, FileInputStream }
import java.nio.charset.StandardCharsets
object Main extends App {
// run my bracket
def run(args: List[String]) =
mybracket.orDie.as(ExitCode.success)
def closeStream(is: FileInputStream) =
UIO(is.close())
// helper method to work around in Java 8
def readAll(fis: FileInputStream, len: Long): Array[Byte] = {
val content: Array[Byte] = Array.ofDim(len.toInt)
fis.read(content)
content
}
def convertBytes(is: FileInputStream, len: Long) =
Task.effect(println(new String(readAll(is, len), StandardCharsets.UTF_8))) // Java 8
//Task.effect(println(new String(is.readAllBytes(), StandardCharsets.UTF_8))) // Java 11+
// mybracket is just a value. Won't execute anything here until interpreted
val mybracket: Task[Unit] = for {
file <- Task(new File("/tmp/hello"))
len = file.length
string <- Task(new FileInputStream(file)).bracket(closeStream)(convertBytes(_, len))
} yield string
}
Unswallowed Exceptions
The Java and Scala error models are broken. Because if we have the right combinations of try
/finally
/catch
es we can actually throw many exceptions, and then we are only able to catch one of them. All the other ones are lost. They are swallowed into a black hole, and also the one that we catch is the wrong one. It is not the primary cause of the failure.
In the following example, we are going to show this behavior:
try {
try throw new Error("e1")
finally throw new Error("e2")
} catch {
case e: Error => println(e)
}
The above program just prints the e2
, which is lossy and, also is not the primary cause of failure.
But in the ZIO version, all the errors will still be reported. So even though we are only able to catch one error, the other ones will be reported which we have full control over them. They don't get lost.
Let's write a ZIO version:
IO.fail("e1")
.ensuring(IO.effectTotal(throw new Exception("e2")))
.catchAll {
case "e1" => putStrLn("e1")
case "e2" => putStrLn("e2")
}