From d47eaa550f53b5fc97b206f440536e91951f3a05 Mon Sep 17 00:00:00 2001 From: Stas Shevchenko Date: Fri, 31 Jul 2026 22:02:47 +0200 Subject: [PATCH 1/8] Rebuild cache on MapRef, fix cancellation defects --- .../scala/com/evolution/scache/Cache.scala | 12 +- .../com/evolution/scache/ExpiredError.scala | 5 + .../com/evolution/scache/ExpiringCache.scala | 170 +- .../com/evolution/scache/LoadingCache.scala | 1375 +++++++++-------- .../com/evolution/scache/SerialMap.scala | 14 +- .../evolution/scache/CacheDefectsSpec.scala | 251 +++ .../com/evolution/scache/CacheLoadTest.scala | 99 ++ .../com/evolution/scache/CacheSpec.scala | 42 +- .../com/evolution/scache/SerialMapSpec.scala | 10 +- 9 files changed, 1195 insertions(+), 783 deletions(-) create mode 100644 scache/src/main/scala/com/evolution/scache/ExpiredError.scala create mode 100644 scache/src/test/scala/com/evolution/scache/CacheDefectsSpec.scala create mode 100644 scache/src/test/scala/com/evolution/scache/CacheLoadTest.scala diff --git a/scache/src/main/scala/com/evolution/scache/Cache.scala b/scache/src/main/scala/com/evolution/scache/Cache.scala index 14e5726..cf4a488 100644 --- a/scache/src/main/scala/com/evolution/scache/Cache.scala +++ b/scache/src/main/scala/com/evolution/scache/Cache.scala @@ -2,7 +2,7 @@ package com.evolution.scache import cats.effect.kernel.MonadCancel import cats.effect.syntax.all.* -import cats.effect.{Concurrent, Resource, Temporal} +import cats.effect.{Async, Concurrent, Resource, Temporal} import cats.kernel.CommutativeMonoid import cats.syntax.all.* import cats.{Applicative, Functor, Hash, Monad, MonadThrow, Monoid, Parallel, ~>} @@ -441,7 +441,7 @@ object Cache { * method will be called on underlying cache when resource is released to make sure all * resources stored in a cache are also released. */ - def loading[F[_]: Concurrent: Parallel: Runtime, K, V]: Resource[F, Cache[F, K, V]] = { + def loading[F[_]: Async: Parallel: Runtime, K, V]: Resource[F, Cache[F, K, V]] = { loading(none) } @@ -460,7 +460,7 @@ object Cache { * method will be called on underlying cache when resource is released to make sure all * resources stored in a cache are also released. */ - def loading[F[_]: Concurrent: Parallel: Runtime, K, V](partitions: Int): Resource[F, Cache[F, K, V]] = { + def loading[F[_]: Async: Parallel: Runtime, K, V](partitions: Int): Resource[F, Cache[F, K, V]] = { loading(partitions.some) } @@ -508,7 +508,7 @@ object Cache { * method will be called on underlying cache when resource is released to make sure all * resources stored in a cache are also released. */ - def loading[F[_]: Concurrent: Parallel: Runtime, K, V](partitions: Option[Int] = None) + def loading[F[_]: Async: Parallel: Runtime, K, V](partitions: Option[Int] = None) : Resource[F, Cache[F, K, V]] = { implicit val hash: Hash[K] = Hash.fromUniversalHashCode[K] @@ -518,7 +518,7 @@ object Cache { .map { _.pure[F] } .getOrElse { NrOfPartitions[F]() } .toResource - cache = LoadingCache.of(LoadingCache.EntryRefs.empty[F, K, V]) + cache = LoadingCache.of[F, K, V] partitions <- Partitions.of[Resource[F, _], K, Cache[F, K, V]](nrOfPartitions, _ => cache) } yield { fromPartitions(partitions) @@ -570,7 +570,7 @@ object Cache { * method will be called on underlying cache when resource is released to make sure all * resources stored in a cache are also released. */ - def expiring[F[_]: Temporal: Runtime: Parallel, K, V]( + def expiring[F[_]: Async: Runtime: Parallel, K, V]( config: ExpiringCache.Config[F, K, V], partitions: Option[Int] = None, ): Resource[F, Cache[F, K, V]] = { diff --git a/scache/src/main/scala/com/evolution/scache/ExpiredError.scala b/scache/src/main/scala/com/evolution/scache/ExpiredError.scala new file mode 100644 index 0000000..e150971 --- /dev/null +++ b/scache/src/main/scala/com/evolution/scache/ExpiredError.scala @@ -0,0 +1,5 @@ +package com.evolution.scache + +import scala.util.control.NoStackTrace + +case object ExpiredError extends RuntimeException with NoStackTrace diff --git a/scache/src/main/scala/com/evolution/scache/ExpiringCache.scala b/scache/src/main/scala/com/evolution/scache/ExpiringCache.scala index 03c8eaf..cf24a5c 100644 --- a/scache/src/main/scala/com/evolution/scache/ExpiringCache.scala +++ b/scache/src/main/scala/com/evolution/scache/ExpiringCache.scala @@ -1,10 +1,10 @@ package com.evolution.scache import cats.effect.syntax.all.* -import cats.effect.{Clock, Ref, Resource, Temporal} +import cats.effect.{Async, Clock, Ref, Resource} import cats.kernel.CommutativeMonoid import cats.syntax.all.* -import cats.{Applicative, Monad, MonadThrow, Monoid} +import cats.{Applicative, MonadThrow, Monoid} import com.evolution.scache.Cache.Directive import com.evolution.scache.LoadingCache.EntryState import com.evolutiongaming.catshelper.ClockHelper.* @@ -19,11 +19,13 @@ object ExpiringCache { private[scache] def of[F[_], K, V]( config: Config[F, K, V], )(implicit - G: Temporal[F], + G: Async[F], ): Resource[F, Cache[F, K, V]] = { type E = Entry[V] + type DeferredE = LoadingCache.DeferredThrow[F, LoadingCache.Entry[F, E]] + val cooldown = math.max(config.expireAfterRead.toMillis / 5, 10L) val expireAfterReadMs = config.expireAfterRead.toMillis + cooldown / 2 val expireAfterWriteMs = config.expireAfterWrite.map { _.toMillis } @@ -32,16 +34,20 @@ object ExpiringCache { (expireInterval / 10).millis } - def removeExpiredAndCheckSize(ref: Ref[F, LoadingCache.EntryRefs[F, K, E]], cache: Cache[F, K, E]) = { + def removeExpiredAndCheckSize( + entryMap: LoadingCache.EntryMap[F, K, E], + cache: Cache[F, K, E], + loadingSince: Ref[F, Map[K, (DeferredE, Timestamp)]], + ): F[Unit] = { - def remove(key: K) = { + def remove(key: K): F[Unit] = { cache .remove(key) .flatten .void } - def removeExpired(key: K, entryRef: LoadingCache.EntryRef[F, Entry[V]]) = { + def removeExpired(key: K, entryRef: LoadingCache.EntryRef[F, Entry[V]]): F[Unit] = { entryRef .get .flatMap { @@ -58,14 +64,62 @@ object ExpiringCache { } } - def notExceedMaxSize(maxSize: Int) = { + def evictLoading(key: K, entryRef: LoadingCache.EntryRef[F, E], deferred: DeferredE): F[Unit] = { + entryRef + .modify { + case state: EntryState.Loading[F, E] if state.deferred == deferred => + (EntryState.Removed, true) + case state => + (state, false) + } + .flatMap { + case true => + entryMap + .ref(key) + .update { + case Some(`entryRef`) => none + case other => other + } + .productR { deferred.complete(ExpiredError.asLeft).void } + case false => + ().pure[F] + } + .uncancelable + } + + def removeExpiredLoading(loading: List[(K, LoadingCache.EntryRef[F, E], DeferredE)]): F[Unit] = { + val threshold = expireAfterWriteMs.fold(expireAfterReadMs) { _ min expireAfterReadMs } + for { + now <- Clock[F].millis + expired <- loadingSince.modify { seen => + val seen1 = loading + .map { case (key, _, deferred) => + val since = seen + .get(key) + .collect { case (`deferred`, since) => since } + .getOrElse(now) + (key, (deferred, since)) + } + .toMap + val expired = loading.filter { case (key, _, deferred) => + seen1.get(key).exists { case (deferred1, since) => + (deferred1 == deferred) && (since + threshold < now) + } + } + (seen1 -- expired.map { case (key, _, _) => key }, expired) + } + result <- expired.foldMapM { case (key, entryRef, deferred) => evictLoading(key, entryRef, deferred) } + } yield result + } + + def notExceedMaxSize(maxSize: Int): F[Unit] = { - def drop(entryRefs: LoadingCache.EntryRefs[F, K, E]) = { + def drop(entries: List[(K, LoadingCache.EntryRef[F, E])]): F[Unit] = { final case class Elem(key: K, timestamp: Timestamp) val zero = List.empty[Elem] - entryRefs + entries .foldLeft(zero.pure[F]) { case (result, (key, entryRef)) => result.flatMap { result => entryRef @@ -86,14 +140,22 @@ object ExpiringCache { } for { - entryRefs <- ref.get - result <- if (entryRefs.size > maxSize) drop(entryRefs) else ().pure[F] + size <- entryMap.size + result <- if (size > maxSize) entryMap.entries.flatMap(drop) else ().pure[F] } yield result } for { - entryRefs <- ref.get - result <- entryRefs.foldMapM { case (key, entryRef) => removeExpired(key, entryRef) } + entries <- entryMap.entries + result <- entries.foldMapM { case (key, entryRef) => removeExpired(key, entryRef) } + loading <- entries.foldLeftM(List.empty[(K, LoadingCache.EntryRef[F, E], DeferredE)]) { + case (acc, (key, entryRef)) => + entryRef.get.map { + case state: EntryState.Loading[F, Entry[V]] => (key, entryRef, state.deferred) :: acc + case _ => acc + } + } + _ <- removeExpiredLoading(loading) _ <- config .maxSize .foldMapM { maxSize => notExceedMaxSize(maxSize) } @@ -102,13 +164,13 @@ object ExpiringCache { def refreshEntries( refresh: Refresh[K, F[Option[V]]], - ref: Ref[F, LoadingCache.EntryRefs[F, K, E]], + entryMap: LoadingCache.EntryMap[F, K, E], cache: Cache[F, K, E], - ) = { - ref - .get - .flatMap { entryRefs => - entryRefs.foldMapM { case (key, entryRef) => + ): F[Unit] = { + entryMap + .entries + .flatMap { entries => + entries.foldMapM { case (key, entryRef) => entryRef .get .flatMap { @@ -127,32 +189,32 @@ object ExpiringCache { } } - def schedule(interval: FiniteDuration)(fa: F[Unit]) = Schedule(interval, interval)(fa) + def schedule(interval: FiniteDuration)(fa: F[Unit]): Resource[F, Unit] = Schedule(interval, interval)(fa) - val entryRefs = LoadingCache.EntryRefs.empty[F, K, E] for { - ref <- Ref[F].of(entryRefs).toResource - cache <- LoadingCache.of(ref) - _ <- schedule(expireInterval) { removeExpiredAndCheckSize(ref, cache) } + entryMap <- LoadingCache.EntryMap.of[F, K, E].toResource + loadingSince <- Ref[F].of(Map.empty[K, (DeferredE, Timestamp)]).toResource + cache <- LoadingCache.of(entryMap) + _ <- schedule(expireInterval) { removeExpiredAndCheckSize(entryMap, cache, loadingSince) } _ <- config .refresh .foldMapM { refresh => - schedule(refresh.interval) { refreshEntries(refresh, ref, cache) } + schedule(refresh.interval) { refreshEntries(refresh, entryMap, cache) } } } yield { - apply(ref, cache, cooldown) + apply(entryMap, cache, cooldown) } } def apply[F[_]: MonadThrow: Clock, K, V]( - ref: Ref[F, LoadingCache.EntryRefs[F, K, Entry[V]]], + entryMap: LoadingCache.EntryMap[F, K, Entry[V]], cache: Cache[F, K, Entry[V]], cooldown: Long, ): Cache[F, K, V] = { type E = Entry[V] - def entryOf(value: V) = { + def entryOf(value: V): F[Entry[V]] = { Clock[F] .millis .map { timestamp => @@ -162,17 +224,13 @@ object ExpiringCache { implicit def monoidUnit: Monoid[F[Unit]] = Applicative.monoid[F, Unit] - def touch(key: K, entry: E) = { + def touch(key: K, entry: E): F[Unit] = { for { now <- Clock[F].millis result <- if ((entry.touched + cooldown) <= now) { - ref - .get - .flatMap { entries => - entries - .get(key) - .foldMap { _.update1 { _.touch(now) } } - } + entryMap + .lookup(key) + .flatMap { _.foldMap { _.update1 { _.touch(now) } } } } else { ().pure[F] } @@ -182,7 +240,7 @@ object ExpiringCache { abstract class ExpiringCache extends Cache.Abstract1[F, K, V] new ExpiringCache { self => - def get(key: K) = { + def get(key: K): F[Option[V]] = { cache .get1(key) .flatMap { @@ -201,7 +259,7 @@ object ExpiringCache { } } - def get1(key: K) = { + def get1(key: K): F[Option[Either[F[V], V]]] = { cache .get1(key) .flatMap { @@ -223,7 +281,7 @@ object ExpiringCache { } } - def getOrUpdate(key: K)(value: => F[V]) = { + def getOrUpdate(key: K)(value: => F[V]): F[V] = { getOrUpdate1(key) { value.map { a => (a, a, none[Release]) } } .flatMap { case Right(Right(a)) => a.pure[F] @@ -232,7 +290,7 @@ object ExpiringCache { } } - def getOrUpdate1[A](key: K)(value: => F[(A, V, Option[Release])]) = { + def getOrUpdate1[A](key: K)(value: => F[(A, V, Option[Release])]): F[Either[A, Either[F[V], V]]] = { cache .getOrUpdate1(key) { value.flatMap { case (a, value, release) => @@ -261,7 +319,7 @@ object ExpiringCache { } } - def put(key: K, value: V, release: Option[Release]) = { + def put(key: K, value: V, release: Option[Release]): F[F[Option[V]]] = { entryOf(value) .flatMap { entry => cache @@ -285,13 +343,13 @@ object ExpiringCache { cache.modify(key)(adaptedF) } - def contains(key: K) = cache.contains(key) + def contains(key: K): F[Boolean] = cache.contains(key) - def size = cache.size + def size: F[Int] = cache.size - def keys = cache.keys + def keys: F[Set[K]] = cache.keys - def values = { + def values: F[Map[K, F[V]]] = { cache .values .map { values => @@ -301,7 +359,7 @@ object ExpiringCache { } } - def values1 = { + def values1: F[Map[K, Either[F[V], V]]] = { cache .values1 .map { entries => @@ -315,22 +373,22 @@ object ExpiringCache { } } - def remove(key: K) = { + def remove(key: K): F[F[Option[V]]] = { cache .remove(key) .map { _.map { _.map { _.value } } } } - def clear = cache.clear + def clear: F[F[Unit]] = cache.clear - def foldMap[A: CommutativeMonoid](f: (K, Either[F[V], V]) => F[A]) = { + def foldMap[A: CommutativeMonoid](f: (K, Either[F[V], V]) => F[A]): F[A] = { cache.foldMap { case (k, Right(v)) => f(k, v.value.asRight) case (k, Left(v)) => f(k, v.map { _.value }.asLeft) } } - def foldMapPar[A: CommutativeMonoid](f: (K, Either[F[V], V]) => F[A]) = { + def foldMapPar[A: CommutativeMonoid](f: (K, Either[F[V], V]) => F[A]): F[A] = { cache.foldMap { case (k, Right(v)) => f(k, v.value.asRight) case (k, Left(v)) => f(k, v.map { _.value }.asLeft) @@ -429,16 +487,4 @@ object ExpiringCache { refresh: Option[Refresh[K, F[Option[V]]]] = None, ) - private implicit class MapOps[K, V](val self: Map[K, V]) extends AnyVal { - def foldMapM[F[_]: Monad, A: Monoid](f: (K, V) => F[A]): F[A] = { - self.foldLeft(Monoid[A].empty.pure[F]) { case (a, (k, v)) => - for { - a <- a - b <- f(k, v) - } yield { - a.combine(b) - } - } - } - } } diff --git a/scache/src/main/scala/com/evolution/scache/LoadingCache.scala b/scache/src/main/scala/com/evolution/scache/LoadingCache.scala index c011fd2..e7106d0 100644 --- a/scache/src/main/scala/com/evolution/scache/LoadingCache.scala +++ b/scache/src/main/scala/com/evolution/scache/LoadingCache.scala @@ -1,63 +1,105 @@ package com.evolution.scache +import cats.effect.* import cats.effect.implicits.* -import cats.effect.{Concurrent, Deferred, Fiber, GenConcurrent, Outcome, Ref, Resource} +import cats.effect.std.MapRef import cats.kernel.CommutativeMonoid import cats.syntax.all.* import cats.{Applicative, Functor, Monad, MonadThrow, Parallel} import com.evolution.scache.Cache.Directive import com.evolutiongaming.catshelper.ParallelHelper.* -private[scache] object LoadingCache { +import java.util.concurrent.ConcurrentHashMap +import scala.jdk.CollectionConverters.* - /** - * Maximum number of CAS retry attempts before giving up. This is a safety net against infinite - * spinning under extreme contention. - */ - /** - * Maximum number of CAS retry attempts on the outer map before giving up. Inner entry-level CAS - * loops are unbounded as they always make progress. - */ - private val MaxRetries: Int = 10000 +private[scache] object LoadingCache { - def of[F[_]: Concurrent, K, V]( - map: EntryRefs[F, K, V], - ): Resource[F, Cache[F, K, V]] = { + def of[F[_]: Async, K, V]: Resource[F, Cache[F, K, V]] = { for { - ref <- Ref[F].of(map).toResource - cache <- of(ref) + entryMap <- EntryMap.of[F, K, V].toResource + cache <- of(entryMap) } yield cache } - def of[F[_]: Concurrent, K, V]( - ref: Ref[F, EntryRefs[F, K, V]], + def of[F[_]: Async, K, V]( + entryMap: EntryMap[F, K, V], ): Resource[F, Cache[F, K, V]] = { Resource.make { - apply(ref).pure[F] + apply(entryMap).pure[F] } { cache => cache.clear.flatten } } - def apply[F[_]: Concurrent, K, V]( - ref: Ref[F, EntryRefs[F, K, V]], - ): Cache[F, K, V] = { + /** + * Per-key view over the cache state: mutations go through [[cats.effect.std.MapRef]], so + * operations on distinct keys never contend, while enumeration is served by the backing + * [[java.util.concurrent.ConcurrentHashMap]]. + */ + trait EntryMap[F[_], K, V] { - val handleReleaseError = (e: Throwable) => { - System.err.println(s"scache: failed to release cache entry: $e") + def ref(key: K): Ref[F, Option[EntryRef[F, V]]] + + def lookup(key: K): F[Option[EntryRef[F, V]]] + + def keys: F[Set[K]] + + def entries: F[List[(K, EntryRef[F, V])]] + + def size: F[Int] + + def contains(key: K): F[Boolean] + } + + object EntryMap { + + def of[F[_]: Sync, K, V]: F[EntryMap[F, K, V]] = { + Sync[F] + .delay { new ConcurrentHashMap[K, EntryRef[F, V]]() } + .map { chm => apply(chm) } } - def checkRetries(counter: Int): F[Unit] = { - if (counter >= MaxRetries) { - new IllegalStateException( - s"Cache CAS retry limit ($MaxRetries) exceeded. This indicates extreme contention.", - ).raiseError[F, Unit] - } else { - ().pure[F] + def apply[F[_]: Sync, K, V](chm: ConcurrentHashMap[K, EntryRef[F, V]]): EntryMap[F, K, V] = { + val mapRef = MapRef.fromConcurrentHashMap[F, K, EntryRef[F, V]](chm) + new EntryMap[F, K, V] { + + def ref(key: K): Ref[F, Option[EntryRef[F, V]]] = mapRef(key) + + def lookup(key: K): F[Option[EntryRef[F, V]]] = Sync[F].delay { Option(chm.get(key)) } + + def keys: F[Set[K]] = { + Sync[F].delay { chm.keySet().asScala.toSet } + } + + def entries: F[List[(K, EntryRef[F, V])]] = { + Sync[F].delay { + chm + .entrySet() + .iterator() + .asScala + .map { entry => (entry.getKey, entry.getValue) } + .toList + } + } + + def size: F[Int] = Sync[F].delay { chm.mappingCount().toInt } + + def contains(key: K): F[Boolean] = Sync[F].delay { chm.containsKey(key) } } } + } + + def apply[F[_]: Async, K, V]( + entryMap: EntryMap[F, K, V], + ): Cache[F, K, V] = { - def entryOf(value: V, release: Option[F[Unit]]) = { + val F = Async[F] + + val handleReleaseError = (e: Throwable) => { + System.err.println(s"scache: failed to release cache entry: $e") + } + + def entryOf(value: V, release: Option[F[Unit]]): Entry[F, V] = { Entry( value = value, release = release.map { _.handleError(handleReleaseError) }, @@ -68,51 +110,45 @@ private[scache] object LoadingCache { new LoadingCache { - def get(key: K) = { - ref - .get - .flatMap { entryRefs => - entryRefs - .get(key) - .fold { - none[V].pure[F] - } { entry => - entry - .get - .flatMap { - case state: EntryState.Value[F, V] => - state - .entry - .value - .some - .pure[F] - case state: EntryState.Loading[F, V] => - state - .deferred - .get - .map { entry => - entry - .toOption - .map { _.value } - } - case EntryState.Removed => - none[V].pure[F] - } - } + def get(key: K): F[Option[V]] = { + entryMap + .lookup(key) + .flatMap { + _.fold { + none[V].pure[F] + } { entryRef => + entryRef + .get + .flatMap { + case state: EntryState.Value[F, V] => + state + .entry + .value + .some + .pure[F] + case state: EntryState.Loading[F, V] => + state + .deferred + .get + .map { entry => + entry + .toOption + .map { _.value } + } + case EntryState.Removed => + none[V].pure[F] + } + } } } - def get1(key: K) = { - ref - .get - .flatMap { entryRefs => - entryRefs - .get(key) - .flatTraverse { _.optEither } - } + def get1(key: K): F[Option[Either[F[V], V]]] = { + entryMap + .lookup(key) + .flatMap { _.flatTraverse { _.optEither } } } - def getOrUpdate(key: K)(value: => F[V]) = { + def getOrUpdate(key: K)(value: => F[V]): F[V] = { getOrUpdate1(key) { value.map { a => (a, a, none[Release]) } }.flatMap { case Right(Right(a)) => a.pure[F] case Right(Left(a)) => a @@ -121,364 +157,381 @@ private[scache] object LoadingCache { } def getOrUpdate1[A](key: K)(value: => F[(A, V, Option[Release])]): F[Either[A, Either[F[V], V]]] = { - 0.tailRecM { counter => - checkRetries(counter) *> - ref - .access - .flatMap { case (entryRefs, set) => - entryRefs - .get(key) - .fold { - for { - deferred <- Deferred[F, Either[Throwable, Entry[F, V]]] - entryRef <- Ref[F].of[EntryState[F, V]](EntryState.Loading(deferred)) - result <- set(entryRefs.updated(key, entryRef)) - .flatMap { - case true => - value - .map { case (a, value, release) => - val entry = entryOf(value, release) - (a, entry) - } - .attempt - .race1 { deferred.get } - .flatMap { - // `value` got computed, and deferred was not (yet) completed by any other fiber in `put` - case Left(Right((a, entry))) => - deferred - .complete(entry.asRight) - .flatMap { - // Successfully completed our deferred, - // now trying to place the new value in the entry. - case true => - - def releaseAndReturnValue(state: EntryState.Value[F, V]) - : F[Either[A, Either[F[V], V]]] = - entry - .release1 - .start - .as { - state - .entry - .value - .asRight[F[V]] - .asRight[A] - } - - def releaseAndReturnLoading(state: EntryState.Loading[F, V]) - : F[Either[A, Either[F[V], V]]] = - entry - .release1 - .start - .as { - state - .deferred - .getOrError - .map(_.value) - .asLeft[V] - .asRight[A] - } - - // Try putting computed value in the map, if there is no entry with our key. - // If the map already contains an entry with our key, - // return its value (or value computation). - def tryPutNewValue: F[Either[A, Either[F[V], V]]] = - 0.tailRecM { counter => - ref - .access - .flatMap { case (entryRefs, set) => - entryRefs - .get(key) - .fold { - // No entry present in the map, so we try to add a new one - Ref[F] - .of[EntryState[F, V]](EntryState.Value(entry)) - .flatMap { entryRef => - set(entryRefs.updated(key, entryRef)).map { - case true => - a - .asLeft[Either[F[V], V]] - .asRight[Int] - case false => - (counter + 1) - .asLeft[Either[A, Either[F[V], V]]] - } - } - } { entryRef => - entryRef - .get - .flatMap { - case state: EntryState.Value[F, V] => - releaseAndReturnValue(state).map(_.asRight[Int]) - - case state: EntryState.Loading[F, V] => - releaseAndReturnLoading(state).map(_.asRight[Int]) - - // `Removed` means that this entry won't be present in the map - // next time we look the key up (see `remove` flow), - // so we just retry. - case EntryState.Removed => - (counter + 1) - .asLeft[Either[A, Either[F[V], V]]] - .pure[F] - } - .uncancelable - } - } - } - - entryRef - .access - .flatMap { - // Entry is still in loading state, containing the same deferred we just completed. - // Now we can try to put the computed value in the same entryRef. - case (state: EntryState.Loading[F, V], set) if state.deferred == deferred => - set(EntryState.Value(entry)) - .flatMap { - // Happy path: successfully placed our computed value - case true => - a - .asLeft[Either[F[V], V]] - .pure[F] - // Failed to set our value, meaning the entry was either: - // - Updated: in that case we release our computed value, and return - // the value (or its computation), giving it the priority - // - Removed: in that case we try to put our value back in the map - case false => - entryRef - .get - .flatMap { - case state: EntryState.Value[F, V] => - releaseAndReturnValue(state) - - case state: EntryState.Loading[F, V] => - releaseAndReturnLoading(state) - - case EntryState.Removed => - tryPutNewValue - } - } - - case (state: EntryState.Value[F, V], _) => - releaseAndReturnValue(state) - - case (state: EntryState.Loading[F, V], _) => - releaseAndReturnLoading(state) - - case (EntryState.Removed, _) => - tryPutNewValue - } - - // Deferred got completed by another fiber, so we return what they put there, - // and release the value we just computed. - case false => - entry - .release1 - .start - .productR( - deferred - .getOrError - .map { entry => - entry - .value - .asRight[F[V]] - .asRight[A] - }, - ) - } - // `value` computation completed with error, - // and deferred was not completed in another fiber in `put`. - case Left(Left(error)) => - deferred - .complete(error.asLeft) - .flatMap { - // Successfully completed our deferred with error, - // now trying to remove the entry from the map, if it is still there. - case true => - 0.tailRecM { counter1 => - ref - .access - .flatMap { case (entryRefs, set) => - entryRefs - .get(key) - .fold { - // Key was removed while we were loading, - // so we are just propagating the error - error.raiseError[F, Either[Int, Either[F[V], V]]] - } { - // The entry we added to the map is still there and unmodified, - // so we can safely remove it and propagate the error - case `entryRef` => - set(entryRefs - key).flatMap { - // Happy path: successfully removed our entry - case true => - error.raiseError[F, Either[Int, Either[F[V], V]]] - // Retrying (different keys could've been modified in the map) - case false => - (counter1 + 1) - .asLeft[Either[F[V], V]] - .pure[F] - } - // Another fiber replaced the `ref` we added to the map, - // so we return their value (computed or ongoing), - // or propagate our error if our entry got removed. - case entryRef => - entryRef - .optEither - .flatMap(_.liftTo[F](error)) - .map(_.asRight[Int]) - } - } - } + def load( + poll: Poll[F], + entryRef: EntryRef[F, V], + deferred: DeferredThrow[F, Entry[F, V]], + ): F[Either[A, Either[F[V], V]]] = { + Ref[F].of(none[Entry[F, V]]).flatMap { computed => + val cleanupOnCancel = + entryRef + .modify { + case state: EntryState.Loading[F, V] if state.deferred == deferred => + (EntryState.Removed, true) + case state => + (state, false) + } + .flatMap { + case true => + entryMap + .ref(key) + .update { + case Some(`entryRef`) => none + case other => other + } + .productR { deferred.complete(CancelledError.asLeft).void } + case false => + ().pure[F] + } + .productR { + computed + .get + .flatMap { _.foldMapM { _.release1 } } + } - // Someone else completed the deferred before us, so they must've take care of - // updating the `ref`, and we return their result. - case false => - deferred - .getOrError - .map { _.value } - .asLeft[V] - .pure[F] - } - .map { _.asRight[A] } - - // Deferred was completed by `put` in another fiber before `value` computation completed. - // We return their value, and schedule release of our value that is still being computed. - case Right((fiber, entry)) => - fiber - .joinWithNever - .flatMap { - case Right((_, entry)) => entry.release1 - case _ => ().pure[F] - } - .start - .productR { - entry - .liftTo[F] - .map { entry => - entry - .value - .asRight[F[V]] - .asRight[A] + poll { + F.uncancelable { poll1 => + poll1 { + value.map { case (a, value, release) => + val entry = entryOf(value, release) + (a, entry) + } + } + .flatTap { case (_, entry) => computed.set(entry.some) } + } + .attempt + .race1 { deferred.get } + } + .onCancel { cleanupOnCancel } + .flatMap { + // `value` got computed, and deferred was not (yet) completed by any other fiber in `put` + case Left(Right((a, entry))) => + deferred + .complete(entry.asRight) + .flatMap { + // Successfully completed our deferred, + // now trying to place the new value in the entry. + case true => + + def releaseAndReturnValue(state: EntryState.Value[F, V]): F[Either[A, Either[F[V], V]]] = + entry + .release1 + .start + .as { + state + .entry + .value + .asRight[F[V]] + .asRight[A] + } + + def releaseAndReturnLoading(state: EntryState.Loading[F, V]): F[Either[A, Either[F[V], V]]] = + entry + .release1 + .start + .as { + state + .deferred + .getOrError + .map(_.value) + .asLeft[V] + .asRight[A] + } + + // Try putting computed value in the map, if there is no entry with our key. + // If the map already contains an entry with our key, + // return its value (or value computation). + def tryPutNewValue: F[Either[A, Either[F[V], V]]] = + Ref[F] + .of[EntryState[F, V]](EntryState.Value(entry)) + .flatMap { newRef => + ().tailRecM { _ => + entryMap + .ref(key) + .modify { + case None => (newRef.some, none[EntryRef[F, V]]) + case some => (some, some) + } + .flatMap { + case None => + a + .asLeft[Either[F[V], V]] + .asRight[Unit] + .pure[F] + case Some(existingRef) => + existingRef + .get + .flatMap { + case state: EntryState.Value[F, V] => + releaseAndReturnValue(state).map(_.asRight[Unit]) + + case state: EntryState.Loading[F, V] => + releaseAndReturnLoading(state).map(_.asRight[Unit]) + + // `Removed` means that this entry won't be present in the map + // next time we look the key up (see `remove` flow), + // so we just retry. + case EntryState.Removed => + () + .asLeft[Either[A, Either[F[V], V]]] + .pure[F] } - } + } } - .map { _.asRight[Int] } + } - case false => - (counter + 1) - .asLeft[Either[A, Either[F[V], V]]] - .pure[F] - } - .uncancelable - } yield result - } { entryRef => - // Map already contained an entry under our key, so we return that value (or its ongoing computation) - entryRef - .optEither - .map { - case Some(either) => - either + entryRef + .access + .flatMap { + // Entry is still in loading state, containing the same deferred we just completed. + // Now we can try to put the computed value in the same entryRef. + case (state: EntryState.Loading[F, V], set) if state.deferred == deferred => + set(EntryState.Value(entry)) + .flatMap { + // Happy path: successfully placed our computed value + case true => + a + .asLeft[Either[F[V], V]] + .pure[F] + // Failed to set our value, meaning the entry was either: + // - Updated: in that case we release our computed value, and return + // the value (or its computation), giving it the priority + // - Removed: in that case we try to put our value back in the map + case false => + entryRef + .get + .flatMap { + case state: EntryState.Value[F, V] => + releaseAndReturnValue(state) + + case state: EntryState.Loading[F, V] => + releaseAndReturnLoading(state) + + case EntryState.Removed => + tryPutNewValue + } + } + + case (state: EntryState.Value[F, V], _) => + releaseAndReturnValue(state) + + case (state: EntryState.Loading[F, V], _) => + releaseAndReturnLoading(state) + + case (EntryState.Removed, _) => + tryPutNewValue + } + + // Deferred got completed by another fiber, so we return what they put there, + // and release the value we just computed. + case false => + entry + .release1 + .start + .productR( + deferred + .getOrError + .map { entry => + entry + .value + .asRight[F[V]] + .asRight[A] + }, + ) + } + + // `value` computation completed with error, + // and deferred was not completed in another fiber in `put`. + case Left(Left(error)) => + deferred + .complete(error.asLeft) + .flatMap { + // Successfully completed our deferred with error, + // now trying to remove the entry from the map, if it is still there. + case true => + entryMap + .ref(key) + .modify { + // The entry we added to the map is still there and unmodified, + // so we can safely remove it and propagate the error + case Some(`entryRef`) => (none[EntryRef[F, V]], none[EntryRef[F, V]]) + case other => (other, other) + } + .flatMap { + // Key was removed (or removed and replaced by us) while we were loading, + // so we are just propagating the error + case None => + error.raiseError[F, Either[F[V], V]] + // Another fiber replaced the entry we added to the map, + // so we return their value (computed or ongoing), + // or propagate our error if their entry got removed. + case Some(otherRef) => + otherRef + .optEither + .flatMap(_.liftTo[F](error)) + } + + // Someone else completed the deferred before us, so they must've take care of + // updating the entry, and we return their result. + case false => + deferred + .getOrError + .map { _.value } + .asLeft[V] + .pure[F] + } + .map { _.asRight[A] } + + // Deferred was completed by `put` in another fiber before `value` computation completed. + // We return their value, and schedule release of our value that is still being computed. + case Right((fiber, entry)) => + fiber + .joinWithNever + .flatMap { + case Right((_, entry)) => entry.release1 + case _ => ().pure[F] + } + .start + .productR { + entry + .liftTo[F] + .map { entry => + entry + .value + .asRight[F[V]] .asRight[A] - .asRight[Int] - // Entry got removed (see `remove` flow), so we retry expecting to get something else with our key. - case None => - (counter + 1) - .asLeft[Either[A, Either[F[V], V]]] - } - } + } + } } + } + } + + ().tailRecM { _ => + entryMap + .lookup(key) + .flatMap { + case Some(entryRef) => + entryRef + .optEither + .map { + case Some(either) => + either + .asRight[A] + .asRight[Unit] + // Entry got removed (see `remove` flow), so we retry expecting to get something else with our key. + case None => + ().asLeft[Either[A, Either[F[V], V]]] + } + case None => + F.uncancelable { poll => + for { + deferred <- Deferred[F, Either[Throwable, Entry[F, V]]] + entryRef <- Ref[F].of[EntryState[F, V]](EntryState.Loading(deferred)) + existing <- entryMap + .ref(key) + .modify { + case None => (entryRef.some, none[EntryRef[F, V]]) + case some => (some, some) + } + result <- existing match { + case Some(existingRef) => + existingRef + .optEither + .map { + case Some(either) => + either + .asRight[A] + .asRight[Unit] + case None => + ().asLeft[Either[A, Either[F[V], V]]] + } + case None => + load(poll, entryRef, deferred).map { _.asRight[Unit] } + } + } yield result + } + } } } def put(key: K, value: V, release: Option[Release]): F[F[Option[V]]] = { val entry = entryOf(value, release) - 0.tailRecM { counter => - checkRetries(counter) *> - ref - .access - .flatMap { case (entryRefs, set) => - entryRefs - .get(key) - .fold { - // No entry present in the map, so we add a new one - Ref[F] - .of[EntryState[F, V]](EntryState.Value(entry)) - .flatMap { entryRef => - set(entryRefs.updated(key, entryRef)).map { + ().tailRecM { _ => + entryMap + .lookup(key) + .flatMap { + case None => + // No entry present in the map, so we add a new one + Ref[F] + .of[EntryState[F, V]](EntryState.Value(entry)) + .flatMap { entryRef => + entryMap + .ref(key) + .modify { + case None => (entryRef.some, true) + case some => (some, false) + } + .map { + case true => + none[V] + .pure[F] + .asRight[Unit] + case false => + ().asLeft[F[Option[V]]] + } + } + case Some(entryRef) => + entryRef + .access + .flatMap { + // A computed value is already present in the map, so we are replacing it with our value. + case (state: EntryState.Value[F, V], set) => + set(EntryState.Value(entry)) + .flatMap { + // Successfully replaced the entryRef with our value, + // now we are responsible for releasing the old value. case true => - none[V] - .pure[F] - .asRight[Int] + state + .entry + .release + .traverse { _.start } + .map { fiber => + fiber + .foldMapM { _.joinWithNever } + .as { state.entry.value.some } + .asRight[Unit] + } + // Failed to set the entryRef to our value + // so we just release our value and exit. case false => - (counter + 1) - .asLeft[F[Option[V]]] + entry + .release + .traverse { _.start } // Start releasing and forget + .as { + none[V] + .pure[F] + .asRight[Unit] + } } - } - } { entryRef => - entryRef - .access - .flatMap { - // A computed value is already present in the map, so we are replacing it with our value. - case (state: EntryState.Value[F, V], set) => - set(EntryState.Value(entry)) - .flatMap { - // Successfully replaced the entryRef with our value, - // now we are responsible for releasing the old value. - case true => - state - .entry - .release - .traverse { _.start } - .map { fiber => - fiber - .foldMapM { _.joinWithNever } - .as { state.entry.value.some } - .asRight[Int] - } - // Failed to set the entryRef to our value - // so we just release our value and exit. - case false => - entry - .release - .traverse { _.start } // Start releasing and forget - .as { - none[V] - .pure[F] - .asRight[Int] - } - } - // The value is still loading, so we first try to complete the deferred with it, - // and then replace it with our value. - case (state: EntryState.Loading[F, V], set) => - state - .deferred - .complete(entry.asRight) - .flatMap { - // We successfully completed the deferred, now trying to set the value. + // The value is still loading, so we first try to complete the deferred with it, + // and then replace it with our value. + case (state: EntryState.Loading[F, V], set) => + state + .deferred + .complete(entry.asRight) + .flatMap { + // We successfully completed the deferred, now trying to set the value. + case true => + set(EntryState.Value(entry)).flatMap { + // We successfully replaced the entry with our value, so we are done. case true => - set(EntryState.Value(entry)).flatMap { - // We successfully replaced the entry with our value, so we are done. - case true => - none[V] - .pure[F] - .asRight[Int] - .pure[F] - // Another fiber placed their new value before us - // so we just release our value and exit. - case false => - entry - .release - .traverse { _.start } // Start releasing and forget - .as { - none[V] - .pure[F] - .asRight[Int] - } - } - // Someone just completed the deferred we saw + none[V] + .pure[F] + .asRight[Unit] + .pure[F] + // Another fiber placed their new value before us // so we just release our value and exit. case false => entry @@ -487,214 +540,212 @@ private[scache] object LoadingCache { .as { none[V] .pure[F] - .asRight[Int] + .asRight[Unit] } } + // Someone just completed the deferred we saw + // so we just release our value and exit. + case false => + entry + .release + .traverse { _.start } // Start releasing and forget + .as { + none[V] + .pure[F] + .asRight[Unit] + } + } - // The key was just removed from the map, so just release the value and exit. - case (EntryState.Removed, _) => - entry - .release - .traverse { _.start } // Start releasing and forget - .as { - none[V] - .pure[F] - .asRight[Int] - } - } - .uncancelable + // The key was just removed from the map, so just release the value and exit. + case (EntryState.Removed, _) => + entry + .release + .traverse { _.start } // Start releasing and forget + .as { + none[V] + .pure[F] + .asRight[Unit] + } } - } + .uncancelable + } } } override def modify[A](key: K)(f: Option[V] => (A, Directive[F, V])): F[(A, Option[F[Unit]])] = { - 0.tailRecM { counter => - checkRetries(counter) *> - ref - .access - .flatMap { case (entryRefs, setMap) => - entryRefs - .get(key) - .fold { - f(None) match { - // No entry present in the map, and we want to add a new one - case (a, put: Directive.Put[F, V]) => - Ref[F] - .of[EntryState[F, V]](EntryState.Value(entryOf(put.value, put.release))) - .flatMap { entryRef => - setMap(entryRefs.updated(key, entryRef)).map { - case true => - (a, none[F[Unit]]) - .asRight[Int] - // Failed adding new entry to the map, retrying accessing the map - case false => - (counter + 1) - .asLeft[(A, Option[F[Unit]])] - } + ().tailRecM { _ => + entryMap + .lookup(key) + .flatMap { + case None => + f(None) match { + // No entry present in the map, and we want to add a new one + case (a, put: Directive.Put[F, V]) => + Ref[F] + .of[EntryState[F, V]](EntryState.Value(entryOf(put.value, put.release))) + .flatMap { entryRef => + entryMap + .ref(key) + .modify { + case None => (entryRef.some, true) + case some => (some, false) } - // No entry present in the map, and we don't want to have any, so exiting - case (a, Directive.Ignore | Directive.Remove) => - (a, none[F[Unit]]) - .asRight[Int] - .pure[F] - } - } { entryRef => - 0.tailRecM { counter1 => - entryRef - .access - .flatMap { - // A value is already present in the map - case (state: EntryState.Value[F, V], setRef) => - f(state.entry.value.some) match { - case (a, put: Directive.Put[F, V]) => - setRef(EntryState.Value(entryOf(put.value, put.release))) - .flatMap { - // Successfully replaced the entryRef with our value, - // now we are responsible for releasing the old value. - case true => + .map { + case true => + (a, none[F[Unit]]) + .asRight[Unit] + // Failed adding new entry to the map, retrying accessing the map + case false => + ().asLeft[(A, Option[F[Unit]])] + } + } + // No entry present in the map, and we don't want to have any, so exiting + case (a, Directive.Ignore | Directive.Remove) => + (a, none[F[Unit]]) + .asRight[Unit] + .pure[F] + } + case Some(entryRef) => + ().tailRecM { _ => + entryRef + .access + .flatMap { + // A value is already present in the map + case (state: EntryState.Value[F, V], setRef) => + f(state.entry.value.some) match { + case (a, put: Directive.Put[F, V]) => + setRef(EntryState.Value(entryOf(put.value, put.release))) + .flatMap { + // Successfully replaced the entryRef with our value, + // now we are responsible for releasing the old value. + case true => + state + .entry + .release + .traverse { _.start } + .map { release => + (a, release.map(_.joinWithNever)) + .asRight[Unit] + .asRight[Unit] + } + // Failed updating entryRef, retrying + case false => + () + .asLeft[Either[Unit, (A, Option[F[Unit]])]] + .pure[F] + } + // Keeping the value intact and exiting + case (a, Directive.Ignore) => + (a, none[F[Unit]]) + .asRight[Unit] + .asRight[Unit] + .pure[F] + // Removing the value + case (a, Directive.Remove) => + setRef(EntryState.Removed) + .flatMap { + // Successfully set the entryRef to `Removed` state, now removing it from the map. + // Only removing the key if it still contains this entry, otherwise noop. + case true => + entryMap + .ref(key) + .update { + case Some(`entryRef`) => none + case other => other + } + .flatMap { _ => + // Releasing the value regardless of the map update result. state .entry .release .traverse { _.start } .map { release => (a, release.map(_.joinWithNever)) - .asRight[Int] - .asRight[Int] + .asRight[Unit] + .asRight[Unit] } - // Failed updating entryRef, retrying - case false => - (counter1 + 1) - .asLeft[Either[Int, (A, Option[F[Unit]])]] - .pure[F] - } - // Keeping the value intact and exiting - case (a, Directive.Ignore) => - (a, none[F[Unit]]) - .asRight[Int] - .asRight[Int] - .pure[F] - // Removing the value - case (a, Directive.Remove) => - setRef(EntryState.Removed) - .flatMap { - // Successfully set the entryRef to `Removed` state, now removing it from the map. - // Only removing the key if it still contains this entry, otherwise noop. - case true => - ref - .update { entryRefs => - entryRefs.get(key) match { - case Some(`entryRef`) => entryRefs - key - case _ => entryRefs - } - } - .flatMap { _ => - // Releasing the value regardless of the map update result. - state - .entry - .release - .traverse { _.start } - .map { release => - (a, release.map(_.joinWithNever)) - .asRight[Int] - .asRight[Int] - } - } - // Failed updating entryRef, retrying - case false => - (counter1 + 1) - .asLeft[Either[Int, (A, Option[F[Unit]])]] - .pure[F] - } - } + } + // Failed updating entryRef, retrying + case false => + () + .asLeft[Either[Unit, (A, Option[F[Unit]])]] + .pure[F] + } + } - // Entry in the map is still loading - case (state: EntryState.Loading[F, V], setRef) => - f(None) match { - // Trying to replace it with our value - case (a, put: Directive.Put[F, V]) => - val entry = entryOf(put.value, put.release) - state - .deferred - .complete(entry.asRight) - .flatMap { - // We successfully completed the deferred, now trying to set the value. + // Entry in the map is still loading + case (state: EntryState.Loading[F, V], setRef) => + f(None) match { + // Trying to replace it with our value + case (a, put: Directive.Put[F, V]) => + val entry = entryOf(put.value, put.release) + state + .deferred + .complete(entry.asRight) + .flatMap { + // We successfully completed the deferred, now trying to set the value. + case true => + setRef(EntryState.Value(entry)).map { + // We successfully replaced the entry with our value, so we are done. case true => - setRef(EntryState.Value(entry)).map { - // We successfully replaced the entry with our value, so we are done. - case true => - (a, none[F[Unit]]) - .asRight[Int] - .asRight[Int] - // Another fiber placed their new value (only Removed should be possible) - // before us so we retry accessing the entry. - case false => - (counter1 + 1) - .asLeft[Either[Int, (A, Option[F[Unit]])]] - } - // Failed to complete the deferred, meaning someone else completed it, and will - // now set the new value in the entryRef. Retrying the lookup. + (a, none[F[Unit]]) + .asRight[Unit] + .asRight[Unit] + // Another fiber placed their new value (only Removed should be possible) + // before us so we retry accessing the entry. case false => - (counter1 + 1) - .asLeft[Either[Int, (A, Option[F[Unit]])]] - .pure[F] + ().asLeft[Either[Unit, (A, Option[F[Unit]])]] } - // Noop decision, exiting - case (a, Directive.Ignore | Directive.Remove) => - (a, none[F[Unit]]) - .asRight[Int] - .asRight[Int] - .pure[F] - } + // Failed to complete the deferred, meaning someone else completed it, and will + // now set the new value in the entryRef. Retrying the lookup. + case false => + () + .asLeft[Either[Unit, (A, Option[F[Unit]])]] + .pure[F] + } + // Noop decision, exiting + case (a, Directive.Ignore | Directive.Remove) => + (a, none[F[Unit]]) + .asRight[Unit] + .asRight[Unit] + .pure[F] + } - // Entry was just removed, it soon will be gone from the map. - case (EntryState.Removed, _) => - f(None) match { - // We want to place the new value; - // Retrying the map lookup, expecting a different result for our key. - case (_, _: Directive.Put[F, V]) => - (counter + 1) - .asLeft[(A, Option[F[Unit]])] - .asRight[Int] - .pure[F] - // Noop decision, exiting - case (a, Directive.Ignore | Directive.Remove) => - (a, none[F[Unit]]) - .asRight[Int] - .asRight[Int] - .pure[F] - } + // Entry was just removed, it soon will be gone from the map. + case (EntryState.Removed, _) => + f(None) match { + // We want to place the new value; + // Retrying the map lookup, expecting a different result for our key. + case (_, _: Directive.Put[F, V]) => + () + .asLeft[(A, Option[F[Unit]])] + .asRight[Unit] + .pure[F] + // Noop decision, exiting + case (a, Directive.Ignore | Directive.Remove) => + (a, none[F[Unit]]) + .asRight[Unit] + .asRight[Unit] + .pure[F] } - .uncancelable } - } - } + .uncancelable + } + } } } - def contains(key: K) = { - ref - .get - .map { _.contains(key) } - } + def contains(key: K): F[Boolean] = entryMap.contains(key) - def size = { - ref - .get - .map { _.size } - } + def size: F[Int] = entryMap.size - def keys = { - ref - .get - .map { _.keySet } - } + def keys: F[Set[K]] = entryMap.keys - def values = { - ref - .get - .flatMap { entryRefs => - entryRefs + def values: F[Map[K, F[V]]] = { + entryMap + .entries + .flatMap { entries => + entries .foldLeft { List .empty[(K, F[V])] @@ -713,11 +764,11 @@ private[scache] object LoadingCache { .map { _.toMap } } - def values1 = { - ref - .get - .flatMap { entryRefs => - entryRefs + def values1: F[Map[K, Either[F[V], V]]] = { + entryMap + .entries + .flatMap { entries => + entries .foldLeft { List .empty[(K, Either[F[V], V])] @@ -737,72 +788,60 @@ private[scache] object LoadingCache { } def remove(key: K): F[F[Option[V]]] = { - 0.tailRecM { counter => - checkRetries(counter) *> - ref - .access - .flatMap { case (entryRefs, set) => - entryRefs - .get(key) - .fold { + entryMap + .ref(key) + .getAndSet(none) + .flatMap { + case Some(entryRef) => + // We just removed the entry from the map, now we need to release it. + // Replacing the value of the ref with `Removed` means that we are getting responsible for the release. + entryRef + .getAndSet(EntryState.Removed) + .flatMap { + // We removed a loaded value, so we are responsible for releasing it. + case state: EntryState.Value[F, V] => + state + .entry + .release1 + .as { state.entry.value.some } + .start + .map { fiber => + fiber.joinWithNever + } + + // We removed a loading value, and the fiber that will complete it will also + // release that value, so there is nothing for us to return. + case _: EntryState.Loading[F, V] => none[V] .pure[F] - .asRight[Int] .pure[F] - } { entryRef => - set(entryRefs - key) - .flatMap { - case true => - // We just removed the entry for the map, now we need to release it. - // Replacing the value of the ref with `Removed` means that we are getting responsible for the release. - entryRef - .getAndSet(EntryState.Removed) - .flatMap { - // We removed a loaded value, so we are responsible for releasing it. - case state: EntryState.Value[F, V] => - state - .entry - .release1 - .as { state.entry.value.some } - .start - .map { fiber => - fiber - .joinWithNever - .asRight[Int] - } - - // We removed a loading value, and the fiber that will complete it will also - // release that value, so there is nothing for us to return. - case _: EntryState.Loading[F, V] => - none[V] - .pure[F] - .asRight[Int] - .pure[F] - // We removed an entry that was already being removed by another fiber, so we are done. - case EntryState.Removed => - none[V] - .pure[F] - .asRight[Int] - .pure[F] - } - case false => - (counter + 1) - .asLeft[F[Option[V]]] - .pure[F] - } - .uncancelable - } - } - } + // We removed an entry that was already being removed by another fiber, so we are done. + case EntryState.Removed => + none[V] + .pure[F] + .pure[F] + } + case None => + none[V] + .pure[F] + .pure[F] + } + .uncancelable } - def clear = { - ref - .getAndSet(EntryRefs.empty) + def clear: F[F[Unit]] = { + entryMap + .keys + .flatMap { keys => + keys + .toList + .traverse { key => entryMap.ref(key).getAndSet(none) } + .map { _.flatten } + } .flatMap { entryRefs => entryRefs - .parFoldMap1 { case (_, entryRef) => + .parFoldMap1 { entryRef => entryRef .getOption .flatMap { _.foldMapM { _.release1 } } @@ -814,14 +853,14 @@ private[scache] object LoadingCache { .map { _.joinWithNever } } - def foldMap[A: CommutativeMonoid](f: (K, Either[F[V], V]) => F[A]) = { - ref - .get - .flatMap { entryRefs => + def foldMap[A: CommutativeMonoid](f: (K, Either[F[V], V]) => F[A]): F[A] = { + entryMap + .entries + .flatMap { entries => val zero = CommutativeMonoid[A] .empty .pure[F] - entryRefs.foldLeft(zero) { case (a, (key, entryRef)) => + entries.foldLeft(zero) { case (a, (key, entryRef)) => for { a <- a v <- entryRef.optEither @@ -833,15 +872,15 @@ private[scache] object LoadingCache { } } - def foldMapPar[A: CommutativeMonoid](f: (K, Either[F[V], V]) => F[A]) = { - ref - .get - .flatMap { entryRefs => + def foldMapPar[A: CommutativeMonoid](f: (K, Either[F[V], V]) => F[A]): F[A] = { + entryMap + .entries + .flatMap { entries => Parallel[F].sequential { val zero = Parallel[F] .applicative .pure(CommutativeMonoid[A].empty) - entryRefs + entries .foldLeft(zero) { case (a, (key, entryRef)) => val b = Parallel[F].parallel { for { @@ -881,12 +920,6 @@ private[scache] object LoadingCache { type EntryRef[F[_], A] = Ref[F, EntryState[F, A]] - type EntryRefs[F[_], K, V] = Map[K, EntryRef[F, V]] - - object EntryRefs { - def empty[F[_], K, V]: EntryRefs[F, K, V] = Map.empty - } - implicit class DeferredThrowOps[F[_], A](val self: DeferredThrow[F, A]) extends AnyVal { def getOrError( implicit diff --git a/scache/src/main/scala/com/evolution/scache/SerialMap.scala b/scache/src/main/scala/com/evolution/scache/SerialMap.scala index 9003b4c..053d94d 100644 --- a/scache/src/main/scala/com/evolution/scache/SerialMap.scala +++ b/scache/src/main/scala/com/evolution/scache/SerialMap.scala @@ -2,7 +2,7 @@ package com.evolution.scache import cats.Applicative import cats.effect.implicits.* -import cats.effect.{Concurrent, Ref} +import cats.effect.{Async, Concurrent, Ref} import cats.syntax.all.* import com.evolutiongaming.catshelper.{Runtime, SerialRef} @@ -72,14 +72,14 @@ object SerialMap { self => def apply[F[_]]( implicit - F: Concurrent[F], + F: Async[F], ): Apply[F] = new Apply(F) - def of[F[_]: Concurrent: Runtime, K, V]: F[SerialMap[F, K, V]] = of(None) + def of[F[_]: Async: Runtime, K, V]: F[SerialMap[F, K, V]] = of(None) - def of[F[_]: Concurrent: Runtime, K, V](partitions: Int): F[SerialMap[F, K, V]] = of(Some(partitions)) + def of[F[_]: Async: Runtime, K, V](partitions: Int): F[SerialMap[F, K, V]] = of(Some(partitions)) - def of[F[_]: Concurrent: Runtime, K, V](partitions: Option[Int] = None): F[SerialMap[F, K, V]] = { + def of[F[_]: Async: Runtime, K, V](partitions: Option[Int] = None): F[SerialMap[F, K, V]] = { Cache .loading[F, K, SerialRef[F, State[V]]](partitions) .allocated @@ -224,13 +224,13 @@ object SerialMap { self => } } - class Apply[F[_]](val F: Concurrent[F]) extends AnyVal { + class Apply[F[_]](val F: Async[F]) extends AnyVal { def of[K, V]( implicit runtime: Runtime[F], ): F[SerialMap[F, K, V]] = { - implicit val concurrent: Concurrent[F] = F + implicit val async: Async[F] = F self.of[F, K, V](None) } } diff --git a/scache/src/test/scala/com/evolution/scache/CacheDefectsSpec.scala b/scache/src/test/scala/com/evolution/scache/CacheDefectsSpec.scala new file mode 100644 index 0000000..490bbc5 --- /dev/null +++ b/scache/src/test/scala/com/evolution/scache/CacheDefectsSpec.scala @@ -0,0 +1,251 @@ +package com.evolution.scache + +import cats.effect.* +import cats.syntax.all.* +import com.evolution.scache.IOSuite.* +import com.evolution.scache.LoadingCache.{EntryMap, EntryRef} +import org.scalatest.funsuite.AsyncFunSuite +import org.scalatest.matchers.should.Matchers + +import scala.concurrent.duration.* + +/** + * Asserts the expected behavior for four defects originally present in LoadingCache / + * ExpiringCache, fixed by rebuilding the cache on [[cats.effect.std.MapRef]]: + * - claim 1: loads are cancelable and cancellation cleans up the `Loading` entry; + * - claim 2: entries stuck in `Loading` state are evicted by the expiration routine; + * - claim 3: waiters on a `Loading` entry are unblocked when the load is cancelled; + * - claim 4: operations on distinct keys are independent, no shared-state CAS retries. + * + * Every stuck load is modeled with a `gate` Deferred instead of `IO.never` and released in a + * `guarantee`, so a failed assertion produces a clean test failure instead of hanging resource + * finalizers (`clear` waits on Loading entries). + */ +class CacheDefectsSpec extends AsyncFunSuite with Matchers { + + test("claim 1: cancelled load must not block the key for subsequent calls") { + val io = for { + entryMap <- EntryMap.of[IO, Int, Int] + cache = LoadingCache(entryMap) + started <- Deferred[IO, Unit] + gate <- Deferred[IO, Int] + loader <- cache.getOrUpdate(0) { started.complete(()) *> gate.get }.start + _ <- started.get + cancelling <- loader.cancel.start + result <- { + for { + cancelled <- cancelling.join.timeout(500.millis).attempt + _ <- IO { cancelled should matchPattern { case Right(_) => } } + second <- cache.getOrUpdate(0)(1.pure[IO]).timeout(500.millis).attempt + _ <- IO { second shouldEqual 1.asRight } + } yield {} + }.guarantee { gate.complete(42) *> cancelling.join.void } + } yield result + io.run() + } + + test("claim 2: expiration cleanup must evict entries stuck in Loading state") { + val config = ExpiringCache.Config[IO, Int, Int](expireAfterRead = 100.millis) + val io = ExpiringCache.of[IO, Int, Int](config).use { cache => + for { + started <- Deferred[IO, Unit] + gate <- Deferred[IO, Int] + loader <- cache.getOrUpdate(0) { started.complete(()) *> gate.get }.start + _ <- started.get + result <- { + for { + _ <- cache.put(1, 1).flatten + _ <- IO.sleep(500.millis) + control <- cache.contains(1) + _ <- IO { control shouldEqual false } + poisoned <- cache.contains(0) + _ <- IO { poisoned shouldEqual false } + second <- cache.getOrUpdate(0)(2.pure[IO]).timeout(500.millis).attempt + _ <- IO { second shouldEqual 2.asRight } + } yield {} + }.guarantee { gate.complete(42) *> loader.join.void } + } yield result + } + io.run() + } + + test("claim 3: remove must unblock fibers waiting on a Loading entry") { + val io = for { + entryMap <- EntryMap.of[IO, Int, Int] + cache = LoadingCache(entryMap) + started <- Deferred[IO, Unit] + gate <- Deferred[IO, Int] + loader <- cache.getOrUpdate(0) { started.complete(()) *> gate.get }.start + _ <- started.get + waiter <- cache.getOrUpdate(0)(99.pure[IO]).start + _ <- IO.sleep(100.millis) + cancelling <- loader.cancel.start + _ <- IO.sleep(100.millis) + _ <- cache.remove(0).flatten + result <- { + for { + outcome <- waiter.join.timeout(500.millis).attempt + _ <- IO { outcome should matchPattern { case Right(_) => } } + } yield {} + }.guarantee { gate.complete(42) *> cancelling.join.void } + } yield result + io.run() + } + + test("claim 4: getOrUpdate must not fail due to sustained writes of unrelated keys") { + val io = for { + underlying <- EntryMap.of[IO, Int, Int] + counter <- Ref[IO].of(0) + noise = counter + .updateAndGet { _ + 1 } + .flatMap { key => insertUnrelated(underlying, key) } + cache = LoadingCache(intercepted(underlying, noise, none)) + result <- cache.getOrUpdate(0)(1.pure[IO]).timeout(10.seconds).attempt + _ <- IO { result shouldEqual 1.asRight } + } yield {} + io.run() + } + + test("claim 4 mechanism: insert of an unrelated key must not force a retry of getOrUpdate") { + val io = for { + underlying <- EntryMap.of[IO, Int, Int] + attempts <- Ref[IO].of(0) + noise = insertUnrelated(underlying, 1) + cache = LoadingCache(intercepted(underlying, noise, attempts.some)) + value <- cache.getOrUpdate(0)(1.pure[IO]) + _ <- IO { value shouldEqual 1 } + attempts <- attempts.get + _ <- IO { attempts shouldEqual 1 } + keys <- cache.keys + _ <- IO { keys shouldEqual Set(0, 1) } + } yield {} + io.run() + } + + test("claim 4 mechanism: parallel getOrUpdate of distinct keys causes no insert retries") { + val io = for { + underlying <- EntryMap.of[IO, Int, Int] + attempts <- Ref[IO].of(0) + cache = LoadingCache(intercepted(underlying, IO.unit, attempts.some)) + _ <- (0 until 10000).toList.parTraverse { key => cache.getOrUpdate(key)(key.pure[IO]) } + size <- cache.size + _ <- IO { size shouldEqual 10000 } + attempts <- attempts.get + _ <- IO { attempts shouldEqual 10000 } + } yield {} + io.run(timeout = 30.seconds) + } + + test("evicting a stuck Loading entry unblocks fibers waiting on it") { + val config = ExpiringCache.Config[IO, Int, Int](expireAfterRead = 100.millis) + val io = ExpiringCache.of[IO, Int, Int](config).use { cache => + for { + started <- Deferred[IO, Unit] + gate <- Deferred[IO, Int] + loader <- cache.getOrUpdate(0) { started.complete(()) *> gate.get }.start + _ <- started.get + waiter <- cache.getOrUpdate(0)(99.pure[IO]).attempt.start + result <- { + for { + outcome <- waiter.joinWithNever.timeout(2.seconds) + _ <- IO { outcome should matchPattern { case Left(ExpiredError) => } } + } yield {} + }.guarantee { gate.complete(42) *> loader.join.void } + } yield result + } + io.run() + } + + test("a new load generation does not inherit the previous generation's stuck-timer") { + val config = ExpiringCache.Config[IO, Int, Int](expireAfterRead = 200.millis) + val io = ExpiringCache.of[IO, Int, Int](config).use { cache => + for { + started1 <- Deferred[IO, Unit] + gate1 <- Deferred[IO, Int] + loader1 <- cache.getOrUpdate(0) { started1.complete(()) *> gate1.get }.start + _ <- started1.get + _ <- IO.sleep(150.millis) + _ <- gate1.complete(1) + _ <- loader1.join + _ <- cache.remove(0).flatten + started2 <- Deferred[IO, Unit] + gate2 <- Deferred[IO, Int] + loader2 <- cache.getOrUpdate(0) { started2.complete(()) *> gate2.get }.start + _ <- started2.get + result <- { + for { + _ <- IO.sleep(150.millis) + present <- cache.contains(0) + _ <- IO { present shouldEqual true } + } yield {} + }.guarantee { gate2.complete(2) *> loader2.join.void } + } yield result + } + io.run() + } + + test("cancellation races neither poison the key nor leak releases") { + val io = for { + entryMap <- EntryMap.of[IO, Int, Int] + cache = LoadingCache(entryMap) + balance <- Ref[IO].of(0) + _ <- (1 to 500).toList.traverse_ { i => + for { + fiber <- cache.getOrUpdate1(0) { balance.update { _ + 1 }.as((i, i, balance.update { _ - 1 }.some)) }.start + _ <- fiber.cancel.start + _ <- fiber.join + _ <- cache.getOrUpdate(0)((-1).pure[IO]).timeout(1.second) + _ <- cache.remove(0).flatten + } yield {} + } + _ <- (IO.sleep(10.millis) *> balance.get).iterateUntil { _ == 0 }.timeout(3.seconds) + } yield {} + io.run(timeout = 60.seconds) + } + + private def insertUnrelated(underlying: EntryMap[IO, Int, Int], key: Int): IO[Unit] = { + for { + entryRef <- Ref[IO].of[LoadingCache.EntryState[IO, Int]]( + LoadingCache.EntryState.Value(LoadingCache.Entry(key, none)), + ) + _ <- underlying.ref(key).set(entryRef.some) + } yield {} + } + + /** + * EntryMap that runs `noise` before every entry transition going through the cache (simulating a + * concurrent writer of other keys) and counts those transitions, so the tests can assert that + * writes to unrelated keys neither invalidate the transition nor force retries. `noise` writes + * through `underlying` directly and is not counted. + */ + private def intercepted( + underlying: EntryMap[IO, Int, Int], + noise: IO[Unit], + attempts: Option[Ref[IO, Int]], + ): EntryMap[IO, Int, Int] = { + def wrap(ref: Ref[IO, Option[EntryRef[IO, Int]]]): Ref[IO, Option[EntryRef[IO, Int]]] = { + type A = Option[EntryRef[IO, Int]] + val observe = noise *> attempts.foldMapM { _.update { _ + 1 } } + new Ref[IO, A] { + def get: IO[A] = ref.get + def set(a: A): IO[Unit] = observe *> ref.set(a) + def access: IO[(A, A => IO[Boolean])] = ref.access.map { case (a, set) => (a, (a1: A) => observe *> set(a1)) } + def tryUpdate(f: A => A): IO[Boolean] = observe *> ref.tryUpdate(f) + def tryModify[B](f: A => (A, B)): IO[Option[B]] = observe *> ref.tryModify(f) + def update(f: A => A): IO[Unit] = observe *> ref.update(f) + def modify[B](f: A => (A, B)): IO[B] = observe *> ref.modify(f) + def tryModifyState[B](state: cats.data.State[A, B]): IO[Option[B]] = observe *> ref.tryModifyState(state) + def modifyState[B](state: cats.data.State[A, B]): IO[B] = observe *> ref.modifyState(state) + } + } + + new EntryMap[IO, Int, Int] { + def ref(key: Int): Ref[IO, Option[EntryRef[IO, Int]]] = wrap(underlying.ref(key)) + def lookup(key: Int): IO[Option[EntryRef[IO, Int]]] = underlying.lookup(key) + def keys: IO[Set[Int]] = underlying.keys + def entries: IO[List[(Int, EntryRef[IO, Int])]] = underlying.entries + def size: IO[Int] = underlying.size + def contains(key: Int): IO[Boolean] = underlying.contains(key) + } + } +} diff --git a/scache/src/test/scala/com/evolution/scache/CacheLoadTest.scala b/scache/src/test/scala/com/evolution/scache/CacheLoadTest.scala new file mode 100644 index 0000000..6137354 --- /dev/null +++ b/scache/src/test/scala/com/evolution/scache/CacheLoadTest.scala @@ -0,0 +1,99 @@ +package com.evolution.scache + +import cats.effect.{IO, IOApp, Resource} +import cats.syntax.all.* + +import scala.concurrent.duration.* + +/** + * Load test comparing cache flavors under contention. Not executed as part of the test suite, run + * it with: + * {{{ + * sbt "scache/Test/runMain com.evolution.scache.CacheLoadTest" + * }}} + */ +object CacheLoadTest extends IOApp.Simple { + + private val fibers = Runtime.getRuntime.availableProcessors + private val opsPerFiber = 100000 + private val keySpace = 10000 + + val run: IO[Unit] = { + val caches = List( + ("LoadingCache (single partition)", LoadingCache.of[IO, Int, Int]), + ("Cache.loading (partitioned)", Cache.loading[IO, Int, Int]), + ( + "Cache.expiring (partitioned)", + Cache.expiring[IO, Int, Int](ExpiringCache.Config[IO, Int, Int](expireAfterRead = 1.minute)), + ), + ) + for { + _ <- IO.println(f"fibers=$fibers, ops/fiber=$opsPerFiber, keySpace=$keySpace") + _ <- caches.traverse_ { case (name, cache) => + IO.println(s"--- $name") *> scenarios(cache) + } + } yield {} + } + + private def scenarios(cache: Resource[IO, Cache[IO, Int, Int]]): IO[Unit] = { + cache.use { cache => + for { + _ <- measure("getOrUpdate, insert distinct keys") { + parRun { (fiber, i) => cache.getOrUpdate(fiber * opsPerFiber + i)(i.pure[IO]).void } + } + _ <- cache.clear.flatten + _ <- (0 until keySpace).toList.traverse_ { key => cache.put(key, key).flatten } + _ <- measure("getOrUpdate, hit random keys") { + parRun { (fiber, i) => + val key = scramble(fiber * opsPerFiber + i) % keySpace + cache.getOrUpdate(key)(key.pure[IO]).void + } + } + _ <- measure("getOrUpdate, hit single hot key") { + parRun { (_, _) => cache.getOrUpdate(0)(0.pure[IO]).void } + } + _ <- measure("put, replace random keys") { + parRun { (fiber, i) => + val key = scramble(fiber * opsPerFiber + i) % keySpace + cache.put(key, i).flatten.void + } + } + _ <- measure("mixed get/put/remove, random keys") { + parRun { (fiber, i) => + val n = scramble(fiber * opsPerFiber + i) + val key = n % keySpace + (n / keySpace) % 10 match { + case 0 => cache.put(key, i).flatten.void + case 1 => cache.remove(key).flatten.void + case _ => cache.getOrUpdate(key)(i.pure[IO]).void + } + } + } + } yield {} + } + } + + private def parRun(op: (Int, Int) => IO[Unit]): IO[Unit] = { + (0 until fibers) + .toList + .parTraverse_ { fiber => + (0 until opsPerFiber).toList.traverse_ { i => op(fiber, i) } + } + } + + private def measure(name: String)(io: IO[Unit]): IO[Unit] = { + for { + start <- IO.monotonic + _ <- io + end <- IO.monotonic + millis = (end - start).toMillis.max(1) + opsPerSec = fibers.toLong * opsPerFiber * 1000 / millis + _ <- IO.println(f"$name%-42s ${ millis }%6d ms $opsPerSec%,12d ops/s") + } yield {} + } + + private def scramble(i: Int): Int = { + val h = i * 0x9e3775cd + (h ^ (h >>> 16)) & Int.MaxValue + } +} diff --git a/scache/src/test/scala/com/evolution/scache/CacheSpec.scala b/scache/src/test/scala/com/evolution/scache/CacheSpec.scala index be2fe03..a435f7c 100644 --- a/scache/src/test/scala/com/evolution/scache/CacheSpec.scala +++ b/scache/src/test/scala/com/evolution/scache/CacheSpec.scala @@ -26,7 +26,7 @@ class CacheSpec extends AsyncFunSuite with Matchers { for { (name, cache0) <- List( ("default", Cache.loading[IO, Int, Int]), - ("no partitions", LoadingCache.of(LoadingCache.EntryRefs.empty[IO, Int, Int])), + ("no partitions", LoadingCache.of[IO, Int, Int]), ("expiring", expiringCache), ( "expiring no partitions", @@ -615,21 +615,17 @@ class CacheSpec extends AsyncFunSuite with Matchers { for { deferred0 <- Deferred[IO, (Int, Option[IO[Unit]])] fiber0 <- cache.getOrUpdate1Ensure(0) { deferred0.get } - fiber1 <- cache.getOrUpdate2(0) { IO.never }.startEnsure - release <- Deferred[IO, Unit] - _ <- fiber0.cancel.start - _ <- deferred0.complete((0, release.complete(()).void.some)) - value <- fiber0.join - _ <- IO { value shouldEqual Outcome.canceled } - value <- fiber1.joinWithNever - _ <- IO { value shouldEqual 0.asRight } - _ <- cache.remove(0) - _ <- release.get + _ <- fiber0.cancel + outcome <- fiber0.join + _ <- IO { outcome shouldEqual Outcome.canceled } + value <- cache.getOrUpdate2(0) { (0, 0, none[IO[Unit]]).pure[IO] } + _ <- IO { value shouldEqual 0.asLeft } + value <- cache.get(0) + _ <- IO { value shouldEqual 0.some } _ <- metrics.expect( - metrics.expectedGet(hit = false) -> 1, + metrics.expectedGet(hit = false) -> 2, metrics.expectedGet(hit = true) -> 1, metrics.expectedLoad(success = true) -> 1, - metrics.expectedLife -> 1, ) } yield {} } @@ -1076,25 +1072,7 @@ class CacheSpec extends AsyncFunSuite with Matchers { } yield {} } - check(s"cancellation: $name") { (cache, metrics) => - for { - deferred <- Deferred[IO, Int] - fiber <- cache.getOrUpdateEnsure(0) { deferred.get } - _ <- fiber.cancel.start - _ <- deferred.complete(0) - cancelOutcome <- fiber.join - _ <- IO { cancelOutcome shouldEqual Outcome.canceled } - value <- cache.get(0) - _ <- IO { value shouldEqual 0.some } - _ <- metrics.expect( - metrics.expectedGet(hit = false) -> 1, - metrics.expectedLoad(success = true) -> 1, - metrics.expectedGet(hit = true) -> 1, - ) - } yield {} - } - - ignore(s"cancellation proper: $name") { + test(s"cancellation proper: $name") { cacheAndMetrics .use { case (cache, metrics) => for { diff --git a/scache/src/test/scala/com/evolution/scache/SerialMapSpec.scala b/scache/src/test/scala/com/evolution/scache/SerialMapSpec.scala index 0040a68..5fe1d3f 100644 --- a/scache/src/test/scala/com/evolution/scache/SerialMapSpec.scala +++ b/scache/src/test/scala/com/evolution/scache/SerialMapSpec.scala @@ -1,6 +1,6 @@ package com.evolution.scache -import cats.effect.{Async, Concurrent, Deferred, IO, Outcome} +import cats.effect.{Async, Deferred, IO, Outcome} import cats.syntax.all.* import com.evolution.scache.IOSuite.* import com.evolutiongaming.catshelper.CatsHelper.* @@ -228,9 +228,9 @@ class SerialMapSpec extends AsyncFunSuite with Matchers { } } - private def remove[F[_]: Concurrent] = { + private def remove[F[_]: Async] = { val key = "key" - val cache = LoadingCache.of(LoadingCache.EntryRefs.empty[F, String, SerialRef[F, SerialMap.State[Int]]]) + val cache = LoadingCache.of[F, String, SerialRef[F, SerialMap.State[Int]]] cache.use { cache => val serialMap = SerialMap(cache) for { @@ -262,9 +262,9 @@ class SerialMapSpec extends AsyncFunSuite with Matchers { } } - private def `not leak on failures`[F[_]: Concurrent] = { + private def `not leak on failures`[F[_]: Async] = { val key = "key" - val cache = LoadingCache.of(LoadingCache.EntryRefs.empty[F, String, SerialRef[F, SerialMap.State[Int]]]) + val cache = LoadingCache.of[F, String, SerialRef[F, SerialMap.State[Int]]] cache.use { cache => val serialMap = SerialMap(cache) val modifyError = serialMap.modify(key) { _ => TestError.raiseError[F, (Option[Int], Unit)] }.attempt From df170adc176bde45bfe7e5496d55bfc2fce834fd Mon Sep 17 00:00:00 2001 From: Stas Shevchenko Date: Wed, 5 Aug 2026 12:31:40 +0200 Subject: [PATCH 2/8] Document cache algorithm and defects it fixes --- .../com/evolution/scache/CancelledError.scala | 4 + .../com/evolution/scache/ExpiredError.scala | 4 + .../com/evolution/scache/ExpiringCache.scala | 24 +++ .../com/evolution/scache/LoadingCache.scala | 202 ++++++++++++++++++ 4 files changed, 234 insertions(+) diff --git a/scache/src/main/scala/com/evolution/scache/CancelledError.scala b/scache/src/main/scala/com/evolution/scache/CancelledError.scala index 14f9ac2..10ffc08 100644 --- a/scache/src/main/scala/com/evolution/scache/CancelledError.scala +++ b/scache/src/main/scala/com/evolution/scache/CancelledError.scala @@ -2,4 +2,8 @@ package com.evolution.scache import scala.util.control.NoStackTrace +/** + * Failure of a value computation that got cancelled, reported to the callers that were waiting for + * its result rather than running it themselves. + */ case object CancelledError extends RuntimeException with NoStackTrace diff --git a/scache/src/main/scala/com/evolution/scache/ExpiredError.scala b/scache/src/main/scala/com/evolution/scache/ExpiredError.scala index e150971..11e4d19 100644 --- a/scache/src/main/scala/com/evolution/scache/ExpiredError.scala +++ b/scache/src/main/scala/com/evolution/scache/ExpiredError.scala @@ -2,4 +2,8 @@ package com.evolution.scache import scala.util.control.NoStackTrace +/** + * Failure of a value computation that was taking so long the entry got expired while still loading, + * see [[ExpiringCache]]. + */ case object ExpiredError extends RuntimeException with NoStackTrace diff --git a/scache/src/main/scala/com/evolution/scache/ExpiringCache.scala b/scache/src/main/scala/com/evolution/scache/ExpiringCache.scala index cf24a5c..7673ea3 100644 --- a/scache/src/main/scala/com/evolution/scache/ExpiringCache.scala +++ b/scache/src/main/scala/com/evolution/scache/ExpiringCache.scala @@ -34,6 +34,16 @@ object ExpiringCache { (expireInterval / 10).millis } + /* One run of the expiration routine: drops the values that are too old, evicts the loads that + * are taking too long, and enforces `maxSize`. + * + * Loads are expired as well, because a load that never completes would otherwise stay in the + * map forever, holding the key hostage: nothing can be stored under it, everyone asking for it + * waits on a `Deferred` that will never complete, and so does the release of the cache itself. + * + * `loadingSince` holds the moment each of the currently loading keys was first seen loading, + * carried over between the runs, as this is the only way to tell how long a load is running. + */ def removeExpiredAndCheckSize( entryMap: LoadingCache.EntryMap[F, K, E], cache: Cache[F, K, E], @@ -64,6 +74,11 @@ object ExpiringCache { } } + /* Drops an entry that is still loading, failing everyone waiting for it with `ExpiredError`. + * + * Does nothing unless the entry is still loading the very same `deferred`, so that a load + * that has completed, or has been replaced by a newer one, in the meantime is left alone. + */ def evictLoading(key: K, entryRef: LoadingCache.EntryRef[F, E], deferred: DeferredE): F[Unit] = { entryRef .modify { @@ -87,6 +102,15 @@ object ExpiringCache { .uncancelable } + /* Evicts the loads that have been running longer than the shortest of the configured + * expiration intervals. + * + * A load has no timestamp of its own, so its age is counted from the first run of the routine + * that has seen it, which may be up to one run interval later than the load actually started. + * The bookkeeping is keyed by the `Deferred` of the load rather than by the key alone, so + * that a new load of the same key starts its own countdown instead of inheriting the one of + * its predecessor. + */ def removeExpiredLoading(loading: List[(K, LoadingCache.EntryRef[F, E], DeferredE)]): F[Unit] = { val threshold = expireAfterWriteMs.fold(expireAfterReadMs) { _ min expireAfterReadMs } for { diff --git a/scache/src/main/scala/com/evolution/scache/LoadingCache.scala b/scache/src/main/scala/com/evolution/scache/LoadingCache.scala index e7106d0..6c8ac0a 100644 --- a/scache/src/main/scala/com/evolution/scache/LoadingCache.scala +++ b/scache/src/main/scala/com/evolution/scache/LoadingCache.scala @@ -12,6 +12,68 @@ import com.evolutiongaming.catshelper.ParallelHelper.* import java.util.concurrent.ConcurrentHashMap import scala.jdk.CollectionConverters.* +/** + * Cache able to load values, i.e. to deduplicate concurrent computations of the same key. + * + * =State= + * + * The state is kept on two levels: + * - the outer level, [[LoadingCache.EntryMap]], answers "is there an entry for this key?" and is + * backed by a [[java.util.concurrent.ConcurrentHashMap]] exposed as a per-key + * [[cats.effect.std.MapRef]]; + * - the inner level, [[LoadingCache.EntryRef]], answers "what happened to the value of this + * entry?" and is a `Ref` holding an [[LoadingCache.EntryState]]. + * + * A key is in the cache if, and only if, the outer level holds an `EntryRef` for it and that + * `EntryRef` is not in [[LoadingCache.EntryState.Removed]] state. + * + * =Why a ConcurrentHashMap= + * + * The outer level used to be a single `Ref[F, Map[K, EntryRef]]`, so every insertion or removal of + * any key had to CAS one and the same `Ref`. That had two consequences: + * - operations on unrelated keys invalidated each other, so a `getOrUpdate` of one key could be + * starved by a steady stream of writes of other keys, and the retry limit guarding that loop + * turned such contention into a failure; + * - every write copied the entire map. + * + * With `MapRef.fromConcurrentHashMap` a CAS is scoped to a single key: operations on distinct keys + * never contend, and no retry limit is needed, because the loops below only spin on a real race + * over the same key, and every such race is won by a fiber that makes progress. Enumeration + * (`keys`, `entries`, `size`) is served by the `ConcurrentHashMap` itself, i.e. it is a weakly + * consistent view rather than an atomic snapshot. + * + * =Entry lifecycle= + * + * `getOrUpdate` installs an entry in `Loading` state, holding a `Deferred` that every other fiber + * asking for the same key awaits, and only then computes the value. The load then either + * - stores the computed value, moving the entry to `Value` state, or + * - drops the entry from the map and propagates the error to the caller and to the waiters, if + * the computation failed, or + * - discards its own result, if it lost a race to `put`, `modify`, `remove`, `clear` or + * cancellation, in which case the value of the winner is returned to the caller. + * + * `Removed` is a tombstone meaning "this `EntryRef` is no longer in the map, look the key up + * again". It is needed because the two levels cannot be updated atomically together, so a fiber + * that looked an `EntryRef` up earlier needs a way to notice that its reference went stale. All + * retry loops here are driven by it: seeing `Removed` means re-reading the key, and the fiber that + * installed the tombstone is already committed to unlinking that key, hence the loops terminate. + * + * =Releasing values= + * + * A value is released exactly once, by the fiber that took it out of the entry, i.e. replaced or + * removed it. A fiber whose computed value did not make it into the map releases it itself. To + * avoid making unrelated callers wait for a foreign `release`, releases of values the caller did + * not ask about are started in the background. + * + * =Cancellation= + * + * Only the user-supplied computation is cancelable, all state transitions are masked. Cancelling a + * load flips its own `Loading` state to `Removed`, unlinks the key, completes the `Deferred` with + * [[CancelledError]] so that waiters fail instead of hanging, and releases the value if the + * computation did manage to produce one. Without that cleanup a cancelled load would leave behind a + * `Loading` entry with a `Deferred` nobody is going to complete, which makes the key unusable + * forever and blocks the waiters, `clear`, and therefore the release of the cache itself. + */ private[scache] object LoadingCache { def of[F[_]: Async, K, V]: Resource[F, Cache[F, K, V]] = { @@ -21,6 +83,10 @@ private[scache] object LoadingCache { } yield cache } + /** + * Cache over an existing [[EntryMap]], clearing it, and thus releasing all the values, when the + * resource is released. + */ def of[F[_]: Async, K, V]( entryMap: EntryMap[F, K, V], ): Resource[F, Cache[F, K, V]] = { @@ -35,17 +101,39 @@ private[scache] object LoadingCache { * Per-key view over the cache state: mutations go through [[cats.effect.std.MapRef]], so * operations on distinct keys never contend, while enumeration is served by the backing * [[java.util.concurrent.ConcurrentHashMap]]. + * + * Exposed as a trait rather than used directly, because [[ExpiringCache]] needs to walk and evict + * entries behind the back of the [[Cache]] interface, and because it makes the contention + * behaviour testable. */ trait EntryMap[F[_], K, V] { + /** + * Atomic per-key handle on the map, where `None` stands for "no entry for this key": setting it + * to `None` removes the key, setting it to `Some` inserts or replaces the entry. This is the + * only way the mapping itself is modified, and the CAS it performs is scoped to `key`. + */ def ref(key: K): Ref[F, Option[EntryRef[F, V]]] + /** + * Non-atomic read of the entry, used when the mapping is not going to be modified. + */ def lookup(key: K): F[Option[EntryRef[F, V]]] + /** + * Weakly consistent view of the keys, i.e. concurrent modifications may or may not be seen. + */ def keys: F[Set[K]] + /** + * Weakly consistent view of the entries, see [[keys]]. + */ def entries: F[List[(K, EntryRef[F, V])]] + /** + * Number of entries, including the ones being loaded or removed, hence an upper bound of the + * number of values available. + */ def size: F[Int] def contains(key: K): F[Boolean] @@ -89,6 +177,10 @@ private[scache] object LoadingCache { } } + /** + * Cache over an existing [[EntryMap]], which never releases the values it still holds, hence + * meant to be wrapped into a resource by [[of]] rather than used directly. + */ def apply[F[_]: Async, K, V]( entryMap: EntryMap[F, K, V], ): Cache[F, K, V] = { @@ -156,8 +248,33 @@ private[scache] object LoadingCache { } } + /** + * Returns the value of the key, computing it if the key is not in the cache yet. + * + * The result is `Left` if this call did compute the value, and `Right` if the value came from + * the cache, either already computed (`Right`) or still being computed by another fiber + * (`Left`), so that the callers can tell a cache hit from a miss without waiting. + * + * The flow is: look the key up and return what is there, or, if there is nothing, install a + * `Loading` entry and compute the value. Installing the entry is masked and done with a + * single per-key CAS, so of the fibers racing to install one exactly one wins and the losers + * simply await its `Deferred`. A `Removed` entry is a stale reference, and means the lookup + * has to be repeated. + */ def getOrUpdate1[A](key: K)(value: => F[(A, V, Option[Release])]): F[Either[A, Either[F[V], V]]] = { + /* Runs the value computation for the `Loading` entry this fiber installed, and publishes + * its result. + * + * The computation is the only cancelable part of `getOrUpdate1`, hence it runs under + * `poll`, and `cleanupOnCancel` has to undo the installed entry: unlink the key, complete + * the `deferred` with `CancelledError` to unblock the waiters, and release the value if + * the computation completed before the cancellation was observed. + * + * The computation is raced against `deferred` to also handle being overtaken by a `put` of + * the same key: whoever completes the `deferred` first defines the value of the entry, and + * the loser releases the value it produced. + */ def load( poll: Poll[F], entryRef: EntryRef[F, V], @@ -455,6 +572,16 @@ private[scache] object LoadingCache { } } + /** + * Stores the value under the key, returning the replaced value, if any. + * + * The outer effect performs the replacement, the inner one awaits the release of the replaced + * value, so that the caller can decide whether to wait for it. + * + * A `Loading` entry is not waited for: its `deferred` is completed with the new value, which + * both unblocks the waiters immediately and tells the loading fiber that it lost the race and + * has to release the value it computes. + */ def put(key: K, value: V, release: Option[Release]): F[F[Option[V]]] = { val entry = entryOf(value, release) ().tailRecM { _ => @@ -572,6 +699,14 @@ private[scache] object LoadingCache { } } + /** + * Applies the decision of `f` to the current value of the key, atomically. + * + * `f` is called with the value of the key, or `None` if there is none, and may be called more + * than once, because a lost CAS means the decision was made on a stale value and has to be + * taken again. A `Loading` entry is presented to `f` as `None`, as there is no value to + * decide upon yet, and is only overwritten if `f` decides to put one. + */ override def modify[A](key: K)(f: Option[V] => (A, Directive[F, V])): F[(A, Option[F[Unit]])] = { ().tailRecM { _ => entryMap @@ -787,6 +922,16 @@ private[scache] object LoadingCache { .map { _.toMap } } + /** + * Removes the key from the cache, returning the removed value, if any. + * + * Unlinking the key and marking the entry `Removed` happen in that order and uncancelably: + * the mark is what makes this fiber the one responsible for the release, and what tells the + * fibers holding this `EntryRef` that they are looking at a stale reference. + * + * A `Loading` entry has no value to return, and is left to the loading fiber to release, + * which it will do upon discovering the `Removed` mark. + */ def remove(key: K): F[F[Option[V]]] = { entryMap .ref(key) @@ -830,6 +975,14 @@ private[scache] object LoadingCache { .uncancelable } + /** + * Removes all the entries, returning an effect awaiting the release of all their values. + * + * The keys are unlinked one by one, as there is no atomic bulk operation on a per-key `Ref`, + * so entries added concurrently may survive the clearing. Values of entries that are still + * loading are awaited before being released, which is why a load that never completes would + * make this, and the release of the cache resource, hang. + */ def clear: F[F[Unit]] = { entryMap .keys @@ -898,6 +1051,9 @@ private[scache] object LoadingCache { } } + /** + * Cached value together with the effect releasing it, if it needs releasing. + */ final case class Entry[+F[_], +A](value: A, release: Option[F[Unit]]) object Entry { @@ -909,10 +1065,36 @@ private[scache] object LoadingCache { } } + /** + * State of a cache entry. + * + * The possible transitions are `Loading -> Value`, `Loading -> Removed`, `Value -> Value` and + * `Value -> Removed`, with `Removed` being terminal, so that a stale reference stays recognizable + * as such. + */ sealed trait EntryState[+F[_], +A] object EntryState { + + /** + * The value is being computed, and `deferred` will hold it, or the reason it will never be + * available: [[CancelledError]] if the load got cancelled, [[ExpiredError]] if it was evicted + * for taking too long, or the error the computation failed with. + * + * The `deferred` doubles as the identity of the load: a fiber may only act on the entry as long + * as it still holds the very same `deferred` it installed, which is what keeps a fiber from + * interfering with a load started after its own one ended. + */ final case class Loading[F[_], A](deferred: Deferred[F, Either[Throwable, Entry[F, A]]]) extends EntryState[F, A] + + /** + * The value is computed and available. + */ final case class Value[F[_], A](entry: Entry[F, A]) extends EntryState[F, A] + + /** + * The entry is gone, and this reference to it is stale: the key it used to be mapped to is + * either unlinked already, or is about to be, and has to be looked up anew. + */ case object Removed extends EntryState[Nothing, Nothing] } @@ -945,6 +1127,9 @@ private[scache] object LoadingCache { implicit class EntryStateOps[F[_], A](val self: EntryState[F, A]) extends AnyVal { + /** + * Value of the entry, awaiting it if it is still loading, and `None` if it will never arrive. + */ def getOption( implicit F: Applicative[F], @@ -956,6 +1141,10 @@ private[scache] object LoadingCache { } } + /** + * The entry as the cache API sees it: `None` for an entry that is gone, `Left` for a value that + * is still being computed, and `Right` for a value that is already there. + */ def optEither( implicit F: MonadThrow[F], @@ -1020,6 +1209,10 @@ private[scache] object LoadingCache { } } + /** + * Updates the value of the entry, if there is one, retrying on a lost CAS, and doing nothing at + * all if the entry is still loading or is gone. + */ def update1( f: A => A, )(implicit @@ -1049,6 +1242,15 @@ private[scache] object LoadingCache { } implicit class Ops[F[_], A, E](val fa: F[A]) extends AnyVal { + + /** + * Races `fa` against `fb`, returning `Left` if `fa` won, and `Right` with the still running + * `fa` if `fb` did. + * + * Unlike `race`, a losing `fa` is not cancelled, but handed over to the caller instead, because + * `fa` computes a value that will have to be released once it is there. A cancelled `fa` + * cancels the race, while a cancelled `fb` leaves the race waiting for `fa`. + */ def race1[B]( fb: F[B], )(implicit From 1ec2da374e9d8efde0ff6dd917fb67248dbff022 Mon Sep 17 00:00:00 2001 From: Stas Shevchenko Date: Thu, 6 Aug 2026 23:09:45 +0200 Subject: [PATCH 3/8] Address review comments, add JMH benchmark module --- README.md | 34 + .../scache/bench/CacheBenchmark.scala | 248 ++++ .../com/evolution/scache/v1/CacheV1.scala | 64 + .../evolution/scache/v1/ExpiringCache.scala | 451 +++++++ .../evolution/scache/v1/LoadingCache.scala | 1066 +++++++++++++++++ build.sbt | 19 +- project/plugins.sbt | 2 + .../com/evolution/scache/ExpiringCache.scala | 90 +- .../com/evolution/scache/LoadingCache.scala | 106 +- .../evolution/scache/CacheDefectsSpec.scala | 185 ++- .../com/evolution/scache/CacheLoadTest.scala | 99 -- 11 files changed, 2112 insertions(+), 252 deletions(-) create mode 100644 benchmark/src/main/scala/com/evolution/scache/bench/CacheBenchmark.scala create mode 100644 benchmark/src/main/scala/com/evolution/scache/v1/CacheV1.scala create mode 100644 benchmark/src/main/scala/com/evolution/scache/v1/ExpiringCache.scala create mode 100644 benchmark/src/main/scala/com/evolution/scache/v1/LoadingCache.scala delete mode 100644 scache/src/test/scala/com/evolution/scache/CacheLoadTest.scala diff --git a/README.md b/README.md index ec2708b..b44faf9 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,40 @@ libraryDependencies += "com.evolution" %% "scache" % ">> 16)) & Int.MaxValue) % KeySpace + } + + def parRun(op: (Int, Int) => IO[Unit]): IO[Unit] = { + fiberIndices.parTraverse_ { fiber => + opIndices.traverse_ { i => op(fiber, i) } + } + } +} + +/** + * Cache under benchmark, allocated once per trial. + * + * `impl` selects the implementation, `flavor` selects how it is put together: a single unpartitioned + * [[com.evolution.scache.LoadingCache]], the partitioned `Cache.loading`, or the partitioned + * `Cache.expiring` with expiration far enough away not to interfere. + */ +@State(Scope.Benchmark) +abstract class CacheState { + + @Param(Array("v1", "v2")) + var impl: String = "v2" + + @Param(Array("single", "partitioned", "expiring")) + var flavor: String = "partitioned" + + var cache: Cache[IO, Int, Int] = null + + private var release: IO[Unit] = IO.unit + + private def resource = { + val expireAfterRead = 1.hour + (impl, flavor) match { + case ("v1", "single") => v1.LoadingCache.of(v1.LoadingCache.EntryRefs.empty[IO, Int, Int]) + case ("v1", "partitioned") => CacheV1.loading[IO, Int, Int]() + case ("v1", "expiring") => CacheV1.expiring[IO, Int, Int](v1.ExpiringCache.Config[IO, Int, Int](expireAfterRead)) + case ("v2", "single") => LoadingCache.of[IO, Int, Int] + case ("v2", "partitioned") => Cache.loading[IO, Int, Int] + case ("v2", "expiring") => Cache.expiring[IO, Int, Int](ExpiringCache.Config[IO, Int, Int](expireAfterRead)) + case (impl, flavor) => sys.error(s"unknown impl=$impl flavor=$flavor") + } + } + + @Setup(Level.Trial) + def allocate(): Unit = { + val (cache, release) = resource.allocated.unsafeRunSync() + this.cache = cache + this.release = release + } + + @TearDown(Level.Trial) + def free(): Unit = release.unsafeRunSync() +} + +/** + * Cache emptied before every invocation, so that the scenarios adding keys always take the path of + * a missing key. + */ +@State(Scope.Benchmark) +class EmptyCacheState extends CacheState { + + @Setup(Level.Invocation) + def empty(): Unit = cache.clear.flatten.unsafeRunSync() +} + +/** + * Cache holding the whole key space, refilled between the iterations, so that the scenarios reading + * or replacing keys always take the path of a present key. + */ +@State(Scope.Benchmark) +class PopulatedCacheState extends CacheState { + + @Setup(Level.Iteration) + def populate(): Unit = { + (0 until CacheBenchmark.KeySpace) + .toList + .traverse_ { key => cache.put(key, key).flatten } + .unsafeRunSync() + } +} + +@BenchmarkMode(Array(Mode.Throughput)) +@OutputTimeUnit(TimeUnit.SECONDS) +@OperationsPerInvocation(160000) +@Warmup(iterations = 3, time = 3, timeUnit = TimeUnit.SECONDS) +@Measurement(iterations = 5, time = 3, timeUnit = TimeUnit.SECONDS) +@Fork(1) +@Threads(1) +class CacheBenchmark { + + import CacheBenchmark.* + + @Benchmark + def getOrUpdateInsertDistinctKeys(state: EmptyCacheState): Unit = { + parRun { (fiber, i) => + val key = fiber * OpsPerFiber + i + state.cache.getOrUpdate(key)(key.pure[IO]).void + }.unsafeRunSync() + } + + @Benchmark + def putInsertDistinctKeys(state: EmptyCacheState): Unit = { + parRun { (fiber, i) => + val key = fiber * OpsPerFiber + i + state.cache.put(key, i).flatten.void + }.unsafeRunSync() + } + + @Benchmark + def modifyInsertDistinctKeys(state: EmptyCacheState): Unit = { + parRun { (fiber, i) => + val key = fiber * OpsPerFiber + i + state.cache.modify(key) { _ => ((), Cache.Directive.Put(i, none)) }.void + }.unsafeRunSync() + } + + /** + * Not the same as [[getHitRandomKeys]]: `getOrUpdate` of a key that is already there still has to + * decide between a hit and a miss, which is where the old implementation touched the shared `Ref` + * even though it ended up returning a cached value. + */ + @Benchmark + def getOrUpdateHitRandomKeys(state: PopulatedCacheState): Unit = { + parRun { (fiber, i) => + val k = key(fiber, i) + state.cache.getOrUpdate(k)(k.pure[IO]).void + }.unsafeRunSync() + } + + @Benchmark + def getOrUpdateHitSingleHotKey(state: PopulatedCacheState): Unit = { + parRun { (_, _) => state.cache.getOrUpdate(0)(0.pure[IO]).void }.unsafeRunSync() + } + + @Benchmark + def getHitRandomKeys(state: PopulatedCacheState): Unit = { + parRun { (fiber, i) => state.cache.get(key(fiber, i)).void }.unsafeRunSync() + } + + @Benchmark + def get1HitRandomKeys(state: PopulatedCacheState): Unit = { + parRun { (fiber, i) => state.cache.get1(key(fiber, i)).void }.unsafeRunSync() + } + + @Benchmark + def containsRandomKeys(state: PopulatedCacheState): Unit = { + parRun { (fiber, i) => state.cache.contains(key(fiber, i)).void }.unsafeRunSync() + } + + @Benchmark + def putReplaceRandomKeys(state: PopulatedCacheState): Unit = { + parRun { (fiber, i) => state.cache.put(key(fiber, i), i).flatten.void }.unsafeRunSync() + } + + @Benchmark + def modifyUpdateRandomKeys(state: PopulatedCacheState): Unit = { + parRun { (fiber, i) => + state + .cache + .modify(key(fiber, i)) { + case Some(value) => ((), Cache.Directive.Put(value + 1, none)) + case None => ((), Cache.Directive.Ignore) + } + .void + }.unsafeRunSync() + } + + @Benchmark + def removeAndPutRandomKeys(state: PopulatedCacheState): Unit = { + parRun { (fiber, i) => + val k = key(fiber, i) + state.cache.remove(k).flatten *> state.cache.put(k, i).flatten.void + }.unsafeRunSync() + } + + @Benchmark + def mixedRandomKeys(state: PopulatedCacheState): Unit = { + parRun { (fiber, i) => + val k = key(fiber, i) + (i % 10) match { + case 0 => state.cache.put(k, i).flatten.void + case 1 => state.cache.remove(k).flatten.void + case 2 => state.cache.modify(k) { _ => ((), Cache.Directive.Put(i, none)) }.void + case 3 | 4 => state.cache.get(k).void + case _ => state.cache.getOrUpdate(k)(i.pure[IO]).void + } + }.unsafeRunSync() + } + + /** + * Enumeration of the whole cache, one full traversal of `KeySpace` entries per operation, hence + * measured per traversal rather than per key. + */ + @Benchmark + @OperationsPerInvocation(1) + def foldMapWholeCache(state: PopulatedCacheState): Unit = { + state + .cache + .foldMap { case (_, value) => value.fold(identity, _.pure[IO]) } + .void + .unsafeRunSync() + } +} diff --git a/benchmark/src/main/scala/com/evolution/scache/v1/CacheV1.scala b/benchmark/src/main/scala/com/evolution/scache/v1/CacheV1.scala new file mode 100644 index 0000000..cbbcf20 --- /dev/null +++ b/benchmark/src/main/scala/com/evolution/scache/v1/CacheV1.scala @@ -0,0 +1,64 @@ +package com.evolution.scache.v1 + +import cats.effect.syntax.all.* +import cats.effect.{Concurrent, Resource, Temporal} +import cats.syntax.all.* +import cats.{Hash, Parallel} +import com.evolution.scache.{Cache, NrOfPartitions, Partitions} +import com.evolutiongaming.catshelper.CatsHelper.* +import com.evolutiongaming.catshelper.Runtime + +/** + * Constructors of the frozen pre-`MapRef` implementation, mirroring `Cache.loading` and + * `Cache.expiring` as they were before the rewrite, so that the benchmarks can build the old and + * the new cache the same way. + */ +object CacheV1 { + + def loading[F[_]: Concurrent: Parallel: Runtime, K, V]( + partitions: Option[Int] = None, + ): Resource[F, Cache[F, K, V]] = { + + implicit val hash: Hash[K] = Hash.fromUniversalHashCode[K] + + val result = for { + nrOfPartitions <- partitions + .map { _.pure[F] } + .getOrElse { NrOfPartitions[F]() } + .toResource + cache = LoadingCache.of(LoadingCache.EntryRefs.empty[F, K, V]) + partitions <- Partitions.of[Resource[F, _], K, Cache[F, K, V]](nrOfPartitions, _ => cache) + } yield { + Cache.fromPartitions(partitions) + } + result.breakFlatMapChain + } + + def expiring[F[_]: Temporal: Runtime: Parallel, K, V]( + config: ExpiringCache.Config[F, K, V], + partitions: Option[Int] = None, + ): Resource[F, Cache[F, K, V]] = { + + implicit val hash: Hash[K] = Hash.fromUniversalHashCode[K] + + val result = for { + nrOfPartitions <- partitions + .map { _.pure[F] } + .getOrElse { NrOfPartitions[F]() } + .toResource + config1 = config + .maxSize + .fold { + config + } { maxSize => + config.copy(maxSize = (maxSize * 1.1 / nrOfPartitions).toInt.some) + } + cache = ExpiringCache.of[F, K, V](config1) + partitions <- Partitions.of[Resource[F, _], K, Cache[F, K, V]](nrOfPartitions, _ => cache) + } yield { + Cache.fromPartitions(partitions) + } + + result.breakFlatMapChain + } +} diff --git a/benchmark/src/main/scala/com/evolution/scache/v1/ExpiringCache.scala b/benchmark/src/main/scala/com/evolution/scache/v1/ExpiringCache.scala new file mode 100644 index 0000000..30508c3 --- /dev/null +++ b/benchmark/src/main/scala/com/evolution/scache/v1/ExpiringCache.scala @@ -0,0 +1,451 @@ +package com.evolution.scache.v1 + +import com.evolution.scache.Cache + +import cats.effect.syntax.all.* +import cats.effect.{Clock, Ref, Resource, Temporal} +import cats.kernel.CommutativeMonoid +import cats.syntax.all.* +import cats.{Applicative, Monad, MonadThrow, Monoid} +import com.evolution.scache.Cache.Directive +import com.evolution.scache.v1.LoadingCache.EntryState +import com.evolutiongaming.catshelper.ClockHelper.* +import com.evolutiongaming.catshelper.Schedule + +import scala.concurrent.duration.* + +/** + * Frozen copy of `ExpiringCache` as it was before the cache was rebuilt on `MapRef`, running over + * [[LoadingCache]] of this package. Kept here only so the benchmarks can measure the old and the + * new implementation in one run, do not change it. + */ +object ExpiringCache { + + type Timestamp = Long + + private[scache] def of[F[_], K, V]( + config: Config[F, K, V], + )(implicit + G: Temporal[F], + ): Resource[F, Cache[F, K, V]] = { + + type E = Entry[V] + + val cooldown = math.max(config.expireAfterRead.toMillis / 5, 10L) + val expireAfterReadMs = config.expireAfterRead.toMillis + cooldown / 2 + val expireAfterWriteMs = config.expireAfterWrite.map { _.toMillis } + val expireInterval = { + val expireInterval = expireAfterWriteMs.fold(expireAfterReadMs) { _ min expireAfterReadMs } + (expireInterval / 10).millis + } + + def removeExpiredAndCheckSize(ref: Ref[F, LoadingCache.EntryRefs[F, K, E]], cache: Cache[F, K, E]) = { + + def remove(key: K) = { + cache + .remove(key) + .flatten + .void + } + + def removeExpired(key: K, entryRef: LoadingCache.EntryRef[F, Entry[V]]) = { + entryRef + .get + .flatMap { + case state: EntryState.Value[F, Entry[V]] => + for { + now <- Clock[F].millis + expiredAfterRead = expireAfterReadMs + state.entry.value.touched < now + expiredAfterWrite = () => expireAfterWriteMs.exists { _ + state.entry.value.created < now } + expired = expiredAfterRead || expiredAfterWrite() + result <- if (expired) remove(key) else ().pure[F] + } yield result + case _: EntryState.Loading[F, Entry[V]] => ().pure[F] + case EntryState.Removed => ().pure[F] + } + } + + def notExceedMaxSize(maxSize: Int) = { + + def drop(entryRefs: LoadingCache.EntryRefs[F, K, E]) = { + + final case class Elem(key: K, timestamp: Timestamp) + + val zero = List.empty[Elem] + entryRefs + .foldLeft(zero.pure[F]) { case (result, (key, entryRef)) => + result.flatMap { result => + entryRef + .get + .map { + case state: EntryState.Value[F, Entry[V]] => Elem(key, state.entry.value.touched) :: result + case _: EntryState.Loading[F, Entry[V]] => result + case EntryState.Removed => result + } + } + } + .flatMap { entries => + entries + .sortBy(_.timestamp) + .take(maxSize / 10) + .foldMapM { elem => remove(elem.key) } + } + } + + for { + entryRefs <- ref.get + result <- if (entryRefs.size > maxSize) drop(entryRefs) else ().pure[F] + } yield result + } + + for { + entryRefs <- ref.get + result <- entryRefs.foldMapM { case (key, entryRef) => removeExpired(key, entryRef) } + _ <- config + .maxSize + .foldMapM { maxSize => notExceedMaxSize(maxSize) } + } yield result + } + + def refreshEntries( + refresh: Refresh[K, F[Option[V]]], + ref: Ref[F, LoadingCache.EntryRefs[F, K, E]], + cache: Cache[F, K, E], + ) = { + ref + .get + .flatMap { entryRefs => + entryRefs.foldMapM { case (key, entryRef) => + entryRef + .get + .flatMap { + case _: EntryState.Value[F, Entry[V]] => + refresh + .value(key) + .flatMap { + case Some(value) => entryRef.update1 { _.copy(value = value) } + case None => cache.remove(key).void + } + .handleError { _ => () } + case _: EntryState.Loading[F, Entry[V]] => ().pure[F] + case EntryState.Removed => ().pure[F] + } + } + } + } + + def schedule(interval: FiniteDuration)(fa: F[Unit]) = Schedule(interval, interval)(fa) + + val entryRefs = LoadingCache.EntryRefs.empty[F, K, E] + for { + ref <- Ref[F].of(entryRefs).toResource + cache <- LoadingCache.of(ref) + _ <- schedule(expireInterval) { removeExpiredAndCheckSize(ref, cache) } + _ <- config + .refresh + .foldMapM { refresh => + schedule(refresh.interval) { refreshEntries(refresh, ref, cache) } + } + } yield { + apply(ref, cache, cooldown) + } + } + + def apply[F[_]: MonadThrow: Clock, K, V]( + ref: Ref[F, LoadingCache.EntryRefs[F, K, Entry[V]]], + cache: Cache[F, K, Entry[V]], + cooldown: Long, + ): Cache[F, K, V] = { + + type E = Entry[V] + + def entryOf(value: V) = { + Clock[F] + .millis + .map { timestamp => + Entry(value, created = timestamp, read = none) + } + } + + implicit def monoidUnit: Monoid[F[Unit]] = Applicative.monoid[F, Unit] + + def touch(key: K, entry: E) = { + for { + now <- Clock[F].millis + result <- if ((entry.touched + cooldown) <= now) { + ref + .get + .flatMap { entries => + entries + .get(key) + .foldMap { _.update1 { _.touch(now) } } + } + } else { + ().pure[F] + } + } yield result + } + + abstract class ExpiringCache extends Cache.Abstract1[F, K, V] + + new ExpiringCache { self => + def get(key: K) = { + cache + .get1(key) + .flatMap { + case Some(Right(entry)) => + touch(key, entry).as { + entry + .value + .some + } + case Some(Left(entry)) => + entry + .map { _.value.some } + .handleError { _ => none[V] } + case None => + none[V].pure[F] + } + } + + def get1(key: K) = { + cache + .get1(key) + .flatMap { + case Some(Right(entry)) => + touch(key, entry).as { + entry + .value + .asRight[F[V]] + .some + } + case Some(Left(entry)) => + entry + .map { _.value } + .asLeft[V] + .some + .pure[F] + case None => + none[Either[F[V], V]].pure[F] + } + } + + def getOrUpdate(key: K)(value: => F[V]) = { + getOrUpdate1(key) { value.map { a => (a, a, none[Release]) } } + .flatMap { + case Right(Right(a)) => a.pure[F] + case Right(Left(a)) => a + case Left(a) => a.pure[F] + } + } + + def getOrUpdate1[A](key: K)(value: => F[(A, V, Option[Release])]) = { + cache + .getOrUpdate1(key) { + value.flatMap { case (a, value, release) => + entryOf(value).map { value => (a, value, release) } + } + } + .flatMap { + case Right(Right(entry)) => + touch(key, entry).as { + entry + .value + .asRight[F[V]] + .asRight[A] + } + case Right(Left(entry)) => + entry + .map { _.value } + .asLeft[V] + .asRight[A] + .pure[F] + + case Left(a) => + a + .asLeft[Either[F[V], V]] + .pure[F] + } + } + + def put(key: K, value: V, release: Option[Release]) = { + entryOf(value) + .flatMap { entry => + cache + .put(key, entry, release) + .map { _.map { _.map { _.value } } } + } + } + + // Modifying existing entry creates a new one, since the old one will be released. + def modify[A](key: K)(f: Option[V] => (A, Directive[F, V])): F[(A, Option[F[Unit]])] = + Clock[F] + .millis + .flatMap { timestamp => + val adaptedF: Option[Entry[V]] => (A, Directive[F, Entry[V]]) = entry => + f(entry.map(_.value)) match { + case (a, put: Directive.Put[F, V]) => + (a, Directive.Put(Entry(put.value, timestamp, none), put.release)) + case (a, Directive.Ignore) => (a, Directive.Ignore) + case (a, Directive.Remove) => (a, Directive.Remove) + } + cache.modify(key)(adaptedF) + } + + def contains(key: K) = cache.contains(key) + + def size = cache.size + + def keys = cache.keys + + def values = { + cache + .values + .map { values => + values.map { case (key, entry) => + (key, entry.map { _.value }) + } + } + } + + def values1 = { + cache + .values1 + .map { entries => + entries.map { case (key, entry) => + val value = entry match { + case Right(a) => a.value.asRight[F[V]] + case Left(a) => a.map { _.value }.asLeft[V] + } + (key, value) + } + } + } + + def remove(key: K) = { + cache + .remove(key) + .map { _.map { _.map { _.value } } } + } + + def clear = cache.clear + + def foldMap[A: CommutativeMonoid](f: (K, Either[F[V], V]) => F[A]) = { + cache.foldMap { + case (k, Right(v)) => f(k, v.value.asRight) + case (k, Left(v)) => f(k, v.map { _.value }.asLeft) + } + } + + def foldMapPar[A: CommutativeMonoid](f: (K, Either[F[V], V]) => F[A]) = { + cache.foldMap { + case (k, Right(v)) => f(k, v.value.asRight) + case (k, Left(v)) => f(k, v.map { _.value }.asLeft) + } + } + } + } + + final case class Entry[A](value: A, created: Timestamp, read: Option[Timestamp]) { self => + + def touch(timestamp: Timestamp): Entry[A] = { + if (self.read.forall { timestamp > _ }) copy(read = timestamp.some) + else self + } + + def touched: Timestamp = read.getOrElse(created) + } + + /** + * Configuration of a refresh background job. + * + * Usage example (`SettingService.get` returns `F[Option[Setting]]`): + * {{{ + * ExpiringCache.Refresh( + * interval = 1.minute, + * value = key => SettingService.getOrNone(key) + * ) + * }}} + * + * @param interval + * How often the refresh routine should be called. Note, that all cache entries will be + * refreshed regardless how long ago these were added to the cache, hence the operation might be + * expensive. + * @param value + * The function which returns a value for the specific key. While the function itself is pure, + * all the current implementation use `Refresh[K, F[Option[T]]]`, so `V` is not a real value, + * but an effectful function which calculates a value. The [[scala.Option]] is used to indicate + * if value should be removed (i.e. [[scala.None]] means the key is to be deleted). + */ + final case class Refresh[-K, +V](interval: FiniteDuration, value: K => V) + + object Refresh { + def apply[K](interval: FiniteDuration): Apply[K] = new Apply(interval) + + private[Refresh] final class Apply[K](val interval: FiniteDuration) extends AnyVal { + + def apply[V](f: K => V): Refresh[K, V] = Refresh(interval, f) + } + } + + /** + * Configuration of expiring cache, including the potential refresh routine. + * + * Performance consideration: The frequency of internal expiration routine depends on + * `expireAfterRead` and `expireAfterWrite` parameters (it is actually done more often, for sake + * of faster cleanup), so the very small value set for any of these parameters may affect the + * performance of the cache, as cleanup will happen too often. + * + * Usage example (`SettingService.get` returns `F[Option[Setting]]`): + * {{{ + * ExpiringCache.Config( + * expireAfterRead = 1.minute, + * expireAfterWrite = None, + * maxSize = None, + * refresh = Some(ExpiringCache.Refresh( + * interval = 1.minute, + * value = key => SettingService.get(key) + * )) + * }}} + * + * @param expireAfterRead + * The value will be removed after the period set by this parameter if it was not read (i.e. one + * of methods reading the value such as [[Cache#get]] or [[Cache#getOrUpdate]] method was not + * called). Note, that this removal has a best effort guarantee, i.e. there is possibility that + * value is still there after it expires. + * @param expireAfterWrite + * If set to [[scala.Some]], the value will be removed after the period set by this parameter + * regardless if it was touched by [[Cache#get]] or similar methods. Note, that this removal has + * a best effort guarantee, i.e. there is possibility that value is still there after it + * expires. + * @param maxSize + * If set then the cache implementation will try to keep the cache size under `maxSize` whenever + * clean up routine happens. If the cache size exceeds the value, it will try to drop part of + * non-expired element sorted by the timestamp, when these elements were last read. There is no + * guarantee, though, that this size will not be exceeded a bit, if a lot of elements are put + * into cache between the cleanup calls. + * @param refresh + * If set to [[scala.Some]], the cache will schedule a background job, which will refresh or + * remove the _existing_ values regularly. The keys not already present in a cache will not be + * affected anyhow. See [[Refresh]] documentation for more details. + */ + final case class Config[F[_], -K, V]( + expireAfterRead: FiniteDuration, + expireAfterWrite: Option[FiniteDuration] = None, + maxSize: Option[Int] = None, + refresh: Option[Refresh[K, F[Option[V]]]] = None, + ) + + private implicit class MapOps[K, V](val self: Map[K, V]) extends AnyVal { + def foldMapM[F[_]: Monad, A: Monoid](f: (K, V) => F[A]): F[A] = { + self.foldLeft(Monoid[A].empty.pure[F]) { case (a, (k, v)) => + for { + a <- a + b <- f(k, v) + } yield { + a.combine(b) + } + } + } + } +} diff --git a/benchmark/src/main/scala/com/evolution/scache/v1/LoadingCache.scala b/benchmark/src/main/scala/com/evolution/scache/v1/LoadingCache.scala new file mode 100644 index 0000000..9dc06e9 --- /dev/null +++ b/benchmark/src/main/scala/com/evolution/scache/v1/LoadingCache.scala @@ -0,0 +1,1066 @@ +package com.evolution.scache.v1 + +import com.evolution.scache.Cache + +import cats.effect.implicits.* +import cats.effect.{Concurrent, Deferred, Fiber, GenConcurrent, Outcome, Ref, Resource} +import cats.kernel.CommutativeMonoid +import cats.syntax.all.* +import cats.{Applicative, Functor, Monad, MonadThrow, Parallel} +import com.evolution.scache.Cache.Directive +import com.evolutiongaming.catshelper.ParallelHelper.* + +/** + * Frozen copy of `LoadingCache` as it was before the cache was rebuilt on `MapRef`: the whole map + * lives in a single `Ref[F, Map[K, EntryRef]]`, so every insertion or removal of any key CASes it, + * with `MaxRetries` as the safety net. Kept here only so the benchmarks can measure the old and the + * new implementation in one run, do not change it. + */ +private[scache] object LoadingCache { + + /** + * Maximum number of CAS retry attempts before giving up. This is a safety net against infinite + * spinning under extreme contention. + */ + /** + * Maximum number of CAS retry attempts on the outer map before giving up. Inner entry-level CAS + * loops are unbounded as they always make progress. + */ + private val MaxRetries: Int = 10000 + + def of[F[_]: Concurrent, K, V]( + map: EntryRefs[F, K, V], + ): Resource[F, Cache[F, K, V]] = { + for { + ref <- Ref[F].of(map).toResource + cache <- of(ref) + } yield cache + } + + def of[F[_]: Concurrent, K, V]( + ref: Ref[F, EntryRefs[F, K, V]], + ): Resource[F, Cache[F, K, V]] = { + Resource.make { + apply(ref).pure[F] + } { cache => + cache.clear.flatten + } + } + + def apply[F[_]: Concurrent, K, V]( + ref: Ref[F, EntryRefs[F, K, V]], + ): Cache[F, K, V] = { + + val handleReleaseError = (e: Throwable) => { + System.err.println(s"scache: failed to release cache entry: $e") + } + + def checkRetries(counter: Int): F[Unit] = { + if (counter >= MaxRetries) { + new IllegalStateException( + s"Cache CAS retry limit ($MaxRetries) exceeded. This indicates extreme contention.", + ).raiseError[F, Unit] + } else { + ().pure[F] + } + } + + def entryOf(value: V, release: Option[F[Unit]]) = { + Entry( + value = value, + release = release.map { _.handleError(handleReleaseError) }, + ) + } + + abstract class LoadingCache extends Cache.Abstract1[F, K, V] + + new LoadingCache { + + def get(key: K) = { + ref + .get + .flatMap { entryRefs => + entryRefs + .get(key) + .fold { + none[V].pure[F] + } { entry => + entry + .get + .flatMap { + case state: EntryState.Value[F, V] => + state + .entry + .value + .some + .pure[F] + case state: EntryState.Loading[F, V] => + state + .deferred + .get + .map { entry => + entry + .toOption + .map { _.value } + } + case EntryState.Removed => + none[V].pure[F] + } + } + } + } + + def get1(key: K) = { + ref + .get + .flatMap { entryRefs => + entryRefs + .get(key) + .flatTraverse { _.optEither } + } + } + + def getOrUpdate(key: K)(value: => F[V]) = { + getOrUpdate1(key) { value.map { a => (a, a, none[Release]) } }.flatMap { + case Right(Right(a)) => a.pure[F] + case Right(Left(a)) => a + case Left(a) => a.pure[F] + } + } + + def getOrUpdate1[A](key: K)(value: => F[(A, V, Option[Release])]): F[Either[A, Either[F[V], V]]] = { + 0.tailRecM { counter => + checkRetries(counter) *> + ref + .access + .flatMap { case (entryRefs, set) => + entryRefs + .get(key) + .fold { + for { + deferred <- Deferred[F, Either[Throwable, Entry[F, V]]] + entryRef <- Ref[F].of[EntryState[F, V]](EntryState.Loading(deferred)) + result <- set(entryRefs.updated(key, entryRef)) + .flatMap { + case true => + value + .map { case (a, value, release) => + val entry = entryOf(value, release) + (a, entry) + } + .attempt + .race1 { deferred.get } + .flatMap { + // `value` got computed, and deferred was not (yet) completed by any other fiber in `put` + case Left(Right((a, entry))) => + deferred + .complete(entry.asRight) + .flatMap { + // Successfully completed our deferred, + // now trying to place the new value in the entry. + case true => + + def releaseAndReturnValue(state: EntryState.Value[F, V]) + : F[Either[A, Either[F[V], V]]] = + entry + .release1 + .start + .as { + state + .entry + .value + .asRight[F[V]] + .asRight[A] + } + + def releaseAndReturnLoading(state: EntryState.Loading[F, V]) + : F[Either[A, Either[F[V], V]]] = + entry + .release1 + .start + .as { + state + .deferred + .getOrError + .map(_.value) + .asLeft[V] + .asRight[A] + } + + // Try putting computed value in the map, if there is no entry with our key. + // If the map already contains an entry with our key, + // return its value (or value computation). + def tryPutNewValue: F[Either[A, Either[F[V], V]]] = + 0.tailRecM { counter => + ref + .access + .flatMap { case (entryRefs, set) => + entryRefs + .get(key) + .fold { + // No entry present in the map, so we try to add a new one + Ref[F] + .of[EntryState[F, V]](EntryState.Value(entry)) + .flatMap { entryRef => + set(entryRefs.updated(key, entryRef)).map { + case true => + a + .asLeft[Either[F[V], V]] + .asRight[Int] + case false => + (counter + 1) + .asLeft[Either[A, Either[F[V], V]]] + } + } + } { entryRef => + entryRef + .get + .flatMap { + case state: EntryState.Value[F, V] => + releaseAndReturnValue(state).map(_.asRight[Int]) + + case state: EntryState.Loading[F, V] => + releaseAndReturnLoading(state).map(_.asRight[Int]) + + // `Removed` means that this entry won't be present in the map + // next time we look the key up (see `remove` flow), + // so we just retry. + case EntryState.Removed => + (counter + 1) + .asLeft[Either[A, Either[F[V], V]]] + .pure[F] + } + .uncancelable + } + } + } + + entryRef + .access + .flatMap { + // Entry is still in loading state, containing the same deferred we just completed. + // Now we can try to put the computed value in the same entryRef. + case (state: EntryState.Loading[F, V], set) if state.deferred == deferred => + set(EntryState.Value(entry)) + .flatMap { + // Happy path: successfully placed our computed value + case true => + a + .asLeft[Either[F[V], V]] + .pure[F] + // Failed to set our value, meaning the entry was either: + // - Updated: in that case we release our computed value, and return + // the value (or its computation), giving it the priority + // - Removed: in that case we try to put our value back in the map + case false => + entryRef + .get + .flatMap { + case state: EntryState.Value[F, V] => + releaseAndReturnValue(state) + + case state: EntryState.Loading[F, V] => + releaseAndReturnLoading(state) + + case EntryState.Removed => + tryPutNewValue + } + } + + case (state: EntryState.Value[F, V], _) => + releaseAndReturnValue(state) + + case (state: EntryState.Loading[F, V], _) => + releaseAndReturnLoading(state) + + case (EntryState.Removed, _) => + tryPutNewValue + } + + // Deferred got completed by another fiber, so we return what they put there, + // and release the value we just computed. + case false => + entry + .release1 + .start + .productR( + deferred + .getOrError + .map { entry => + entry + .value + .asRight[F[V]] + .asRight[A] + }, + ) + } + + // `value` computation completed with error, + // and deferred was not completed in another fiber in `put`. + case Left(Left(error)) => + deferred + .complete(error.asLeft) + .flatMap { + // Successfully completed our deferred with error, + // now trying to remove the entry from the map, if it is still there. + case true => + 0.tailRecM { counter1 => + ref + .access + .flatMap { case (entryRefs, set) => + entryRefs + .get(key) + .fold { + // Key was removed while we were loading, + // so we are just propagating the error + error.raiseError[F, Either[Int, Either[F[V], V]]] + } { + // The entry we added to the map is still there and unmodified, + // so we can safely remove it and propagate the error + case `entryRef` => + set(entryRefs - key).flatMap { + // Happy path: successfully removed our entry + case true => + error.raiseError[F, Either[Int, Either[F[V], V]]] + // Retrying (different keys could've been modified in the map) + case false => + (counter1 + 1) + .asLeft[Either[F[V], V]] + .pure[F] + } + // Another fiber replaced the `ref` we added to the map, + // so we return their value (computed or ongoing), + // or propagate our error if our entry got removed. + case entryRef => + entryRef + .optEither + .flatMap(_.liftTo[F](error)) + .map(_.asRight[Int]) + } + } + } + + // Someone else completed the deferred before us, so they must've take care of + // updating the `ref`, and we return their result. + case false => + deferred + .getOrError + .map { _.value } + .asLeft[V] + .pure[F] + } + .map { _.asRight[A] } + + // Deferred was completed by `put` in another fiber before `value` computation completed. + // We return their value, and schedule release of our value that is still being computed. + case Right((fiber, entry)) => + fiber + .joinWithNever + .flatMap { + case Right((_, entry)) => entry.release1 + case _ => ().pure[F] + } + .start + .productR { + entry + .liftTo[F] + .map { entry => + entry + .value + .asRight[F[V]] + .asRight[A] + } + } + } + .map { _.asRight[Int] } + + case false => + (counter + 1) + .asLeft[Either[A, Either[F[V], V]]] + .pure[F] + } + .uncancelable + } yield result + } { entryRef => + // Map already contained an entry under our key, so we return that value (or its ongoing computation) + entryRef + .optEither + .map { + case Some(either) => + either + .asRight[A] + .asRight[Int] + // Entry got removed (see `remove` flow), so we retry expecting to get something else with our key. + case None => + (counter + 1) + .asLeft[Either[A, Either[F[V], V]]] + } + } + } + } + } + + def put(key: K, value: V, release: Option[Release]): F[F[Option[V]]] = { + val entry = entryOf(value, release) + 0.tailRecM { counter => + checkRetries(counter) *> + ref + .access + .flatMap { case (entryRefs, set) => + entryRefs + .get(key) + .fold { + // No entry present in the map, so we add a new one + Ref[F] + .of[EntryState[F, V]](EntryState.Value(entry)) + .flatMap { entryRef => + set(entryRefs.updated(key, entryRef)).map { + case true => + none[V] + .pure[F] + .asRight[Int] + case false => + (counter + 1) + .asLeft[F[Option[V]]] + } + } + } { entryRef => + entryRef + .access + .flatMap { + // A computed value is already present in the map, so we are replacing it with our value. + case (state: EntryState.Value[F, V], set) => + set(EntryState.Value(entry)) + .flatMap { + // Successfully replaced the entryRef with our value, + // now we are responsible for releasing the old value. + case true => + state + .entry + .release + .traverse { _.start } + .map { fiber => + fiber + .foldMapM { _.joinWithNever } + .as { state.entry.value.some } + .asRight[Int] + } + // Failed to set the entryRef to our value + // so we just release our value and exit. + case false => + entry + .release + .traverse { _.start } // Start releasing and forget + .as { + none[V] + .pure[F] + .asRight[Int] + } + } + + // The value is still loading, so we first try to complete the deferred with it, + // and then replace it with our value. + case (state: EntryState.Loading[F, V], set) => + state + .deferred + .complete(entry.asRight) + .flatMap { + // We successfully completed the deferred, now trying to set the value. + case true => + set(EntryState.Value(entry)).flatMap { + // We successfully replaced the entry with our value, so we are done. + case true => + none[V] + .pure[F] + .asRight[Int] + .pure[F] + // Another fiber placed their new value before us + // so we just release our value and exit. + case false => + entry + .release + .traverse { _.start } // Start releasing and forget + .as { + none[V] + .pure[F] + .asRight[Int] + } + } + // Someone just completed the deferred we saw + // so we just release our value and exit. + case false => + entry + .release + .traverse { _.start } // Start releasing and forget + .as { + none[V] + .pure[F] + .asRight[Int] + } + } + + // The key was just removed from the map, so just release the value and exit. + case (EntryState.Removed, _) => + entry + .release + .traverse { _.start } // Start releasing and forget + .as { + none[V] + .pure[F] + .asRight[Int] + } + } + .uncancelable + } + } + } + } + + override def modify[A](key: K)(f: Option[V] => (A, Directive[F, V])): F[(A, Option[F[Unit]])] = { + 0.tailRecM { counter => + checkRetries(counter) *> + ref + .access + .flatMap { case (entryRefs, setMap) => + entryRefs + .get(key) + .fold { + f(None) match { + // No entry present in the map, and we want to add a new one + case (a, put: Directive.Put[F, V]) => + Ref[F] + .of[EntryState[F, V]](EntryState.Value(entryOf(put.value, put.release))) + .flatMap { entryRef => + setMap(entryRefs.updated(key, entryRef)).map { + case true => + (a, none[F[Unit]]) + .asRight[Int] + // Failed adding new entry to the map, retrying accessing the map + case false => + (counter + 1) + .asLeft[(A, Option[F[Unit]])] + } + } + // No entry present in the map, and we don't want to have any, so exiting + case (a, Directive.Ignore | Directive.Remove) => + (a, none[F[Unit]]) + .asRight[Int] + .pure[F] + } + } { entryRef => + 0.tailRecM { counter1 => + entryRef + .access + .flatMap { + // A value is already present in the map + case (state: EntryState.Value[F, V], setRef) => + f(state.entry.value.some) match { + case (a, put: Directive.Put[F, V]) => + setRef(EntryState.Value(entryOf(put.value, put.release))) + .flatMap { + // Successfully replaced the entryRef with our value, + // now we are responsible for releasing the old value. + case true => + state + .entry + .release + .traverse { _.start } + .map { release => + (a, release.map(_.joinWithNever)) + .asRight[Int] + .asRight[Int] + } + // Failed updating entryRef, retrying + case false => + (counter1 + 1) + .asLeft[Either[Int, (A, Option[F[Unit]])]] + .pure[F] + } + // Keeping the value intact and exiting + case (a, Directive.Ignore) => + (a, none[F[Unit]]) + .asRight[Int] + .asRight[Int] + .pure[F] + // Removing the value + case (a, Directive.Remove) => + setRef(EntryState.Removed) + .flatMap { + // Successfully set the entryRef to `Removed` state, now removing it from the map. + // Only removing the key if it still contains this entry, otherwise noop. + case true => + ref + .update { entryRefs => + entryRefs.get(key) match { + case Some(`entryRef`) => entryRefs - key + case _ => entryRefs + } + } + .flatMap { _ => + // Releasing the value regardless of the map update result. + state + .entry + .release + .traverse { _.start } + .map { release => + (a, release.map(_.joinWithNever)) + .asRight[Int] + .asRight[Int] + } + } + // Failed updating entryRef, retrying + case false => + (counter1 + 1) + .asLeft[Either[Int, (A, Option[F[Unit]])]] + .pure[F] + } + } + + // Entry in the map is still loading + case (state: EntryState.Loading[F, V], setRef) => + f(None) match { + // Trying to replace it with our value + case (a, put: Directive.Put[F, V]) => + val entry = entryOf(put.value, put.release) + state + .deferred + .complete(entry.asRight) + .flatMap { + // We successfully completed the deferred, now trying to set the value. + case true => + setRef(EntryState.Value(entry)).map { + // We successfully replaced the entry with our value, so we are done. + case true => + (a, none[F[Unit]]) + .asRight[Int] + .asRight[Int] + // Another fiber placed their new value (only Removed should be possible) + // before us so we retry accessing the entry. + case false => + (counter1 + 1) + .asLeft[Either[Int, (A, Option[F[Unit]])]] + } + // Failed to complete the deferred, meaning someone else completed it, and will + // now set the new value in the entryRef. Retrying the lookup. + case false => + (counter1 + 1) + .asLeft[Either[Int, (A, Option[F[Unit]])]] + .pure[F] + } + // Noop decision, exiting + case (a, Directive.Ignore | Directive.Remove) => + (a, none[F[Unit]]) + .asRight[Int] + .asRight[Int] + .pure[F] + } + + // Entry was just removed, it soon will be gone from the map. + case (EntryState.Removed, _) => + f(None) match { + // We want to place the new value; + // Retrying the map lookup, expecting a different result for our key. + case (_, _: Directive.Put[F, V]) => + (counter + 1) + .asLeft[(A, Option[F[Unit]])] + .asRight[Int] + .pure[F] + // Noop decision, exiting + case (a, Directive.Ignore | Directive.Remove) => + (a, none[F[Unit]]) + .asRight[Int] + .asRight[Int] + .pure[F] + } + } + .uncancelable + } + } + } + } + } + + def contains(key: K) = { + ref + .get + .map { _.contains(key) } + } + + def size = { + ref + .get + .map { _.size } + } + + def keys = { + ref + .get + .map { _.keySet } + } + + def values = { + ref + .get + .flatMap { entryRefs => + entryRefs + .foldLeft { + List + .empty[(K, F[V])] + .pure[F] + } { case (values, (key, entryRef)) => + values.flatMap { values => + entryRef + .value + .map { + case Some(value) => (key, value) :: values + case None => values + } + } + } + } + .map { _.toMap } + } + + def values1 = { + ref + .get + .flatMap { entryRefs => + entryRefs + .foldLeft { + List + .empty[(K, Either[F[V], V])] + .pure[F] + } { case (values, (key, entryRef)) => + values.flatMap { values => + entryRef + .optEither + .map { + case Some(value) => (key, value) :: values + case None => values + } + } + } + } + .map { _.toMap } + } + + def remove(key: K): F[F[Option[V]]] = { + 0.tailRecM { counter => + checkRetries(counter) *> + ref + .access + .flatMap { case (entryRefs, set) => + entryRefs + .get(key) + .fold { + none[V] + .pure[F] + .asRight[Int] + .pure[F] + } { entryRef => + set(entryRefs - key) + .flatMap { + case true => + // We just removed the entry for the map, now we need to release it. + // Replacing the value of the ref with `Removed` means that we are getting responsible for the release. + entryRef + .getAndSet(EntryState.Removed) + .flatMap { + // We removed a loaded value, so we are responsible for releasing it. + case state: EntryState.Value[F, V] => + state + .entry + .release1 + .as { state.entry.value.some } + .start + .map { fiber => + fiber + .joinWithNever + .asRight[Int] + } + + // We removed a loading value, and the fiber that will complete it will also + // release that value, so there is nothing for us to return. + case _: EntryState.Loading[F, V] => + none[V] + .pure[F] + .asRight[Int] + .pure[F] + + // We removed an entry that was already being removed by another fiber, so we are done. + case EntryState.Removed => + none[V] + .pure[F] + .asRight[Int] + .pure[F] + } + case false => + (counter + 1) + .asLeft[F[Option[V]]] + .pure[F] + } + .uncancelable + } + } + } + } + + def clear = { + ref + .getAndSet(EntryRefs.empty) + .flatMap { entryRefs => + entryRefs + .parFoldMap1 { case (_, entryRef) => + entryRef + .getOption + .flatMap { _.foldMapM { _.release1 } } + .uncancelable + } + .start + } + .uncancelable + .map { _.joinWithNever } + } + + def foldMap[A: CommutativeMonoid](f: (K, Either[F[V], V]) => F[A]) = { + ref + .get + .flatMap { entryRefs => + val zero = CommutativeMonoid[A] + .empty + .pure[F] + entryRefs.foldLeft(zero) { case (a, (key, entryRef)) => + for { + a <- a + v <- entryRef.optEither + b <- v.fold(CommutativeMonoid[A].empty.pure[F])(v => f(key, v)) + } yield { + CommutativeMonoid[A].combine(a, b) + } + } + } + } + + def foldMapPar[A: CommutativeMonoid](f: (K, Either[F[V], V]) => F[A]) = { + ref + .get + .flatMap { entryRefs => + Parallel[F].sequential { + val zero = Parallel[F] + .applicative + .pure(CommutativeMonoid[A].empty) + entryRefs + .foldLeft(zero) { case (a, (key, entryRef)) => + val b = Parallel[F].parallel { + for { + v <- entryRef.optEither + b <- v.fold(CommutativeMonoid[A].empty.pure[F])(v => f(key, v)) + } yield b + } + Parallel[F] + .applicative + .map2(a, b)(CommutativeMonoid[A].combine) + } + } + } + } + } + } + + final case class Entry[+F[_], +A](value: A, release: Option[F[Unit]]) + + object Entry { + implicit class EntryOps[F[_], A](val self: Entry[F, A]) extends AnyVal { + def release1( + implicit + F: Monad[F], + ): F[Unit] = self.release.foldA + } + } + + sealed trait EntryState[+F[_], +A] + object EntryState { + final case class Loading[F[_], A](deferred: Deferred[F, Either[Throwable, Entry[F, A]]]) extends EntryState[F, A] + final case class Value[F[_], A](entry: Entry[F, A]) extends EntryState[F, A] + case object Removed extends EntryState[Nothing, Nothing] + } + + type DeferredThrow[F[_], A] = Deferred[F, Either[Throwable, A]] + + type EntryRef[F[_], A] = Ref[F, EntryState[F, A]] + + type EntryRefs[F[_], K, V] = Map[K, EntryRef[F, V]] + + object EntryRefs { + def empty[F[_], K, V]: EntryRefs[F, K, V] = Map.empty + } + + implicit class DeferredThrowOps[F[_], A](val self: DeferredThrow[F, A]) extends AnyVal { + def getOrError( + implicit + F: MonadThrow[F], + ): F[A] = { + self + .get + .flatMap { + case Right(a) => a.pure[F] + case Left(a) => a.raiseError[F, A] + } + } + + def getOption( + implicit + F: Functor[F], + ): F[Option[A]] = { + self + .get + .map { _.toOption } + } + } + + implicit class EntryStateOps[F[_], A](val self: EntryState[F, A]) extends AnyVal { + + def getOption( + implicit + F: Applicative[F], + ): F[Option[Entry[F, A]]] = { + self match { + case EntryState.Loading(deferred: Deferred[F, Either[Throwable, Entry[F, A]]]) => deferred.getOption + case EntryState.Value(entry) => entry.some.pure[F] + case EntryState.Removed => none[Entry[F, A]].pure[F] + } + } + + def optEither( + implicit + F: MonadThrow[F], + ): Option[Either[F[A], A]] = + self match { + case EntryState.Value(entry) => + entry + .value + .asRight[F[A]] + .some + case EntryState.Loading(deferred: Deferred[F, Either[Throwable, Entry[F, A]]]) => + deferred + .getOrError + .map(_.value) + .asLeft[A] + .some + case EntryState.Removed => + none[Either[F[A], A]] + } + + } + + implicit class EntryRefOps[F[_], A](val self: EntryRef[F, A]) extends AnyVal { + + def getOption( + implicit + F: Monad[F], + ): F[Option[Entry[F, A]]] = { + self + .get + .flatMap(_.getOption) + } + + def optEither( + implicit + F: MonadThrow[F], + ): F[Option[Either[F[A], A]]] = { + self + .get + .map(_.optEither) + } + + def value( + implicit + F: MonadThrow[F], + ): F[Option[F[A]]] = { + self + .get + .map { + case EntryState.Value(entry) => + entry + .value + .pure[F] + .some + case EntryState.Loading(deferred: Deferred[F, Either[Throwable, Entry[F, A]]]) => + deferred + .getOrError + .map { _.value } + .some + case EntryState.Removed => + none[F[A]] + } + } + + def update1( + f: A => A, + )(implicit + F: Monad[F], + ): F[Unit] = { + 0.tailRecM { counter => + self + .access + .flatMap { + case (EntryState.Value(entry), set) => + val entry1 = entry.copy(value = f(entry.value)) + set(EntryState.Value(entry1)).map { + case true => ().asRight[Int] + case false => (counter + 1).asLeft[Unit] + } + case (_: EntryState.Loading[F, A], _) => + () + .asRight[Int] + .pure[F] + case (EntryState.Removed, _) => + () + .asRight[Int] + .pure[F] + } + } + } + } + + implicit class Ops[F[_], A, E](val fa: F[A]) extends AnyVal { + def race1[B]( + fb: F[B], + )(implicit + F: GenConcurrent[F, E], + ): F[Either[A, (Fiber[F, E, A], B)]] = { + import F.* + uncancelable { poll => + poll(racePair(fa, fb)).flatMap { + case Left((a, fiber)) => + a match { + case Outcome.Succeeded(a) => + fiber + .cancel + .productR { a } + .map { _.asLeft } + case Outcome.Errored(a) => + fiber + .cancel + .productR { raiseError(a) } + case Outcome.Canceled() => + poll(canceled) *> never + } + case Right((fiber, b)) => + b match { + case Outcome.Succeeded(b) => b.map { b => (fiber, b).asRight[A] } + case Outcome.Errored(eb) => raiseError(eb) + case Outcome.Canceled() => + poll(fiber.join) + .onCancel(fiber.cancel) + .flatMap { + case Outcome.Succeeded(a) => a.map { _.asLeft[(Fiber[F, E, A], B)] } + case Outcome.Errored(a) => raiseError(a) + case Outcome.Canceled() => poll(canceled) *> never + } + } + } + } + } + } +} diff --git a/build.sbt b/build.sbt index 90a29d1..ab62176 100644 --- a/build.sbt +++ b/build.sbt @@ -70,7 +70,7 @@ lazy val root = (project in file(".")) publish / skip := true, publishArtifact := false, ) - .aggregate(`cache-adt`, scache) + .aggregate(`cache-adt`, scache, benchmark) lazy val `cache-adt` = (project in file("cache-adt")) .settings(commonSettings) @@ -96,6 +96,23 @@ lazy val scache = (project in file("scache")) ) .dependsOn(`cache-adt`) +lazy val benchmark = (project in file("benchmark")) + .enablePlugins(JmhPlugin) + .settings(commonSettings) + .settings( + name := "scache-benchmark", + description := "JMH benchmarks for scache", + publish / skip := true, + publishArtifact := false, + versionPolicyCheck / skip := true, + versionPolicyReportDependencyIssues / skip := true, + coverageEnabled := false, + // The frozen pre-MapRef copy is not going to be cleaned up, and the benchmarks do use the + // deprecated members it exposes. + scalacOptsFailOnWarn := Some(false), + ) + .dependsOn(scache) + addCommandAlias("fmt", "+scalafmtRepo") addCommandAlias("check", "+all versionPolicyCheck Compile/doc scalafmtCheckRepo") addCommandAlias("build", "all test package") diff --git a/project/plugins.sbt b/project/plugins.sbt index da6b5db..9a667c2 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -9,3 +9,5 @@ addSbtPlugin("com.evolution" % "sbt-scalac-opts-plugin" % "0.1.0") addSbtPlugin("com.evolution" % "sbt-artifactory-plugin" % "0.1.2") addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.6.2") + +addSbtPlugin("pl.project13.scala" % "sbt-jmh" % "0.4.8") diff --git a/scache/src/main/scala/com/evolution/scache/ExpiringCache.scala b/scache/src/main/scala/com/evolution/scache/ExpiringCache.scala index 7673ea3..38a742d 100644 --- a/scache/src/main/scala/com/evolution/scache/ExpiringCache.scala +++ b/scache/src/main/scala/com/evolution/scache/ExpiringCache.scala @@ -22,17 +22,18 @@ object ExpiringCache { G: Async[F], ): Resource[F, Cache[F, K, V]] = { - type E = Entry[V] + type TimestampedValue = Entry[V] - type DeferredE = LoadingCache.DeferredThrow[F, LoadingCache.Entry[F, E]] + type LoadingDeferred = LoadingCache.DeferredThrow[F, LoadingCache.Entry[F, TimestampedValue]] val cooldown = math.max(config.expireAfterRead.toMillis / 5, 10L) val expireAfterReadMs = config.expireAfterRead.toMillis + cooldown / 2 val expireAfterWriteMs = config.expireAfterWrite.map { _.toMillis } - val expireInterval = { - val expireInterval = expireAfterWriteMs.fold(expireAfterReadMs) { _ min expireAfterReadMs } - (expireInterval / 10).millis - } + val expireAfterMs = expireAfterWriteMs.fold(expireAfterReadMs) { _ min expireAfterReadMs } + val loadingTimeoutMs = config + .loadingTimeout + .fold(expireAfterMs) { _.toMillis } + val expireInterval = ((expireAfterMs min loadingTimeoutMs) / 10).millis /* One run of the expiration routine: drops the values that are too old, evicts the loads that * are taking too long, and enforces `maxSize`. @@ -41,13 +42,19 @@ object ExpiringCache { * map forever, holding the key hostage: nothing can be stored under it, everyone asking for it * waits on a `Deferred` that will never complete, and so does the release of the cache itself. * - * `loadingSince` holds the moment each of the currently loading keys was first seen loading, - * carried over between the runs, as this is the only way to tell how long a load is running. + * The three pieces of state are one and the same map seen from three angles, and are not kept + * in sync by hand: `entryMap` is the raw per-key state, needed here because the [[Cache]] + * interface exposes neither the entry states nor the `Deferred` of a load; `cache` is the very + * same map behind that interface, used for the removals, so that they go through the regular + * release logic; `loadingSince` is bookkeeping private to this routine, holding the moment each + * of the currently loading keys was first seen loading, carried over between the runs, as this + * is the only way to tell how long a load is running. Anything stale in `loadingSince` is + * ignored and dropped on the next run. */ def removeExpiredAndCheckSize( - entryMap: LoadingCache.EntryMap[F, K, E], - cache: Cache[F, K, E], - loadingSince: Ref[F, Map[K, (DeferredE, Timestamp)]], + entryMap: LoadingCache.EntryMap[F, K, TimestampedValue], + cache: Cache[F, K, TimestampedValue], + loadingSince: Ref[F, Map[K, (LoadingDeferred, Timestamp)]], ): F[Unit] = { def remove(key: K): F[Unit] = { @@ -57,11 +64,11 @@ object ExpiringCache { .void } - def removeExpired(key: K, entryRef: LoadingCache.EntryRef[F, Entry[V]]): F[Unit] = { + def removeExpired(key: K, entryRef: LoadingCache.EntryRef[F, TimestampedValue]): F[Unit] = { entryRef .get .flatMap { - case state: EntryState.Value[F, Entry[V]] => + case state: EntryState.Value[F, TimestampedValue] => for { now <- Clock[F].millis expiredAfterRead = expireAfterReadMs + state.entry.value.touched < now @@ -69,7 +76,7 @@ object ExpiringCache { expired = expiredAfterRead || expiredAfterWrite() result <- if (expired) remove(key) else ().pure[F] } yield result - case _: EntryState.Loading[F, Entry[V]] => ().pure[F] + case _: EntryState.Loading[F, TimestampedValue] => ().pure[F] case EntryState.Removed => ().pure[F] } } @@ -79,10 +86,14 @@ object ExpiringCache { * Does nothing unless the entry is still loading the very same `deferred`, so that a load * that has completed, or has been replaced by a newer one, in the meantime is left alone. */ - def evictLoading(key: K, entryRef: LoadingCache.EntryRef[F, E], deferred: DeferredE): F[Unit] = { + def evictLoading( + key: K, + entryRef: LoadingCache.EntryRef[F, TimestampedValue], + deferred: LoadingDeferred, + ): F[Unit] = { entryRef .modify { - case state: EntryState.Loading[F, E] if state.deferred == deferred => + case state: EntryState.Loading[F, TimestampedValue] if state.deferred == deferred => (EntryState.Removed, true) case state => (state, false) @@ -102,8 +113,7 @@ object ExpiringCache { .uncancelable } - /* Evicts the loads that have been running longer than the shortest of the configured - * expiration intervals. + /* Evicts the loads that have been running longer than `Config.loadingTimeout`. * * A load has no timestamp of its own, so its age is counted from the first run of the routine * that has seen it, which may be up to one run interval later than the load actually started. @@ -111,8 +121,10 @@ object ExpiringCache { * that a new load of the same key starts its own countdown instead of inheriting the one of * its predecessor. */ - def removeExpiredLoading(loading: List[(K, LoadingCache.EntryRef[F, E], DeferredE)]): F[Unit] = { - val threshold = expireAfterWriteMs.fold(expireAfterReadMs) { _ min expireAfterReadMs } + def removeExpiredLoading( + loading: List[(K, LoadingCache.EntryRef[F, TimestampedValue], LoadingDeferred)], + ): F[Unit] = { + val threshold = loadingTimeoutMs for { now <- Clock[F].millis expired <- loadingSince.modify { seen => @@ -138,7 +150,7 @@ object ExpiringCache { def notExceedMaxSize(maxSize: Int): F[Unit] = { - def drop(entries: List[(K, LoadingCache.EntryRef[F, E])]): F[Unit] = { + def drop(entries: List[(K, LoadingCache.EntryRef[F, TimestampedValue])]): F[Unit] = { final case class Elem(key: K, timestamp: Timestamp) @@ -149,8 +161,8 @@ object ExpiringCache { entryRef .get .map { - case state: EntryState.Value[F, Entry[V]] => Elem(key, state.entry.value.touched) :: result - case _: EntryState.Loading[F, Entry[V]] => result + case state: EntryState.Value[F, TimestampedValue] => Elem(key, state.entry.value.touched) :: result + case _: EntryState.Loading[F, TimestampedValue] => result case EntryState.Removed => result } } @@ -165,17 +177,17 @@ object ExpiringCache { for { size <- entryMap.size - result <- if (size > maxSize) entryMap.entries.flatMap(drop) else ().pure[F] + result <- Async[F].whenA(size > maxSize) { entryMap.entries.flatMap(drop) } } yield result } for { entries <- entryMap.entries result <- entries.foldMapM { case (key, entryRef) => removeExpired(key, entryRef) } - loading <- entries.foldLeftM(List.empty[(K, LoadingCache.EntryRef[F, E], DeferredE)]) { + loading <- entries.foldLeftM(List.empty[(K, LoadingCache.EntryRef[F, TimestampedValue], LoadingDeferred)]) { case (acc, (key, entryRef)) => entryRef.get.map { - case state: EntryState.Loading[F, Entry[V]] => (key, entryRef, state.deferred) :: acc + case state: EntryState.Loading[F, TimestampedValue] => (key, entryRef, state.deferred) :: acc case _ => acc } } @@ -188,8 +200,8 @@ object ExpiringCache { def refreshEntries( refresh: Refresh[K, F[Option[V]]], - entryMap: LoadingCache.EntryMap[F, K, E], - cache: Cache[F, K, E], + entryMap: LoadingCache.EntryMap[F, K, TimestampedValue], + cache: Cache[F, K, TimestampedValue], ): F[Unit] = { entryMap .entries @@ -198,7 +210,7 @@ object ExpiringCache { entryRef .get .flatMap { - case _: EntryState.Value[F, Entry[V]] => + case _: EntryState.Value[F, TimestampedValue] => refresh .value(key) .flatMap { @@ -206,7 +218,7 @@ object ExpiringCache { case None => cache.remove(key).void } .handleError { _ => () } - case _: EntryState.Loading[F, Entry[V]] => ().pure[F] + case _: EntryState.Loading[F, TimestampedValue] => ().pure[F] case EntryState.Removed => ().pure[F] } } @@ -216,8 +228,8 @@ object ExpiringCache { def schedule(interval: FiniteDuration)(fa: F[Unit]): Resource[F, Unit] = Schedule(interval, interval)(fa) for { - entryMap <- LoadingCache.EntryMap.of[F, K, E].toResource - loadingSince <- Ref[F].of(Map.empty[K, (DeferredE, Timestamp)]).toResource + entryMap <- LoadingCache.EntryMap.of[F, K, TimestampedValue].toResource + loadingSince <- Ref[F].of(Map.empty[K, (LoadingDeferred, Timestamp)]).toResource cache <- LoadingCache.of(entryMap) _ <- schedule(expireInterval) { removeExpiredAndCheckSize(entryMap, cache, loadingSince) } _ <- config @@ -236,9 +248,9 @@ object ExpiringCache { cooldown: Long, ): Cache[F, K, V] = { - type E = Entry[V] + type TimestampedValue = Entry[V] - def entryOf(value: V): F[Entry[V]] = { + def entryOf(value: V): F[TimestampedValue] = { Clock[F] .millis .map { timestamp => @@ -248,7 +260,7 @@ object ExpiringCache { implicit def monoidUnit: Monoid[F[Unit]] = Applicative.monoid[F, Unit] - def touch(key: K, entry: E): F[Unit] = { + def touch(key: K, entry: TimestampedValue): F[Unit] = { for { now <- Clock[F].millis result <- if ((entry.touched + cooldown) <= now) { @@ -503,12 +515,20 @@ object ExpiringCache { * If set to [[scala.Some]], the cache will schedule a background job, which will refresh or * remove the _existing_ values regularly. The keys not already present in a cache will not be * affected anyhow. See [[Refresh]] documentation for more details. + * @param loadingTimeout + * How long a value computation started by [[Cache#getOrUpdate]] is allowed to run before the + * entry is evicted and everyone waiting for it fails with [[ExpiredError]]. Without it a + * computation that never completes would hold the key forever. If set to [[scala.None]], the + * smaller of `expireAfterRead` and `expireAfterWrite` is used. Note, that the load is not + * cancelled, only detached from the cache, and that this, too, is best effort: the eviction + * only happens on a cleanup run, so a load may outlive the timeout by up to one run interval. */ final case class Config[F[_], -K, V]( expireAfterRead: FiniteDuration, expireAfterWrite: Option[FiniteDuration] = None, maxSize: Option[Int] = None, refresh: Option[Refresh[K, F[Option[V]]]] = None, + loadingTimeout: Option[FiniteDuration] = None, ) } diff --git a/scache/src/main/scala/com/evolution/scache/LoadingCache.scala b/scache/src/main/scala/com/evolution/scache/LoadingCache.scala index 6c8ac0a..8312123 100644 --- a/scache/src/main/scala/com/evolution/scache/LoadingCache.scala +++ b/scache/src/main/scala/com/evolution/scache/LoadingCache.scala @@ -68,11 +68,12 @@ import scala.jdk.CollectionConverters.* * =Cancellation= * * Only the user-supplied computation is cancelable, all state transitions are masked. Cancelling a - * load flips its own `Loading` state to `Removed`, unlinks the key, completes the `Deferred` with - * [[CancelledError]] so that waiters fail instead of hanging, and releases the value if the - * computation did manage to produce one. Without that cleanup a cancelled load would leave behind a - * `Loading` entry with a `Deferred` nobody is going to complete, which makes the key unusable - * forever and blocks the waiters, `clear`, and therefore the release of the cache itself. + * load flips its own `Loading` state to `Removed`, unlinks the key, always completes the `Deferred` + * with [[CancelledError]] so that waiters fail instead of hanging, even if the entry had already + * been taken away by `remove`, and releases the value if the computation did manage to produce one. + * Without that cleanup a cancelled load would leave behind a `Loading` entry with a `Deferred` + * nobody is going to complete, which makes the key unusable forever and blocks the waiters, + * `clear`, and therefore the release of the cache itself. */ private[scache] object LoadingCache { @@ -147,19 +148,26 @@ private[scache] object LoadingCache { .map { chm => apply(chm) } } + /** + * Built over an explicitly passed [[java.util.concurrent.ConcurrentHashMap]] rather than via + * `MapRef.ofConcurrentHashMap`, because the latter only hands out the per-key `Ref`s, while + * [[EntryMap.keys]], [[EntryMap.entries]], [[EntryMap.size]] and [[EntryMap.contains]] need the + * map itself. + */ def apply[F[_]: Sync, K, V](chm: ConcurrentHashMap[K, EntryRef[F, V]]): EntryMap[F, K, V] = { val mapRef = MapRef.fromConcurrentHashMap[F, K, EntryRef[F, V]](chm) new EntryMap[F, K, V] { - def ref(key: K): Ref[F, Option[EntryRef[F, V]]] = mapRef(key) + def ref(key: K): Ref[F, Option[EntryRef[F, V]]] = + mapRef(key) - def lookup(key: K): F[Option[EntryRef[F, V]]] = Sync[F].delay { Option(chm.get(key)) } + def lookup(key: K): F[Option[EntryRef[F, V]]] = + Sync[F].delay { Option(chm.get(key)) } - def keys: F[Set[K]] = { + def keys: F[Set[K]] = Sync[F].delay { chm.keySet().asScala.toSet } - } - def entries: F[List[(K, EntryRef[F, V])]] = { + def entries: F[List[(K, EntryRef[F, V])]] = Sync[F].delay { chm .entrySet() @@ -168,11 +176,12 @@ private[scache] object LoadingCache { .map { entry => (entry.getKey, entry.getValue) } .toList } - } - def size: F[Int] = Sync[F].delay { chm.mappingCount().toInt } + def size: F[Int] = + Sync[F].delay { chm.mappingCount().toInt } - def contains(key: K): F[Boolean] = Sync[F].delay { chm.containsKey(key) } + def contains(key: K): F[Boolean] = + Sync[F].delay { chm.containsKey(key) } } } } @@ -297,10 +306,14 @@ private[scache] object LoadingCache { case Some(`entryRef`) => none case other => other } - .productR { deferred.complete(CancelledError.asLeft).void } case false => ().pure[F] } + // Completed regardless of whether the entry was still ours: the waiters hold this + // very `deferred`, and if the entry was taken away without completing it, as + // `remove` does, we are the only one left to unblock them. A `deferred` already + // completed by `put` ignores this. + .productR { deferred.complete(CancelledError.asLeft).void } .productR { computed .get @@ -308,8 +321,8 @@ private[scache] object LoadingCache { } poll { - F.uncancelable { poll1 => - poll1 { + F.uncancelable { + _ { value.map { case (a, value, release) => val entry = entryOf(value, release) (a, entry) @@ -362,12 +375,12 @@ private[scache] object LoadingCache { def tryPutNewValue: F[Either[A, Either[F[V], V]]] = Ref[F] .of[EntryState[F, V]](EntryState.Value(entry)) - .flatMap { newRef => + .flatMap { newEntryRef => ().tailRecM { _ => entryMap .ref(key) .modify { - case None => (newRef.some, none[EntryRef[F, V]]) + case None => (newEntryRef.some, none[EntryRef[F, V]]) case some => (some, some) } .flatMap { @@ -411,7 +424,9 @@ private[scache] object LoadingCache { a .asLeft[Either[F[V], V]] .pure[F] - // Failed to set our value, meaning the entry was either: + + // Failed to set our value: while we were loading, `put`, `modify`, + // `remove` or `clear` got to the same entry, so it was either: // - Updated: in that case we release our computed value, and return // the value (or its computation), giving it the priority // - Removed: in that case we try to put our value back in the map @@ -584,6 +599,20 @@ private[scache] object LoadingCache { */ def put(key: K, value: V, release: Option[Release]): F[F[Option[V]]] = { val entry = entryOf(value, release) + + // Our value did not make it into the map, so nothing was replaced and we own its release, + // which we start and forget, as no caller is waiting for it. + def releaseAndExit: F[Either[Unit, F[Option[V]]]] = { + entry + .release + .traverse { _.start } + .as { + none[V] + .pure[F] + .asRight[Unit] + } + } + ().tailRecM { _ => entryMap .lookup(key) @@ -629,17 +658,11 @@ private[scache] object LoadingCache { .as { state.entry.value.some } .asRight[Unit] } + // Failed to set the entryRef to our value // so we just release our value and exit. case false => - entry - .release - .traverse { _.start } // Start releasing and forget - .as { - none[V] - .pure[F] - .asRight[Unit] - } + releaseAndExit } // The value is still loading, so we first try to complete the deferred with it, @@ -658,41 +681,22 @@ private[scache] object LoadingCache { .pure[F] .asRight[Unit] .pure[F] + // Another fiber placed their new value before us // so we just release our value and exit. case false => - entry - .release - .traverse { _.start } // Start releasing and forget - .as { - none[V] - .pure[F] - .asRight[Unit] - } + releaseAndExit } + // Someone just completed the deferred we saw // so we just release our value and exit. case false => - entry - .release - .traverse { _.start } // Start releasing and forget - .as { - none[V] - .pure[F] - .asRight[Unit] - } + releaseAndExit } // The key was just removed from the map, so just release the value and exit. case (EntryState.Removed, _) => - entry - .release - .traverse { _.start } // Start releasing and forget - .as { - none[V] - .pure[F] - .asRight[Unit] - } + releaseAndExit } .uncancelable } diff --git a/scache/src/test/scala/com/evolution/scache/CacheDefectsSpec.scala b/scache/src/test/scala/com/evolution/scache/CacheDefectsSpec.scala index 490bbc5..6d322f3 100644 --- a/scache/src/test/scala/com/evolution/scache/CacheDefectsSpec.scala +++ b/scache/src/test/scala/com/evolution/scache/CacheDefectsSpec.scala @@ -28,84 +28,121 @@ class CacheDefectsSpec extends AsyncFunSuite with Matchers { entryMap <- EntryMap.of[IO, Int, Int] cache = LoadingCache(entryMap) started <- Deferred[IO, Unit] - gate <- Deferred[IO, Int] - loader <- cache.getOrUpdate(0) { started.complete(()) *> gate.get }.start + gate <- Deferred[IO, Unit] + loader <- cache.getOrUpdate(0) { started.complete(()) *> gate.get.as(1) }.start _ <- started.get cancelling <- loader.cancel.start result <- { for { - cancelled <- cancelling.join.timeout(500.millis).attempt - _ <- IO { cancelled should matchPattern { case Right(_) => } } - second <- cache.getOrUpdate(0)(1.pure[IO]).timeout(500.millis).attempt - _ <- IO { second shouldEqual 1.asRight } - } yield {} - }.guarantee { gate.complete(42) *> cancelling.join.void } + cancelled <- cancelling.join.timeout(500.millis) + _ = cancelled should matchPattern { case Outcome.Succeeded(_) => } + present <- cache.get(0) + _ = present shouldEqual none + second <- cache.getOrUpdate(0)(2.pure[IO]).timeout(500.millis) + _ = second shouldEqual 2 + } yield () + }.guarantee { gate.complete(()) *> cancelling.join.void } } yield result io.run() } test("claim 2: expiration cleanup must evict entries stuck in Loading state") { - val config = ExpiringCache.Config[IO, Int, Int](expireAfterRead = 100.millis) + val config = ExpiringCache.Config[IO, Int, Int]( + expireAfterRead = 100.millis, + loadingTimeout = 100.millis.some, + ) val io = ExpiringCache.of[IO, Int, Int](config).use { cache => for { started <- Deferred[IO, Unit] - gate <- Deferred[IO, Int] - loader <- cache.getOrUpdate(0) { started.complete(()) *> gate.get }.start + gate <- Deferred[IO, Unit] + loader <- cache.getOrUpdate(0) { started.complete(()) *> gate.get.as(1) }.start _ <- started.get result <- { for { _ <- cache.put(1, 1).flatten _ <- IO.sleep(500.millis) + // Control: an ordinary value of the same age is gone, so the cleanup did run. control <- cache.contains(1) - _ <- IO { control shouldEqual false } + _ = control shouldEqual false poisoned <- cache.contains(0) - _ <- IO { poisoned shouldEqual false } - second <- cache.getOrUpdate(0)(2.pure[IO]).timeout(500.millis).attempt - _ <- IO { second shouldEqual 2.asRight } - } yield {} - }.guarantee { gate.complete(42) *> loader.join.void } + _ = poisoned shouldEqual false + second <- cache.getOrUpdate(0)(2.pure[IO]).timeout(500.millis) + _ = second shouldEqual 2 + } yield () + }.guarantee { gate.complete(()) *> loader.join.void } } yield result } io.run() } - test("claim 3: remove must unblock fibers waiting on a Loading entry") { + test("claim 3: cancelling a load must unblock the fibers waiting on it") { val io = for { entryMap <- EntryMap.of[IO, Int, Int] cache = LoadingCache(entryMap) started <- Deferred[IO, Unit] - gate <- Deferred[IO, Int] - loader <- cache.getOrUpdate(0) { started.complete(()) *> gate.get }.start + gate <- Deferred[IO, Unit] + loader <- cache.getOrUpdate(0) { started.complete(()) *> gate.get.as(1) }.start _ <- started.get waiter <- cache.getOrUpdate(0)(99.pure[IO]).start _ <- IO.sleep(100.millis) cancelling <- loader.cancel.start - _ <- IO.sleep(100.millis) - _ <- cache.remove(0).flatten result <- { for { - outcome <- waiter.join.timeout(500.millis).attempt - _ <- IO { outcome should matchPattern { case Right(_) => } } - } yield {} - }.guarantee { gate.complete(42) *> cancelling.join.void } + outcome <- waiter.join.timeout(500.millis) + _ = outcome should matchPattern { case Outcome.Errored(CancelledError) => } + present <- cache.get(0) + _ = present shouldEqual none + } yield () + }.guarantee { gate.complete(()) *> cancelling.join.void } } yield result io.run() } - test("claim 4: getOrUpdate must not fail due to sustained writes of unrelated keys") { + test("claim 3: cancelling a load removed while loading must unblock the fibers waiting on it") { val io = for { - underlying <- EntryMap.of[IO, Int, Int] - counter <- Ref[IO].of(0) - noise = counter - .updateAndGet { _ + 1 } - .flatMap { key => insertUnrelated(underlying, key) } - cache = LoadingCache(intercepted(underlying, noise, none)) - result <- cache.getOrUpdate(0)(1.pure[IO]).timeout(10.seconds).attempt - _ <- IO { result shouldEqual 1.asRight } - } yield {} + entryMap <- EntryMap.of[IO, Int, Int] + cache = LoadingCache(entryMap) + started <- Deferred[IO, Unit] + gate <- Deferred[IO, Unit] + loader <- cache.getOrUpdate(0) { started.complete(()) *> gate.get.as(1) }.start + _ <- started.get + waiter <- cache.getOrUpdate(0)(99.pure[IO]).start + _ <- IO.sleep(100.millis) + // The entry stops being the loader's, so only the loader itself can still unblock the waiter. + _ <- cache.remove(0).flatten + cancelling <- loader.cancel.start + result <- { + for { + outcome <- waiter.join.timeout(500.millis) + _ = outcome should matchPattern { case Outcome.Errored(CancelledError) => } + } yield () + }.guarantee { gate.complete(()) *> cancelling.join.void } + } yield result io.run() } + test("claim 4: getOrUpdate must complete under sustained writes of unrelated keys") { + val io = LoadingCache.of[IO, Int, Int].use { cache => + for { + writers <- (1 to 8) + .toList + .traverse { key => + (cache.put(key, key).flatten *> cache.remove(key).flatten) + .foreverM + .start + } + _ <- IO.sleep(100.millis) + result <- { + for { + value <- cache.getOrUpdate(0)(1.pure[IO]).timeout(5.seconds) + _ = value shouldEqual 1 + } yield () + }.guarantee { writers.parTraverse_ { _.cancel } } + } yield result + } + io.run(timeout = 30.seconds) + } + test("claim 4 mechanism: insert of an unrelated key must not force a retry of getOrUpdate") { val io = for { underlying <- EntryMap.of[IO, Int, Int] @@ -113,12 +150,12 @@ class CacheDefectsSpec extends AsyncFunSuite with Matchers { noise = insertUnrelated(underlying, 1) cache = LoadingCache(intercepted(underlying, noise, attempts.some)) value <- cache.getOrUpdate(0)(1.pure[IO]) - _ <- IO { value shouldEqual 1 } + _ = value shouldEqual 1 attempts <- attempts.get - _ <- IO { attempts shouldEqual 1 } + _ = attempts shouldEqual 1 keys <- cache.keys - _ <- IO { keys shouldEqual Set(0, 1) } - } yield {} + _ = keys shouldEqual Set(0, 1) + } yield () io.run() } @@ -129,56 +166,62 @@ class CacheDefectsSpec extends AsyncFunSuite with Matchers { cache = LoadingCache(intercepted(underlying, IO.unit, attempts.some)) _ <- (0 until 10000).toList.parTraverse { key => cache.getOrUpdate(key)(key.pure[IO]) } size <- cache.size - _ <- IO { size shouldEqual 10000 } + _ = size shouldEqual 10000 attempts <- attempts.get - _ <- IO { attempts shouldEqual 10000 } - } yield {} + _ = attempts shouldEqual 10000 + } yield () io.run(timeout = 30.seconds) } test("evicting a stuck Loading entry unblocks fibers waiting on it") { - val config = ExpiringCache.Config[IO, Int, Int](expireAfterRead = 100.millis) + val config = ExpiringCache.Config[IO, Int, Int]( + expireAfterRead = 1.minute, + loadingTimeout = 100.millis.some, + ) val io = ExpiringCache.of[IO, Int, Int](config).use { cache => for { started <- Deferred[IO, Unit] - gate <- Deferred[IO, Int] - loader <- cache.getOrUpdate(0) { started.complete(()) *> gate.get }.start + gate <- Deferred[IO, Unit] + loader <- cache.getOrUpdate(0) { started.complete(()) *> gate.get.as(1) }.start _ <- started.get waiter <- cache.getOrUpdate(0)(99.pure[IO]).attempt.start result <- { for { outcome <- waiter.joinWithNever.timeout(2.seconds) - _ <- IO { outcome should matchPattern { case Left(ExpiredError) => } } - } yield {} - }.guarantee { gate.complete(42) *> loader.join.void } + _ = outcome should matchPattern { case Left(ExpiredError) => } + } yield () + }.guarantee { gate.complete(()) *> loader.join.void } } yield result } io.run() } test("a new load generation does not inherit the previous generation's stuck-timer") { - val config = ExpiringCache.Config[IO, Int, Int](expireAfterRead = 200.millis) + val config = ExpiringCache.Config[IO, Int, Int]( + expireAfterRead = 1.minute, + loadingTimeout = 200.millis.some, + ) val io = ExpiringCache.of[IO, Int, Int](config).use { cache => for { started1 <- Deferred[IO, Unit] - gate1 <- Deferred[IO, Int] - loader1 <- cache.getOrUpdate(0) { started1.complete(()) *> gate1.get }.start + gate1 <- Deferred[IO, Unit] + loader1 <- cache.getOrUpdate(0) { started1.complete(()) *> gate1.get.as(1) }.start _ <- started1.get _ <- IO.sleep(150.millis) - _ <- gate1.complete(1) + _ <- gate1.complete(()) _ <- loader1.join _ <- cache.remove(0).flatten started2 <- Deferred[IO, Unit] - gate2 <- Deferred[IO, Int] - loader2 <- cache.getOrUpdate(0) { started2.complete(()) *> gate2.get }.start + gate2 <- Deferred[IO, Unit] + loader2 <- cache.getOrUpdate(0) { started2.complete(()) *> gate2.get.as(2) }.start _ <- started2.get result <- { for { _ <- IO.sleep(150.millis) present <- cache.contains(0) - _ <- IO { present shouldEqual true } - } yield {} - }.guarantee { gate2.complete(2) *> loader2.join.void } + _ = present shouldEqual true + } yield () + }.guarantee { gate2.complete(()) *> loader2.join.void } } yield result } io.run() @@ -194,12 +237,17 @@ class CacheDefectsSpec extends AsyncFunSuite with Matchers { fiber <- cache.getOrUpdate1(0) { balance.update { _ + 1 }.as((i, i, balance.update { _ - 1 }.some)) }.start _ <- fiber.cancel.start _ <- fiber.join - _ <- cache.getOrUpdate(0)((-1).pure[IO]).timeout(1.second) + // The key must be usable right away, holding either the value of the load that made it + // in before the cancellation, or the one we compute here. + value <- cache.getOrUpdate(0)((-1).pure[IO]).timeout(1.second) + _ = value should (equal(i) or equal(-1)) _ <- cache.remove(0).flatten - } yield {} + } yield () } + // Releases of values nobody asked about are started in the background, so the balance is + // settled shortly after the last removal rather than at the moment of it. _ <- (IO.sleep(10.millis) *> balance.get).iterateUntil { _ == 0 }.timeout(3.seconds) - } yield {} + } yield () io.run(timeout = 60.seconds) } @@ -209,14 +257,19 @@ class CacheDefectsSpec extends AsyncFunSuite with Matchers { LoadingCache.EntryState.Value(LoadingCache.Entry(key, none)), ) _ <- underlying.ref(key).set(entryRef.some) - } yield {} + } yield () } /** - * EntryMap that runs `noise` before every entry transition going through the cache (simulating a - * concurrent writer of other keys) and counts those transitions, so the tests can assert that - * writes to unrelated keys neither invalidate the transition nor force retries. `noise` writes - * through `underlying` directly and is not counted. + * `underlying` with every per-key `Ref` wrapped, so that each attempt of the cache to modify the + * mapping first runs `noise`, a write of some other key, and then is counted in `attempts`. + * + * That gives the deterministic version of what the `claim 4` test does with background fibers: an + * unrelated write is guaranteed to land between reading and writing the mapping, i.e. exactly + * where the shared `Ref[F, Map[K, EntryRef]]` used to lose its CAS. With a per-key `Ref` the + * attempt still succeeds, so the count stays at one attempt per insert. + * + * `noise` writes through `underlying` directly and is not counted. */ private def intercepted( underlying: EntryMap[IO, Int, Int], diff --git a/scache/src/test/scala/com/evolution/scache/CacheLoadTest.scala b/scache/src/test/scala/com/evolution/scache/CacheLoadTest.scala deleted file mode 100644 index 6137354..0000000 --- a/scache/src/test/scala/com/evolution/scache/CacheLoadTest.scala +++ /dev/null @@ -1,99 +0,0 @@ -package com.evolution.scache - -import cats.effect.{IO, IOApp, Resource} -import cats.syntax.all.* - -import scala.concurrent.duration.* - -/** - * Load test comparing cache flavors under contention. Not executed as part of the test suite, run - * it with: - * {{{ - * sbt "scache/Test/runMain com.evolution.scache.CacheLoadTest" - * }}} - */ -object CacheLoadTest extends IOApp.Simple { - - private val fibers = Runtime.getRuntime.availableProcessors - private val opsPerFiber = 100000 - private val keySpace = 10000 - - val run: IO[Unit] = { - val caches = List( - ("LoadingCache (single partition)", LoadingCache.of[IO, Int, Int]), - ("Cache.loading (partitioned)", Cache.loading[IO, Int, Int]), - ( - "Cache.expiring (partitioned)", - Cache.expiring[IO, Int, Int](ExpiringCache.Config[IO, Int, Int](expireAfterRead = 1.minute)), - ), - ) - for { - _ <- IO.println(f"fibers=$fibers, ops/fiber=$opsPerFiber, keySpace=$keySpace") - _ <- caches.traverse_ { case (name, cache) => - IO.println(s"--- $name") *> scenarios(cache) - } - } yield {} - } - - private def scenarios(cache: Resource[IO, Cache[IO, Int, Int]]): IO[Unit] = { - cache.use { cache => - for { - _ <- measure("getOrUpdate, insert distinct keys") { - parRun { (fiber, i) => cache.getOrUpdate(fiber * opsPerFiber + i)(i.pure[IO]).void } - } - _ <- cache.clear.flatten - _ <- (0 until keySpace).toList.traverse_ { key => cache.put(key, key).flatten } - _ <- measure("getOrUpdate, hit random keys") { - parRun { (fiber, i) => - val key = scramble(fiber * opsPerFiber + i) % keySpace - cache.getOrUpdate(key)(key.pure[IO]).void - } - } - _ <- measure("getOrUpdate, hit single hot key") { - parRun { (_, _) => cache.getOrUpdate(0)(0.pure[IO]).void } - } - _ <- measure("put, replace random keys") { - parRun { (fiber, i) => - val key = scramble(fiber * opsPerFiber + i) % keySpace - cache.put(key, i).flatten.void - } - } - _ <- measure("mixed get/put/remove, random keys") { - parRun { (fiber, i) => - val n = scramble(fiber * opsPerFiber + i) - val key = n % keySpace - (n / keySpace) % 10 match { - case 0 => cache.put(key, i).flatten.void - case 1 => cache.remove(key).flatten.void - case _ => cache.getOrUpdate(key)(i.pure[IO]).void - } - } - } - } yield {} - } - } - - private def parRun(op: (Int, Int) => IO[Unit]): IO[Unit] = { - (0 until fibers) - .toList - .parTraverse_ { fiber => - (0 until opsPerFiber).toList.traverse_ { i => op(fiber, i) } - } - } - - private def measure(name: String)(io: IO[Unit]): IO[Unit] = { - for { - start <- IO.monotonic - _ <- io - end <- IO.monotonic - millis = (end - start).toMillis.max(1) - opsPerSec = fibers.toLong * opsPerFiber * 1000 / millis - _ <- IO.println(f"$name%-42s ${ millis }%6d ms $opsPerSec%,12d ops/s") - } yield {} - } - - private def scramble(i: Int): Int = { - val h = i * 0x9e3775cd - (h ^ (h >>> 16)) & Int.MaxValue - } -} From 559c93b41b684cbd8e4a578a3dda690f8984c69f Mon Sep 17 00:00:00 2001 From: Stas Shevchenko Date: Thu, 6 Aug 2026 23:53:28 +0200 Subject: [PATCH 4/8] Drop vendored old cache from benchmark, document results --- README.md | 45 + .../scache/bench/CacheBenchmark.scala | 36 +- .../com/evolution/scache/v1/CacheV1.scala | 64 - .../evolution/scache/v1/ExpiringCache.scala | 451 ------- .../evolution/scache/v1/LoadingCache.scala | 1066 ----------------- build.sbt | 3 - 6 files changed, 60 insertions(+), 1605 deletions(-) delete mode 100644 benchmark/src/main/scala/com/evolution/scache/v1/CacheV1.scala delete mode 100644 benchmark/src/main/scala/com/evolution/scache/v1/ExpiringCache.scala delete mode 100644 benchmark/src/main/scala/com/evolution/scache/v1/LoadingCache.scala diff --git a/README.md b/README.md index b44faf9..9ca0924 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,51 @@ libraryDependencies += "com.evolution" %% "scache" % " v1.LoadingCache.of(v1.LoadingCache.EntryRefs.empty[IO, Int, Int]) - case ("v1", "partitioned") => CacheV1.loading[IO, Int, Int]() - case ("v1", "expiring") => CacheV1.expiring[IO, Int, Int](v1.ExpiringCache.Config[IO, Int, Int](expireAfterRead)) - case ("v2", "single") => LoadingCache.of[IO, Int, Int] - case ("v2", "partitioned") => Cache.loading[IO, Int, Int] - case ("v2", "expiring") => Cache.expiring[IO, Int, Int](ExpiringCache.Config[IO, Int, Int](expireAfterRead)) - case (impl, flavor) => sys.error(s"unknown impl=$impl flavor=$flavor") + flavor match { + case "single" => LoadingCache.of[IO, Int, Int] + case "partitioned" => Cache.loading[IO, Int, Int] + case "expiring" => Cache.expiring[IO, Int, Int](ExpiringCache.Config[IO, Int, Int](expireAfterRead)) + case flavor => sys.error(s"unknown flavor=$flavor") } } @@ -127,8 +121,8 @@ class PopulatedCacheState extends CacheState { @BenchmarkMode(Array(Mode.Throughput)) @OutputTimeUnit(TimeUnit.SECONDS) @OperationsPerInvocation(160000) -@Warmup(iterations = 3, time = 3, timeUnit = TimeUnit.SECONDS) -@Measurement(iterations = 5, time = 3, timeUnit = TimeUnit.SECONDS) +@Warmup(iterations = 1, time = 3, timeUnit = TimeUnit.SECONDS) +@Measurement(iterations = 5, time = 2, timeUnit = TimeUnit.SECONDS) @Fork(1) @Threads(1) class CacheBenchmark { diff --git a/benchmark/src/main/scala/com/evolution/scache/v1/CacheV1.scala b/benchmark/src/main/scala/com/evolution/scache/v1/CacheV1.scala deleted file mode 100644 index cbbcf20..0000000 --- a/benchmark/src/main/scala/com/evolution/scache/v1/CacheV1.scala +++ /dev/null @@ -1,64 +0,0 @@ -package com.evolution.scache.v1 - -import cats.effect.syntax.all.* -import cats.effect.{Concurrent, Resource, Temporal} -import cats.syntax.all.* -import cats.{Hash, Parallel} -import com.evolution.scache.{Cache, NrOfPartitions, Partitions} -import com.evolutiongaming.catshelper.CatsHelper.* -import com.evolutiongaming.catshelper.Runtime - -/** - * Constructors of the frozen pre-`MapRef` implementation, mirroring `Cache.loading` and - * `Cache.expiring` as they were before the rewrite, so that the benchmarks can build the old and - * the new cache the same way. - */ -object CacheV1 { - - def loading[F[_]: Concurrent: Parallel: Runtime, K, V]( - partitions: Option[Int] = None, - ): Resource[F, Cache[F, K, V]] = { - - implicit val hash: Hash[K] = Hash.fromUniversalHashCode[K] - - val result = for { - nrOfPartitions <- partitions - .map { _.pure[F] } - .getOrElse { NrOfPartitions[F]() } - .toResource - cache = LoadingCache.of(LoadingCache.EntryRefs.empty[F, K, V]) - partitions <- Partitions.of[Resource[F, _], K, Cache[F, K, V]](nrOfPartitions, _ => cache) - } yield { - Cache.fromPartitions(partitions) - } - result.breakFlatMapChain - } - - def expiring[F[_]: Temporal: Runtime: Parallel, K, V]( - config: ExpiringCache.Config[F, K, V], - partitions: Option[Int] = None, - ): Resource[F, Cache[F, K, V]] = { - - implicit val hash: Hash[K] = Hash.fromUniversalHashCode[K] - - val result = for { - nrOfPartitions <- partitions - .map { _.pure[F] } - .getOrElse { NrOfPartitions[F]() } - .toResource - config1 = config - .maxSize - .fold { - config - } { maxSize => - config.copy(maxSize = (maxSize * 1.1 / nrOfPartitions).toInt.some) - } - cache = ExpiringCache.of[F, K, V](config1) - partitions <- Partitions.of[Resource[F, _], K, Cache[F, K, V]](nrOfPartitions, _ => cache) - } yield { - Cache.fromPartitions(partitions) - } - - result.breakFlatMapChain - } -} diff --git a/benchmark/src/main/scala/com/evolution/scache/v1/ExpiringCache.scala b/benchmark/src/main/scala/com/evolution/scache/v1/ExpiringCache.scala deleted file mode 100644 index 30508c3..0000000 --- a/benchmark/src/main/scala/com/evolution/scache/v1/ExpiringCache.scala +++ /dev/null @@ -1,451 +0,0 @@ -package com.evolution.scache.v1 - -import com.evolution.scache.Cache - -import cats.effect.syntax.all.* -import cats.effect.{Clock, Ref, Resource, Temporal} -import cats.kernel.CommutativeMonoid -import cats.syntax.all.* -import cats.{Applicative, Monad, MonadThrow, Monoid} -import com.evolution.scache.Cache.Directive -import com.evolution.scache.v1.LoadingCache.EntryState -import com.evolutiongaming.catshelper.ClockHelper.* -import com.evolutiongaming.catshelper.Schedule - -import scala.concurrent.duration.* - -/** - * Frozen copy of `ExpiringCache` as it was before the cache was rebuilt on `MapRef`, running over - * [[LoadingCache]] of this package. Kept here only so the benchmarks can measure the old and the - * new implementation in one run, do not change it. - */ -object ExpiringCache { - - type Timestamp = Long - - private[scache] def of[F[_], K, V]( - config: Config[F, K, V], - )(implicit - G: Temporal[F], - ): Resource[F, Cache[F, K, V]] = { - - type E = Entry[V] - - val cooldown = math.max(config.expireAfterRead.toMillis / 5, 10L) - val expireAfterReadMs = config.expireAfterRead.toMillis + cooldown / 2 - val expireAfterWriteMs = config.expireAfterWrite.map { _.toMillis } - val expireInterval = { - val expireInterval = expireAfterWriteMs.fold(expireAfterReadMs) { _ min expireAfterReadMs } - (expireInterval / 10).millis - } - - def removeExpiredAndCheckSize(ref: Ref[F, LoadingCache.EntryRefs[F, K, E]], cache: Cache[F, K, E]) = { - - def remove(key: K) = { - cache - .remove(key) - .flatten - .void - } - - def removeExpired(key: K, entryRef: LoadingCache.EntryRef[F, Entry[V]]) = { - entryRef - .get - .flatMap { - case state: EntryState.Value[F, Entry[V]] => - for { - now <- Clock[F].millis - expiredAfterRead = expireAfterReadMs + state.entry.value.touched < now - expiredAfterWrite = () => expireAfterWriteMs.exists { _ + state.entry.value.created < now } - expired = expiredAfterRead || expiredAfterWrite() - result <- if (expired) remove(key) else ().pure[F] - } yield result - case _: EntryState.Loading[F, Entry[V]] => ().pure[F] - case EntryState.Removed => ().pure[F] - } - } - - def notExceedMaxSize(maxSize: Int) = { - - def drop(entryRefs: LoadingCache.EntryRefs[F, K, E]) = { - - final case class Elem(key: K, timestamp: Timestamp) - - val zero = List.empty[Elem] - entryRefs - .foldLeft(zero.pure[F]) { case (result, (key, entryRef)) => - result.flatMap { result => - entryRef - .get - .map { - case state: EntryState.Value[F, Entry[V]] => Elem(key, state.entry.value.touched) :: result - case _: EntryState.Loading[F, Entry[V]] => result - case EntryState.Removed => result - } - } - } - .flatMap { entries => - entries - .sortBy(_.timestamp) - .take(maxSize / 10) - .foldMapM { elem => remove(elem.key) } - } - } - - for { - entryRefs <- ref.get - result <- if (entryRefs.size > maxSize) drop(entryRefs) else ().pure[F] - } yield result - } - - for { - entryRefs <- ref.get - result <- entryRefs.foldMapM { case (key, entryRef) => removeExpired(key, entryRef) } - _ <- config - .maxSize - .foldMapM { maxSize => notExceedMaxSize(maxSize) } - } yield result - } - - def refreshEntries( - refresh: Refresh[K, F[Option[V]]], - ref: Ref[F, LoadingCache.EntryRefs[F, K, E]], - cache: Cache[F, K, E], - ) = { - ref - .get - .flatMap { entryRefs => - entryRefs.foldMapM { case (key, entryRef) => - entryRef - .get - .flatMap { - case _: EntryState.Value[F, Entry[V]] => - refresh - .value(key) - .flatMap { - case Some(value) => entryRef.update1 { _.copy(value = value) } - case None => cache.remove(key).void - } - .handleError { _ => () } - case _: EntryState.Loading[F, Entry[V]] => ().pure[F] - case EntryState.Removed => ().pure[F] - } - } - } - } - - def schedule(interval: FiniteDuration)(fa: F[Unit]) = Schedule(interval, interval)(fa) - - val entryRefs = LoadingCache.EntryRefs.empty[F, K, E] - for { - ref <- Ref[F].of(entryRefs).toResource - cache <- LoadingCache.of(ref) - _ <- schedule(expireInterval) { removeExpiredAndCheckSize(ref, cache) } - _ <- config - .refresh - .foldMapM { refresh => - schedule(refresh.interval) { refreshEntries(refresh, ref, cache) } - } - } yield { - apply(ref, cache, cooldown) - } - } - - def apply[F[_]: MonadThrow: Clock, K, V]( - ref: Ref[F, LoadingCache.EntryRefs[F, K, Entry[V]]], - cache: Cache[F, K, Entry[V]], - cooldown: Long, - ): Cache[F, K, V] = { - - type E = Entry[V] - - def entryOf(value: V) = { - Clock[F] - .millis - .map { timestamp => - Entry(value, created = timestamp, read = none) - } - } - - implicit def monoidUnit: Monoid[F[Unit]] = Applicative.monoid[F, Unit] - - def touch(key: K, entry: E) = { - for { - now <- Clock[F].millis - result <- if ((entry.touched + cooldown) <= now) { - ref - .get - .flatMap { entries => - entries - .get(key) - .foldMap { _.update1 { _.touch(now) } } - } - } else { - ().pure[F] - } - } yield result - } - - abstract class ExpiringCache extends Cache.Abstract1[F, K, V] - - new ExpiringCache { self => - def get(key: K) = { - cache - .get1(key) - .flatMap { - case Some(Right(entry)) => - touch(key, entry).as { - entry - .value - .some - } - case Some(Left(entry)) => - entry - .map { _.value.some } - .handleError { _ => none[V] } - case None => - none[V].pure[F] - } - } - - def get1(key: K) = { - cache - .get1(key) - .flatMap { - case Some(Right(entry)) => - touch(key, entry).as { - entry - .value - .asRight[F[V]] - .some - } - case Some(Left(entry)) => - entry - .map { _.value } - .asLeft[V] - .some - .pure[F] - case None => - none[Either[F[V], V]].pure[F] - } - } - - def getOrUpdate(key: K)(value: => F[V]) = { - getOrUpdate1(key) { value.map { a => (a, a, none[Release]) } } - .flatMap { - case Right(Right(a)) => a.pure[F] - case Right(Left(a)) => a - case Left(a) => a.pure[F] - } - } - - def getOrUpdate1[A](key: K)(value: => F[(A, V, Option[Release])]) = { - cache - .getOrUpdate1(key) { - value.flatMap { case (a, value, release) => - entryOf(value).map { value => (a, value, release) } - } - } - .flatMap { - case Right(Right(entry)) => - touch(key, entry).as { - entry - .value - .asRight[F[V]] - .asRight[A] - } - case Right(Left(entry)) => - entry - .map { _.value } - .asLeft[V] - .asRight[A] - .pure[F] - - case Left(a) => - a - .asLeft[Either[F[V], V]] - .pure[F] - } - } - - def put(key: K, value: V, release: Option[Release]) = { - entryOf(value) - .flatMap { entry => - cache - .put(key, entry, release) - .map { _.map { _.map { _.value } } } - } - } - - // Modifying existing entry creates a new one, since the old one will be released. - def modify[A](key: K)(f: Option[V] => (A, Directive[F, V])): F[(A, Option[F[Unit]])] = - Clock[F] - .millis - .flatMap { timestamp => - val adaptedF: Option[Entry[V]] => (A, Directive[F, Entry[V]]) = entry => - f(entry.map(_.value)) match { - case (a, put: Directive.Put[F, V]) => - (a, Directive.Put(Entry(put.value, timestamp, none), put.release)) - case (a, Directive.Ignore) => (a, Directive.Ignore) - case (a, Directive.Remove) => (a, Directive.Remove) - } - cache.modify(key)(adaptedF) - } - - def contains(key: K) = cache.contains(key) - - def size = cache.size - - def keys = cache.keys - - def values = { - cache - .values - .map { values => - values.map { case (key, entry) => - (key, entry.map { _.value }) - } - } - } - - def values1 = { - cache - .values1 - .map { entries => - entries.map { case (key, entry) => - val value = entry match { - case Right(a) => a.value.asRight[F[V]] - case Left(a) => a.map { _.value }.asLeft[V] - } - (key, value) - } - } - } - - def remove(key: K) = { - cache - .remove(key) - .map { _.map { _.map { _.value } } } - } - - def clear = cache.clear - - def foldMap[A: CommutativeMonoid](f: (K, Either[F[V], V]) => F[A]) = { - cache.foldMap { - case (k, Right(v)) => f(k, v.value.asRight) - case (k, Left(v)) => f(k, v.map { _.value }.asLeft) - } - } - - def foldMapPar[A: CommutativeMonoid](f: (K, Either[F[V], V]) => F[A]) = { - cache.foldMap { - case (k, Right(v)) => f(k, v.value.asRight) - case (k, Left(v)) => f(k, v.map { _.value }.asLeft) - } - } - } - } - - final case class Entry[A](value: A, created: Timestamp, read: Option[Timestamp]) { self => - - def touch(timestamp: Timestamp): Entry[A] = { - if (self.read.forall { timestamp > _ }) copy(read = timestamp.some) - else self - } - - def touched: Timestamp = read.getOrElse(created) - } - - /** - * Configuration of a refresh background job. - * - * Usage example (`SettingService.get` returns `F[Option[Setting]]`): - * {{{ - * ExpiringCache.Refresh( - * interval = 1.minute, - * value = key => SettingService.getOrNone(key) - * ) - * }}} - * - * @param interval - * How often the refresh routine should be called. Note, that all cache entries will be - * refreshed regardless how long ago these were added to the cache, hence the operation might be - * expensive. - * @param value - * The function which returns a value for the specific key. While the function itself is pure, - * all the current implementation use `Refresh[K, F[Option[T]]]`, so `V` is not a real value, - * but an effectful function which calculates a value. The [[scala.Option]] is used to indicate - * if value should be removed (i.e. [[scala.None]] means the key is to be deleted). - */ - final case class Refresh[-K, +V](interval: FiniteDuration, value: K => V) - - object Refresh { - def apply[K](interval: FiniteDuration): Apply[K] = new Apply(interval) - - private[Refresh] final class Apply[K](val interval: FiniteDuration) extends AnyVal { - - def apply[V](f: K => V): Refresh[K, V] = Refresh(interval, f) - } - } - - /** - * Configuration of expiring cache, including the potential refresh routine. - * - * Performance consideration: The frequency of internal expiration routine depends on - * `expireAfterRead` and `expireAfterWrite` parameters (it is actually done more often, for sake - * of faster cleanup), so the very small value set for any of these parameters may affect the - * performance of the cache, as cleanup will happen too often. - * - * Usage example (`SettingService.get` returns `F[Option[Setting]]`): - * {{{ - * ExpiringCache.Config( - * expireAfterRead = 1.minute, - * expireAfterWrite = None, - * maxSize = None, - * refresh = Some(ExpiringCache.Refresh( - * interval = 1.minute, - * value = key => SettingService.get(key) - * )) - * }}} - * - * @param expireAfterRead - * The value will be removed after the period set by this parameter if it was not read (i.e. one - * of methods reading the value such as [[Cache#get]] or [[Cache#getOrUpdate]] method was not - * called). Note, that this removal has a best effort guarantee, i.e. there is possibility that - * value is still there after it expires. - * @param expireAfterWrite - * If set to [[scala.Some]], the value will be removed after the period set by this parameter - * regardless if it was touched by [[Cache#get]] or similar methods. Note, that this removal has - * a best effort guarantee, i.e. there is possibility that value is still there after it - * expires. - * @param maxSize - * If set then the cache implementation will try to keep the cache size under `maxSize` whenever - * clean up routine happens. If the cache size exceeds the value, it will try to drop part of - * non-expired element sorted by the timestamp, when these elements were last read. There is no - * guarantee, though, that this size will not be exceeded a bit, if a lot of elements are put - * into cache between the cleanup calls. - * @param refresh - * If set to [[scala.Some]], the cache will schedule a background job, which will refresh or - * remove the _existing_ values regularly. The keys not already present in a cache will not be - * affected anyhow. See [[Refresh]] documentation for more details. - */ - final case class Config[F[_], -K, V]( - expireAfterRead: FiniteDuration, - expireAfterWrite: Option[FiniteDuration] = None, - maxSize: Option[Int] = None, - refresh: Option[Refresh[K, F[Option[V]]]] = None, - ) - - private implicit class MapOps[K, V](val self: Map[K, V]) extends AnyVal { - def foldMapM[F[_]: Monad, A: Monoid](f: (K, V) => F[A]): F[A] = { - self.foldLeft(Monoid[A].empty.pure[F]) { case (a, (k, v)) => - for { - a <- a - b <- f(k, v) - } yield { - a.combine(b) - } - } - } - } -} diff --git a/benchmark/src/main/scala/com/evolution/scache/v1/LoadingCache.scala b/benchmark/src/main/scala/com/evolution/scache/v1/LoadingCache.scala deleted file mode 100644 index 9dc06e9..0000000 --- a/benchmark/src/main/scala/com/evolution/scache/v1/LoadingCache.scala +++ /dev/null @@ -1,1066 +0,0 @@ -package com.evolution.scache.v1 - -import com.evolution.scache.Cache - -import cats.effect.implicits.* -import cats.effect.{Concurrent, Deferred, Fiber, GenConcurrent, Outcome, Ref, Resource} -import cats.kernel.CommutativeMonoid -import cats.syntax.all.* -import cats.{Applicative, Functor, Monad, MonadThrow, Parallel} -import com.evolution.scache.Cache.Directive -import com.evolutiongaming.catshelper.ParallelHelper.* - -/** - * Frozen copy of `LoadingCache` as it was before the cache was rebuilt on `MapRef`: the whole map - * lives in a single `Ref[F, Map[K, EntryRef]]`, so every insertion or removal of any key CASes it, - * with `MaxRetries` as the safety net. Kept here only so the benchmarks can measure the old and the - * new implementation in one run, do not change it. - */ -private[scache] object LoadingCache { - - /** - * Maximum number of CAS retry attempts before giving up. This is a safety net against infinite - * spinning under extreme contention. - */ - /** - * Maximum number of CAS retry attempts on the outer map before giving up. Inner entry-level CAS - * loops are unbounded as they always make progress. - */ - private val MaxRetries: Int = 10000 - - def of[F[_]: Concurrent, K, V]( - map: EntryRefs[F, K, V], - ): Resource[F, Cache[F, K, V]] = { - for { - ref <- Ref[F].of(map).toResource - cache <- of(ref) - } yield cache - } - - def of[F[_]: Concurrent, K, V]( - ref: Ref[F, EntryRefs[F, K, V]], - ): Resource[F, Cache[F, K, V]] = { - Resource.make { - apply(ref).pure[F] - } { cache => - cache.clear.flatten - } - } - - def apply[F[_]: Concurrent, K, V]( - ref: Ref[F, EntryRefs[F, K, V]], - ): Cache[F, K, V] = { - - val handleReleaseError = (e: Throwable) => { - System.err.println(s"scache: failed to release cache entry: $e") - } - - def checkRetries(counter: Int): F[Unit] = { - if (counter >= MaxRetries) { - new IllegalStateException( - s"Cache CAS retry limit ($MaxRetries) exceeded. This indicates extreme contention.", - ).raiseError[F, Unit] - } else { - ().pure[F] - } - } - - def entryOf(value: V, release: Option[F[Unit]]) = { - Entry( - value = value, - release = release.map { _.handleError(handleReleaseError) }, - ) - } - - abstract class LoadingCache extends Cache.Abstract1[F, K, V] - - new LoadingCache { - - def get(key: K) = { - ref - .get - .flatMap { entryRefs => - entryRefs - .get(key) - .fold { - none[V].pure[F] - } { entry => - entry - .get - .flatMap { - case state: EntryState.Value[F, V] => - state - .entry - .value - .some - .pure[F] - case state: EntryState.Loading[F, V] => - state - .deferred - .get - .map { entry => - entry - .toOption - .map { _.value } - } - case EntryState.Removed => - none[V].pure[F] - } - } - } - } - - def get1(key: K) = { - ref - .get - .flatMap { entryRefs => - entryRefs - .get(key) - .flatTraverse { _.optEither } - } - } - - def getOrUpdate(key: K)(value: => F[V]) = { - getOrUpdate1(key) { value.map { a => (a, a, none[Release]) } }.flatMap { - case Right(Right(a)) => a.pure[F] - case Right(Left(a)) => a - case Left(a) => a.pure[F] - } - } - - def getOrUpdate1[A](key: K)(value: => F[(A, V, Option[Release])]): F[Either[A, Either[F[V], V]]] = { - 0.tailRecM { counter => - checkRetries(counter) *> - ref - .access - .flatMap { case (entryRefs, set) => - entryRefs - .get(key) - .fold { - for { - deferred <- Deferred[F, Either[Throwable, Entry[F, V]]] - entryRef <- Ref[F].of[EntryState[F, V]](EntryState.Loading(deferred)) - result <- set(entryRefs.updated(key, entryRef)) - .flatMap { - case true => - value - .map { case (a, value, release) => - val entry = entryOf(value, release) - (a, entry) - } - .attempt - .race1 { deferred.get } - .flatMap { - // `value` got computed, and deferred was not (yet) completed by any other fiber in `put` - case Left(Right((a, entry))) => - deferred - .complete(entry.asRight) - .flatMap { - // Successfully completed our deferred, - // now trying to place the new value in the entry. - case true => - - def releaseAndReturnValue(state: EntryState.Value[F, V]) - : F[Either[A, Either[F[V], V]]] = - entry - .release1 - .start - .as { - state - .entry - .value - .asRight[F[V]] - .asRight[A] - } - - def releaseAndReturnLoading(state: EntryState.Loading[F, V]) - : F[Either[A, Either[F[V], V]]] = - entry - .release1 - .start - .as { - state - .deferred - .getOrError - .map(_.value) - .asLeft[V] - .asRight[A] - } - - // Try putting computed value in the map, if there is no entry with our key. - // If the map already contains an entry with our key, - // return its value (or value computation). - def tryPutNewValue: F[Either[A, Either[F[V], V]]] = - 0.tailRecM { counter => - ref - .access - .flatMap { case (entryRefs, set) => - entryRefs - .get(key) - .fold { - // No entry present in the map, so we try to add a new one - Ref[F] - .of[EntryState[F, V]](EntryState.Value(entry)) - .flatMap { entryRef => - set(entryRefs.updated(key, entryRef)).map { - case true => - a - .asLeft[Either[F[V], V]] - .asRight[Int] - case false => - (counter + 1) - .asLeft[Either[A, Either[F[V], V]]] - } - } - } { entryRef => - entryRef - .get - .flatMap { - case state: EntryState.Value[F, V] => - releaseAndReturnValue(state).map(_.asRight[Int]) - - case state: EntryState.Loading[F, V] => - releaseAndReturnLoading(state).map(_.asRight[Int]) - - // `Removed` means that this entry won't be present in the map - // next time we look the key up (see `remove` flow), - // so we just retry. - case EntryState.Removed => - (counter + 1) - .asLeft[Either[A, Either[F[V], V]]] - .pure[F] - } - .uncancelable - } - } - } - - entryRef - .access - .flatMap { - // Entry is still in loading state, containing the same deferred we just completed. - // Now we can try to put the computed value in the same entryRef. - case (state: EntryState.Loading[F, V], set) if state.deferred == deferred => - set(EntryState.Value(entry)) - .flatMap { - // Happy path: successfully placed our computed value - case true => - a - .asLeft[Either[F[V], V]] - .pure[F] - // Failed to set our value, meaning the entry was either: - // - Updated: in that case we release our computed value, and return - // the value (or its computation), giving it the priority - // - Removed: in that case we try to put our value back in the map - case false => - entryRef - .get - .flatMap { - case state: EntryState.Value[F, V] => - releaseAndReturnValue(state) - - case state: EntryState.Loading[F, V] => - releaseAndReturnLoading(state) - - case EntryState.Removed => - tryPutNewValue - } - } - - case (state: EntryState.Value[F, V], _) => - releaseAndReturnValue(state) - - case (state: EntryState.Loading[F, V], _) => - releaseAndReturnLoading(state) - - case (EntryState.Removed, _) => - tryPutNewValue - } - - // Deferred got completed by another fiber, so we return what they put there, - // and release the value we just computed. - case false => - entry - .release1 - .start - .productR( - deferred - .getOrError - .map { entry => - entry - .value - .asRight[F[V]] - .asRight[A] - }, - ) - } - - // `value` computation completed with error, - // and deferred was not completed in another fiber in `put`. - case Left(Left(error)) => - deferred - .complete(error.asLeft) - .flatMap { - // Successfully completed our deferred with error, - // now trying to remove the entry from the map, if it is still there. - case true => - 0.tailRecM { counter1 => - ref - .access - .flatMap { case (entryRefs, set) => - entryRefs - .get(key) - .fold { - // Key was removed while we were loading, - // so we are just propagating the error - error.raiseError[F, Either[Int, Either[F[V], V]]] - } { - // The entry we added to the map is still there and unmodified, - // so we can safely remove it and propagate the error - case `entryRef` => - set(entryRefs - key).flatMap { - // Happy path: successfully removed our entry - case true => - error.raiseError[F, Either[Int, Either[F[V], V]]] - // Retrying (different keys could've been modified in the map) - case false => - (counter1 + 1) - .asLeft[Either[F[V], V]] - .pure[F] - } - // Another fiber replaced the `ref` we added to the map, - // so we return their value (computed or ongoing), - // or propagate our error if our entry got removed. - case entryRef => - entryRef - .optEither - .flatMap(_.liftTo[F](error)) - .map(_.asRight[Int]) - } - } - } - - // Someone else completed the deferred before us, so they must've take care of - // updating the `ref`, and we return their result. - case false => - deferred - .getOrError - .map { _.value } - .asLeft[V] - .pure[F] - } - .map { _.asRight[A] } - - // Deferred was completed by `put` in another fiber before `value` computation completed. - // We return their value, and schedule release of our value that is still being computed. - case Right((fiber, entry)) => - fiber - .joinWithNever - .flatMap { - case Right((_, entry)) => entry.release1 - case _ => ().pure[F] - } - .start - .productR { - entry - .liftTo[F] - .map { entry => - entry - .value - .asRight[F[V]] - .asRight[A] - } - } - } - .map { _.asRight[Int] } - - case false => - (counter + 1) - .asLeft[Either[A, Either[F[V], V]]] - .pure[F] - } - .uncancelable - } yield result - } { entryRef => - // Map already contained an entry under our key, so we return that value (or its ongoing computation) - entryRef - .optEither - .map { - case Some(either) => - either - .asRight[A] - .asRight[Int] - // Entry got removed (see `remove` flow), so we retry expecting to get something else with our key. - case None => - (counter + 1) - .asLeft[Either[A, Either[F[V], V]]] - } - } - } - } - } - - def put(key: K, value: V, release: Option[Release]): F[F[Option[V]]] = { - val entry = entryOf(value, release) - 0.tailRecM { counter => - checkRetries(counter) *> - ref - .access - .flatMap { case (entryRefs, set) => - entryRefs - .get(key) - .fold { - // No entry present in the map, so we add a new one - Ref[F] - .of[EntryState[F, V]](EntryState.Value(entry)) - .flatMap { entryRef => - set(entryRefs.updated(key, entryRef)).map { - case true => - none[V] - .pure[F] - .asRight[Int] - case false => - (counter + 1) - .asLeft[F[Option[V]]] - } - } - } { entryRef => - entryRef - .access - .flatMap { - // A computed value is already present in the map, so we are replacing it with our value. - case (state: EntryState.Value[F, V], set) => - set(EntryState.Value(entry)) - .flatMap { - // Successfully replaced the entryRef with our value, - // now we are responsible for releasing the old value. - case true => - state - .entry - .release - .traverse { _.start } - .map { fiber => - fiber - .foldMapM { _.joinWithNever } - .as { state.entry.value.some } - .asRight[Int] - } - // Failed to set the entryRef to our value - // so we just release our value and exit. - case false => - entry - .release - .traverse { _.start } // Start releasing and forget - .as { - none[V] - .pure[F] - .asRight[Int] - } - } - - // The value is still loading, so we first try to complete the deferred with it, - // and then replace it with our value. - case (state: EntryState.Loading[F, V], set) => - state - .deferred - .complete(entry.asRight) - .flatMap { - // We successfully completed the deferred, now trying to set the value. - case true => - set(EntryState.Value(entry)).flatMap { - // We successfully replaced the entry with our value, so we are done. - case true => - none[V] - .pure[F] - .asRight[Int] - .pure[F] - // Another fiber placed their new value before us - // so we just release our value and exit. - case false => - entry - .release - .traverse { _.start } // Start releasing and forget - .as { - none[V] - .pure[F] - .asRight[Int] - } - } - // Someone just completed the deferred we saw - // so we just release our value and exit. - case false => - entry - .release - .traverse { _.start } // Start releasing and forget - .as { - none[V] - .pure[F] - .asRight[Int] - } - } - - // The key was just removed from the map, so just release the value and exit. - case (EntryState.Removed, _) => - entry - .release - .traverse { _.start } // Start releasing and forget - .as { - none[V] - .pure[F] - .asRight[Int] - } - } - .uncancelable - } - } - } - } - - override def modify[A](key: K)(f: Option[V] => (A, Directive[F, V])): F[(A, Option[F[Unit]])] = { - 0.tailRecM { counter => - checkRetries(counter) *> - ref - .access - .flatMap { case (entryRefs, setMap) => - entryRefs - .get(key) - .fold { - f(None) match { - // No entry present in the map, and we want to add a new one - case (a, put: Directive.Put[F, V]) => - Ref[F] - .of[EntryState[F, V]](EntryState.Value(entryOf(put.value, put.release))) - .flatMap { entryRef => - setMap(entryRefs.updated(key, entryRef)).map { - case true => - (a, none[F[Unit]]) - .asRight[Int] - // Failed adding new entry to the map, retrying accessing the map - case false => - (counter + 1) - .asLeft[(A, Option[F[Unit]])] - } - } - // No entry present in the map, and we don't want to have any, so exiting - case (a, Directive.Ignore | Directive.Remove) => - (a, none[F[Unit]]) - .asRight[Int] - .pure[F] - } - } { entryRef => - 0.tailRecM { counter1 => - entryRef - .access - .flatMap { - // A value is already present in the map - case (state: EntryState.Value[F, V], setRef) => - f(state.entry.value.some) match { - case (a, put: Directive.Put[F, V]) => - setRef(EntryState.Value(entryOf(put.value, put.release))) - .flatMap { - // Successfully replaced the entryRef with our value, - // now we are responsible for releasing the old value. - case true => - state - .entry - .release - .traverse { _.start } - .map { release => - (a, release.map(_.joinWithNever)) - .asRight[Int] - .asRight[Int] - } - // Failed updating entryRef, retrying - case false => - (counter1 + 1) - .asLeft[Either[Int, (A, Option[F[Unit]])]] - .pure[F] - } - // Keeping the value intact and exiting - case (a, Directive.Ignore) => - (a, none[F[Unit]]) - .asRight[Int] - .asRight[Int] - .pure[F] - // Removing the value - case (a, Directive.Remove) => - setRef(EntryState.Removed) - .flatMap { - // Successfully set the entryRef to `Removed` state, now removing it from the map. - // Only removing the key if it still contains this entry, otherwise noop. - case true => - ref - .update { entryRefs => - entryRefs.get(key) match { - case Some(`entryRef`) => entryRefs - key - case _ => entryRefs - } - } - .flatMap { _ => - // Releasing the value regardless of the map update result. - state - .entry - .release - .traverse { _.start } - .map { release => - (a, release.map(_.joinWithNever)) - .asRight[Int] - .asRight[Int] - } - } - // Failed updating entryRef, retrying - case false => - (counter1 + 1) - .asLeft[Either[Int, (A, Option[F[Unit]])]] - .pure[F] - } - } - - // Entry in the map is still loading - case (state: EntryState.Loading[F, V], setRef) => - f(None) match { - // Trying to replace it with our value - case (a, put: Directive.Put[F, V]) => - val entry = entryOf(put.value, put.release) - state - .deferred - .complete(entry.asRight) - .flatMap { - // We successfully completed the deferred, now trying to set the value. - case true => - setRef(EntryState.Value(entry)).map { - // We successfully replaced the entry with our value, so we are done. - case true => - (a, none[F[Unit]]) - .asRight[Int] - .asRight[Int] - // Another fiber placed their new value (only Removed should be possible) - // before us so we retry accessing the entry. - case false => - (counter1 + 1) - .asLeft[Either[Int, (A, Option[F[Unit]])]] - } - // Failed to complete the deferred, meaning someone else completed it, and will - // now set the new value in the entryRef. Retrying the lookup. - case false => - (counter1 + 1) - .asLeft[Either[Int, (A, Option[F[Unit]])]] - .pure[F] - } - // Noop decision, exiting - case (a, Directive.Ignore | Directive.Remove) => - (a, none[F[Unit]]) - .asRight[Int] - .asRight[Int] - .pure[F] - } - - // Entry was just removed, it soon will be gone from the map. - case (EntryState.Removed, _) => - f(None) match { - // We want to place the new value; - // Retrying the map lookup, expecting a different result for our key. - case (_, _: Directive.Put[F, V]) => - (counter + 1) - .asLeft[(A, Option[F[Unit]])] - .asRight[Int] - .pure[F] - // Noop decision, exiting - case (a, Directive.Ignore | Directive.Remove) => - (a, none[F[Unit]]) - .asRight[Int] - .asRight[Int] - .pure[F] - } - } - .uncancelable - } - } - } - } - } - - def contains(key: K) = { - ref - .get - .map { _.contains(key) } - } - - def size = { - ref - .get - .map { _.size } - } - - def keys = { - ref - .get - .map { _.keySet } - } - - def values = { - ref - .get - .flatMap { entryRefs => - entryRefs - .foldLeft { - List - .empty[(K, F[V])] - .pure[F] - } { case (values, (key, entryRef)) => - values.flatMap { values => - entryRef - .value - .map { - case Some(value) => (key, value) :: values - case None => values - } - } - } - } - .map { _.toMap } - } - - def values1 = { - ref - .get - .flatMap { entryRefs => - entryRefs - .foldLeft { - List - .empty[(K, Either[F[V], V])] - .pure[F] - } { case (values, (key, entryRef)) => - values.flatMap { values => - entryRef - .optEither - .map { - case Some(value) => (key, value) :: values - case None => values - } - } - } - } - .map { _.toMap } - } - - def remove(key: K): F[F[Option[V]]] = { - 0.tailRecM { counter => - checkRetries(counter) *> - ref - .access - .flatMap { case (entryRefs, set) => - entryRefs - .get(key) - .fold { - none[V] - .pure[F] - .asRight[Int] - .pure[F] - } { entryRef => - set(entryRefs - key) - .flatMap { - case true => - // We just removed the entry for the map, now we need to release it. - // Replacing the value of the ref with `Removed` means that we are getting responsible for the release. - entryRef - .getAndSet(EntryState.Removed) - .flatMap { - // We removed a loaded value, so we are responsible for releasing it. - case state: EntryState.Value[F, V] => - state - .entry - .release1 - .as { state.entry.value.some } - .start - .map { fiber => - fiber - .joinWithNever - .asRight[Int] - } - - // We removed a loading value, and the fiber that will complete it will also - // release that value, so there is nothing for us to return. - case _: EntryState.Loading[F, V] => - none[V] - .pure[F] - .asRight[Int] - .pure[F] - - // We removed an entry that was already being removed by another fiber, so we are done. - case EntryState.Removed => - none[V] - .pure[F] - .asRight[Int] - .pure[F] - } - case false => - (counter + 1) - .asLeft[F[Option[V]]] - .pure[F] - } - .uncancelable - } - } - } - } - - def clear = { - ref - .getAndSet(EntryRefs.empty) - .flatMap { entryRefs => - entryRefs - .parFoldMap1 { case (_, entryRef) => - entryRef - .getOption - .flatMap { _.foldMapM { _.release1 } } - .uncancelable - } - .start - } - .uncancelable - .map { _.joinWithNever } - } - - def foldMap[A: CommutativeMonoid](f: (K, Either[F[V], V]) => F[A]) = { - ref - .get - .flatMap { entryRefs => - val zero = CommutativeMonoid[A] - .empty - .pure[F] - entryRefs.foldLeft(zero) { case (a, (key, entryRef)) => - for { - a <- a - v <- entryRef.optEither - b <- v.fold(CommutativeMonoid[A].empty.pure[F])(v => f(key, v)) - } yield { - CommutativeMonoid[A].combine(a, b) - } - } - } - } - - def foldMapPar[A: CommutativeMonoid](f: (K, Either[F[V], V]) => F[A]) = { - ref - .get - .flatMap { entryRefs => - Parallel[F].sequential { - val zero = Parallel[F] - .applicative - .pure(CommutativeMonoid[A].empty) - entryRefs - .foldLeft(zero) { case (a, (key, entryRef)) => - val b = Parallel[F].parallel { - for { - v <- entryRef.optEither - b <- v.fold(CommutativeMonoid[A].empty.pure[F])(v => f(key, v)) - } yield b - } - Parallel[F] - .applicative - .map2(a, b)(CommutativeMonoid[A].combine) - } - } - } - } - } - } - - final case class Entry[+F[_], +A](value: A, release: Option[F[Unit]]) - - object Entry { - implicit class EntryOps[F[_], A](val self: Entry[F, A]) extends AnyVal { - def release1( - implicit - F: Monad[F], - ): F[Unit] = self.release.foldA - } - } - - sealed trait EntryState[+F[_], +A] - object EntryState { - final case class Loading[F[_], A](deferred: Deferred[F, Either[Throwable, Entry[F, A]]]) extends EntryState[F, A] - final case class Value[F[_], A](entry: Entry[F, A]) extends EntryState[F, A] - case object Removed extends EntryState[Nothing, Nothing] - } - - type DeferredThrow[F[_], A] = Deferred[F, Either[Throwable, A]] - - type EntryRef[F[_], A] = Ref[F, EntryState[F, A]] - - type EntryRefs[F[_], K, V] = Map[K, EntryRef[F, V]] - - object EntryRefs { - def empty[F[_], K, V]: EntryRefs[F, K, V] = Map.empty - } - - implicit class DeferredThrowOps[F[_], A](val self: DeferredThrow[F, A]) extends AnyVal { - def getOrError( - implicit - F: MonadThrow[F], - ): F[A] = { - self - .get - .flatMap { - case Right(a) => a.pure[F] - case Left(a) => a.raiseError[F, A] - } - } - - def getOption( - implicit - F: Functor[F], - ): F[Option[A]] = { - self - .get - .map { _.toOption } - } - } - - implicit class EntryStateOps[F[_], A](val self: EntryState[F, A]) extends AnyVal { - - def getOption( - implicit - F: Applicative[F], - ): F[Option[Entry[F, A]]] = { - self match { - case EntryState.Loading(deferred: Deferred[F, Either[Throwable, Entry[F, A]]]) => deferred.getOption - case EntryState.Value(entry) => entry.some.pure[F] - case EntryState.Removed => none[Entry[F, A]].pure[F] - } - } - - def optEither( - implicit - F: MonadThrow[F], - ): Option[Either[F[A], A]] = - self match { - case EntryState.Value(entry) => - entry - .value - .asRight[F[A]] - .some - case EntryState.Loading(deferred: Deferred[F, Either[Throwable, Entry[F, A]]]) => - deferred - .getOrError - .map(_.value) - .asLeft[A] - .some - case EntryState.Removed => - none[Either[F[A], A]] - } - - } - - implicit class EntryRefOps[F[_], A](val self: EntryRef[F, A]) extends AnyVal { - - def getOption( - implicit - F: Monad[F], - ): F[Option[Entry[F, A]]] = { - self - .get - .flatMap(_.getOption) - } - - def optEither( - implicit - F: MonadThrow[F], - ): F[Option[Either[F[A], A]]] = { - self - .get - .map(_.optEither) - } - - def value( - implicit - F: MonadThrow[F], - ): F[Option[F[A]]] = { - self - .get - .map { - case EntryState.Value(entry) => - entry - .value - .pure[F] - .some - case EntryState.Loading(deferred: Deferred[F, Either[Throwable, Entry[F, A]]]) => - deferred - .getOrError - .map { _.value } - .some - case EntryState.Removed => - none[F[A]] - } - } - - def update1( - f: A => A, - )(implicit - F: Monad[F], - ): F[Unit] = { - 0.tailRecM { counter => - self - .access - .flatMap { - case (EntryState.Value(entry), set) => - val entry1 = entry.copy(value = f(entry.value)) - set(EntryState.Value(entry1)).map { - case true => ().asRight[Int] - case false => (counter + 1).asLeft[Unit] - } - case (_: EntryState.Loading[F, A], _) => - () - .asRight[Int] - .pure[F] - case (EntryState.Removed, _) => - () - .asRight[Int] - .pure[F] - } - } - } - } - - implicit class Ops[F[_], A, E](val fa: F[A]) extends AnyVal { - def race1[B]( - fb: F[B], - )(implicit - F: GenConcurrent[F, E], - ): F[Either[A, (Fiber[F, E, A], B)]] = { - import F.* - uncancelable { poll => - poll(racePair(fa, fb)).flatMap { - case Left((a, fiber)) => - a match { - case Outcome.Succeeded(a) => - fiber - .cancel - .productR { a } - .map { _.asLeft } - case Outcome.Errored(a) => - fiber - .cancel - .productR { raiseError(a) } - case Outcome.Canceled() => - poll(canceled) *> never - } - case Right((fiber, b)) => - b match { - case Outcome.Succeeded(b) => b.map { b => (fiber, b).asRight[A] } - case Outcome.Errored(eb) => raiseError(eb) - case Outcome.Canceled() => - poll(fiber.join) - .onCancel(fiber.cancel) - .flatMap { - case Outcome.Succeeded(a) => a.map { _.asLeft[(Fiber[F, E, A], B)] } - case Outcome.Errored(a) => raiseError(a) - case Outcome.Canceled() => poll(canceled) *> never - } - } - } - } - } - } -} diff --git a/build.sbt b/build.sbt index ab62176..af192f3 100644 --- a/build.sbt +++ b/build.sbt @@ -107,9 +107,6 @@ lazy val benchmark = (project in file("benchmark")) versionPolicyCheck / skip := true, versionPolicyReportDependencyIssues / skip := true, coverageEnabled := false, - // The frozen pre-MapRef copy is not going to be cleaned up, and the benchmarks do use the - // deprecated members it exposes. - scalacOptsFailOnWarn := Some(false), ) .dependsOn(scache) From 1e40b88c57f602ac05b379122ea24945ed42d14b Mon Sep 17 00:00:00 2001 From: Stas Shevchenko Date: Thu, 6 Aug 2026 23:57:12 +0200 Subject: [PATCH 5/8] Document comparing benchmark against another revision --- README.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/README.md b/README.md index 9ca0924..a0c5a96 100644 --- a/README.md +++ b/README.md @@ -184,6 +184,33 @@ Measured on 12 cores, JDK 25, Scala 2.13.18, in millions of operations per secon `foldMap` walks the whole cache, so it is measured per traversal of 10000 entries rather than per key: 1148 ± 51, 1109 ± 44 and 989 ± 46 traversals per second respectively. +### Comparing against another revision + +The module builds against the `scache` sources next to it, so an older revision is measured by +putting the module on top of that revision and running it there. Both runs have to happen on the +same machine, one after the other, or the numbers are not comparable. + +```shell +git worktree add /tmp/scache-old +cp -r benchmark /tmp/scache-old/ +cp build.sbt /tmp/scache-old/build.sbt +cp project/plugins.sbt /tmp/scache-old/project/plugins.sbt + +cd /tmp/scache-old && sbt "benchmark/Jmh/run -rf json -rff /tmp/old.json" +cd - && sbt "benchmark/Jmh/run -rf json -rff /tmp/new.json" +``` + +The `benchmark` project and the JMH plugin come from `build.sbt` and `project/plugins.sbt`, which is +why those two are copied over as well. If the older revision has a different internal API, the +benchmark will not compile there until the affected lines are adjusted. Going back past the `MapRef` +rewrite, for instance, only the `single` flavor needs it: + +```scala +case "single" => LoadingCache.of(LoadingCache.EntryRefs.empty[IO, Int, Int]) +``` + +Finally, `git worktree remove /tmp/scache-old` when done. + ## Migrating to 7.0 The cache state moved from a single `Ref[F, Map[K, EntryRef]]` to a per-key `MapRef` over a From 7cc8e3fc7b416f5d67f898163ae25e72ee85fae3 Mon Sep 17 00:00:00 2001 From: Stas Shevchenko Date: Fri, 7 Aug 2026 00:29:54 +0200 Subject: [PATCH 6/8] Add before and after benchmark results --- README.md | 43 +- benchmark/results/mapref.json | 2110 +++++++++++++++++++++++++ benchmark/results/master-7c9fa9f.json | 2110 +++++++++++++++++++++++++ 3 files changed, 4247 insertions(+), 16 deletions(-) create mode 100644 benchmark/results/mapref.json create mode 100644 benchmark/results/master-7c9fa9f.json diff --git a/README.md b/README.md index a0c5a96..f16c7f3 100644 --- a/README.md +++ b/README.md @@ -164,25 +164,36 @@ The whole suite is kept under ten minutes, which is one warmup and five measurem scenario. That is enough to compare implementations or spot a regression, not to argue about a few percent, and some of the scenarios below are visibly noisy. -Measured on 12 cores, JDK 25, Scala 2.13.18, in millions of operations per second, higher is better: +### Results + +Two runs back to back on the same machine, 12 cores, JDK 25, Scala 2.13.18: the cache as of commit +`7c9fa9f`, where the whole map sat in one `Ref[F, Map[K, EntryRef]]`, and the same cache after it +was rebuilt on `MapRef`. Millions of operations per second, before to after, higher is better. | Scenario | single | partitioned | expiring | |---|---:|---:|---:| -| `getOrUpdate`, insert distinct keys | 2.32 ± 1.38 | 2.40 ± 0.44 | 1.84 ± 0.30 | -| `getOrUpdate`, hit random keys | 12.71 ± 1.43 | 11.37 ± 1.47 | 9.22 ± 3.27 | -| `getOrUpdate`, hit single hot key | 11.65 ± 1.56 | 12.47 ± 1.66 | 10.52 ± 0.47 | -| `get`, hit random keys | 28.21 ± 0.33 | 23.54 ± 1.25 | 13.14 ± 0.91 | -| `get1`, hit random keys | 23.52 ± 0.48 | 20.51 ± 0.99 | 13.18 ± 0.43 | -| `contains`, random keys | 34.11 ± 36.63 | 37.59 ± 2.81 | 34.21 ± 1.04 | -| `put`, insert distinct keys | 10.91 ± 2.85 | 10.31 ± 2.70 | 9.05 ± 1.42 | -| `put`, replace random keys | 9.93 ± 0.68 | 8.76 ± 0.42 | 8.23 ± 0.60 | -| `modify`, insert distinct keys | 11.77 ± 5.37 | 11.42 ± 1.74 | 10.91 ± 2.24 | -| `modify`, update random keys | 11.21 ± 1.42 | 9.26 ± 3.56 | 10.42 ± 2.03 | -| `remove` and `put`, random keys | 4.05 ± 0.11 | 3.87 ± 0.16 | 2.72 ± 2.33 | -| mixed `get`/`getOrUpdate`/`put`/`modify`/`remove` | 7.78 ± 0.10 | 7.20 ± 0.20 | 5.78 ± 0.14 | - -`foldMap` walks the whole cache, so it is measured per traversal of 10000 entries rather than per -key: 1148 ± 51, 1109 ± 44 and 989 ± 46 traversals per second respectively. +| `getOrUpdate`, insert distinct keys | 1.25 to 2.06 (1.65x) | 2.11 to 2.41 (1.14x) | 1.91 to 1.99 (1.04x) | +| `getOrUpdate`, hit random keys | 9.56 to 12.60 (1.32x) | 10.68 to 11.46 (1.07x) | 7.89 to 9.12 (1.16x) | +| `getOrUpdate`, hit single hot key | 10.65 to 13.82 (1.30x) | 12.14 to 12.58 (1.04x) | 9.38 to 10.40 (1.11x) | +| `get`, hit random keys | 21.74 to 26.10 (1.20x) | 21.37 to 23.02 (1.08x) | 12.68 to 13.22 (1.04x) | +| `get1`, hit random keys | 19.47 to 23.66 (1.21x) | 18.78 to 22.14 (1.18x) | 12.59 to 12.92 (1.03x) | +| `contains`, random keys | 26.69 to 31.97 (1.20x) | 23.59 to 33.70 (1.43x) | 23.22 to 33.68 (1.45x) | +| `put`, insert distinct keys | 1.66 to 9.24 (5.56x) | 5.40 to 10.30 (1.91x) | 5.47 to 8.86 (1.62x) | +| `put`, replace random keys | 8.13 to 9.53 (1.17x) | 7.37 to 8.83 (1.20x) | 7.88 to 8.00 (1.01x) | +| `modify`, insert distinct keys | 1.88 to 11.44 (6.09x) | 6.13 to 10.65 (1.74x) | 7.15 to 11.76 (1.65x) | +| `modify`, update random keys | 7.24 to 11.13 (1.54x) | 8.03 to 9.27 (1.15x) | 9.63 to 10.53 (1.09x) | +| `remove` and `put`, random keys | 0.84 to 3.87 (4.59x) | 2.45 to 4.05 (1.65x) | 2.42 to 2.97 (1.23x) | +| mixed `get`/`getOrUpdate`/`put`/`modify`/`remove` | 5.40 to 7.45 (1.38x) | 6.32 to 7.40 (1.17x) | 5.08 to 5.56 (1.09x) | + +The gains are largest exactly where the old implementation had to CAS the shared map, i.e. inserting +and removing keys, and they shrink with partitioning, which is what partitioning was there to work +around in the first place. Reads gain less, and `foldMap`, the one operation that used to get an +atomic snapshot and now walks a `ConcurrentHashMap`, is a few percent slower: 1176 to 1146, 1142 to +1105 and 1009 to 996 traversals of 10000 entries per second. + +Do not read too much into a single digit of these numbers. The suite is short by design, several +scenarios have error margins of tens of percent, and the two runs were taken on a shared machine. +The raw JMH output of both runs, error margins and all, is in `benchmark/results`. ### Comparing against another revision diff --git a/benchmark/results/mapref.json b/benchmark/results/mapref.json new file mode 100644 index 0000000..fa3e94f --- /dev/null +++ b/benchmark/results/mapref.json @@ -0,0 +1,2110 @@ +[ + { + "jmhVersion" : "1.37", + "benchmark" : "com.evolution.scache.bench.CacheBenchmark.containsRandomKeys", + "mode" : "thrpt", + "threads" : 1, + "forks" : 1, + "jvm" : "/usr/lib/jvm/temurin-25-jdk-arm64/bin/java", + "jvmArgs" : [ + ], + "jdkVersion" : "25.0.4", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.4+7-LTS", + "warmupIterations" : 1, + "warmupTime" : "3 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "2 s", + "measurementBatchSize" : 1, + "params" : { + "flavor" : "single" + }, + "primaryMetric" : { + "score" : 3.1969619388670303E7, + "scoreError" : 3.369091011404188E7, + "scoreConfidence" : [ + -1721290.7253715768, + 6.566052950271218E7 + ], + "scorePercentiles" : { + "0.0" : 2.0373560202060994E7, + "50.0" : 3.717077135867445E7, + "90.0" : 3.890113688088267E7, + "95.0" : 3.890113688088267E7, + "99.0" : 3.890113688088267E7, + "99.9" : 3.890113688088267E7, + "99.99" : 3.890113688088267E7, + "99.999" : 3.890113688088267E7, + "99.9999" : 3.890113688088267E7, + "100.0" : 3.890113688088267E7 + }, + "scoreUnit" : "ops/s", + "rawData" : [ + [ + 3.890113688088267E7, + 3.717077135867445E7, + 2.475512513453481E7, + 2.0373560202060994E7, + 3.8647503367198594E7 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.evolution.scache.bench.CacheBenchmark.containsRandomKeys", + "mode" : "thrpt", + "threads" : 1, + "forks" : 1, + "jvm" : "/usr/lib/jvm/temurin-25-jdk-arm64/bin/java", + "jvmArgs" : [ + ], + "jdkVersion" : "25.0.4", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.4+7-LTS", + "warmupIterations" : 1, + "warmupTime" : "3 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "2 s", + "measurementBatchSize" : 1, + "params" : { + "flavor" : "partitioned" + }, + "primaryMetric" : { + "score" : 3.369634244912512E7, + "scoreError" : 1462710.1392786421, + "scoreConfidence" : [ + 3.2233632309846476E7, + 3.515905258840376E7 + ], + "scorePercentiles" : { + "0.0" : 3.316293488205917E7, + "50.0" : 3.3857893332021646E7, + "90.0" : 3.409063406529033E7, + "95.0" : 3.409063406529033E7, + "99.0" : 3.409063406529033E7, + "99.9" : 3.409063406529033E7, + "99.99" : 3.409063406529033E7, + "99.999" : 3.409063406529033E7, + "99.9999" : 3.409063406529033E7, + "100.0" : 3.409063406529033E7 + }, + "scoreUnit" : "ops/s", + "rawData" : [ + [ + 3.316293488205917E7, + 3.392053759598523E7, + 3.3449712370269198E7, + 3.3857893332021646E7, + 3.409063406529033E7 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.evolution.scache.bench.CacheBenchmark.containsRandomKeys", + "mode" : "thrpt", + "threads" : 1, + "forks" : 1, + "jvm" : "/usr/lib/jvm/temurin-25-jdk-arm64/bin/java", + "jvmArgs" : [ + ], + "jdkVersion" : "25.0.4", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.4+7-LTS", + "warmupIterations" : 1, + "warmupTime" : "3 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "2 s", + "measurementBatchSize" : 1, + "params" : { + "flavor" : "expiring" + }, + "primaryMetric" : { + "score" : 3.367826930484824E7, + "scoreError" : 964179.5461959628, + "scoreConfidence" : [ + 3.2714089758652277E7, + 3.46424488510442E7 + ], + "scorePercentiles" : { + "0.0" : 3.3411977775183685E7, + "50.0" : 3.375408739218799E7, + "90.0" : 3.392657087366003E7, + "95.0" : 3.392657087366003E7, + "99.0" : 3.392657087366003E7, + "99.9" : 3.392657087366003E7, + "99.99" : 3.392657087366003E7, + "99.999" : 3.392657087366003E7, + "99.9999" : 3.392657087366003E7, + "100.0" : 3.392657087366003E7 + }, + "scoreUnit" : "ops/s", + "rawData" : [ + [ + 3.3413978863959692E7, + 3.388473161924981E7, + 3.3411977775183685E7, + 3.392657087366003E7, + 3.375408739218799E7 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.evolution.scache.bench.CacheBenchmark.foldMapWholeCache", + "mode" : "thrpt", + "threads" : 1, + "forks" : 1, + "jvm" : "/usr/lib/jvm/temurin-25-jdk-arm64/bin/java", + "jvmArgs" : [ + ], + "jdkVersion" : "25.0.4", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.4+7-LTS", + "warmupIterations" : 1, + "warmupTime" : "3 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "2 s", + "measurementBatchSize" : 1, + "params" : { + "flavor" : "single" + }, + "primaryMetric" : { + "score" : 1146.354938255863, + "scoreError" : 54.39132606319258, + "scoreConfidence" : [ + 1091.9636121926706, + 1200.7462643190556 + ], + "scorePercentiles" : { + "0.0" : 1128.6117569912892, + "50.0" : 1149.4846693874074, + "90.0" : 1165.0450493257158, + "95.0" : 1165.0450493257158, + "99.0" : 1165.0450493257158, + "99.9" : 1165.0450493257158, + "99.99" : 1165.0450493257158, + "99.999" : 1165.0450493257158, + "99.9999" : 1165.0450493257158, + "100.0" : 1165.0450493257158 + }, + "scoreUnit" : "ops/s", + "rawData" : [ + [ + 1128.6117569912892, + 1151.9273154924313, + 1165.0450493257158, + 1149.4846693874074, + 1136.7059000824718 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.evolution.scache.bench.CacheBenchmark.foldMapWholeCache", + "mode" : "thrpt", + "threads" : 1, + "forks" : 1, + "jvm" : "/usr/lib/jvm/temurin-25-jdk-arm64/bin/java", + "jvmArgs" : [ + ], + "jdkVersion" : "25.0.4", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.4+7-LTS", + "warmupIterations" : 1, + "warmupTime" : "3 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "2 s", + "measurementBatchSize" : 1, + "params" : { + "flavor" : "partitioned" + }, + "primaryMetric" : { + "score" : 1104.8148849227277, + "scoreError" : 72.82069539939145, + "scoreConfidence" : [ + 1031.9941895233362, + 1177.6355803221193 + ], + "scorePercentiles" : { + "0.0" : 1087.1582899807274, + "50.0" : 1095.1061264648129, + "90.0" : 1128.0064030101485, + "95.0" : 1128.0064030101485, + "99.0" : 1128.0064030101485, + "99.9" : 1128.0064030101485, + "99.99" : 1128.0064030101485, + "99.999" : 1128.0064030101485, + "99.9999" : 1128.0064030101485, + "100.0" : 1128.0064030101485 + }, + "scoreUnit" : "ops/s", + "rawData" : [ + [ + 1087.1582899807274, + 1128.0064030101485, + 1122.3610599836188, + 1091.442545174331, + 1095.1061264648129 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.evolution.scache.bench.CacheBenchmark.foldMapWholeCache", + "mode" : "thrpt", + "threads" : 1, + "forks" : 1, + "jvm" : "/usr/lib/jvm/temurin-25-jdk-arm64/bin/java", + "jvmArgs" : [ + ], + "jdkVersion" : "25.0.4", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.4+7-LTS", + "warmupIterations" : 1, + "warmupTime" : "3 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "2 s", + "measurementBatchSize" : 1, + "params" : { + "flavor" : "expiring" + }, + "primaryMetric" : { + "score" : 996.2937519226587, + "scoreError" : 55.13808937840677, + "scoreConfidence" : [ + 941.1556625442519, + 1051.4318413010653 + ], + "scorePercentiles" : { + "0.0" : 976.8675998491138, + "50.0" : 1000.1594280106416, + "90.0" : 1010.6124720573836, + "95.0" : 1010.6124720573836, + "99.0" : 1010.6124720573836, + "99.9" : 1010.6124720573836, + "99.99" : 1010.6124720573836, + "99.999" : 1010.6124720573836, + "99.9999" : 1010.6124720573836, + "100.0" : 1010.6124720573836 + }, + "scoreUnit" : "ops/s", + "rawData" : [ + [ + 976.8675998491138, + 986.3780063044616, + 1000.1594280106416, + 1007.4512533916926, + 1010.6124720573836 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.evolution.scache.bench.CacheBenchmark.get1HitRandomKeys", + "mode" : "thrpt", + "threads" : 1, + "forks" : 1, + "jvm" : "/usr/lib/jvm/temurin-25-jdk-arm64/bin/java", + "jvmArgs" : [ + ], + "jdkVersion" : "25.0.4", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.4+7-LTS", + "warmupIterations" : 1, + "warmupTime" : "3 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "2 s", + "measurementBatchSize" : 1, + "params" : { + "flavor" : "single" + }, + "primaryMetric" : { + "score" : 2.3656329352353036E7, + "scoreError" : 240852.02929078127, + "scoreConfidence" : [ + 2.3415477323062256E7, + 2.3897181381643817E7 + ], + "scorePercentiles" : { + "0.0" : 2.357634653523525E7, + "50.0" : 2.3638906703739434E7, + "90.0" : 2.3727581681384802E7, + "95.0" : 2.3727581681384802E7, + "99.0" : 2.3727581681384802E7, + "99.9" : 2.3727581681384802E7, + "99.99" : 2.3727581681384802E7, + "99.999" : 2.3727581681384802E7, + "99.9999" : 2.3727581681384802E7, + "100.0" : 2.3727581681384802E7 + }, + "scoreUnit" : "ops/s", + "rawData" : [ + [ + 2.357634653523525E7, + 2.362739654320742E7, + 2.3727581681384802E7, + 2.3638906703739434E7, + 2.3711415298198283E7 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.evolution.scache.bench.CacheBenchmark.get1HitRandomKeys", + "mode" : "thrpt", + "threads" : 1, + "forks" : 1, + "jvm" : "/usr/lib/jvm/temurin-25-jdk-arm64/bin/java", + "jvmArgs" : [ + ], + "jdkVersion" : "25.0.4", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.4+7-LTS", + "warmupIterations" : 1, + "warmupTime" : "3 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "2 s", + "measurementBatchSize" : 1, + "params" : { + "flavor" : "partitioned" + }, + "primaryMetric" : { + "score" : 2.214371938171286E7, + "scoreError" : 224303.0131105092, + "scoreConfidence" : [ + 2.191941636860235E7, + 2.2368022394823372E7 + ], + "scorePercentiles" : { + "0.0" : 2.2055403163757384E7, + "50.0" : 2.214594285170191E7, + "90.0" : 2.221896973403725E7, + "95.0" : 2.221896973403725E7, + "99.0" : 2.221896973403725E7, + "99.9" : 2.221896973403725E7, + "99.99" : 2.221896973403725E7, + "99.999" : 2.221896973403725E7, + "99.9999" : 2.221896973403725E7, + "100.0" : 2.221896973403725E7 + }, + "scoreUnit" : "ops/s", + "rawData" : [ + [ + 2.2055403163757384E7, + 2.2144317659965023E7, + 2.221896973403725E7, + 2.2153963499102745E7, + 2.214594285170191E7 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.evolution.scache.bench.CacheBenchmark.get1HitRandomKeys", + "mode" : "thrpt", + "threads" : 1, + "forks" : 1, + "jvm" : "/usr/lib/jvm/temurin-25-jdk-arm64/bin/java", + "jvmArgs" : [ + ], + "jdkVersion" : "25.0.4", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.4+7-LTS", + "warmupIterations" : 1, + "warmupTime" : "3 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "2 s", + "measurementBatchSize" : 1, + "params" : { + "flavor" : "expiring" + }, + "primaryMetric" : { + "score" : 1.2918849083181381E7, + "scoreError" : 1122776.0073354242, + "scoreConfidence" : [ + 1.1796073075845957E7, + 1.4041625090516806E7 + ], + "scorePercentiles" : { + "0.0" : 1.2502198162162138E7, + "50.0" : 1.2902247919082075E7, + "90.0" : 1.3275992024473783E7, + "95.0" : 1.3275992024473783E7, + "99.0" : 1.3275992024473783E7, + "99.9" : 1.3275992024473783E7, + "99.99" : 1.3275992024473783E7, + "99.999" : 1.3275992024473783E7, + "99.9999" : 1.3275992024473783E7, + "100.0" : 1.3275992024473783E7 + }, + "scoreUnit" : "ops/s", + "rawData" : [ + [ + 1.2502198162162138E7, + 1.2823191658055855E7, + 1.2902247919082075E7, + 1.3090615652133044E7, + 1.3275992024473783E7 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.evolution.scache.bench.CacheBenchmark.getHitRandomKeys", + "mode" : "thrpt", + "threads" : 1, + "forks" : 1, + "jvm" : "/usr/lib/jvm/temurin-25-jdk-arm64/bin/java", + "jvmArgs" : [ + ], + "jdkVersion" : "25.0.4", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.4+7-LTS", + "warmupIterations" : 1, + "warmupTime" : "3 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "2 s", + "measurementBatchSize" : 1, + "params" : { + "flavor" : "single" + }, + "primaryMetric" : { + "score" : 2.610205362810062E7, + "scoreError" : 300192.6968273039, + "scoreConfidence" : [ + 2.5801860931273315E7, + 2.6402246324927922E7 + ], + "scorePercentiles" : { + "0.0" : 2.5975453183753625E7, + "50.0" : 2.611428963881623E7, + "90.0" : 2.618940271968549E7, + "95.0" : 2.618940271968549E7, + "99.0" : 2.618940271968549E7, + "99.9" : 2.618940271968549E7, + "99.99" : 2.618940271968549E7, + "99.999" : 2.618940271968549E7, + "99.9999" : 2.618940271968549E7, + "100.0" : 2.618940271968549E7 + }, + "scoreUnit" : "ops/s", + "rawData" : [ + [ + 2.6123877195745643E7, + 2.618940271968549E7, + 2.611428963881623E7, + 2.610724540250211E7, + 2.5975453183753625E7 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.evolution.scache.bench.CacheBenchmark.getHitRandomKeys", + "mode" : "thrpt", + "threads" : 1, + "forks" : 1, + "jvm" : "/usr/lib/jvm/temurin-25-jdk-arm64/bin/java", + "jvmArgs" : [ + ], + "jdkVersion" : "25.0.4", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.4+7-LTS", + "warmupIterations" : 1, + "warmupTime" : "3 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "2 s", + "measurementBatchSize" : 1, + "params" : { + "flavor" : "partitioned" + }, + "primaryMetric" : { + "score" : 2.3022334327981513E7, + "scoreError" : 2837115.7378140083, + "scoreConfidence" : [ + 2.0185218590167504E7, + 2.5859450065795522E7 + ], + "scorePercentiles" : { + "0.0" : 2.1835290389886387E7, + "50.0" : 2.3179949818595886E7, + "90.0" : 2.386566532613996E7, + "95.0" : 2.386566532613996E7, + "99.0" : 2.386566532613996E7, + "99.9" : 2.386566532613996E7, + "99.99" : 2.386566532613996E7, + "99.999" : 2.386566532613996E7, + "99.9999" : 2.386566532613996E7, + "100.0" : 2.386566532613996E7 + }, + "scoreUnit" : "ops/s", + "rawData" : [ + [ + 2.1835290389886387E7, + 2.386566532613996E7, + 2.304830678968735E7, + 2.3179949818595886E7, + 2.3182459315597977E7 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.evolution.scache.bench.CacheBenchmark.getHitRandomKeys", + "mode" : "thrpt", + "threads" : 1, + "forks" : 1, + "jvm" : "/usr/lib/jvm/temurin-25-jdk-arm64/bin/java", + "jvmArgs" : [ + ], + "jdkVersion" : "25.0.4", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.4+7-LTS", + "warmupIterations" : 1, + "warmupTime" : "3 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "2 s", + "measurementBatchSize" : 1, + "params" : { + "flavor" : "expiring" + }, + "primaryMetric" : { + "score" : 1.322145857456372E7, + "scoreError" : 804864.2979845649, + "scoreConfidence" : [ + 1.2416594276579155E7, + 1.4026322872548284E7 + ], + "scorePercentiles" : { + "0.0" : 1.2980734554712847E7, + "50.0" : 1.3247878855106996E7, + "90.0" : 1.3531393857558675E7, + "95.0" : 1.3531393857558675E7, + "99.0" : 1.3531393857558675E7, + "99.9" : 1.3531393857558675E7, + "99.99" : 1.3531393857558675E7, + "99.999" : 1.3531393857558675E7, + "99.9999" : 1.3531393857558675E7, + "100.0" : 1.3531393857558675E7 + }, + "scoreUnit" : "ops/s", + "rawData" : [ + [ + 1.2980734554712847E7, + 1.3247878855106996E7, + 1.3261619945113301E7, + 1.308566566032678E7, + 1.3531393857558675E7 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.evolution.scache.bench.CacheBenchmark.getOrUpdateHitRandomKeys", + "mode" : "thrpt", + "threads" : 1, + "forks" : 1, + "jvm" : "/usr/lib/jvm/temurin-25-jdk-arm64/bin/java", + "jvmArgs" : [ + ], + "jdkVersion" : "25.0.4", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.4+7-LTS", + "warmupIterations" : 1, + "warmupTime" : "3 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "2 s", + "measurementBatchSize" : 1, + "params" : { + "flavor" : "single" + }, + "primaryMetric" : { + "score" : 1.2600264369109768E7, + "scoreError" : 1955554.316665008, + "scoreConfidence" : [ + 1.064471005244476E7, + 1.4555818685774777E7 + ], + "scorePercentiles" : { + "0.0" : 1.2257446670466183E7, + "50.0" : 1.2444519889789457E7, + "90.0" : 1.3486929475731004E7, + "95.0" : 1.3486929475731004E7, + "99.0" : 1.3486929475731004E7, + "99.9" : 1.3486929475731004E7, + "99.99" : 1.3486929475731004E7, + "99.999" : 1.3486929475731004E7, + "99.9999" : 1.3486929475731004E7, + "100.0" : 1.3486929475731004E7 + }, + "scoreUnit" : "ops/s", + "rawData" : [ + [ + 1.2257446670466183E7, + 1.228708213287984E7, + 1.2525343676682362E7, + 1.3486929475731004E7, + 1.2444519889789457E7 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.evolution.scache.bench.CacheBenchmark.getOrUpdateHitRandomKeys", + "mode" : "thrpt", + "threads" : 1, + "forks" : 1, + "jvm" : "/usr/lib/jvm/temurin-25-jdk-arm64/bin/java", + "jvmArgs" : [ + ], + "jdkVersion" : "25.0.4", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.4+7-LTS", + "warmupIterations" : 1, + "warmupTime" : "3 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "2 s", + "measurementBatchSize" : 1, + "params" : { + "flavor" : "partitioned" + }, + "primaryMetric" : { + "score" : 1.1455778008713856E7, + "scoreError" : 1604837.5158296826, + "scoreConfidence" : [ + 9850940.492884174, + 1.3060615524543539E7 + ], + "scorePercentiles" : { + "0.0" : 1.0712238977504678E7, + "50.0" : 1.1632535370421866E7, + "90.0" : 1.1678866180872906E7, + "95.0" : 1.1678866180872906E7, + "99.0" : 1.1678866180872906E7, + "99.9" : 1.1678866180872906E7, + "99.99" : 1.1678866180872906E7, + "99.999" : 1.1678866180872906E7, + "99.9999" : 1.1678866180872906E7, + "100.0" : 1.1678866180872906E7 + }, + "scoreUnit" : "ops/s", + "rawData" : [ + [ + 1.0712238977504678E7, + 1.1597058798032353E7, + 1.1632535370421866E7, + 1.1678866180872906E7, + 1.165819071673748E7 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.evolution.scache.bench.CacheBenchmark.getOrUpdateHitRandomKeys", + "mode" : "thrpt", + "threads" : 1, + "forks" : 1, + "jvm" : "/usr/lib/jvm/temurin-25-jdk-arm64/bin/java", + "jvmArgs" : [ + ], + "jdkVersion" : "25.0.4", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.4+7-LTS", + "warmupIterations" : 1, + "warmupTime" : "3 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "2 s", + "measurementBatchSize" : 1, + "params" : { + "flavor" : "expiring" + }, + "primaryMetric" : { + "score" : 9117919.948766232, + "scoreError" : 2958044.5409369254, + "scoreConfidence" : [ + 6159875.407829306, + 1.2075964489703156E7 + ], + "scorePercentiles" : { + "0.0" : 7912061.891600653, + "50.0" : 9587634.745973224, + "90.0" : 9675586.113721954, + "95.0" : 9675586.113721954, + "99.0" : 9675586.113721954, + "99.9" : 9675586.113721954, + "99.99" : 9675586.113721954, + "99.999" : 9675586.113721954, + "99.9999" : 9675586.113721954, + "100.0" : 9675586.113721954 + }, + "scoreUnit" : "ops/s", + "rawData" : [ + [ + 7912061.891600653, + 8783573.435274316, + 9587634.745973224, + 9675586.113721954, + 9630743.557261016 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.evolution.scache.bench.CacheBenchmark.getOrUpdateHitSingleHotKey", + "mode" : "thrpt", + "threads" : 1, + "forks" : 1, + "jvm" : "/usr/lib/jvm/temurin-25-jdk-arm64/bin/java", + "jvmArgs" : [ + ], + "jdkVersion" : "25.0.4", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.4+7-LTS", + "warmupIterations" : 1, + "warmupTime" : "3 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "2 s", + "measurementBatchSize" : 1, + "params" : { + "flavor" : "single" + }, + "primaryMetric" : { + "score" : 1.3816754329865208E7, + "scoreError" : 1215345.5910112457, + "scoreConfidence" : [ + 1.2601408738853961E7, + 1.5032099920876455E7 + ], + "scorePercentiles" : { + "0.0" : 1.3265795371024773E7, + "50.0" : 1.38960232733952E7, + "90.0" : 1.4059458464590153E7, + "95.0" : 1.4059458464590153E7, + "99.0" : 1.4059458464590153E7, + "99.9" : 1.4059458464590153E7, + "99.99" : 1.4059458464590153E7, + "99.999" : 1.4059458464590153E7, + "99.9999" : 1.4059458464590153E7, + "100.0" : 1.4059458464590153E7 + }, + "scoreUnit" : "ops/s", + "rawData" : [ + [ + 1.3889381144000364E7, + 1.4059458464590153E7, + 1.3973113396315552E7, + 1.3265795371024773E7, + 1.38960232733952E7 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.evolution.scache.bench.CacheBenchmark.getOrUpdateHitSingleHotKey", + "mode" : "thrpt", + "threads" : 1, + "forks" : 1, + "jvm" : "/usr/lib/jvm/temurin-25-jdk-arm64/bin/java", + "jvmArgs" : [ + ], + "jdkVersion" : "25.0.4", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.4+7-LTS", + "warmupIterations" : 1, + "warmupTime" : "3 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "2 s", + "measurementBatchSize" : 1, + "params" : { + "flavor" : "partitioned" + }, + "primaryMetric" : { + "score" : 1.258033162435461E7, + "scoreError" : 1669391.8295807883, + "scoreConfidence" : [ + 1.0910939794773823E7, + 1.4249723453935398E7 + ], + "scorePercentiles" : { + "0.0" : 1.1912368882887961E7, + "50.0" : 1.2547707945330203E7, + "90.0" : 1.2987591005178122E7, + "95.0" : 1.2987591005178122E7, + "99.0" : 1.2987591005178122E7, + "99.9" : 1.2987591005178122E7, + "99.99" : 1.2987591005178122E7, + "99.999" : 1.2987591005178122E7, + "99.9999" : 1.2987591005178122E7, + "100.0" : 1.2987591005178122E7 + }, + "scoreUnit" : "ops/s", + "rawData" : [ + [ + 1.2508255685273835E7, + 1.2547707945330203E7, + 1.1912368882887961E7, + 1.2945734603102926E7, + 1.2987591005178122E7 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.evolution.scache.bench.CacheBenchmark.getOrUpdateHitSingleHotKey", + "mode" : "thrpt", + "threads" : 1, + "forks" : 1, + "jvm" : "/usr/lib/jvm/temurin-25-jdk-arm64/bin/java", + "jvmArgs" : [ + ], + "jdkVersion" : "25.0.4", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.4+7-LTS", + "warmupIterations" : 1, + "warmupTime" : "3 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "2 s", + "measurementBatchSize" : 1, + "params" : { + "flavor" : "expiring" + }, + "primaryMetric" : { + "score" : 1.0402956935076972E7, + "scoreError" : 1844031.4084078763, + "scoreConfidence" : [ + 8558925.526669096, + 1.2246988343484849E7 + ], + "scorePercentiles" : { + "0.0" : 9623459.366173595, + "50.0" : 1.0576976635197667E7, + "90.0" : 1.0816858836448867E7, + "95.0" : 1.0816858836448867E7, + "99.0" : 1.0816858836448867E7, + "99.9" : 1.0816858836448867E7, + "99.99" : 1.0816858836448867E7, + "99.999" : 1.0816858836448867E7, + "99.9999" : 1.0816858836448867E7, + "100.0" : 1.0816858836448867E7 + }, + "scoreUnit" : "ops/s", + "rawData" : [ + [ + 9623459.366173595, + 1.0576976635197667E7, + 1.0286879032117361E7, + 1.0816858836448867E7, + 1.071061080544737E7 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.evolution.scache.bench.CacheBenchmark.getOrUpdateInsertDistinctKeys", + "mode" : "thrpt", + "threads" : 1, + "forks" : 1, + "jvm" : "/usr/lib/jvm/temurin-25-jdk-arm64/bin/java", + "jvmArgs" : [ + ], + "jdkVersion" : "25.0.4", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.4+7-LTS", + "warmupIterations" : 1, + "warmupTime" : "3 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "2 s", + "measurementBatchSize" : 1, + "params" : { + "flavor" : "single" + }, + "primaryMetric" : { + "score" : 2064391.2397355475, + "scoreError" : 495114.50418752234, + "scoreConfidence" : [ + 1569276.735548025, + 2559505.74392307 + ], + "scorePercentiles" : { + "0.0" : 1921090.5339368568, + "50.0" : 2032096.8985423783, + "90.0" : 2251906.465421067, + "95.0" : 2251906.465421067, + "99.0" : 2251906.465421067, + "99.9" : 2251906.465421067, + "99.99" : 2251906.465421067, + "99.999" : 2251906.465421067, + "99.9999" : 2251906.465421067, + "100.0" : 2251906.465421067 + }, + "scoreUnit" : "ops/s", + "rawData" : [ + [ + 2251906.465421067, + 2126694.971239933, + 1921090.5339368568, + 1990167.3295375037, + 2032096.8985423783 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.evolution.scache.bench.CacheBenchmark.getOrUpdateInsertDistinctKeys", + "mode" : "thrpt", + "threads" : 1, + "forks" : 1, + "jvm" : "/usr/lib/jvm/temurin-25-jdk-arm64/bin/java", + "jvmArgs" : [ + ], + "jdkVersion" : "25.0.4", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.4+7-LTS", + "warmupIterations" : 1, + "warmupTime" : "3 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "2 s", + "measurementBatchSize" : 1, + "params" : { + "flavor" : "partitioned" + }, + "primaryMetric" : { + "score" : 2408100.7966738828, + "scoreError" : 688277.3099934626, + "scoreConfidence" : [ + 1719823.48668042, + 3096378.1066673454 + ], + "scorePercentiles" : { + "0.0" : 2214902.446455962, + "50.0" : 2397090.830640664, + "90.0" : 2591226.0007935627, + "95.0" : 2591226.0007935627, + "99.0" : 2591226.0007935627, + "99.9" : 2591226.0007935627, + "99.99" : 2591226.0007935627, + "99.999" : 2591226.0007935627, + "99.9999" : 2591226.0007935627, + "100.0" : 2591226.0007935627 + }, + "scoreUnit" : "ops/s", + "rawData" : [ + [ + 2214902.446455962, + 2397090.830640664, + 2586857.90852089, + 2250426.796958335, + 2591226.0007935627 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.evolution.scache.bench.CacheBenchmark.getOrUpdateInsertDistinctKeys", + "mode" : "thrpt", + "threads" : 1, + "forks" : 1, + "jvm" : "/usr/lib/jvm/temurin-25-jdk-arm64/bin/java", + "jvmArgs" : [ + ], + "jdkVersion" : "25.0.4", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.4+7-LTS", + "warmupIterations" : 1, + "warmupTime" : "3 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "2 s", + "measurementBatchSize" : 1, + "params" : { + "flavor" : "expiring" + }, + "primaryMetric" : { + "score" : 1986562.3204064355, + "scoreError" : 554031.9622058993, + "scoreConfidence" : [ + 1432530.358200536, + 2540594.282612335 + ], + "scorePercentiles" : { + "0.0" : 1802236.711151572, + "50.0" : 2013762.1033552743, + "90.0" : 2143329.708784185, + "95.0" : 2143329.708784185, + "99.0" : 2143329.708784185, + "99.9" : 2143329.708784185, + "99.99" : 2143329.708784185, + "99.999" : 2143329.708784185, + "99.9999" : 2143329.708784185, + "100.0" : 2143329.708784185 + }, + "scoreUnit" : "ops/s", + "rawData" : [ + [ + 1802236.711151572, + 1878310.977267158, + 2013762.1033552743, + 2143329.708784185, + 2095172.1014739864 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.evolution.scache.bench.CacheBenchmark.mixedRandomKeys", + "mode" : "thrpt", + "threads" : 1, + "forks" : 1, + "jvm" : "/usr/lib/jvm/temurin-25-jdk-arm64/bin/java", + "jvmArgs" : [ + ], + "jdkVersion" : "25.0.4", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.4+7-LTS", + "warmupIterations" : 1, + "warmupTime" : "3 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "2 s", + "measurementBatchSize" : 1, + "params" : { + "flavor" : "single" + }, + "primaryMetric" : { + "score" : 7446103.188974698, + "scoreError" : 217271.73676120647, + "scoreConfidence" : [ + 7228831.452213491, + 7663374.925735905 + ], + "scorePercentiles" : { + "0.0" : 7368027.720041424, + "50.0" : 7458117.974019589, + "90.0" : 7518687.673216142, + "95.0" : 7518687.673216142, + "99.0" : 7518687.673216142, + "99.9" : 7518687.673216142, + "99.99" : 7518687.673216142, + "99.999" : 7518687.673216142, + "99.9999" : 7518687.673216142, + "100.0" : 7518687.673216142 + }, + "scoreUnit" : "ops/s", + "rawData" : [ + [ + 7368027.720041424, + 7518687.673216142, + 7458117.974019589, + 7418294.374809377, + 7467388.20278696 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.evolution.scache.bench.CacheBenchmark.mixedRandomKeys", + "mode" : "thrpt", + "threads" : 1, + "forks" : 1, + "jvm" : "/usr/lib/jvm/temurin-25-jdk-arm64/bin/java", + "jvmArgs" : [ + ], + "jdkVersion" : "25.0.4", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.4+7-LTS", + "warmupIterations" : 1, + "warmupTime" : "3 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "2 s", + "measurementBatchSize" : 1, + "params" : { + "flavor" : "partitioned" + }, + "primaryMetric" : { + "score" : 7395581.1615020335, + "scoreError" : 330065.4723422714, + "scoreConfidence" : [ + 7065515.689159762, + 7725646.633844305 + ], + "scorePercentiles" : { + "0.0" : 7308351.271318262, + "50.0" : 7377370.118848213, + "90.0" : 7532547.912811478, + "95.0" : 7532547.912811478, + "99.0" : 7532547.912811478, + "99.9" : 7532547.912811478, + "99.99" : 7532547.912811478, + "99.999" : 7532547.912811478, + "99.9999" : 7532547.912811478, + "100.0" : 7532547.912811478 + }, + "scoreUnit" : "ops/s", + "rawData" : [ + [ + 7377370.118848213, + 7308351.271318262, + 7346711.907692262, + 7532547.912811478, + 7412924.59683995 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.evolution.scache.bench.CacheBenchmark.mixedRandomKeys", + "mode" : "thrpt", + "threads" : 1, + "forks" : 1, + "jvm" : "/usr/lib/jvm/temurin-25-jdk-arm64/bin/java", + "jvmArgs" : [ + ], + "jdkVersion" : "25.0.4", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.4+7-LTS", + "warmupIterations" : 1, + "warmupTime" : "3 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "2 s", + "measurementBatchSize" : 1, + "params" : { + "flavor" : "expiring" + }, + "primaryMetric" : { + "score" : 5557347.54920658, + "scoreError" : 179023.68039684725, + "scoreConfidence" : [ + 5378323.868809733, + 5736371.229603427 + ], + "scorePercentiles" : { + "0.0" : 5487301.510511556, + "50.0" : 5564301.986902345, + "90.0" : 5614419.715007943, + "95.0" : 5614419.715007943, + "99.0" : 5614419.715007943, + "99.9" : 5614419.715007943, + "99.99" : 5614419.715007943, + "99.999" : 5614419.715007943, + "99.9999" : 5614419.715007943, + "100.0" : 5614419.715007943 + }, + "scoreUnit" : "ops/s", + "rawData" : [ + [ + 5574776.34187705, + 5487301.510511556, + 5545938.191734008, + 5564301.986902345, + 5614419.715007943 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.evolution.scache.bench.CacheBenchmark.modifyInsertDistinctKeys", + "mode" : "thrpt", + "threads" : 1, + "forks" : 1, + "jvm" : "/usr/lib/jvm/temurin-25-jdk-arm64/bin/java", + "jvmArgs" : [ + ], + "jdkVersion" : "25.0.4", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.4+7-LTS", + "warmupIterations" : 1, + "warmupTime" : "3 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "2 s", + "measurementBatchSize" : 1, + "params" : { + "flavor" : "single" + }, + "primaryMetric" : { + "score" : 1.1440416365124347E7, + "scoreError" : 3644668.2121006115, + "scoreConfidence" : [ + 7795748.153023735, + 1.5085084577224959E7 + ], + "scorePercentiles" : { + "0.0" : 1.0820762378391398E7, + "50.0" : 1.0856903795876203E7, + "90.0" : 1.3008989591236407E7, + "95.0" : 1.3008989591236407E7, + "99.0" : 1.3008989591236407E7, + "99.9" : 1.3008989591236407E7, + "99.99" : 1.3008989591236407E7, + "99.999" : 1.3008989591236407E7, + "99.9999" : 1.3008989591236407E7, + "100.0" : 1.3008989591236407E7 + }, + "scoreUnit" : "ops/s", + "rawData" : [ + [ + 1.0856903795876203E7, + 1.1665066912099674E7, + 1.0820762378391398E7, + 1.3008989591236407E7, + 1.0850359148018045E7 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.evolution.scache.bench.CacheBenchmark.modifyInsertDistinctKeys", + "mode" : "thrpt", + "threads" : 1, + "forks" : 1, + "jvm" : "/usr/lib/jvm/temurin-25-jdk-arm64/bin/java", + "jvmArgs" : [ + ], + "jdkVersion" : "25.0.4", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.4+7-LTS", + "warmupIterations" : 1, + "warmupTime" : "3 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "2 s", + "measurementBatchSize" : 1, + "params" : { + "flavor" : "partitioned" + }, + "primaryMetric" : { + "score" : 1.065320648434866E7, + "scoreError" : 3018056.3397372253, + "scoreConfidence" : [ + 7635150.144611435, + 1.3671262824085886E7 + ], + "scorePercentiles" : { + "0.0" : 9391118.019878384, + "50.0" : 1.08953151604612E7, + "90.0" : 1.1295289274193797E7, + "95.0" : 1.1295289274193797E7, + "99.0" : 1.1295289274193797E7, + "99.9" : 1.1295289274193797E7, + "99.99" : 1.1295289274193797E7, + "99.999" : 1.1295289274193797E7, + "99.9999" : 1.1295289274193797E7, + "100.0" : 1.1295289274193797E7 + }, + "scoreUnit" : "ops/s", + "rawData" : [ + [ + 1.1243455904601825E7, + 9391118.019878384, + 1.1295289274193797E7, + 1.08953151604612E7, + 1.0440854062608097E7 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.evolution.scache.bench.CacheBenchmark.modifyInsertDistinctKeys", + "mode" : "thrpt", + "threads" : 1, + "forks" : 1, + "jvm" : "/usr/lib/jvm/temurin-25-jdk-arm64/bin/java", + "jvmArgs" : [ + ], + "jdkVersion" : "25.0.4", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.4+7-LTS", + "warmupIterations" : 1, + "warmupTime" : "3 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "2 s", + "measurementBatchSize" : 1, + "params" : { + "flavor" : "expiring" + }, + "primaryMetric" : { + "score" : 1.1764340847162846E7, + "scoreError" : 1109593.3872117454, + "scoreConfidence" : [ + 1.06547474599511E7, + 1.2873934234374592E7 + ], + "scorePercentiles" : { + "0.0" : 1.1458791699921632E7, + "50.0" : 1.1695132171683468E7, + "90.0" : 1.2124258432993853E7, + "95.0" : 1.2124258432993853E7, + "99.0" : 1.2124258432993853E7, + "99.9" : 1.2124258432993853E7, + "99.99" : 1.2124258432993853E7, + "99.999" : 1.2124258432993853E7, + "99.9999" : 1.2124258432993853E7, + "100.0" : 1.2124258432993853E7 + }, + "scoreUnit" : "ops/s", + "rawData" : [ + [ + 1.2000167342333589E7, + 1.1458791699921632E7, + 1.1543354588881696E7, + 1.2124258432993853E7, + 1.1695132171683468E7 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.evolution.scache.bench.CacheBenchmark.modifyUpdateRandomKeys", + "mode" : "thrpt", + "threads" : 1, + "forks" : 1, + "jvm" : "/usr/lib/jvm/temurin-25-jdk-arm64/bin/java", + "jvmArgs" : [ + ], + "jdkVersion" : "25.0.4", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.4+7-LTS", + "warmupIterations" : 1, + "warmupTime" : "3 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "2 s", + "measurementBatchSize" : 1, + "params" : { + "flavor" : "single" + }, + "primaryMetric" : { + "score" : 1.1133317544516152E7, + "scoreError" : 1593637.5576875797, + "scoreConfidence" : [ + 9539679.986828573, + 1.272695510220373E7 + ], + "scorePercentiles" : { + "0.0" : 1.0465849078364821E7, + "50.0" : 1.131587585815989E7, + "90.0" : 1.1512799935215317E7, + "95.0" : 1.1512799935215317E7, + "99.0" : 1.1512799935215317E7, + "99.9" : 1.1512799935215317E7, + "99.99" : 1.1512799935215317E7, + "99.999" : 1.1512799935215317E7, + "99.9999" : 1.1512799935215317E7, + "100.0" : 1.1512799935215317E7 + }, + "scoreUnit" : "ops/s", + "rawData" : [ + [ + 1.0465849078364821E7, + 1.1017622745463807E7, + 1.131587585815989E7, + 1.135444010537692E7, + 1.1512799935215317E7 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.evolution.scache.bench.CacheBenchmark.modifyUpdateRandomKeys", + "mode" : "thrpt", + "threads" : 1, + "forks" : 1, + "jvm" : "/usr/lib/jvm/temurin-25-jdk-arm64/bin/java", + "jvmArgs" : [ + ], + "jdkVersion" : "25.0.4", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.4+7-LTS", + "warmupIterations" : 1, + "warmupTime" : "3 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "2 s", + "measurementBatchSize" : 1, + "params" : { + "flavor" : "partitioned" + }, + "primaryMetric" : { + "score" : 9271847.264499156, + "scoreError" : 3252568.9460835992, + "scoreConfidence" : [ + 6019278.318415556, + 1.2524416210582756E7 + ], + "scorePercentiles" : { + "0.0" : 7783762.7963893805, + "50.0" : 9648900.18115227, + "90.0" : 9778672.439737821, + "95.0" : 9778672.439737821, + "99.0" : 9778672.439737821, + "99.9" : 9778672.439737821, + "99.99" : 9778672.439737821, + "99.999" : 9778672.439737821, + "99.9999" : 9778672.439737821, + "100.0" : 9778672.439737821 + }, + "scoreUnit" : "ops/s", + "rawData" : [ + [ + 7783762.7963893805, + 9403684.72806924, + 9648900.18115227, + 9744216.17714707, + 9778672.439737821 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.evolution.scache.bench.CacheBenchmark.modifyUpdateRandomKeys", + "mode" : "thrpt", + "threads" : 1, + "forks" : 1, + "jvm" : "/usr/lib/jvm/temurin-25-jdk-arm64/bin/java", + "jvmArgs" : [ + ], + "jdkVersion" : "25.0.4", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.4+7-LTS", + "warmupIterations" : 1, + "warmupTime" : "3 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "2 s", + "measurementBatchSize" : 1, + "params" : { + "flavor" : "expiring" + }, + "primaryMetric" : { + "score" : 1.0532905817183655E7, + "scoreError" : 2267783.9046359947, + "scoreConfidence" : [ + 8265121.91254766, + 1.280068972181965E7 + ], + "scorePercentiles" : { + "0.0" : 9493733.99943698, + "50.0" : 1.0705230566117764E7, + "90.0" : 1.0929416977530964E7, + "95.0" : 1.0929416977530964E7, + "99.0" : 1.0929416977530964E7, + "99.9" : 1.0929416977530964E7, + "99.99" : 1.0929416977530964E7, + "99.999" : 1.0929416977530964E7, + "99.9999" : 1.0929416977530964E7, + "100.0" : 1.0929416977530964E7 + }, + "scoreUnit" : "ops/s", + "rawData" : [ + [ + 9493733.99943698, + 1.0697430865237301E7, + 1.0838716677595265E7, + 1.0929416977530964E7, + 1.0705230566117764E7 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.evolution.scache.bench.CacheBenchmark.putInsertDistinctKeys", + "mode" : "thrpt", + "threads" : 1, + "forks" : 1, + "jvm" : "/usr/lib/jvm/temurin-25-jdk-arm64/bin/java", + "jvmArgs" : [ + ], + "jdkVersion" : "25.0.4", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.4+7-LTS", + "warmupIterations" : 1, + "warmupTime" : "3 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "2 s", + "measurementBatchSize" : 1, + "params" : { + "flavor" : "single" + }, + "primaryMetric" : { + "score" : 9243132.037909547, + "scoreError" : 5764175.06062249, + "scoreConfidence" : [ + 3478956.977287057, + 1.5007307098532036E7 + ], + "scorePercentiles" : { + "0.0" : 6983322.719652706, + "50.0" : 1.0129765781771481E7, + "90.0" : 1.044484917267873E7, + "95.0" : 1.044484917267873E7, + "99.0" : 1.044484917267873E7, + "99.9" : 1.044484917267873E7, + "99.99" : 1.044484917267873E7, + "99.999" : 1.044484917267873E7, + "99.9999" : 1.044484917267873E7, + "100.0" : 1.044484917267873E7 + }, + "scoreUnit" : "ops/s", + "rawData" : [ + [ + 1.0226528340599027E7, + 6983322.719652706, + 1.0129765781771481E7, + 1.044484917267873E7, + 8431194.174845789 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.evolution.scache.bench.CacheBenchmark.putInsertDistinctKeys", + "mode" : "thrpt", + "threads" : 1, + "forks" : 1, + "jvm" : "/usr/lib/jvm/temurin-25-jdk-arm64/bin/java", + "jvmArgs" : [ + ], + "jdkVersion" : "25.0.4", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.4+7-LTS", + "warmupIterations" : 1, + "warmupTime" : "3 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "2 s", + "measurementBatchSize" : 1, + "params" : { + "flavor" : "partitioned" + }, + "primaryMetric" : { + "score" : 1.0303951827211116E7, + "scoreError" : 644225.2057348174, + "scoreConfidence" : [ + 9659726.621476298, + 1.0948177032945933E7 + ], + "scorePercentiles" : { + "0.0" : 1.0061987798179567E7, + "50.0" : 1.0401208243354078E7, + "90.0" : 1.0451064344557744E7, + "95.0" : 1.0451064344557744E7, + "99.0" : 1.0451064344557744E7, + "99.9" : 1.0451064344557744E7, + "99.99" : 1.0451064344557744E7, + "99.999" : 1.0451064344557744E7, + "99.9999" : 1.0451064344557744E7, + "100.0" : 1.0451064344557744E7 + }, + "scoreUnit" : "ops/s", + "rawData" : [ + [ + 1.0408369236345714E7, + 1.0061987798179567E7, + 1.0197129513618471E7, + 1.0401208243354078E7, + 1.0451064344557744E7 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.evolution.scache.bench.CacheBenchmark.putInsertDistinctKeys", + "mode" : "thrpt", + "threads" : 1, + "forks" : 1, + "jvm" : "/usr/lib/jvm/temurin-25-jdk-arm64/bin/java", + "jvmArgs" : [ + ], + "jdkVersion" : "25.0.4", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.4+7-LTS", + "warmupIterations" : 1, + "warmupTime" : "3 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "2 s", + "measurementBatchSize" : 1, + "params" : { + "flavor" : "expiring" + }, + "primaryMetric" : { + "score" : 8858241.84881128, + "scoreError" : 800409.5549905874, + "scoreConfidence" : [ + 8057832.293820692, + 9658651.403801868 + ], + "scorePercentiles" : { + "0.0" : 8645918.501259424, + "50.0" : 8804002.231917279, + "90.0" : 9197328.698727867, + "95.0" : 9197328.698727867, + "99.0" : 9197328.698727867, + "99.9" : 9197328.698727867, + "99.99" : 9197328.698727867, + "99.999" : 9197328.698727867, + "99.9999" : 9197328.698727867, + "100.0" : 9197328.698727867 + }, + "scoreUnit" : "ops/s", + "rawData" : [ + [ + 8804002.231917279, + 8761994.152325422, + 9197328.698727867, + 8881965.659826407, + 8645918.501259424 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.evolution.scache.bench.CacheBenchmark.putReplaceRandomKeys", + "mode" : "thrpt", + "threads" : 1, + "forks" : 1, + "jvm" : "/usr/lib/jvm/temurin-25-jdk-arm64/bin/java", + "jvmArgs" : [ + ], + "jdkVersion" : "25.0.4", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.4+7-LTS", + "warmupIterations" : 1, + "warmupTime" : "3 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "2 s", + "measurementBatchSize" : 1, + "params" : { + "flavor" : "single" + }, + "primaryMetric" : { + "score" : 9534665.240798771, + "scoreError" : 532363.9869741199, + "scoreConfidence" : [ + 9002301.253824651, + 1.0067029227772892E7 + ], + "scorePercentiles" : { + "0.0" : 9400367.26667304, + "50.0" : 9536747.12415566, + "90.0" : 9717542.231094409, + "95.0" : 9717542.231094409, + "99.0" : 9717542.231094409, + "99.9" : 9717542.231094409, + "99.99" : 9717542.231094409, + "99.999" : 9717542.231094409, + "99.9999" : 9717542.231094409, + "100.0" : 9717542.231094409 + }, + "scoreUnit" : "ops/s", + "rawData" : [ + [ + 9400503.975258283, + 9400367.26667304, + 9536747.12415566, + 9717542.231094409, + 9618165.606812466 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.evolution.scache.bench.CacheBenchmark.putReplaceRandomKeys", + "mode" : "thrpt", + "threads" : 1, + "forks" : 1, + "jvm" : "/usr/lib/jvm/temurin-25-jdk-arm64/bin/java", + "jvmArgs" : [ + ], + "jdkVersion" : "25.0.4", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.4+7-LTS", + "warmupIterations" : 1, + "warmupTime" : "3 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "2 s", + "measurementBatchSize" : 1, + "params" : { + "flavor" : "partitioned" + }, + "primaryMetric" : { + "score" : 8832068.910531137, + "scoreError" : 511003.8484566339, + "scoreConfidence" : [ + 8321065.062074503, + 9343072.758987771 + ], + "scorePercentiles" : { + "0.0" : 8646777.46285042, + "50.0" : 8835152.944772968, + "90.0" : 9019671.536448525, + "95.0" : 9019671.536448525, + "99.0" : 9019671.536448525, + "99.9" : 9019671.536448525, + "99.99" : 9019671.536448525, + "99.999" : 9019671.536448525, + "99.9999" : 9019671.536448525, + "100.0" : 9019671.536448525 + }, + "scoreUnit" : "ops/s", + "rawData" : [ + [ + 8646777.46285042, + 8835152.944772968, + 8850488.594511893, + 8808254.014071878, + 9019671.536448525 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.evolution.scache.bench.CacheBenchmark.putReplaceRandomKeys", + "mode" : "thrpt", + "threads" : 1, + "forks" : 1, + "jvm" : "/usr/lib/jvm/temurin-25-jdk-arm64/bin/java", + "jvmArgs" : [ + ], + "jdkVersion" : "25.0.4", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.4+7-LTS", + "warmupIterations" : 1, + "warmupTime" : "3 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "2 s", + "measurementBatchSize" : 1, + "params" : { + "flavor" : "expiring" + }, + "primaryMetric" : { + "score" : 8000773.064419588, + "scoreError" : 526530.2671306859, + "scoreConfidence" : [ + 7474242.797288902, + 8527303.331550274 + ], + "scorePercentiles" : { + "0.0" : 7788425.204067172, + "50.0" : 8008375.253989635, + "90.0" : 8169200.763814323, + "95.0" : 8169200.763814323, + "99.0" : 8169200.763814323, + "99.9" : 8169200.763814323, + "99.99" : 8169200.763814323, + "99.999" : 8169200.763814323, + "99.9999" : 8169200.763814323, + "100.0" : 8169200.763814323 + }, + "scoreUnit" : "ops/s", + "rawData" : [ + [ + 7788425.204067172, + 8036433.317647551, + 8001430.782579265, + 8169200.763814323, + 8008375.253989635 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.evolution.scache.bench.CacheBenchmark.removeAndPutRandomKeys", + "mode" : "thrpt", + "threads" : 1, + "forks" : 1, + "jvm" : "/usr/lib/jvm/temurin-25-jdk-arm64/bin/java", + "jvmArgs" : [ + ], + "jdkVersion" : "25.0.4", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.4+7-LTS", + "warmupIterations" : 1, + "warmupTime" : "3 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "2 s", + "measurementBatchSize" : 1, + "params" : { + "flavor" : "single" + }, + "primaryMetric" : { + "score" : 3868942.3678093804, + "scoreError" : 188930.75680681516, + "scoreConfidence" : [ + 3680011.6110025654, + 4057873.1246161954 + ], + "scorePercentiles" : { + "0.0" : 3806388.7140883896, + "50.0" : 3878788.995648829, + "90.0" : 3917681.1382461563, + "95.0" : 3917681.1382461563, + "99.0" : 3917681.1382461563, + "99.9" : 3917681.1382461563, + "99.99" : 3917681.1382461563, + "99.999" : 3917681.1382461563, + "99.9999" : 3917681.1382461563, + "100.0" : 3917681.1382461563 + }, + "scoreUnit" : "ops/s", + "rawData" : [ + [ + 3806388.7140883896, + 3830701.322462561, + 3878788.995648829, + 3911151.668600967, + 3917681.1382461563 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.evolution.scache.bench.CacheBenchmark.removeAndPutRandomKeys", + "mode" : "thrpt", + "threads" : 1, + "forks" : 1, + "jvm" : "/usr/lib/jvm/temurin-25-jdk-arm64/bin/java", + "jvmArgs" : [ + ], + "jdkVersion" : "25.0.4", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.4+7-LTS", + "warmupIterations" : 1, + "warmupTime" : "3 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "2 s", + "measurementBatchSize" : 1, + "params" : { + "flavor" : "partitioned" + }, + "primaryMetric" : { + "score" : 4048120.4013739116, + "scoreError" : 185157.69285698567, + "scoreConfidence" : [ + 3862962.708516926, + 4233278.094230898 + ], + "scorePercentiles" : { + "0.0" : 3972960.4424302257, + "50.0" : 4047395.9469632925, + "90.0" : 4095390.184556391, + "95.0" : 4095390.184556391, + "99.0" : 4095390.184556391, + "99.9" : 4095390.184556391, + "99.99" : 4095390.184556391, + "99.999" : 4095390.184556391, + "99.9999" : 4095390.184556391, + "100.0" : 4095390.184556391 + }, + "scoreUnit" : "ops/s", + "rawData" : [ + [ + 3972960.4424302257, + 4047395.9469632925, + 4040573.177884957, + 4095390.184556391, + 4084282.2550346935 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.evolution.scache.bench.CacheBenchmark.removeAndPutRandomKeys", + "mode" : "thrpt", + "threads" : 1, + "forks" : 1, + "jvm" : "/usr/lib/jvm/temurin-25-jdk-arm64/bin/java", + "jvmArgs" : [ + ], + "jdkVersion" : "25.0.4", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.4+7-LTS", + "warmupIterations" : 1, + "warmupTime" : "3 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "2 s", + "measurementBatchSize" : 1, + "params" : { + "flavor" : "expiring" + }, + "primaryMetric" : { + "score" : 2968570.210475764, + "scoreError" : 249160.43915868978, + "scoreConfidence" : [ + 2719409.771317074, + 3217730.6496344535 + ], + "scorePercentiles" : { + "0.0" : 2872091.694084293, + "50.0" : 2983536.690458085, + "90.0" : 3034509.685901376, + "95.0" : 3034509.685901376, + "99.0" : 3034509.685901376, + "99.9" : 3034509.685901376, + "99.99" : 3034509.685901376, + "99.999" : 3034509.685901376, + "99.9999" : 3034509.685901376, + "100.0" : 3034509.685901376 + }, + "scoreUnit" : "ops/s", + "rawData" : [ + [ + 3013411.786385771, + 3034509.685901376, + 2939301.195549292, + 2872091.694084293, + 2983536.690458085 + ] + ] + }, + "secondaryMetrics" : { + } + } +] + + diff --git a/benchmark/results/master-7c9fa9f.json b/benchmark/results/master-7c9fa9f.json new file mode 100644 index 0000000..b7c5c5f --- /dev/null +++ b/benchmark/results/master-7c9fa9f.json @@ -0,0 +1,2110 @@ +[ + { + "jmhVersion" : "1.37", + "benchmark" : "com.evolution.scache.bench.CacheBenchmark.containsRandomKeys", + "mode" : "thrpt", + "threads" : 1, + "forks" : 1, + "jvm" : "/usr/lib/jvm/temurin-25-jdk-arm64/bin/java", + "jvmArgs" : [ + ], + "jdkVersion" : "25.0.4", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.4+7-LTS", + "warmupIterations" : 1, + "warmupTime" : "3 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "2 s", + "measurementBatchSize" : 1, + "params" : { + "flavor" : "single" + }, + "primaryMetric" : { + "score" : 2.668982695605997E7, + "scoreError" : 3908765.7953641405, + "scoreConfidence" : [ + 2.278106116069583E7, + 3.059859275142411E7 + ], + "scorePercentiles" : { + "0.0" : 2.4936202534090832E7, + "50.0" : 2.710991340428906E7, + "90.0" : 2.7464093391158577E7, + "95.0" : 2.7464093391158577E7, + "99.0" : 2.7464093391158577E7, + "99.9" : 2.7464093391158577E7, + "99.99" : 2.7464093391158577E7, + "99.999" : 2.7464093391158577E7, + "99.9999" : 2.7464093391158577E7, + "100.0" : 2.7464093391158577E7 + }, + "scoreUnit" : "ops/s", + "rawData" : [ + [ + 2.4936202534090832E7, + 2.6730598998322867E7, + 2.710991340428906E7, + 2.72083264524385E7, + 2.7464093391158577E7 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.evolution.scache.bench.CacheBenchmark.containsRandomKeys", + "mode" : "thrpt", + "threads" : 1, + "forks" : 1, + "jvm" : "/usr/lib/jvm/temurin-25-jdk-arm64/bin/java", + "jvmArgs" : [ + ], + "jdkVersion" : "25.0.4", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.4+7-LTS", + "warmupIterations" : 1, + "warmupTime" : "3 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "2 s", + "measurementBatchSize" : 1, + "params" : { + "flavor" : "partitioned" + }, + "primaryMetric" : { + "score" : 2.359470895910461E7, + "scoreError" : 3374028.1439309292, + "scoreConfidence" : [ + 2.0220680815173678E7, + 2.696873710303554E7 + ], + "scorePercentiles" : { + "0.0" : 2.206558013235072E7, + "50.0" : 2.399476163160392E7, + "90.0" : 2.415304742453361E7, + "95.0" : 2.415304742453361E7, + "99.0" : 2.415304742453361E7, + "99.9" : 2.415304742453361E7, + "99.99" : 2.415304742453361E7, + "99.999" : 2.415304742453361E7, + "99.9999" : 2.415304742453361E7, + "100.0" : 2.415304742453361E7 + }, + "scoreUnit" : "ops/s", + "rawData" : [ + [ + 2.206558013235072E7, + 2.3658507216192886E7, + 2.399476163160392E7, + 2.415304742453361E7, + 2.4101648390841924E7 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.evolution.scache.bench.CacheBenchmark.containsRandomKeys", + "mode" : "thrpt", + "threads" : 1, + "forks" : 1, + "jvm" : "/usr/lib/jvm/temurin-25-jdk-arm64/bin/java", + "jvmArgs" : [ + ], + "jdkVersion" : "25.0.4", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.4+7-LTS", + "warmupIterations" : 1, + "warmupTime" : "3 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "2 s", + "measurementBatchSize" : 1, + "params" : { + "flavor" : "expiring" + }, + "primaryMetric" : { + "score" : 2.321925890862229E7, + "scoreError" : 1274521.9146968399, + "scoreConfidence" : [ + 2.1944736993925452E7, + 2.449378082331913E7 + ], + "scorePercentiles" : { + "0.0" : 2.274430770816594E7, + "50.0" : 2.3410046342388917E7, + "90.0" : 2.348376014315221E7, + "95.0" : 2.348376014315221E7, + "99.0" : 2.348376014315221E7, + "99.9" : 2.348376014315221E7, + "99.99" : 2.348376014315221E7, + "99.999" : 2.348376014315221E7, + "99.9999" : 2.348376014315221E7, + "100.0" : 2.348376014315221E7 + }, + "scoreUnit" : "ops/s", + "rawData" : [ + [ + 2.274430770816594E7, + 2.299878282335825E7, + 2.3410046342388917E7, + 2.348376014315221E7, + 2.3459397526046146E7 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.evolution.scache.bench.CacheBenchmark.foldMapWholeCache", + "mode" : "thrpt", + "threads" : 1, + "forks" : 1, + "jvm" : "/usr/lib/jvm/temurin-25-jdk-arm64/bin/java", + "jvmArgs" : [ + ], + "jdkVersion" : "25.0.4", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.4+7-LTS", + "warmupIterations" : 1, + "warmupTime" : "3 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "2 s", + "measurementBatchSize" : 1, + "params" : { + "flavor" : "single" + }, + "primaryMetric" : { + "score" : 1175.7306999297216, + "scoreError" : 57.255009410261, + "scoreConfidence" : [ + 1118.4756905194606, + 1232.9857093399826 + ], + "scorePercentiles" : { + "0.0" : 1161.7614996582122, + "50.0" : 1168.9664344937914, + "90.0" : 1195.3207088037525, + "95.0" : 1195.3207088037525, + "99.0" : 1195.3207088037525, + "99.9" : 1195.3207088037525, + "99.99" : 1195.3207088037525, + "99.999" : 1195.3207088037525, + "99.9999" : 1195.3207088037525, + "100.0" : 1195.3207088037525 + }, + "scoreUnit" : "ops/s", + "rawData" : [ + [ + 1168.9664344937914, + 1187.6828053116567, + 1195.3207088037525, + 1161.7614996582122, + 1164.9220513811954 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.evolution.scache.bench.CacheBenchmark.foldMapWholeCache", + "mode" : "thrpt", + "threads" : 1, + "forks" : 1, + "jvm" : "/usr/lib/jvm/temurin-25-jdk-arm64/bin/java", + "jvmArgs" : [ + ], + "jdkVersion" : "25.0.4", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.4+7-LTS", + "warmupIterations" : 1, + "warmupTime" : "3 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "2 s", + "measurementBatchSize" : 1, + "params" : { + "flavor" : "partitioned" + }, + "primaryMetric" : { + "score" : 1142.3657111775376, + "scoreError" : 107.43051564370381, + "scoreConfidence" : [ + 1034.9351955338338, + 1249.7962268212414 + ], + "scorePercentiles" : { + "0.0" : 1112.3730498575105, + "50.0" : 1130.314022236891, + "90.0" : 1173.078057402639, + "95.0" : 1173.078057402639, + "99.0" : 1173.078057402639, + "99.9" : 1173.078057402639, + "99.99" : 1173.078057402639, + "99.999" : 1173.078057402639, + "99.9999" : 1173.078057402639, + "100.0" : 1173.078057402639 + }, + "scoreUnit" : "ops/s", + "rawData" : [ + [ + 1124.996526161093, + 1173.078057402639, + 1171.066900229554, + 1130.314022236891, + 1112.3730498575105 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.evolution.scache.bench.CacheBenchmark.foldMapWholeCache", + "mode" : "thrpt", + "threads" : 1, + "forks" : 1, + "jvm" : "/usr/lib/jvm/temurin-25-jdk-arm64/bin/java", + "jvmArgs" : [ + ], + "jdkVersion" : "25.0.4", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.4+7-LTS", + "warmupIterations" : 1, + "warmupTime" : "3 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "2 s", + "measurementBatchSize" : 1, + "params" : { + "flavor" : "expiring" + }, + "primaryMetric" : { + "score" : 1008.5905754913513, + "scoreError" : 129.6834833238242, + "scoreConfidence" : [ + 878.907092167527, + 1138.2740588151755 + ], + "scorePercentiles" : { + "0.0" : 972.3203475908738, + "50.0" : 1017.4770798645286, + "90.0" : 1050.2159596417712, + "95.0" : 1050.2159596417712, + "99.0" : 1050.2159596417712, + "99.9" : 1050.2159596417712, + "99.99" : 1050.2159596417712, + "99.999" : 1050.2159596417712, + "99.9999" : 1050.2159596417712, + "100.0" : 1050.2159596417712 + }, + "scoreUnit" : "ops/s", + "rawData" : [ + [ + 1017.4770798645286, + 1050.2159596417712, + 1027.0466003437505, + 972.3203475908738, + 975.892890015832 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.evolution.scache.bench.CacheBenchmark.get1HitRandomKeys", + "mode" : "thrpt", + "threads" : 1, + "forks" : 1, + "jvm" : "/usr/lib/jvm/temurin-25-jdk-arm64/bin/java", + "jvmArgs" : [ + ], + "jdkVersion" : "25.0.4", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.4+7-LTS", + "warmupIterations" : 1, + "warmupTime" : "3 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "2 s", + "measurementBatchSize" : 1, + "params" : { + "flavor" : "single" + }, + "primaryMetric" : { + "score" : 1.947357814889176E7, + "scoreError" : 1897224.727346683, + "scoreConfidence" : [ + 1.7576353421545073E7, + 2.1370802876238443E7 + ], + "scorePercentiles" : { + "0.0" : 1.8655865406924173E7, + "50.0" : 1.960507592829235E7, + "90.0" : 1.990721002565632E7, + "95.0" : 1.990721002565632E7, + "99.0" : 1.990721002565632E7, + "99.9" : 1.990721002565632E7, + "99.99" : 1.990721002565632E7, + "99.999" : 1.990721002565632E7, + "99.9999" : 1.990721002565632E7, + "100.0" : 1.990721002565632E7 + }, + "scoreUnit" : "ops/s", + "rawData" : [ + [ + 1.8655865406924173E7, + 1.960507592829235E7, + 1.9419391976170056E7, + 1.978034740741591E7, + 1.990721002565632E7 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.evolution.scache.bench.CacheBenchmark.get1HitRandomKeys", + "mode" : "thrpt", + "threads" : 1, + "forks" : 1, + "jvm" : "/usr/lib/jvm/temurin-25-jdk-arm64/bin/java", + "jvmArgs" : [ + ], + "jdkVersion" : "25.0.4", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.4+7-LTS", + "warmupIterations" : 1, + "warmupTime" : "3 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "2 s", + "measurementBatchSize" : 1, + "params" : { + "flavor" : "partitioned" + }, + "primaryMetric" : { + "score" : 1.877709693039935E7, + "scoreError" : 1778281.4034962654, + "scoreConfidence" : [ + 1.6998815526903085E7, + 2.0555378333895616E7 + ], + "scorePercentiles" : { + "0.0" : 1.79658669613779E7, + "50.0" : 1.894370524042261E7, + "90.0" : 1.9071809504318964E7, + "95.0" : 1.9071809504318964E7, + "99.0" : 1.9071809504318964E7, + "99.9" : 1.9071809504318964E7, + "99.99" : 1.9071809504318964E7, + "99.999" : 1.9071809504318964E7, + "99.9999" : 1.9071809504318964E7, + "100.0" : 1.9071809504318964E7 + }, + "scoreUnit" : "ops/s", + "rawData" : [ + [ + 1.79658669613779E7, + 1.894370524042261E7, + 1.8854167029034752E7, + 1.9071809504318964E7, + 1.9049935916842535E7 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.evolution.scache.bench.CacheBenchmark.get1HitRandomKeys", + "mode" : "thrpt", + "threads" : 1, + "forks" : 1, + "jvm" : "/usr/lib/jvm/temurin-25-jdk-arm64/bin/java", + "jvmArgs" : [ + ], + "jdkVersion" : "25.0.4", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.4+7-LTS", + "warmupIterations" : 1, + "warmupTime" : "3 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "2 s", + "measurementBatchSize" : 1, + "params" : { + "flavor" : "expiring" + }, + "primaryMetric" : { + "score" : 1.2585153418621961E7, + "scoreError" : 596201.1953998889, + "scoreConfidence" : [ + 1.1988952223222071E7, + 1.318135461402185E7 + ], + "scorePercentiles" : { + "0.0" : 1.2314784165527573E7, + "50.0" : 1.2636106513270374E7, + "90.0" : 1.2697950010560602E7, + "95.0" : 1.2697950010560602E7, + "99.0" : 1.2697950010560602E7, + "99.9" : 1.2697950010560602E7, + "99.99" : 1.2697950010560602E7, + "99.999" : 1.2697950010560602E7, + "99.9999" : 1.2697950010560602E7, + "100.0" : 1.2697950010560602E7 + }, + "scoreUnit" : "ops/s", + "rawData" : [ + [ + 1.2314784165527573E7, + 1.2668367685843222E7, + 1.2697950010560602E7, + 1.2636106513270374E7, + 1.2608558717908025E7 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.evolution.scache.bench.CacheBenchmark.getHitRandomKeys", + "mode" : "thrpt", + "threads" : 1, + "forks" : 1, + "jvm" : "/usr/lib/jvm/temurin-25-jdk-arm64/bin/java", + "jvmArgs" : [ + ], + "jdkVersion" : "25.0.4", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.4+7-LTS", + "warmupIterations" : 1, + "warmupTime" : "3 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "2 s", + "measurementBatchSize" : 1, + "params" : { + "flavor" : "single" + }, + "primaryMetric" : { + "score" : 2.173830758601103E7, + "scoreError" : 4045725.465183449, + "scoreConfidence" : [ + 1.769258212082758E7, + 2.5784033051194478E7 + ], + "scorePercentiles" : { + "0.0" : 2.0081695594979215E7, + "50.0" : 2.2310687984531026E7, + "90.0" : 2.2605461206244823E7, + "95.0" : 2.2605461206244823E7, + "99.0" : 2.2605461206244823E7, + "99.9" : 2.2605461206244823E7, + "99.99" : 2.2605461206244823E7, + "99.999" : 2.2605461206244823E7, + "99.9999" : 2.2605461206244823E7, + "100.0" : 2.2605461206244823E7 + }, + "scoreUnit" : "ops/s", + "rawData" : [ + [ + 2.0081695594979215E7, + 2.131390023977005E7, + 2.2310687984531026E7, + 2.2379792904530022E7, + 2.2605461206244823E7 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.evolution.scache.bench.CacheBenchmark.getHitRandomKeys", + "mode" : "thrpt", + "threads" : 1, + "forks" : 1, + "jvm" : "/usr/lib/jvm/temurin-25-jdk-arm64/bin/java", + "jvmArgs" : [ + ], + "jdkVersion" : "25.0.4", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.4+7-LTS", + "warmupIterations" : 1, + "warmupTime" : "3 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "2 s", + "measurementBatchSize" : 1, + "params" : { + "flavor" : "partitioned" + }, + "primaryMetric" : { + "score" : 2.136679595243842E7, + "scoreError" : 1910198.4313375792, + "scoreConfidence" : [ + 1.945659752110084E7, + 2.3276994383776E7 + ], + "scorePercentiles" : { + "0.0" : 2.052901742214534E7, + "50.0" : 2.1458461146165032E7, + "90.0" : 2.1769067301032174E7, + "95.0" : 2.1769067301032174E7, + "99.0" : 2.1769067301032174E7, + "99.9" : 2.1769067301032174E7, + "99.99" : 2.1769067301032174E7, + "99.999" : 2.1769067301032174E7, + "99.9999" : 2.1769067301032174E7, + "100.0" : 2.1769067301032174E7 + }, + "scoreUnit" : "ops/s", + "rawData" : [ + [ + 2.052901742214534E7, + 2.1375713418298718E7, + 2.1458461146165032E7, + 2.1701720474550862E7, + 2.1769067301032174E7 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.evolution.scache.bench.CacheBenchmark.getHitRandomKeys", + "mode" : "thrpt", + "threads" : 1, + "forks" : 1, + "jvm" : "/usr/lib/jvm/temurin-25-jdk-arm64/bin/java", + "jvmArgs" : [ + ], + "jdkVersion" : "25.0.4", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.4+7-LTS", + "warmupIterations" : 1, + "warmupTime" : "3 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "2 s", + "measurementBatchSize" : 1, + "params" : { + "flavor" : "expiring" + }, + "primaryMetric" : { + "score" : 1.2675177745194595E7, + "scoreError" : 432636.6995276609, + "scoreConfidence" : [ + 1.2242541045666935E7, + 1.3107814444722256E7 + ], + "scorePercentiles" : { + "0.0" : 1.2533921340431755E7, + "50.0" : 1.2689733918666331E7, + "90.0" : 1.2801837574175106E7, + "95.0" : 1.2801837574175106E7, + "99.0" : 1.2801837574175106E7, + "99.9" : 1.2801837574175106E7, + "99.99" : 1.2801837574175106E7, + "99.999" : 1.2801837574175106E7, + "99.9999" : 1.2801837574175106E7, + "100.0" : 1.2801837574175106E7 + }, + "scoreUnit" : "ops/s", + "rawData" : [ + [ + 1.2590681507322881E7, + 1.2689733918666331E7, + 1.2533921340431755E7, + 1.2801837574175106E7, + 1.2759714385376912E7 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.evolution.scache.bench.CacheBenchmark.getOrUpdateHitRandomKeys", + "mode" : "thrpt", + "threads" : 1, + "forks" : 1, + "jvm" : "/usr/lib/jvm/temurin-25-jdk-arm64/bin/java", + "jvmArgs" : [ + ], + "jdkVersion" : "25.0.4", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.4+7-LTS", + "warmupIterations" : 1, + "warmupTime" : "3 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "2 s", + "measurementBatchSize" : 1, + "params" : { + "flavor" : "single" + }, + "primaryMetric" : { + "score" : 9556350.80244932, + "scoreError" : 2933656.516220063, + "scoreConfidence" : [ + 6622694.286229257, + 1.2490007318669382E7 + ], + "scorePercentiles" : { + "0.0" : 8232155.611846094, + "50.0" : 9913316.977013214, + "90.0" : 1.0064043243061196E7, + "95.0" : 1.0064043243061196E7, + "99.0" : 1.0064043243061196E7, + "99.9" : 1.0064043243061196E7, + "99.99" : 1.0064043243061196E7, + "99.999" : 1.0064043243061196E7, + "99.9999" : 1.0064043243061196E7, + "100.0" : 1.0064043243061196E7 + }, + "scoreUnit" : "ops/s", + "rawData" : [ + [ + 8232155.611846094, + 9589301.729088498, + 9913316.977013214, + 9982936.45123759, + 1.0064043243061196E7 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.evolution.scache.bench.CacheBenchmark.getOrUpdateHitRandomKeys", + "mode" : "thrpt", + "threads" : 1, + "forks" : 1, + "jvm" : "/usr/lib/jvm/temurin-25-jdk-arm64/bin/java", + "jvmArgs" : [ + ], + "jdkVersion" : "25.0.4", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.4+7-LTS", + "warmupIterations" : 1, + "warmupTime" : "3 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "2 s", + "measurementBatchSize" : 1, + "params" : { + "flavor" : "partitioned" + }, + "primaryMetric" : { + "score" : 1.0678442144623997E7, + "scoreError" : 865953.3582824023, + "scoreConfidence" : [ + 9812488.786341595, + 1.1544395502906399E7 + ], + "scorePercentiles" : { + "0.0" : 1.0283514302068304E7, + "50.0" : 1.0742798620523794E7, + "90.0" : 1.0838678211317401E7, + "95.0" : 1.0838678211317401E7, + "99.0" : 1.0838678211317401E7, + "99.9" : 1.0838678211317401E7, + "99.99" : 1.0838678211317401E7, + "99.999" : 1.0838678211317401E7, + "99.9999" : 1.0838678211317401E7, + "100.0" : 1.0838678211317401E7 + }, + "scoreUnit" : "ops/s", + "rawData" : [ + [ + 1.0283514302068304E7, + 1.0742798620523794E7, + 1.0731977149710754E7, + 1.0838678211317401E7, + 1.079524243949973E7 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.evolution.scache.bench.CacheBenchmark.getOrUpdateHitRandomKeys", + "mode" : "thrpt", + "threads" : 1, + "forks" : 1, + "jvm" : "/usr/lib/jvm/temurin-25-jdk-arm64/bin/java", + "jvmArgs" : [ + ], + "jdkVersion" : "25.0.4", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.4+7-LTS", + "warmupIterations" : 1, + "warmupTime" : "3 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "2 s", + "measurementBatchSize" : 1, + "params" : { + "flavor" : "expiring" + }, + "primaryMetric" : { + "score" : 7890637.145902408, + "scoreError" : 3207007.3351330687, + "scoreConfidence" : [ + 4683629.81076934, + 1.1097644481035477E7 + ], + "scorePercentiles" : { + "0.0" : 6637084.796563348, + "50.0" : 8442102.51538766, + "90.0" : 8502972.86107493, + "95.0" : 8502972.86107493, + "99.0" : 8502972.86107493, + "99.9" : 8502972.86107493, + "99.99" : 8502972.86107493, + "99.999" : 8502972.86107493, + "99.9999" : 8502972.86107493, + "100.0" : 8502972.86107493 + }, + "scoreUnit" : "ops/s", + "rawData" : [ + [ + 6637084.796563348, + 7425579.575101286, + 8442102.51538766, + 8445445.981384816, + 8502972.86107493 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.evolution.scache.bench.CacheBenchmark.getOrUpdateHitSingleHotKey", + "mode" : "thrpt", + "threads" : 1, + "forks" : 1, + "jvm" : "/usr/lib/jvm/temurin-25-jdk-arm64/bin/java", + "jvmArgs" : [ + ], + "jdkVersion" : "25.0.4", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.4+7-LTS", + "warmupIterations" : 1, + "warmupTime" : "3 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "2 s", + "measurementBatchSize" : 1, + "params" : { + "flavor" : "single" + }, + "primaryMetric" : { + "score" : 1.0647052535059381E7, + "scoreError" : 908172.9386971546, + "scoreConfidence" : [ + 9738879.596362226, + 1.1555225473756537E7 + ], + "scorePercentiles" : { + "0.0" : 1.0312061852126697E7, + "50.0" : 1.0761203837061074E7, + "90.0" : 1.0843945010610003E7, + "95.0" : 1.0843945010610003E7, + "99.0" : 1.0843945010610003E7, + "99.9" : 1.0843945010610003E7, + "99.99" : 1.0843945010610003E7, + "99.999" : 1.0843945010610003E7, + "99.9999" : 1.0843945010610003E7, + "100.0" : 1.0843945010610003E7 + }, + "scoreUnit" : "ops/s", + "rawData" : [ + [ + 1.0312061852126697E7, + 1.0761203837061074E7, + 1.0488442376385598E7, + 1.0843945010610003E7, + 1.0829609599113524E7 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.evolution.scache.bench.CacheBenchmark.getOrUpdateHitSingleHotKey", + "mode" : "thrpt", + "threads" : 1, + "forks" : 1, + "jvm" : "/usr/lib/jvm/temurin-25-jdk-arm64/bin/java", + "jvmArgs" : [ + ], + "jdkVersion" : "25.0.4", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.4+7-LTS", + "warmupIterations" : 1, + "warmupTime" : "3 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "2 s", + "measurementBatchSize" : 1, + "params" : { + "flavor" : "partitioned" + }, + "primaryMetric" : { + "score" : 1.2143347636833161E7, + "scoreError" : 2532473.185762147, + "scoreConfidence" : [ + 9610874.451071015, + 1.4675820822595308E7 + ], + "scorePercentiles" : { + "0.0" : 1.097553632337268E7, + "50.0" : 1.2404860398847016E7, + "90.0" : 1.2568646480506606E7, + "95.0" : 1.2568646480506606E7, + "99.0" : 1.2568646480506606E7, + "99.9" : 1.2568646480506606E7, + "99.99" : 1.2568646480506606E7, + "99.999" : 1.2568646480506606E7, + "99.9999" : 1.2568646480506606E7, + "100.0" : 1.2568646480506606E7 + }, + "scoreUnit" : "ops/s", + "rawData" : [ + [ + 1.097553632337268E7, + 1.2357246244976053E7, + 1.2568646480506606E7, + 1.2404860398847016E7, + 1.241044873646345E7 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.evolution.scache.bench.CacheBenchmark.getOrUpdateHitSingleHotKey", + "mode" : "thrpt", + "threads" : 1, + "forks" : 1, + "jvm" : "/usr/lib/jvm/temurin-25-jdk-arm64/bin/java", + "jvmArgs" : [ + ], + "jdkVersion" : "25.0.4", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.4+7-LTS", + "warmupIterations" : 1, + "warmupTime" : "3 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "2 s", + "measurementBatchSize" : 1, + "params" : { + "flavor" : "expiring" + }, + "primaryMetric" : { + "score" : 9378680.328936517, + "scoreError" : 982420.6031170777, + "scoreConfidence" : [ + 8396259.725819439, + 1.0361100932053596E7 + ], + "scorePercentiles" : { + "0.0" : 8974443.743600614, + "50.0" : 9479807.389392955, + "90.0" : 9592566.154525455, + "95.0" : 9592566.154525455, + "99.0" : 9592566.154525455, + "99.9" : 9592566.154525455, + "99.99" : 9592566.154525455, + "99.999" : 9592566.154525455, + "99.9999" : 9592566.154525455, + "100.0" : 9592566.154525455 + }, + "scoreUnit" : "ops/s", + "rawData" : [ + [ + 8974443.743600614, + 9287264.844333252, + 9479807.389392955, + 9559319.512830311, + 9592566.154525455 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.evolution.scache.bench.CacheBenchmark.getOrUpdateInsertDistinctKeys", + "mode" : "thrpt", + "threads" : 1, + "forks" : 1, + "jvm" : "/usr/lib/jvm/temurin-25-jdk-arm64/bin/java", + "jvmArgs" : [ + ], + "jdkVersion" : "25.0.4", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.4+7-LTS", + "warmupIterations" : 1, + "warmupTime" : "3 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "2 s", + "measurementBatchSize" : 1, + "params" : { + "flavor" : "single" + }, + "primaryMetric" : { + "score" : 1253353.2148731076, + "scoreError" : 144163.4089606328, + "scoreConfidence" : [ + 1109189.8059124749, + 1397516.6238337404 + ], + "scorePercentiles" : { + "0.0" : 1207483.8870237072, + "50.0" : 1254419.5979144573, + "90.0" : 1304434.668470544, + "95.0" : 1304434.668470544, + "99.0" : 1304434.668470544, + "99.9" : 1304434.668470544, + "99.99" : 1304434.668470544, + "99.999" : 1304434.668470544, + "99.9999" : 1304434.668470544, + "100.0" : 1304434.668470544 + }, + "scoreUnit" : "ops/s", + "rawData" : [ + [ + 1207483.8870237072, + 1229327.461338026, + 1304434.668470544, + 1271100.459618804, + 1254419.5979144573 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.evolution.scache.bench.CacheBenchmark.getOrUpdateInsertDistinctKeys", + "mode" : "thrpt", + "threads" : 1, + "forks" : 1, + "jvm" : "/usr/lib/jvm/temurin-25-jdk-arm64/bin/java", + "jvmArgs" : [ + ], + "jdkVersion" : "25.0.4", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.4+7-LTS", + "warmupIterations" : 1, + "warmupTime" : "3 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "2 s", + "measurementBatchSize" : 1, + "params" : { + "flavor" : "partitioned" + }, + "primaryMetric" : { + "score" : 2107224.7218189174, + "scoreError" : 73260.93856119034, + "scoreConfidence" : [ + 2033963.783257727, + 2180485.660380108 + ], + "scorePercentiles" : { + "0.0" : 2087234.443357716, + "50.0" : 2098368.9134500367, + "90.0" : 2134047.996604957, + "95.0" : 2134047.996604957, + "99.0" : 2134047.996604957, + "99.9" : 2134047.996604957, + "99.99" : 2134047.996604957, + "99.999" : 2134047.996604957, + "99.9999" : 2134047.996604957, + "100.0" : 2134047.996604957 + }, + "scoreUnit" : "ops/s", + "rawData" : [ + [ + 2097093.2303452946, + 2119379.0253365827, + 2098368.9134500367, + 2134047.996604957, + 2087234.443357716 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.evolution.scache.bench.CacheBenchmark.getOrUpdateInsertDistinctKeys", + "mode" : "thrpt", + "threads" : 1, + "forks" : 1, + "jvm" : "/usr/lib/jvm/temurin-25-jdk-arm64/bin/java", + "jvmArgs" : [ + ], + "jdkVersion" : "25.0.4", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.4+7-LTS", + "warmupIterations" : 1, + "warmupTime" : "3 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "2 s", + "measurementBatchSize" : 1, + "params" : { + "flavor" : "expiring" + }, + "primaryMetric" : { + "score" : 1914230.6612272218, + "scoreError" : 128210.15459434713, + "scoreConfidence" : [ + 1786020.5066328747, + 2042440.815821569 + ], + "scorePercentiles" : { + "0.0" : 1888847.3830820452, + "50.0" : 1894839.0163127994, + "90.0" : 1964813.3213276572, + "95.0" : 1964813.3213276572, + "99.0" : 1964813.3213276572, + "99.9" : 1964813.3213276572, + "99.99" : 1964813.3213276572, + "99.999" : 1964813.3213276572, + "99.9999" : 1964813.3213276572, + "100.0" : 1964813.3213276572 + }, + "scoreUnit" : "ops/s", + "rawData" : [ + [ + 1894839.0163127994, + 1890849.780108352, + 1931803.8053052544, + 1888847.3830820452, + 1964813.3213276572 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.evolution.scache.bench.CacheBenchmark.mixedRandomKeys", + "mode" : "thrpt", + "threads" : 1, + "forks" : 1, + "jvm" : "/usr/lib/jvm/temurin-25-jdk-arm64/bin/java", + "jvmArgs" : [ + ], + "jdkVersion" : "25.0.4", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.4+7-LTS", + "warmupIterations" : 1, + "warmupTime" : "3 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "2 s", + "measurementBatchSize" : 1, + "params" : { + "flavor" : "single" + }, + "primaryMetric" : { + "score" : 5396895.228224106, + "scoreError" : 297135.3572683813, + "scoreConfidence" : [ + 5099759.870955725, + 5694030.585492487 + ], + "scorePercentiles" : { + "0.0" : 5303767.247966121, + "50.0" : 5425071.556631504, + "90.0" : 5488523.089563385, + "95.0" : 5488523.089563385, + "99.0" : 5488523.089563385, + "99.9" : 5488523.089563385, + "99.99" : 5488523.089563385, + "99.999" : 5488523.089563385, + "99.9999" : 5488523.089563385, + "100.0" : 5488523.089563385 + }, + "scoreUnit" : "ops/s", + "rawData" : [ + [ + 5436470.3295649765, + 5330643.917394542, + 5303767.247966121, + 5425071.556631504, + 5488523.089563385 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.evolution.scache.bench.CacheBenchmark.mixedRandomKeys", + "mode" : "thrpt", + "threads" : 1, + "forks" : 1, + "jvm" : "/usr/lib/jvm/temurin-25-jdk-arm64/bin/java", + "jvmArgs" : [ + ], + "jdkVersion" : "25.0.4", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.4+7-LTS", + "warmupIterations" : 1, + "warmupTime" : "3 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "2 s", + "measurementBatchSize" : 1, + "params" : { + "flavor" : "partitioned" + }, + "primaryMetric" : { + "score" : 6319172.36619421, + "scoreError" : 341152.9393909181, + "scoreConfidence" : [ + 5978019.426803292, + 6660325.305585128 + ], + "scorePercentiles" : { + "0.0" : 6247116.580698031, + "50.0" : 6309656.365791242, + "90.0" : 6466804.885572892, + "95.0" : 6466804.885572892, + "99.0" : 6466804.885572892, + "99.9" : 6466804.885572892, + "99.99" : 6466804.885572892, + "99.999" : 6466804.885572892, + "99.9999" : 6466804.885572892, + "100.0" : 6466804.885572892 + }, + "scoreUnit" : "ops/s", + "rawData" : [ + [ + 6247116.580698031, + 6253451.933914791, + 6309656.365791242, + 6318832.064994099, + 6466804.885572892 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.evolution.scache.bench.CacheBenchmark.mixedRandomKeys", + "mode" : "thrpt", + "threads" : 1, + "forks" : 1, + "jvm" : "/usr/lib/jvm/temurin-25-jdk-arm64/bin/java", + "jvmArgs" : [ + ], + "jdkVersion" : "25.0.4", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.4+7-LTS", + "warmupIterations" : 1, + "warmupTime" : "3 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "2 s", + "measurementBatchSize" : 1, + "params" : { + "flavor" : "expiring" + }, + "primaryMetric" : { + "score" : 5079640.769863769, + "scoreError" : 196585.28113545506, + "scoreConfidence" : [ + 4883055.488728315, + 5276226.050999224 + ], + "scorePercentiles" : { + "0.0" : 5022015.116776671, + "50.0" : 5064039.304473805, + "90.0" : 5154656.62996337, + "95.0" : 5154656.62996337, + "99.0" : 5154656.62996337, + "99.9" : 5154656.62996337, + "99.99" : 5154656.62996337, + "99.999" : 5154656.62996337, + "99.9999" : 5154656.62996337, + "100.0" : 5154656.62996337 + }, + "scoreUnit" : "ops/s", + "rawData" : [ + [ + 5064039.304473805, + 5053923.423285929, + 5022015.116776671, + 5103569.374819071, + 5154656.62996337 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.evolution.scache.bench.CacheBenchmark.modifyInsertDistinctKeys", + "mode" : "thrpt", + "threads" : 1, + "forks" : 1, + "jvm" : "/usr/lib/jvm/temurin-25-jdk-arm64/bin/java", + "jvmArgs" : [ + ], + "jdkVersion" : "25.0.4", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.4+7-LTS", + "warmupIterations" : 1, + "warmupTime" : "3 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "2 s", + "measurementBatchSize" : 1, + "params" : { + "flavor" : "single" + }, + "primaryMetric" : { + "score" : 1878912.4210543842, + "scoreError" : 710903.9727294672, + "scoreConfidence" : [ + 1168008.448324917, + 2589816.3937838515 + ], + "scorePercentiles" : { + "0.0" : 1600679.0614138204, + "50.0" : 1904158.1997164863, + "90.0" : 2050392.6750582848, + "95.0" : 2050392.6750582848, + "99.0" : 2050392.6750582848, + "99.9" : 2050392.6750582848, + "99.99" : 2050392.6750582848, + "99.999" : 2050392.6750582848, + "99.9999" : 2050392.6750582848, + "100.0" : 2050392.6750582848 + }, + "scoreUnit" : "ops/s", + "rawData" : [ + [ + 1600679.0614138204, + 1806619.426479329, + 1904158.1997164863, + 2032712.7426040012, + 2050392.6750582848 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.evolution.scache.bench.CacheBenchmark.modifyInsertDistinctKeys", + "mode" : "thrpt", + "threads" : 1, + "forks" : 1, + "jvm" : "/usr/lib/jvm/temurin-25-jdk-arm64/bin/java", + "jvmArgs" : [ + ], + "jdkVersion" : "25.0.4", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.4+7-LTS", + "warmupIterations" : 1, + "warmupTime" : "3 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "2 s", + "measurementBatchSize" : 1, + "params" : { + "flavor" : "partitioned" + }, + "primaryMetric" : { + "score" : 6129375.557986379, + "scoreError" : 1038058.0956069265, + "scoreConfidence" : [ + 5091317.462379452, + 7167433.6535933055 + ], + "scorePercentiles" : { + "0.0" : 5737430.714697042, + "50.0" : 6223255.650005074, + "90.0" : 6430690.723111775, + "95.0" : 6430690.723111775, + "99.0" : 6430690.723111775, + "99.9" : 6430690.723111775, + "99.99" : 6430690.723111775, + "99.999" : 6430690.723111775, + "99.9999" : 6430690.723111775, + "100.0" : 6430690.723111775 + }, + "scoreUnit" : "ops/s", + "rawData" : [ + [ + 5737430.714697042, + 6430690.723111775, + 6264615.258024062, + 6223255.650005074, + 5990885.444093944 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.evolution.scache.bench.CacheBenchmark.modifyInsertDistinctKeys", + "mode" : "thrpt", + "threads" : 1, + "forks" : 1, + "jvm" : "/usr/lib/jvm/temurin-25-jdk-arm64/bin/java", + "jvmArgs" : [ + ], + "jdkVersion" : "25.0.4", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.4+7-LTS", + "warmupIterations" : 1, + "warmupTime" : "3 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "2 s", + "measurementBatchSize" : 1, + "params" : { + "flavor" : "expiring" + }, + "primaryMetric" : { + "score" : 7146225.916640465, + "scoreError" : 1320913.3081067128, + "scoreConfidence" : [ + 5825312.608533752, + 8467139.224747177 + ], + "scorePercentiles" : { + "0.0" : 6681314.535673143, + "50.0" : 7371364.66202603, + "90.0" : 7423390.68066954, + "95.0" : 7423390.68066954, + "99.0" : 7423390.68066954, + "99.9" : 7423390.68066954, + "99.99" : 7423390.68066954, + "99.999" : 7423390.68066954, + "99.9999" : 7423390.68066954, + "100.0" : 7423390.68066954 + }, + "scoreUnit" : "ops/s", + "rawData" : [ + [ + 6876185.396613163, + 6681314.535673143, + 7423390.68066954, + 7378874.308220445, + 7371364.66202603 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.evolution.scache.bench.CacheBenchmark.modifyUpdateRandomKeys", + "mode" : "thrpt", + "threads" : 1, + "forks" : 1, + "jvm" : "/usr/lib/jvm/temurin-25-jdk-arm64/bin/java", + "jvmArgs" : [ + ], + "jdkVersion" : "25.0.4", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.4+7-LTS", + "warmupIterations" : 1, + "warmupTime" : "3 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "2 s", + "measurementBatchSize" : 1, + "params" : { + "flavor" : "single" + }, + "primaryMetric" : { + "score" : 7235161.054910319, + "scoreError" : 868101.8851271113, + "scoreConfidence" : [ + 6367059.169783208, + 8103262.94003743 + ], + "scorePercentiles" : { + "0.0" : 6838042.764532054, + "50.0" : 7333214.81689126, + "90.0" : 7380274.508596127, + "95.0" : 7380274.508596127, + "99.0" : 7380274.508596127, + "99.9" : 7380274.508596127, + "99.99" : 7380274.508596127, + "99.999" : 7380274.508596127, + "99.9999" : 7380274.508596127, + "100.0" : 7380274.508596127 + }, + "scoreUnit" : "ops/s", + "rawData" : [ + [ + 6838042.764532054, + 7272939.186993804, + 7333214.81689126, + 7380274.508596127, + 7351333.997538346 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.evolution.scache.bench.CacheBenchmark.modifyUpdateRandomKeys", + "mode" : "thrpt", + "threads" : 1, + "forks" : 1, + "jvm" : "/usr/lib/jvm/temurin-25-jdk-arm64/bin/java", + "jvmArgs" : [ + ], + "jdkVersion" : "25.0.4", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.4+7-LTS", + "warmupIterations" : 1, + "warmupTime" : "3 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "2 s", + "measurementBatchSize" : 1, + "params" : { + "flavor" : "partitioned" + }, + "primaryMetric" : { + "score" : 8030418.014427399, + "scoreError" : 1135749.166291302, + "scoreConfidence" : [ + 6894668.848136097, + 9166167.180718701 + ], + "scorePercentiles" : { + "0.0" : 7542307.161561721, + "50.0" : 8099953.18405735, + "90.0" : 8270018.419383614, + "95.0" : 8270018.419383614, + "99.0" : 8270018.419383614, + "99.9" : 8270018.419383614, + "99.99" : 8270018.419383614, + "99.999" : 8270018.419383614, + "99.9999" : 8270018.419383614, + "100.0" : 8270018.419383614 + }, + "scoreUnit" : "ops/s", + "rawData" : [ + [ + 7542307.161561721, + 7994463.829800631, + 8099953.18405735, + 8245347.477333684, + 8270018.419383614 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.evolution.scache.bench.CacheBenchmark.modifyUpdateRandomKeys", + "mode" : "thrpt", + "threads" : 1, + "forks" : 1, + "jvm" : "/usr/lib/jvm/temurin-25-jdk-arm64/bin/java", + "jvmArgs" : [ + ], + "jdkVersion" : "25.0.4", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.4+7-LTS", + "warmupIterations" : 1, + "warmupTime" : "3 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "2 s", + "measurementBatchSize" : 1, + "params" : { + "flavor" : "expiring" + }, + "primaryMetric" : { + "score" : 9630572.550058808, + "scoreError" : 2720816.029386905, + "scoreConfidence" : [ + 6909756.520671903, + 1.2351388579445712E7 + ], + "scorePercentiles" : { + "0.0" : 8388855.925012687, + "50.0" : 9987812.57645374, + "90.0" : 1.0064960216799403E7, + "95.0" : 1.0064960216799403E7, + "99.0" : 1.0064960216799403E7, + "99.9" : 1.0064960216799403E7, + "99.99" : 1.0064960216799403E7, + "99.999" : 1.0064960216799403E7, + "99.9999" : 1.0064960216799403E7, + "100.0" : 1.0064960216799403E7 + }, + "scoreUnit" : "ops/s", + "rawData" : [ + [ + 8388855.925012687, + 9718505.620580476, + 9992728.41144773, + 9987812.57645374, + 1.0064960216799403E7 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.evolution.scache.bench.CacheBenchmark.putInsertDistinctKeys", + "mode" : "thrpt", + "threads" : 1, + "forks" : 1, + "jvm" : "/usr/lib/jvm/temurin-25-jdk-arm64/bin/java", + "jvmArgs" : [ + ], + "jdkVersion" : "25.0.4", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.4+7-LTS", + "warmupIterations" : 1, + "warmupTime" : "3 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "2 s", + "measurementBatchSize" : 1, + "params" : { + "flavor" : "single" + }, + "primaryMetric" : { + "score" : 1663899.5832672145, + "scoreError" : 659633.0505148813, + "scoreConfidence" : [ + 1004266.5327523332, + 2323532.6337820957 + ], + "scorePercentiles" : { + "0.0" : 1372915.4396342183, + "50.0" : 1763971.7069522035, + "90.0" : 1772858.9117825238, + "95.0" : 1772858.9117825238, + "99.0" : 1772858.9117825238, + "99.9" : 1772858.9117825238, + "99.99" : 1772858.9117825238, + "99.999" : 1772858.9117825238, + "99.9999" : 1772858.9117825238, + "100.0" : 1772858.9117825238 + }, + "scoreUnit" : "ops/s", + "rawData" : [ + [ + 1372915.4396342183, + 1643783.3081010312, + 1763971.7069522035, + 1765968.5498660954, + 1772858.9117825238 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.evolution.scache.bench.CacheBenchmark.putInsertDistinctKeys", + "mode" : "thrpt", + "threads" : 1, + "forks" : 1, + "jvm" : "/usr/lib/jvm/temurin-25-jdk-arm64/bin/java", + "jvmArgs" : [ + ], + "jdkVersion" : "25.0.4", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.4+7-LTS", + "warmupIterations" : 1, + "warmupTime" : "3 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "2 s", + "measurementBatchSize" : 1, + "params" : { + "flavor" : "partitioned" + }, + "primaryMetric" : { + "score" : 5398596.1357309045, + "scoreError" : 925411.849705046, + "scoreConfidence" : [ + 4473184.2860258585, + 6324007.985435951 + ], + "scorePercentiles" : { + "0.0" : 5195910.895499133, + "50.0" : 5340762.000445858, + "90.0" : 5814079.125259639, + "95.0" : 5814079.125259639, + "99.0" : 5814079.125259639, + "99.9" : 5814079.125259639, + "99.99" : 5814079.125259639, + "99.999" : 5814079.125259639, + "99.9999" : 5814079.125259639, + "100.0" : 5814079.125259639 + }, + "scoreUnit" : "ops/s", + "rawData" : [ + [ + 5195910.895499133, + 5340762.000445858, + 5352485.278109238, + 5814079.125259639, + 5289743.379340656 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.evolution.scache.bench.CacheBenchmark.putInsertDistinctKeys", + "mode" : "thrpt", + "threads" : 1, + "forks" : 1, + "jvm" : "/usr/lib/jvm/temurin-25-jdk-arm64/bin/java", + "jvmArgs" : [ + ], + "jdkVersion" : "25.0.4", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.4+7-LTS", + "warmupIterations" : 1, + "warmupTime" : "3 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "2 s", + "measurementBatchSize" : 1, + "params" : { + "flavor" : "expiring" + }, + "primaryMetric" : { + "score" : 5468882.5744210975, + "scoreError" : 757216.8948121608, + "scoreConfidence" : [ + 4711665.679608936, + 6226099.469233259 + ], + "scorePercentiles" : { + "0.0" : 5199349.300253403, + "50.0" : 5537402.313959748, + "90.0" : 5668745.757221077, + "95.0" : 5668745.757221077, + "99.0" : 5668745.757221077, + "99.9" : 5668745.757221077, + "99.99" : 5668745.757221077, + "99.999" : 5668745.757221077, + "99.9999" : 5668745.757221077, + "100.0" : 5668745.757221077 + }, + "scoreUnit" : "ops/s", + "rawData" : [ + [ + 5537402.313959748, + 5332726.631343062, + 5199349.300253403, + 5668745.757221077, + 5606188.869328199 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.evolution.scache.bench.CacheBenchmark.putReplaceRandomKeys", + "mode" : "thrpt", + "threads" : 1, + "forks" : 1, + "jvm" : "/usr/lib/jvm/temurin-25-jdk-arm64/bin/java", + "jvmArgs" : [ + ], + "jdkVersion" : "25.0.4", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.4+7-LTS", + "warmupIterations" : 1, + "warmupTime" : "3 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "2 s", + "measurementBatchSize" : 1, + "params" : { + "flavor" : "single" + }, + "primaryMetric" : { + "score" : 8133525.524794443, + "scoreError" : 172710.0605249215, + "scoreConfidence" : [ + 7960815.464269521, + 8306235.585319364 + ], + "scorePercentiles" : { + "0.0" : 8076726.563299344, + "50.0" : 8137610.041392742, + "90.0" : 8188105.828193315, + "95.0" : 8188105.828193315, + "99.0" : 8188105.828193315, + "99.9" : 8188105.828193315, + "99.99" : 8188105.828193315, + "99.999" : 8188105.828193315, + "99.9999" : 8188105.828193315, + "100.0" : 8188105.828193315 + }, + "scoreUnit" : "ops/s", + "rawData" : [ + [ + 8076726.563299344, + 8162786.496507161, + 8102398.694579652, + 8137610.041392742, + 8188105.828193315 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.evolution.scache.bench.CacheBenchmark.putReplaceRandomKeys", + "mode" : "thrpt", + "threads" : 1, + "forks" : 1, + "jvm" : "/usr/lib/jvm/temurin-25-jdk-arm64/bin/java", + "jvmArgs" : [ + ], + "jdkVersion" : "25.0.4", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.4+7-LTS", + "warmupIterations" : 1, + "warmupTime" : "3 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "2 s", + "measurementBatchSize" : 1, + "params" : { + "flavor" : "partitioned" + }, + "primaryMetric" : { + "score" : 7371216.039769781, + "scoreError" : 561587.2248889989, + "scoreConfidence" : [ + 6809628.814880782, + 7932803.26465878 + ], + "scorePercentiles" : { + "0.0" : 7224682.2587843845, + "50.0" : 7310793.784925936, + "90.0" : 7541122.523761597, + "95.0" : 7541122.523761597, + "99.0" : 7541122.523761597, + "99.9" : 7541122.523761597, + "99.99" : 7541122.523761597, + "99.999" : 7541122.523761597, + "99.9999" : 7541122.523761597, + "100.0" : 7541122.523761597 + }, + "scoreUnit" : "ops/s", + "rawData" : [ + [ + 7224682.2587843845, + 7310793.784925936, + 7266447.468722469, + 7541122.523761597, + 7513034.162654514 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.evolution.scache.bench.CacheBenchmark.putReplaceRandomKeys", + "mode" : "thrpt", + "threads" : 1, + "forks" : 1, + "jvm" : "/usr/lib/jvm/temurin-25-jdk-arm64/bin/java", + "jvmArgs" : [ + ], + "jdkVersion" : "25.0.4", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.4+7-LTS", + "warmupIterations" : 1, + "warmupTime" : "3 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "2 s", + "measurementBatchSize" : 1, + "params" : { + "flavor" : "expiring" + }, + "primaryMetric" : { + "score" : 7883961.811926027, + "scoreError" : 614479.7651965666, + "scoreConfidence" : [ + 7269482.04672946, + 8498441.577122593 + ], + "scorePercentiles" : { + "0.0" : 7741411.415805305, + "50.0" : 7809309.410113599, + "90.0" : 8065680.21956806, + "95.0" : 8065680.21956806, + "99.0" : 8065680.21956806, + "99.9" : 8065680.21956806, + "99.99" : 8065680.21956806, + "99.999" : 8065680.21956806, + "99.9999" : 8065680.21956806, + "100.0" : 8065680.21956806 + }, + "scoreUnit" : "ops/s", + "rawData" : [ + [ + 7741411.415805305, + 7756245.470820097, + 7809309.410113599, + 8065680.21956806, + 8047162.543323071 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.evolution.scache.bench.CacheBenchmark.removeAndPutRandomKeys", + "mode" : "thrpt", + "threads" : 1, + "forks" : 1, + "jvm" : "/usr/lib/jvm/temurin-25-jdk-arm64/bin/java", + "jvmArgs" : [ + ], + "jdkVersion" : "25.0.4", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.4+7-LTS", + "warmupIterations" : 1, + "warmupTime" : "3 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "2 s", + "measurementBatchSize" : 1, + "params" : { + "flavor" : "single" + }, + "primaryMetric" : { + "score" : 843648.4191691332, + "scoreError" : 96569.37046297507, + "scoreConfidence" : [ + 747079.0487061581, + 940217.7896321083 + ], + "scorePercentiles" : { + "0.0" : 808168.6869634853, + "50.0" : 852732.3205652174, + "90.0" : 873546.9671121296, + "95.0" : 873546.9671121296, + "99.0" : 873546.9671121296, + "99.9" : 873546.9671121296, + "99.99" : 873546.9671121296, + "99.999" : 873546.9671121296, + "99.9999" : 873546.9671121296, + "100.0" : 873546.9671121296 + }, + "scoreUnit" : "ops/s", + "rawData" : [ + [ + 853610.0925564962, + 852732.3205652174, + 808168.6869634853, + 830184.0286483376, + 873546.9671121296 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.evolution.scache.bench.CacheBenchmark.removeAndPutRandomKeys", + "mode" : "thrpt", + "threads" : 1, + "forks" : 1, + "jvm" : "/usr/lib/jvm/temurin-25-jdk-arm64/bin/java", + "jvmArgs" : [ + ], + "jdkVersion" : "25.0.4", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.4+7-LTS", + "warmupIterations" : 1, + "warmupTime" : "3 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "2 s", + "measurementBatchSize" : 1, + "params" : { + "flavor" : "partitioned" + }, + "primaryMetric" : { + "score" : 2453317.648717503, + "scoreError" : 157696.61327866913, + "scoreConfidence" : [ + 2295621.0354388338, + 2611014.2619961724 + ], + "scorePercentiles" : { + "0.0" : 2398732.007820915, + "50.0" : 2467664.707986163, + "90.0" : 2492391.9965532986, + "95.0" : 2492391.9965532986, + "99.0" : 2492391.9965532986, + "99.9" : 2492391.9965532986, + "99.99" : 2492391.9965532986, + "99.999" : 2492391.9965532986, + "99.9999" : 2492391.9965532986, + "100.0" : 2492391.9965532986 + }, + "scoreUnit" : "ops/s", + "rawData" : [ + [ + 2398732.007820915, + 2467664.707986163, + 2492391.9965532986, + 2485489.1753480323, + 2422310.355879108 + ] + ] + }, + "secondaryMetrics" : { + } + }, + { + "jmhVersion" : "1.37", + "benchmark" : "com.evolution.scache.bench.CacheBenchmark.removeAndPutRandomKeys", + "mode" : "thrpt", + "threads" : 1, + "forks" : 1, + "jvm" : "/usr/lib/jvm/temurin-25-jdk-arm64/bin/java", + "jvmArgs" : [ + ], + "jdkVersion" : "25.0.4", + "vmName" : "OpenJDK 64-Bit Server VM", + "vmVersion" : "25.0.4+7-LTS", + "warmupIterations" : 1, + "warmupTime" : "3 s", + "warmupBatchSize" : 1, + "measurementIterations" : 5, + "measurementTime" : "2 s", + "measurementBatchSize" : 1, + "params" : { + "flavor" : "expiring" + }, + "primaryMetric" : { + "score" : 2415054.3817994045, + "scoreError" : 68398.59839741555, + "scoreConfidence" : [ + 2346655.783401989, + 2483452.98019682 + ], + "scorePercentiles" : { + "0.0" : 2385992.235659156, + "50.0" : 2426136.6321967044, + "90.0" : 2426753.735994349, + "95.0" : 2426753.735994349, + "99.0" : 2426753.735994349, + "99.9" : 2426753.735994349, + "99.99" : 2426753.735994349, + "99.999" : 2426753.735994349, + "99.9999" : 2426753.735994349, + "100.0" : 2426753.735994349 + }, + "scoreUnit" : "ops/s", + "rawData" : [ + [ + 2385992.235659156, + 2409886.2173242806, + 2426136.6321967044, + 2426753.735994349, + 2426503.0878225327 + ] + ] + }, + "secondaryMetrics" : { + } + } +] + + From bdb95d517012f4e4560f88eb1b8a62fc8064c3ab Mon Sep 17 00:00:00 2001 From: Stas Shevchenko Date: Fri, 7 Aug 2026 09:22:51 +0200 Subject: [PATCH 7/8] Assert evicted load fails, stop unhandled error noise --- .../com/evolution/scache/CacheDefectsSpec.scala | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/scache/src/test/scala/com/evolution/scache/CacheDefectsSpec.scala b/scache/src/test/scala/com/evolution/scache/CacheDefectsSpec.scala index 6d322f3..c6047c9 100644 --- a/scache/src/test/scala/com/evolution/scache/CacheDefectsSpec.scala +++ b/scache/src/test/scala/com/evolution/scache/CacheDefectsSpec.scala @@ -55,7 +55,8 @@ class CacheDefectsSpec extends AsyncFunSuite with Matchers { for { started <- Deferred[IO, Unit] gate <- Deferred[IO, Unit] - loader <- cache.getOrUpdate(0) { started.complete(()) *> gate.get.as(1) }.start + // Attempted, see the test evicting a stuck Loading entry below. + loader <- cache.getOrUpdate(0) { started.complete(()) *> gate.get.as(1) }.attempt.start _ <- started.get result <- { for { @@ -182,15 +183,22 @@ class CacheDefectsSpec extends AsyncFunSuite with Matchers { for { started <- Deferred[IO, Unit] gate <- Deferred[IO, Unit] - loader <- cache.getOrUpdate(0) { started.complete(()) *> gate.get.as(1) }.start + // Attempted, because the eviction makes this load fail too, and a fiber left to end in + // `Errored` reports the error to the runtime as unhandled the moment it finishes, before + // the `join` below gets to observe it. + loader <- cache.getOrUpdate(0) { started.complete(()) *> gate.get.as(1) }.attempt.start _ <- started.get waiter <- cache.getOrUpdate(0)(99.pure[IO]).attempt.start result <- { for { outcome <- waiter.joinWithNever.timeout(2.seconds) _ = outcome should matchPattern { case Left(ExpiredError) => } + _ <- gate.complete(()) + // The fiber whose load was evicted learns about it as well. + evicted <- loader.joinWithNever.timeout(2.seconds) + _ = evicted should matchPattern { case Left(ExpiredError) => } } yield () - }.guarantee { gate.complete(()) *> loader.join.void } + }.guarantee { gate.complete(()).attempt *> loader.join.void } } yield result } io.run() From d70469fadf1bc74abda1b96078c66b381a80f6f9 Mon Sep 17 00:00:00 2001 From: Stas Shevchenko Date: Fri, 7 Aug 2026 09:50:44 +0200 Subject: [PATCH 8/8] Fix cleanup interval, correct remove and clear docs --- README.md | 6 +++-- .../com/evolution/scache/ExpiringCache.scala | 17 ++++++++++++- .../com/evolution/scache/LoadingCache.scala | 24 +++++++++++++------ .../evolution/scache/CacheDefectsSpec.scala | 16 ++++++++----- 4 files changed, 47 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index f16c7f3..69ce5a5 100644 --- a/README.md +++ b/README.md @@ -243,8 +243,10 @@ mode. **Cancelling a load cleans up.** Cancelling `getOrUpdate` now removes the entry it installed and fails everyone waiting for that entry with `CancelledError`, instead of leaving the key unusable and -its waiters blocked forever. Code that cancels loads and expects the waiters to keep waiting has to -be adjusted. +its waiters blocked forever. Note that the load is shared, so this reaches callers that were not +cancelled themselves: if two requests ask for the same key, the first one runs the load and the +second one waits for it, then a timeout cancelling the first fails the second with `CancelledError` +as well. It gets to retry, where before it would have hung. **Loads can expire.** `ExpiringCache` evicts entries that have been loading longer than `Config.loadingTimeout`, failing their waiters with `ExpiredError`. The load itself is not diff --git a/scache/src/main/scala/com/evolution/scache/ExpiringCache.scala b/scache/src/main/scala/com/evolution/scache/ExpiringCache.scala index 38a742d..2a841dc 100644 --- a/scache/src/main/scala/com/evolution/scache/ExpiringCache.scala +++ b/scache/src/main/scala/com/evolution/scache/ExpiringCache.scala @@ -16,6 +16,11 @@ object ExpiringCache { type Timestamp = Long + /** + * Shortest delay the cleanup routine is ever scheduled with, in milliseconds. + */ + private val MinExpireInterval = 10L + private[scache] def of[F[_], K, V]( config: Config[F, K, V], )(implicit @@ -33,7 +38,17 @@ object ExpiringCache { val loadingTimeoutMs = config .loadingTimeout .fold(expireAfterMs) { _.toMillis } - val expireInterval = ((expireAfterMs min loadingTimeoutMs) / 10).millis + /* One cleanup run walks every entry, so the interval is what the cost of the routine is traded + * against. Values are sampled ten times per expiration, as before, while loads are sampled only + * twice per `loadingTimeout`, because a load overstaying its welcome by half the timeout is + * harmless and a short `loadingTimeout` next to a long expiration would otherwise turn the + * routine into a busy scan of the whole cache. The floor keeps a tiny configured duration from + * scheduling the routine with no delay at all. + */ + val expireInterval = { + val interval = (expireAfterMs / 10) min (loadingTimeoutMs / 2) + (interval max MinExpireInterval).millis + } /* One run of the expiration routine: drops the values that are too old, evicts the loads that * are taking too long, and enforces `maxSize`. diff --git a/scache/src/main/scala/com/evolution/scache/LoadingCache.scala b/scache/src/main/scala/com/evolution/scache/LoadingCache.scala index 8312123..a0cf3bf 100644 --- a/scache/src/main/scala/com/evolution/scache/LoadingCache.scala +++ b/scache/src/main/scala/com/evolution/scache/LoadingCache.scala @@ -49,8 +49,14 @@ import scala.jdk.CollectionConverters.* * - stores the computed value, moving the entry to `Value` state, or * - drops the entry from the map and propagates the error to the caller and to the waiters, if * the computation failed, or - * - discards its own result, if it lost a race to `put`, `modify`, `remove`, `clear` or - * cancellation, in which case the value of the winner is returned to the caller. + * - discards and releases its own result, if `put` or `modify` stored another value under the key + * meanwhile, in which case the value of the winner is returned to the caller, or + * - discards and releases its own result, if the load was cancelled. + * + * Neither `remove` nor `clear` cancels a load in flight, so a load that outlives one of them still + * has a value on its hands. After a `remove` it stores that value under the key again, putting the + * key back into the cache; after a `clear` it stores it into the entry the `clear` has already + * unlinked, and the `clear` is the one that awaits and releases it. * * `Removed` is a tombstone meaning "this `EntryRef` is no longer in the map, look the key up * again". It is needed because the two levels cannot be updated atomically together, so a fiber @@ -933,8 +939,9 @@ private[scache] object LoadingCache { * the mark is what makes this fiber the one responsible for the release, and what tells the * fibers holding this `EntryRef` that they are looking at a stale reference. * - * A `Loading` entry has no value to return, and is left to the loading fiber to release, - * which it will do upon discovering the `Removed` mark. + * A `Loading` entry has no value to return, and removing it does not cancel the load: the + * loading fiber finds the `Removed` mark, sees that the key is now free, and stores its value + * under it, so a load that outlives the `remove` puts the key back into the cache. */ def remove(key: K): F[F[Option[V]]] = { entryMap @@ -983,9 +990,12 @@ private[scache] object LoadingCache { * Removes all the entries, returning an effect awaiting the release of all their values. * * The keys are unlinked one by one, as there is no atomic bulk operation on a per-key `Ref`, - * so entries added concurrently may survive the clearing. Values of entries that are still - * loading are awaited before being released, which is why a load that never completes would - * make this, and the release of the cache resource, hang. + * so entries added concurrently may survive the clearing. As this also runs on the release of + * the cache resource, an entry added while a large cache is being cleared can outlive the + * cache itself, with its value never released. + * + * Values of entries that are still loading are awaited before being released, which is why a + * load that never completes would make this, and the release of the cache resource, hang. */ def clear: F[F[Unit]] = { entryMap diff --git a/scache/src/test/scala/com/evolution/scache/CacheDefectsSpec.scala b/scache/src/test/scala/com/evolution/scache/CacheDefectsSpec.scala index c6047c9..9fd6b39 100644 --- a/scache/src/test/scala/com/evolution/scache/CacheDefectsSpec.scala +++ b/scache/src/test/scala/com/evolution/scache/CacheDefectsSpec.scala @@ -84,13 +84,16 @@ class CacheDefectsSpec extends AsyncFunSuite with Matchers { gate <- Deferred[IO, Unit] loader <- cache.getOrUpdate(0) { started.complete(()) *> gate.get.as(1) }.start _ <- started.get - waiter <- cache.getOrUpdate(0)(99.pure[IO]).start + // Attempted, so that the failure this test is after is observed as a value: a fiber left to + // end in `Errored` reports the error to the runtime as unhandled the moment it finishes, + // which here races with the `join` below. + waiter <- cache.getOrUpdate(0)(99.pure[IO]).attempt.start _ <- IO.sleep(100.millis) cancelling <- loader.cancel.start result <- { for { - outcome <- waiter.join.timeout(500.millis) - _ = outcome should matchPattern { case Outcome.Errored(CancelledError) => } + outcome <- waiter.joinWithNever.timeout(500.millis) + _ = outcome should matchPattern { case Left(CancelledError) => } present <- cache.get(0) _ = present shouldEqual none } yield () @@ -107,15 +110,16 @@ class CacheDefectsSpec extends AsyncFunSuite with Matchers { gate <- Deferred[IO, Unit] loader <- cache.getOrUpdate(0) { started.complete(()) *> gate.get.as(1) }.start _ <- started.get - waiter <- cache.getOrUpdate(0)(99.pure[IO]).start + // Attempted, see the test above. + waiter <- cache.getOrUpdate(0)(99.pure[IO]).attempt.start _ <- IO.sleep(100.millis) // The entry stops being the loader's, so only the loader itself can still unblock the waiter. _ <- cache.remove(0).flatten cancelling <- loader.cancel.start result <- { for { - outcome <- waiter.join.timeout(500.millis) - _ = outcome should matchPattern { case Outcome.Errored(CancelledError) => } + outcome <- waiter.joinWithNever.timeout(500.millis) + _ = outcome should matchPattern { case Left(CancelledError) => } } yield () }.guarantee { gate.complete(()) *> cancelling.join.void } } yield result