Skip to content

Commit

Permalink
Documentation of ZKeyedPool (#8856)
Browse files Browse the repository at this point in the history
* a note about scope.

* update scope article.

* advanced scope operators.

* add examples and output.

* add more example to zpool.

* add zkeyedpool page.
  • Loading branch information
khajavi committed May 14, 2024
1 parent 727eeba commit 69acbad
Show file tree
Hide file tree
Showing 4 changed files with 364 additions and 7 deletions.
194 changes: 187 additions & 7 deletions docs/reference/resource/scope.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,50 @@ trait Scope {
def addFinalizerExit(finalizer: Exit[Any, Any] => UIO[Any]): UIO[Unit]
def close(exit: => Exit[Any, Any]): UIO[Unit]
}

object Scope {
def make: UIO[Scope] = ???
}
```

The `addFinalizerExit` operator lets us add a finalizer to the `Scope`. Based on the `Exit` value that the `Scope` is closed with, the finalizer will be run. The finalizer is guaranteed to be run when the scope is closed. The `close` operator closes the scope, running all the finalizers that have been added to the scope. It takes an `Exit` value and runs the finalizers based on that value.

In the following example, we create a `Scope`, add a finalizer to it, and then close the scope:

```scala mdoc:compile-only
import zio._

for {
scope <- Scope.make
_ <- ZIO.debug("Scope is created!")
_ <- scope.addFinalizer(
for {
_ <- ZIO.debug("The finalizer is started!")
_ <- ZIO.sleep(5.seconds)
_ <- ZIO.debug("The finalizer is done!")
} yield ()
)
_ <- ZIO.debug("Leaving scope!")
_ <- scope.close(Exit.succeed(()))
_ <- ZIO.debug("Scope is closed!")
} yield ()
```

The `addFinalizerExit` operator lets us add a finalizer to the `Scope`. The `close` operator closes the scope, running all the finalizers that have been added to the scope.
The output of this program will be:

```
Scope is created!
Leaving scope!
The finalizer is started!
The finalizer is done!
Scope is closed!
```

We can see that the finalizer is run after we called `close` on the scope. So the finalizer is guaranteed to be run when the scope is closed.

The `Scope#extend` operator, takes a `ZIO` effect that requires a `Scope` and provides it with a `Scope` without closing it afterwards. This allows us to extend the lifetime of a scoped resource to the lifetime of a scope.

## Scopes and The ZIO Environment

In combination with the ZIO environment, `Scope` gives us an extremely powerful way to manage resources.

Expand Down Expand Up @@ -50,7 +91,15 @@ source("cool.txt").flatMap { source =>
}
```

When we are done working with the file we can close the scope using the `ZIO.scoped` operator, which creates a new `Scope`, provides it to the workflow, and closes the `Scope` when the workflow is done.
Once we are finished working with the file, we can close the scope using the `ZIO.scoped` operator. This function creates a new `Scope`, provides it to the workflow, and closes the `Scope` once the workflow is complete:

```scala
object ZIO {
def scoped[R, E, A](zio: ZIO[Scope with R, E, A]): ZIO[R, E, A] = ???
}
```

The `scoped` operator removes the `Scope` from the environment, indicating that there are no longer any resources used by this workflow that require a scope. We now have a workflow that is ready to run:

```scala mdoc
def contents(name: => String): ZIO[Any, IOException, Chunk[String]] =
Expand All @@ -61,10 +110,71 @@ def contents(name: => String): ZIO[Any, IOException, Chunk[String]] =
}
```

The `scoped` operator removes the `Scope` from the environment, indicating that there are no longer any resources used by this workflow which require a scope. We now have a workflow that is ready to run.

In some cases ZIO applications may provide a `Scope` for us for resources that we don't specify a scope for. For example `ZIOApp` provides a `Scope` for our entire application and ZIO Test provides a `Scope` for each test.

:::note
Please note that like any other services that we can obtain from the ZIO environment, we can do the same with `Scope`. By calling `ZIO.service[Scope]` we can obtain the `Scope` service and then use it to manage resources by adding finalizers to it:

```scala mdoc:silent
import zio._

val resourcefulApp: ZIO[Scope, Nothing, Unit] =
for {
scope <- ZIO.service[Scope]
_ <- ZIO.debug("Entering the scope!")
_ <- scope.addFinalizer(
for {
_ <- ZIO.debug("The finalizer is started!")
_ <- ZIO.sleep(5.seconds)
_ <- ZIO.debug("The finalizer is done!")
} yield ()
)
_ <- ZIO.debug("Leaving scope!")
} yield ()
```

Then we can run the `app` workflow by providing the `Scope` service to it:

```scala mdoc:compile-only
val finalApp: ZIO[Any, Nothing, Unit] =
Scope.make.flatMap(scope => resourcefulApp.provide(ZLayer.succeed(scope)).onExit(scope.close(_)))
```

Here is the output of the program:

```
Entering the scope!
Leaving scope!
The finalizer is started!
The finalizer is done!
```

So we can think of `Scope` as a service that helps us manage resources effectfully. However, the way we utilized it in the previous example is not as per the best practices, and it was only for educational purposes.

In real-world applications, we can easily manage resources by utilizing high-level operators such as `ZIO.acquireRelease` and `ZIO.scoped`.
:::

## Scopes are Dynamic

One important thing to note about `Scope` is that they are dynamic. This means that if we have an effect that requires a `Scope` we can `flatMap` over that effect and use its value to create a new effect. The new effect extends the lifetime of the original scope. So as we don't close the scope (by calling `ZIO.scoped`) the resources will not be released, and they can become bigger and bigger until we close them:

```scala mdoc:invisible
def file(name: String): ZIO[Any, IOException, Source] =
ZIO.attemptBlockingIO(Source.fromFile(name))

def getLines(source: Source): ZIO[Any, Throwable, Iterator[String]] =
ZIO.from(source.getLines())

def processLines(lines: Iterator[String]): ZIO[Any, Nothing, Unit] =
ZIO.succeed(lines.foreach(println))
```

```scala mdoc:compile-only
ZIO.scoped {
file("path/to/file.txt").flatMap(getLines).flatMap(processLines)
}
```

## Defining Resources

We have already seen the `acquireRelease` operator, which is one of the most fundamental operators for creating scoped resources.
Expand Down Expand Up @@ -162,11 +272,81 @@ So far we have seen that while `Scope` is the foundation of safe and composable

In most cases we just use the `acquireRelease` constructor or one of its variants to construct our resource and either work with the resource and close its scope using `ZIO.scoped` or convert the resource into another ZIO data type using an operator such as `ZStream.scoped` or `ZLayer.scoped`. However, for more advanced use cases we may need to work with scopes directly and `Scope` has several useful operators for helping us do so.

First, we can `use` a `Scope` by providing it to a workflow that needs a `Scope` and closing the `Scope` immediately after. This is analogous to the `ZIO.scoped` operator.
### Using a Scope

First, we can `use` a `Scope` by providing it to a workflow that needs a `Scope` and closing the `Scope` immediately after. This is analogous to the `ZIO.scoped` operator:

```scala
trait Closeable extends Scope {
def use[R, E, A](zio: => ZIO[R with Scope, E, A]): ZIO[R, E, A]
}

object ZIO {
def scoped[R, E, A](zio: => ZIO[R with Scope, E, A]): ZIO[R, E, A] = ???
}
```

In the following example, we obtained a `Scope` and added a finalizer to it, and then extended its lifetime to the lifetime of the `resource1` and `resource2`:

```scala mdoc:compile-only
import zio._

object ExtendingScopesExample extends ZIOAppDefault {
val resource1: ZIO[Scope, Nothing, Unit] =
ZIO.acquireRelease(ZIO.debug("Acquiring the resource 1"))(_ =>
ZIO.debug("Releasing the resource one") *> ZIO.sleep(5.seconds)
)
val resource2: ZIO[Scope, Nothing, Unit] =
ZIO.acquireRelease(ZIO.debug("Acquiring the resource 2"))(_ =>
ZIO.debug("Releasing the resource two") *> ZIO.sleep(3.seconds)
)

def run =
ZIO.scoped(
for {
scope <- ZIO.scope
_ <- ZIO.debug("Entering the main scope!")
_ <- scope.addFinalizer(ZIO.debug("Releasing the main resource!") *> ZIO.sleep(2.seconds))
_ <- scope.extend(resource1)
_ <- scope.extend(resource2)
_ <- ZIO.debug("Leaving scope!")
} yield ()
)

}
```

output:

```
Entering the main scope!
Acquiring the resource 1
Acquiring the resource 2
Leaving scope!
Releasing the resource two
Releasing the resource one
Releasing the main resource!
```

### Extending a Scope

Second, we can use the `extend` operator on `Scope` to provide a workflow with a scope without closing it afterwards. This allows us to extend the lifetime of a scoped resource to the lifetime of a scope, effectively allowing us to "extend" the lifetime of that resource.
Second, we can use the `extend` operator on `Scope` to provide a workflow with a scope without closing it afterwards. This allows us to extend the lifetime of a scoped resource to the lifetime of a scope, effectively allowing us to "extend" the lifetime of that resource:

Third, we can `close` a `Scope`. One thing to note here is that by default only the creator of a `Scope` can close it.
```scala
trait Scope {
def extend[R, E, A](zio: => ZIO[Scope with R, E, A]): ZIO[R, E, A]
}
```

### Closing a Scope

Third, we can `close` a `Scope`. One thing to note here is that by default only the creator of a `Scope` can close it:

```scala
trait Closeable extends Scope {
def close(exit: => Exit[Any, Any]): UIO[Unit]
}
```

Creating a new `Scope` returns a `Scope.Closeable` which can be closed. Normally users of a `Scope` will only be provided with a `Scope` which does not expose a `close` operator.

Expand Down
155 changes: 155 additions & 0 deletions docs/reference/resource/zkeyedpool.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
---
id: zkeyedpool
title: "ZKeyedPool"
---

The `ZKeyedPool[+Err, -Key, Item]` is a pool of items of type `Item` that are associated with a key of type `Key`. An attempt to get an item from a pool may fail with an error of type `Err`.

The interface is similar to [`ZPool`](zpool.md), but it allows associating items with keys:

```scala
trait ZKeyedPool[+Err, -Key, Item] {
def get(key: Key): ZIO[Scope, Err, Item]
def invalidate(item: Item): UIO[Unit]
}
```

The two fundamental operators on a `ZPool` is `get` and `invalidate`:
- The `get` operator retrieves an item associated with the given key from the pool in a scoped effect.
- The `invalidate` operator invalidates the specified item. This will cause the pool to eventually reallocate the item.

There couple of ways to create a `ZKeyedPool`:

Generally there are two ways to create a `ZKeyedPool`:
1. Fixed-size Pools
2. Dynamic-size Pools

### Fixed-size Pools

1. We can create a pool that has a fixed number of items for each key:

```scala
object ZKeyedPool {
def make[Key, Env: EnvironmentTag, Err, Item](
get: Key => ZIO[Env, Err, Item],
size: => Int
): ZIO[Env with Scope, Nothing, ZKeyedPool[Err, Key, Item]] = ???
}
```

For example The `ZKeyedPool.make(key => resource(key), 3)` creates a pool of resources where each key has a pool of size 3:

```scala mdoc:compile-only
import zio._

object ZKeyedPoolExample extends ZIOAppDefault {
def resource(key: String): ZIO[Scope, Nothing, String] = ZIO.acquireRelease(
ZIO.random
.flatMap(_.nextUUID.map(_.toString))
.flatMap(uuid => ZIO.debug(s"Acquiring the resource with the $key key and the $uuid id").as(uuid))
)(uuid => ZIO.debug(s"Releasing the resource with the $key key and the $uuid id!"))

def run =
for {
pool <- ZKeyedPool.make(resource, 3)
_ <- pool.get("foo")
item <- pool.get("bar")
_ <- ZIO.debug(s"Item: $item")
} yield ()
}
```

Here is an example output of the above code:

```
Acquiring the resource with the foo key and the 82ee3cab-7f4c-47f1-b3e6-0cd49035925d id!
Acquiring the resource with the foo key and the f9cd881f-fa2e-421c-a6ae-c8d16f6b4500 id!
Acquiring the resource with the foo key and the 09a8f4c9-24ee-411c-b1d0-958479266cb0 id!
Acquiring the resource with the bar key and the 4d6f9c95-8d72-4560-bc20-0965b547cfb7 id!
Acquiring the resource with the bar key and the 44bf6641-bb0f-4088-989b-95fb442d93ab id!
Acquiring the resource with the bar key and the fc2780a7-1717-4027-b201-65441168bfce id!
Item: 4d6f9c95-8d72-4560-bc20-0965b547cfb7
Releasing the resource with the bar key and the fc2780a7-1717-4027-b201-65441168bfce id!
Releasing the resource with the bar key and the 44bf6641-bb0f-4088-989b-95fb442d93ab id!
Releasing the resource with the bar key and the 4d6f9c95-8d72-4560-bc20-0965b547cfb7 id!
Releasing the resource with the foo key and the 09a8f4c9-24ee-411c-b1d0-958479266cb0 id!
Releasing the resource with the foo key and the f9cd881f-fa2e-421c-a6ae-c8d16f6b4500 id!
Releasing the resource with the foo key and the 82ee3cab-7f4c-47f1-b3e6-0cd49035925d id!
```

2. We can create a pool that has a fixed number of items but with different pool size for each key:

```scala
object ZKeyedPool {
def make[Key, Env: EnvironmentTag, Err, Item](
get: Key => ZIO[Env, Err, Item],
size: Key => Int
): ZIO[Env with Scope, Nothing, ZKeyedPool[Err, Key, Item]] = ???
}
```

In the following example, we have created a pool of resources where based on the key, the pool size for that key is different, the pool size for keys starting with "foo" is 2, and for keys starting with "bar" is 3, and for all other keys, the pool size is 1:

```scala mdoc:invisible
import zio._

def resource(key: String): ZIO[Scope, Nothing, String] = ZIO.acquireRelease(
ZIO.random
.flatMap(_.nextUUID.map(_.toString))
.flatMap(uuid => ZIO.debug(s"Acquiring the resource with $key key and $uuid id").as(uuid))
)(uuid => ZIO.debug(s"Releasing the resource with $key key and $uuid id!"))

```

```scala mdoc:compile-only
for {
pool <- ZKeyedPool.make(resource, (key: String) => key match {
case k if k.startsWith("foo") => 2
case k if k.startsWith("bar") => 3
case _ => 1
})
_ <- pool.get("foo1")
item <- pool.get("bar1")
_ <- ZIO.debug(s"Item: $item")
} yield ()
```

Here is an example output of the above code:

```
Acquiring the resource with foo1 key and 052778eb-31c2-4eac-806b-46651813b457 id
Acquiring the resource with foo1 key and bd39dbe4-8f43-4376-a209-5af8ca118af2 id
Acquiring the resource with bar1 key and ecfc80da-c8b2-4726-813c-259748a98c3e id
Acquiring the resource with bar1 key and 0ddfd051-7bf8-4596-a7b9-4011ceeb0976 id
Acquiring the resource with bar1 key and 67239ac8-5def-45ac-962f-b05fb82bf0c3 id
Item: ecfc80da-c8b2-4726-813c-259748a98c3e
Releasing the resource with bar1 key and 67239ac8-5def-45ac-962f-b05fb82bf0c3 id!
Releasing the resource with bar1 key and 0ddfd051-7bf8-4596-a7b9-4011ceeb0976 id!
Releasing the resource with bar1 key and ecfc80da-c8b2-4726-813c-259748a98c3e id!
Releasing the resource with foo1 key and bd39dbe4-8f43-4376-a209-5af8ca118af2 id!
Releasing the resource with foo1 key and 052778eb-31c2-4eac-806b-46651813b457 id!
```

### Dynamic-size Pools

1. We can create a pool with the specified minimum and maximum sized and time to live before a pool whose excess items are not being used will be shrunk down to the minimum size:

```scala
object ZKeyedPool {
def make[Key, Env: EnvironmentTag, Err, Item](
get: Key => ZIO[Env, Err, Item],
range: Key => Range,
timeToLive: Duration
): ZIO[Env with Scope, Nothing, ZKeyedPool[Err, Key, Item]] = ???
}
```

2. Similarly, we can create a pool of resources where the minimum and maximum size of the pool is different for each key. Also, the time to live for each key can be different:

```scala
def make[Key, Env: EnvironmentTag, Err, Item](
get: Key => ZIO[Env, Err, Item],
range: Key => Range,
timeToLive: Key => Duration
): ZIO[Env with Scope, Nothing, ZKeyedPool[Err, Key, Item]] = ???
```

0 comments on commit 69acbad

Please sign in to comment.