Shared Resource Cache for Cats Effect

Micro-library that provides a way to memoize Resource[F, A] so they would be shared between accesses.

The resource is allocated once when the first consumer uses it and is deallocated when the last consumer stops using it.

Motivation

The main use-case for developing this was sharing resources like database connections between test suites.

For example:

import cats.effect.*
import cats.effect.resource_shared_memoized.*
import cats.effect.syntax.all.*
import cats.effect.unsafe.implicits.global

// A fake database connection
class DatabaseConnection extends AutoCloseable {
  override def close() = {} 
}

object MySuite {
  val database = ResourceSharedMemoized.memoize(
    Resource.fromAutoCloseable(IO(new DatabaseConnection))
  ).unsafeRunSync()
}

class MyTests extends munit.CatsEffectSuite {
  val dbFixture = ResourceFunFixture(MySuite.database)

  dbFixture.test("test 1") { db =>
    // The `db` object here is shared with...
    val _ = db // pretend that we use db
  }

  dbFixture.test("test 2") { db =>
    // The `db` object here. 
    val _ = db // pretend that we use db
  }
}

This allows you to run one test case, a suite of tests, or all tests and not have to worry about acquiring or releasing the database connection.

Can't we just use .memoize?

Unfortunately no. memoize returns Resource[F, Resource[F, A]], and we need Resource[F, A]. If we use .allocated on the outer resource then we need to perform the cleanup ourselves, which ResourceSharedMemoized handles for you.

Take a look at this example:

case class State(acquires: Int, releases: Int, users: Int) {
  def acquire = copy(acquires = acquires + 1, users = users + 1)

  def release = copy(releases = releases + 1, users = users - 1)

  override def toString: String = s"[acquires=$acquires, releases=$releases, users=$users]"
}

val (memoizedResource, memoizedResourceState) = (for {
  state <- Ref[IO].of(State(0, 0, 0))
  resource =  Resource.make(state.modify { s =>
    val newState = s.acquire
    (newState, newState)
  })(_ => state.update(_.release))
  resource <- resource.memoize.allocated.map(_._1)
} yield (resource, state)).unsafeRunSync()
// memoizedResource: Resource[IO, State] = Eval(
//   fa = FlatMap(
//     ioe = Delay(
//       thunk = cats.effect.kernel.SyncRef$$Lambda$11998/0x0000000103308040@6e43352e,
//       event = cats.effect.tracing.TracingEvent$StackTrace
//     ),
//     f = cats.effect.kernel.GenConcurrent$$Lambda$11999/0x0000000103308840@2a387979,
//     event = cats.effect.tracing.TracingEvent$StackTrace
//   )
// )
// memoizedResourceState: Ref[IO, State] = cats.effect.kernel.SyncRef@7e8f3e28

memoizedResource.use { value =>
  memoizedResourceState.get.map(state => s"\nstate: $state\nvalue: $value")
}.unsafeRunSync()
// res0: String = """
// state: [acquires=1, releases=0, users=1]
// value: [acquires=1, releases=0, users=1]"""

// The resource is NOT deallocated when the last consumer stops using it.
memoizedResourceState.get.unsafeRunSync()
// res1: State = State(acquires = 1, releases = 0, users = 1)

memoizedResource.use { value1 =>
  memoizedResourceState.get.flatMap { state1 =>
    memoizedResource.use { value2 =>
      memoizedResourceState.get.map { state2 =>
        // All values should be the same
        s"\nstate1: $state1\nvalue1: $value1\nstate2: $state2\nvalue2: $value2"
      }
    }
  }
}.unsafeRunSync()
// res2: String = """
// state1: [acquires=1, releases=0, users=1]
// value1: [acquires=1, releases=0, users=1]
// state2: [acquires=1, releases=0, users=1]
// value2: [acquires=1, releases=0, users=1]"""

// The resource is NOT deallocated when the last consumer stops using it.
memoizedResourceState.get.unsafeRunSync()
// res3: State = State(acquires = 1, releases = 0, users = 1)

We want to use the Resource[F, A] transparently, without caring about managing lifetimes for it.

Usage

There is a single function ResourceSharedMemoized.memoize that takes a Resource[F, A] and returns Resource[F, A].

An .memoizeShared extension method is also provided for Resource[F, A] in the cats.effect.resource_shared_memoized.ResourceSharedMemoizedOps implicit class.

The example below demonstrates on how the library works:

import cats.effect.resource_shared_memoized.*

val (cachedResource, state) = (for {
  state <- Ref[IO].of(State(0, 0, 0))
  resource =  Resource.make(state.modify { s =>
    val newState = s.acquire
    (newState, newState)
  })(_ => state.update(_.release))
  resource <- resource.memoizeShared
} yield (resource, state)).unsafeRunSync()
// cachedResource: Resource[[A]IO[A], State] = Allocate(
//   resource = cats.effect.kernel.Resource$$$Lambda$11991/0x0000000103303040@11b6efbe
// )
// state: Ref[IO, State] = cats.effect.kernel.SyncRef@715f24c4

cachedResource.use { value =>
  state.get.map(state => s"\nstate: $state\nvalue: $value")
}.unsafeRunSync()
// res4: String = """
// state: [acquires=1, releases=0, users=1]
// value: [acquires=1, releases=0, users=1]"""

// The resource is deallocated when the last consumer stops using it.
state.get.unsafeRunSync()
// res5: State = State(acquires = 1, releases = 1, users = 0)

cachedResource.use { value1 =>
  state.get.flatMap { state1 =>
    cachedResource.use { value2 =>
      state.get.map { state2 =>
        // All values should be the same
        s"\nstate1: $state1\nvalue1: $value1\nstate2: $state2\nvalue2: $value2"
      }
    }
  }
}.unsafeRunSync()
// res6: String = """
// state1: [acquires=2, releases=1, users=1]
// value1: [acquires=2, releases=1, users=1]
// state2: [acquires=2, releases=1, users=1]
// value2: [acquires=2, releases=1, users=1]"""

// The resource is deallocated when the last consumer stops using it.
state.get.unsafeRunSync()
// res7: State = State(acquires = 2, releases = 2, users = 0)

Installation

Add the following to your build.sbt:

libraryDependencies += 
  "io.github.arturaz" %% "cats-effect-resource-shared-memoized" % "0.1.1"

Or build.sc if you are using mill:

override def ivyDeps = Agg(
  ivy"io.github.arturaz::cats-effect-resource-shared-memoized:0.1.1"
)

The code from main branch can be obtained with:

resolvers ++= Resolver.sonatypeOssRepos("snapshots")
libraryDependencies += 
  "io.github.arturaz" %% "cats-effect-resource-shared-memoized" % "0.1.1-1-59c5fd9-SNAPSHOT"

For mill:

  override def repositoriesTask = T.task {
    super.repositoriesTask() ++ Seq(
      coursier.Repositories.sonatype("snapshots")
    )
  }

override def ivyDeps = Agg(
  ivy"io.github.arturaz::cats-effect-resource-shared-memoized:0.1.1-1-59c5fd9-SNAPSHOT"
)

Credits

Artūras Šlajus (https://github.com/arturaz)