Skip to content

MapAtomicCell #4635

Description

@vangogh500

Been a big fan of Refs and MapRefs provided by cats-effect but always found myself wanting a way to effect-fully modify the value since it is a common production use case e.g. if there is a cache miss go and fetch from x.

Given now we have AtomicCells which allows us to do this I was surprised at the missing MapAtomicCell which would allow for concurrent access to the values but also allow for effectful modifications.

Here is an implementation with example usage I wrote for myself. Was wondering if others would find it useful as well :)

Example:

// Cache that fetches users on miss — concurrent callers for the same key
// serialize (default runs at-most-once), different keys don't block each other.
for {
  cache <- MapAtomicCell[IO, String, User].map(_.withDefaultF(fetchUser))
  user  <- cache("user-123").get
} yield user

// Manual get-or-update without withDefault
for {
  map  <- MapAtomicCell[IO, String, Config]
  conf <- map("service-a").evalModify {
    case Some(c) => IO.pure((Some(c), c))
    case None    => loadConfig("service-a").map(c => (Some(c), c))
  }
} yield conf 

// Per-key atomic updates — e.g. rate limiting
for {
  counters <- MapAtomicCell[IO, String, Int].map(_.withDefaultValue(0))
  count    <- counters("endpoint-a").evalUpdateAndGet(n => IO.pure(n + 1))
} yield count

Implementation (note only did the ConcurrentMap version for now, doing the sharded version like MapRef should be trivial):

import cats.implicits._
import cats.effect.Async
import cats.effect.std.{AtomicCell}

import java.util.concurrent.ConcurrentHashMap

trait MapAtomicCell[F[_], K, V] {
  def apply(key: K): AtomicCell[F, V]

  def withDefault[A](default: K => A)(implicit ev: V =:= Option[A], F: Async[F]): MapAtomicCell[F, K, A] =
    withDefaultF(k => F.pure(default(k)))

  def withDefaultF[A](default: K => F[A])(implicit ev: V =:= Option[A], F: Async[F]): MapAtomicCell[F, K, A] =
    MapAtomicCell.withDefault(this.asInstanceOf[MapAtomicCell[F, K, Option[A]]], default)

  def withDefaultValue[A](value: => A)(implicit ev: V =:= Option[A], F: Async[F]): MapAtomicCell[F, K, A] =
    withDefaultF(_ => F.pure(value))

  def withDefaultValueF[A](value: F[A])(implicit ev: V =:= Option[A], F: Async[F]): MapAtomicCell[F, K, A] =
    withDefaultF(_ => value)
}

object MapAtomicCell {
  def apply[F[_]: Async, K, V]: F[MapAtomicCell[F, K, Option[V]]] =
    ofConcurrentHashMap[F, K, V]

  def ofConcurrentHashMap[F[_]: Async, K, V](
    initialCapacity: Int = 16,
    loadFactor: Float = 0.75f,
    concurrencyLevel: Int = 16
  ): F[MapAtomicCell[F, K, Option[V]]] =
    Async[F]
      .delay(new ConcurrentHashMap[K, AtomicCell[F, Option[V]]](initialCapacity, loadFactor, concurrencyLevel))
      .map(fromConcurrentHashMap[F, K, V])

  def fromConcurrentHashMap[F[_]: Async, K, V](map: ConcurrentHashMap[K, AtomicCell[F, Option[V]]]): MapAtomicCell[F, K, Option[V]] =
    new ConcurrentHashMapImpl[F, K, V](map)

  def withDefault[F[_]: Async, K, V](underlying: MapAtomicCell[F, K, Option[V]], default: K => F[V]): MapAtomicCell[F, K, V] =
    new DefaultImpl[F, K, V](underlying, default)

  private class DefaultImpl[F[_]: Async, K, V](
    underlying: MapAtomicCell[F, K, Option[V]],
    default: K => F[V]
  ) extends MapAtomicCell[F, K, V] {

    private def eval[B](key: K)(f: V => F[(V, B)]): F[B] =
      underlying(key).evalModify {
        case Some(v) => f(v).map { case (next, b) => (next.some, b) }
        case None    => default(key).flatMap(f).map { case (next, b) => (next.some, b) }
      }

    def apply(key: K): AtomicCell[F, V] =
      new AtomicCell[F, V] {
        def get: F[V] = eval(key)(v => (v, v).pure[F])
        def set(a: V): F[Unit] = underlying(key).set(a.some)
        def modify[B](f: V => (V, B)): F[B] = eval(key)(v => f(v).pure[F])
        def evalModify[B](f: V => F[(V, B)]): F[B] = eval(key)(f)
        def evalUpdate(f: V => F[V]): F[Unit] = eval(key)(v => f(v).map(next => (next, ())))
        def evalGetAndUpdate(f: V => F[V]): F[V] = eval(key)(v => f(v).map(next => (next, v)))
        def evalUpdateAndGet(f: V => F[V]): F[V] = eval(key)(v => f(v).map(next => (next, next)))
      }
  }

  private class ConcurrentHashMapImpl[F[_]: Async, K, V](
    chm: ConcurrentHashMap[K, AtomicCell[F, Option[V]]]
  ) extends MapAtomicCell[F, K, Option[V]] {

    private def cellFor(key: K): F[AtomicCell[F, Option[V]]] =
      Async[F].delay(Option(chm.get(key))).flatMap {
        case Some(cell) => cell.pure[F]
        case None =>
          AtomicCell[F].of(none[V]).flatMap { newCell =>
            Async[F].delay(chm.putIfAbsent(key, newCell)).map {
              case null     => newCell
              case existing => existing
            }
          }
      }

    def apply(key: K): AtomicCell[F, Option[V]] =
      new AtomicCell[F, Option[V]] {
        def get: F[Option[V]] = cellFor(key).flatMap(_.get)
        def set(a: Option[V]): F[Unit] = cellFor(key).flatMap(_.set(a))
        def modify[B](f: Option[V] => (Option[V], B)): F[B] = cellFor(key).flatMap(_.modify(f))
        def evalModify[B](f: Option[V] => F[(Option[V], B)]): F[B] = cellFor(key).flatMap(_.evalModify(f))
        def evalUpdate(f: Option[V] => F[Option[V]]): F[Unit] = cellFor(key).flatMap(_.evalUpdate(f))
        def evalGetAndUpdate(f: Option[V] => F[Option[V]]): F[Option[V]] = cellFor(key).flatMap(_.evalGetAndUpdate(f))
        def evalUpdateAndGet(f: Option[V] => F[Option[V]]): F[Option[V]] = cellFor(key).flatMap(_.evalUpdateAndGet(f))
      }
  }
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions