Skip to content

Add CacheListener for observing cache events - #269

Open
Grryum wants to merge 2 commits into
zio:series/2.xfrom
Grryum:cache-listener
Open

Add CacheListener for observing cache events#269
Grryum wants to merge 2 commits into
zio:series/2.xfrom
Grryum:cache-listener

Conversation

@Grryum

@Grryum Grryum commented Jul 27, 2026

Copy link
Copy Markdown

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:

Changes

This PR adds a CacheListener that is notified of cache events:

trait CacheListener[-Key, -Error, -Value] {
  def onHit(key: Key)(implicit unsafe: Unsafe): Unit
  def onMiss(key: Key)(implicit unsafe: Unsafe): Unit
  def onLoad(key: Key, exit: Exit[Error, Value], loadTime: Duration)(implicit unsafe: Unsafe): Unit
  def onEviction(key: Key, cause: CacheListener.EvictionCause)(implicit unsafe: Unsafe): Unit
}
  • onLoad fires for every completed lookup (both get misses and refresh) with the resulting Exit and the time the lookup took.
  • onEviction carries the cause: Capacity, Expired, or Invalidated.
  • A ready-made 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.
  • A listener is attached via new overloads of Cache.make, makeWith, and makeWithKey; with makeWithKey the listener observes the keys produced by the keyBy function.

Design notes

  • Listeners are synchronous side-effecting callbacks, not ZIO effects. Hits and evictions are tracked on the non-effectful hot path of the cache (getUnsafe, the eviction loop in trackAccess), where running effects would require either blocking the eviction loop on user code or giving up the zero-allocation hit path. This mirrors Caffeine's StatsCounter. An effectful adapter (e.g. forking a handler on a Runtime) can be layered on top later without breaking anything.
  • All methods have no-op defaults, so listeners override only the events they care about, and future events can be added without breaking existing implementations.
  • A listener that throws cannot corrupt the cache: exceptions are caught around every notification, so promises are always completed and the internal state stays consistent.
  • Existing constructors are untouched (binary and source compatible); the only MiMa filter added is for the private CacheImplementation class (its constructor gained a parameter).
  • invalidateAll does not emit per-key events since the underlying map is cleared wholesale; this is documented.

@Grryum
Grryum requested a review from kyri-petrou as a code owner July 27, 2026 11:41
* cache.
*/
def onHit(key: Key)(implicit unsafe: Unsafe): Unit =
()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we should provide default implementations for this trait.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't invalidateAll below also track evictions?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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] {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. Performance penalty aside, is it possible to have these methods return an effect?
  2. Do we have a way to quantify what would be the penalty for the no-op case (i.e., methods returning Exit.unit). .flatMap on Exit is relatively cheap as it doesn't go through the runloop, so we might be able to make this effectful without much of a penalty

@Grryum Grryum Jul 29, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keeping CacheListener non-effectful was purely a performance call, and I agree with your points.

  1. 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.

  2. 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(_) => () }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants