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$11374/0x0000000102fbf040@193223ec,
//       event = cats.effect.tracing.TracingEvent$StackTrace
//     ),
//     f = cats.effect.kernel.GenConcurrent$$Lambda$11375/0x0000000102fbf840@2ac45426,
//     event = cats.effect.tracing.TracingEvent$StackTrace
//   )
// )
// memoizedResourceState: Ref[IO, State] = cats.effect.kernel.SyncRef@7f43b4d8

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$11367/0x0000000102fba040@6b615da3
// )
// state: Ref[IO, State] = cats.effect.kernel.SyncRef@2788b395

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)

Delayed release

Sometimes it is useful to wait a specified duration before releasing the underlying resource after the last user stops using the memoized resource. For example, in tests, if your test framework runs tests sequentially, you do not want to release the database connection after each test, only to reacquire it for the next one.

You can do that with:

import scala.concurrent.duration.*

def createWithDelayedRelease[A](r: Resource[IO, A]) = 
  ResourceSharedMemoized.memoizeWithDelayedRelease(r, 500.millis)
  
// or using the extension method
def createWithDelayedRelease2[A](r: Resource[IO, A]) = 
  r.memoizeSharedWithDelayedRelease(500.millis)

The underlying resource will be released after last user stops using it and the specified timeout passes.

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-2-a15854c-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-2-a15854c-SNAPSHOT"
)

Credits

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