Add CacheListener for observing cache events - #269
Conversation
| * cache. | ||
| */ | ||
| def onHit(key: Key)(implicit unsafe: Unsafe): Unit = | ||
| () |
There was a problem hiding this comment.
I don't think we should provide default implementations for this trait.
There was a problem hiding this comment.
That was my first instinct too, but then I thought about compatibility: with abstract methods, adding a new event in the future becomes a breaking change (source and binary) for every implementation out there, while no-op defaults let the trait grow new events safely. If you're fine with that trade-off, I'll make them abstract. I could also add an open CacheListener.Default base class with no-op implementations for users who only care about a subset of events - would you want that, or keep just the bare trait?
| ZIO.succeed(map.remove(keyBy(in)): Unit) | ||
| ZIO.succeed { | ||
| val k = keyBy(in) | ||
| if (map.remove(k) ne null) trackEviction(k, CacheListener.EvictionCause.Invalidated) |
There was a problem hiding this comment.
Shouldn't invalidateAll below also track evictions?
There was a problem hiding this comment.
Agreed. I'll change invalidateAll to iterate over the keys and remove entries individually, emitting Invalidated for each entry actually removed. Under concurrent puts the semantics are best-effort (an entry inserted while invalidateAll is running may or may not be invalidated), which matches the existing behaviour of map.clear() and what Caffeine does with its removal listeners.
| * so implementations only need to override the events they are interested | ||
| * in. | ||
| */ | ||
| trait CacheListener[-Key, -Error, -Value] { |
There was a problem hiding this comment.
I can already imagine users asking for these methods to be effectful (returning ZIO[R, Nothing, Unit] instead of Unit. And it would kind of make sense - even ZIO's Metric#unsafe API is package-private which means that users won't have a way to increment metrics with this listener.
I have a couple of questions:
- Performance penalty aside, is it possible to have these methods return an effect?
- Do we have a way to quantify what would be the penalty for the no-op case (i.e., methods returning
Exit.unit)..flatMaponExitis relatively cheap as it doesn't go through the runloop, so we might be able to make this effectful without much of a penalty
There was a problem hiding this comment.
Keeping CacheListener non-effectful was purely a performance call, and I agree with your points.
-
Yes, it's possible. get/refresh/invalidate paths can sequence the listener effect naturally. The one tricky spot is capacity eviction: it happens inside the synchronous drain loop in trackAccess (under the updating spinlock), where we can't run arbitrary effects without blocking all other fibers. The fix is to have trackAccess collect the evicted keys (an empty chunk in the common case) and sequence the notifications onto the calling fiber right after, outside the lock.
-
Exactly - since Exit extends ZIO and Exit#flatMap doesn't go through the runloop, a no-op listener returning Exit.unit should cost roughly one lambda allocation per event. I'll prototype the effectful version (methods returning UIO[Unit]) and run FillBenchmark/ChurnBenchmark against the current baseline, and post the numbers here.
On the signature: I'd go with UIO[Unit] rather than ZIO[R, Nothing, Unit] to avoid a fourth type parameter - listeners are constructed in ZIO anyway, so they can close over whatever services they need. WDYT?
| // uncompleted. | ||
| private def notifyListener(f: => Unit): Unit = | ||
| try f | ||
| catch { case NonFatal(_) => () } |
There was a problem hiding this comment.
I'm not a huge fan of this, it'd be a nightmare to debug issues.
At the very minimum, I think it deserves some sort of logging, even if it's printing to Console.err
There was a problem hiding this comment.
Sure. If we go effectful (per the other thread), this disappears nicely: listener defects get handled with catchAllCause(cause => ZIO.logErrorCause("CacheListener failed", cause)), so failures go through ZIO logging and respect the user's loggers. If we stay with synchronous callbacks, I'll print the stack trace to stderr at minimum.
Motivation
Currently the only way to observe cache activity is polling
cacheStats, which exposes just cumulative hit/miss counters. There is no way to react to individual cache events, so common operational needs are impossible to express:invalidationEventsmethod to publish cache invalidation events. #57),Changes
This PR adds a
CacheListenerthat is notified of cache events:onLoadfires for every completed lookup (bothgetmisses andrefresh) with the resultingExitand the time the lookup took.onEvictioncarries the cause:Capacity,Expired, orInvalidated.CacheListener.metrics(cacheName)reports all events with ZIO metrics (zio_cache_hits,zio_cache_misses,zio_cache_load_successes,zio_cache_load_failures,zio_cache_load_duration,zio_cache_evictions), tagged with the cache name.Cache.make,makeWith, andmakeWithKey; withmakeWithKeythe listener observes the keys produced by thekeyByfunction.Design notes
getUnsafe, the eviction loop intrackAccess), where running effects would require either blocking the eviction loop on user code or giving up the zero-allocation hit path. This mirrors Caffeine'sStatsCounter. An effectful adapter (e.g. forking a handler on aRuntime) can be layered on top later without breaking anything.CacheImplementationclass (its constructor gained a parameter).invalidateAlldoes not emit per-key events since the underlying map is cleared wholesale; this is documented.