WIP: rebuild cache on MapRef, fix cancellation defects - #369
Conversation
are the following the above-mentioned defects:
|
|
Yes, exactly those four, declared and asserted in |
|
I am at the beginning of review and I understood that I fail to comprehend what happens in new functionality. ScalaDocs are really required! With explicitly explaining |
|
Added scaladocs on LoadingCache, EntryMap, EntryState, the cache operations and the ExpiringCache eviction routine, describing the algorithm and the four defects this change fixes. P1, contention on shared state. The state used to be one Ref[F, Map[K, EntryRef]], so every insert or removal of any key CAS-ed the same Ref, and a getOrUpdate of one key lost its CAS whenever an unrelated key was written. Sustained writes elsewhere starved it, which is what MaxRetries = 10000 and IllegalStateException("extreme contention") were guarding, i.e. contention turned into a user visible failure. Every write also copied the whole map. Now each key has its own Ref over a ConcurrentHashMap, unrelated keys never interfere and the retry limit is gone. P2, cancellation poisoned the key. The value computation ran unmasked inside the retry loop with no onCancel, so cancelling getOrUpdate left Loading(deferred) in the map with the deferred never completed: the key stayed unusable and every waiter blocked forever, and a value computed just as cancellation hit was leaked. Now state transitions are masked, and cancellation unlinks the key, completes the deferred with CancelledError and releases the value if one was produced. P3, expiration ignored Loading. removeExpiredAndCheckSize only inspected Value states, so an entry whose load never completes was never evicted. Now loads that run longer than the expiration interval are evicted and their waiters get ExpiredError. P4, a stuck load blocked finalization, since clear runs on resource release and waits on Loading entries. P2 and P3 remove both ways of getting stuck there. CacheDefectsSpec covers all four. |
where is the code for benchmarks? |
|
@stasimus, could we get the PR with failing (and ignored or explicitly waiting for failures with corresponding comments and verbose printouts) unit-tests against "current" (as in I tried to do the quick rollback of implementation in the branch, but new unit-tests do not compile against old implementation. It also means that this PR might change public API in incompatible way - we must provide the instructions on how to migrate from "old" to "new" APIs! |
| * 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]] = { |
There was a problem hiding this comment.
this will require major version bump
|
|
||
| import scala.util.control.NoStackTrace | ||
|
|
||
| case object ExpiredError extends RuntimeException with NoStackTrace |
There was a problem hiding this comment.
do we need RuntimeException part?
would following work?:
| case object ExpiredError extends RuntimeException with NoStackTrace | |
| case object ExpiredError extends NoStackTrace |
| def removeExpiredAndCheckSize( | ||
| entryMap: LoadingCache.EntryMap[F, K, E], | ||
| cache: Cache[F, K, E], | ||
| loadingSince: Ref[F, Map[K, (DeferredE, Timestamp)]], |
There was a problem hiding this comment.
I cannot grasp why we need (Deferred[F, Either[Throwable, LoadingCache.Entry[F, E]]], Timestamp)
what is the point in having "failure reason AND timestamp"? shouldn't we keep only Either[Throwable, Timestamp]? I guess that we can assume failed loading as "expired", thus due for removal.
| entryMap: LoadingCache.EntryMap[F, K, E], | ||
| cache: Cache[F, K, E], | ||
| loadingSince: Ref[F, Map[K, (DeferredE, Timestamp)]], |
There was a problem hiding this comment.
- Why cannot we use full names, like
KeyandEntryin type-parameters? - We have 3 structures here:
entryMap,cacheandloadingSince- does it mean that we have to manually keep their content in sync? Why do we need them all?
| 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] |
There was a problem hiding this comment.
somehow I like the form: Async[F].whenA() more, like:
| result <- if (size > maxSize) entryMap.entries.flatMap(drop) else ().pure[F] | |
| result <- Async[F].whenA(size > maxSize) { entryMap.entries.flatMap(drop) } |
| ), | ||
| ) | ||
| for { | ||
| _ <- IO.println(f"fibers=$fibers, ops/fiber=$opsPerFiber, keySpace=$keySpace") |
There was a problem hiding this comment.
fibers is not a number of available CPU cores :)
| * sbt "scache/Test/runMain com.evolution.scache.CacheLoadTest" | ||
| * }}} | ||
| */ | ||
| object CacheLoadTest extends IOApp.Simple { |
There was a problem hiding this comment.
feels like this should be in benchmark module, ideally with copy of previous version, otherwise the generated "report" is just a bunch of random values
| } | ||
| } | ||
| _ <- measure("getOrUpdate, hit single hot key") { | ||
| parRun { (_, _) => cache.getOrUpdate(0)(0.pure[IO]).void } |
There was a problem hiding this comment.
here are on line :49, why test .getOrUpdate against populated cache, where we know that it is the same as .get (ok, it would return an Option, but it doesn't affect the semantics, does it?)?
| case 0 => cache.put(key, i).flatten.void | ||
| case 1 => cache.remove(key).flatten.void | ||
| case _ => cache.getOrUpdate(key)(i.pure[IO]).void |
There was a problem hiding this comment.
why we use only these 3 APIs, while Cache exposes more than 3?
| private def scramble(i: Int): Int = { | ||
| val h = i * 0x9e3775cd | ||
| (h ^ (h >>> 16)) & Int.MaxValue | ||
| } |
There was a problem hiding this comment.
how is this better than Random.nextInt(n + 1)?
Rebuilds LoadingCache on cats.effect.std.MapRef: per-key CAS instead of whole-map Ref, cancelable loads with cleanup, ExpiringCache evicts stale Loading entries. Fixes four proven defects; defect spec now asserts expected behavior and passes. Adds load-test bench (Test/runMain CacheLoadTest). Creation bounds widened Concurrent to Async.
Bench: 8 fibers x 100k ops/fiber, keySpace 10k, median of 3 runs, ops/s.