Moka Eviction Listeners Example: Observing Cache Removals in Rust
Moka's eviction listeners are user-supplied callbacks that receive the key, value, and RemovalCause whenever an entry is removed from the cache, enabling you to react to expirations, replacements, and size-based evictions.
The moka-rs/moka crate provides high-performance concurrent caching for Rust with built-in support for eviction notifications. Understanding how to implement a Moka eviction listeners example allows you to track cache lifecycle events, perform cleanup operations, or audit removals in both synchronous and asynchronous contexts.
How Eviction Listeners Work in Moka
Moka implements eviction listeners through a type-erased callback system that supports both synchronous and asynchronous execution. The implementation spans multiple core files to separate registration, storage, and safe execution.
Key components include:
CacheBuilder::eviction_listenerinsrc/sync/builder.rs(lines 25-46) – Registers a synchronous listener as anArc<dyn Fn(Arc<K>, V, RemovalCause) + Send + Sync>CacheBuilder::async_eviction_listenerinsrc/future/builder.rs(lines 350-360) – Registers an async listener returning aListenerFutureRemovalCauseenum insrc/notification.rs(lines 29-46) – Defines why entries disappear:Expired,Explicit,Replaced, orSizeRemovalNotifierinsrc/notification/notifier.rs(lines 25-42) – Executes listeners insidecatch_unwindpanic guards and disables them after panics
The RemovalCause Enum
The RemovalCause type provides granular context about why an entry was removed. In src/notification.rs, the enum defines four variants:
Expired– The entry's time-to-live (TTL) or time-to-idle (TTI) expiredExplicit– The entry was manually removed viainvalidate()orinvalidate_all()Replaced– A new value was inserted for an existing keySize– The entry was evicted to maintain the cache's maximum capacity
Each variant includes the was_evicted() helper method to distinguish between true evictions (Replaced, Size) and explicit removals.
Registering a Synchronous Eviction Listener
For synchronous caches, register a listener during the build phase. The closure receives owned copies of the key and value along with the removal cause.
The following example, adapted from examples/eviction_listener_sync.rs, demonstrates tracking size-based evictions, replacements, and TTL expirations:
use moka::sync::Cache;
use std::{thread::sleep, time::Duration};
fn main() {
// Small cache, 1 second TTL, to see evictions quickly.
let ttl = 1;
let cache = Cache::builder()
.max_capacity(2)
.time_to_live(Duration::from_secs(ttl))
.eviction_listener(|key, value, cause| {
// `key` and `value` are cloned/owned, `cause` tells why it was removed.
println!("Evicted ({key:?}, {value:?}) because {cause:?}");
})
.build();
// Fill the cache – the third insertion forces a `Replaced` eviction.
cache.insert(&0, "zero".to_string());
cache.insert(&1, "one".to_string());
cache.insert(&2, "twice".to_string()); // evicts entry 0 (Size)
cache.insert(&2, "two".to_string()); // evicts entry 2 (Replaced)
// Wait for TTL-based eviction.
sleep(Duration::from_secs(ttl + 1));
// Explicit removal.
if let Some(v) = cache.remove(&2) {
println!("Removed explicitly: {v}");
}
// Force all pending background removals and invoke listeners.
cache.invalidate_all();
while cache.entry_count() > 0 {
cache.run_pending_tasks();
}
}
In this example, the listener fires for three distinct reasons: capacity constraints trigger Size, updating an existing key triggers Replaced, and the TTL expiration triggers Expired. The final loop ensures background eviction tasks complete by calling run_pending_tasks() until entry_count() reaches zero.
Using Async Eviction Listeners
The asynchronous cache in src/future/cache.rs supports non-blocking eviction handlers through async_eviction_listener. Unlike the synchronous version, the async listener must return a boxed future (ListenerFuture) that the cache's housekeeping task awaits sequentially.
This pattern prevents background eviction from outrunning your async cleanup code:
use moka::future::Cache;
use std::{sync::Arc, time::Duration};
#[tokio::main]
async fn main() {
let cache = Cache::builder()
.max_capacity(2)
.time_to_live(Duration::from_secs(1))
.async_eviction_listener(|key, value, cause| {
// Return a future that does the work.
Box::pin(async move {
println!("Async evicted ({:?}, {:?}) because {:?}", key, value, cause);
// Simulate I/O or other async work here.
})
})
.build();
cache.insert(0, "zero".to_string()).await;
cache.insert(1, "one".to_string()).await;
cache.insert(2, "two".to_string()).await; // triggers eviction
tokio::time::sleep(Duration::from_secs(2)).await; // TTL eviction
// `invalidate_all` triggers async notifications for remaining entries.
cache.invalidate_all().await;
}
The async implementation stores the listener as Option<AsyncEvictionListener<…>> and clones it for each cache segment in src/sync/segment.rs, ensuring thread-safe access across concurrent operations.
Panic Safety and Listener Lifecycle
Moka guards against panicking listeners through RemovalNotifier::notify in src/notification/notifier.rs. The notifier wraps each listener call in std::panic::catch_unwind, converting panics into logged warnings rather than cache crashes.
If a listener panics, the notifier sets its internal is_enabled flag to false, permanently disabling further notifications while keeping the cache operational:
use moka::sync::Cache;
fn main() {
let cache = Cache::builder()
.max_capacity(1)
.eviction_listener(|_k, _v, _c| panic!("oops!"))
.build();
cache.insert(&1, "one".to_string()); // first entry
cache.insert(&2, "two".to_string()); // causes eviction → listener panics
// The listener is now disabled; further evictions will NOT call it.
cache.insert(&3, "three".to_string()); // silent eviction
}
This safety mechanism ensures that a buggy eviction listener cannot destabilize production systems. When the logging feature is enabled, Moka logs the panic reason before disabling the notifier.
When Eviction Listeners Are Not Invoked
Not all cache removals trigger eviction listeners. Understanding these boundaries is crucial for reliable cache instrumentation:
- Cache dropping – When the
Cacheobject is dropped, entries are destroyed without invoking listeners. Usecache.invalidate_all()(orinvalidate_all().awaitfor async) before dropping if you require notifications for every entry. - Delayed background tasks – In synchronous caches, background invalidations may delay if
run_pending_tasks()is not called periodically. Loop untilentry_count() == 0to guarantee delivery of all pending notifications.
Summary
- Moka eviction listeners provide callbacks that receive
Arc<K>, ownedV, andRemovalCausewhenever entries are removed from the cache. - Register synchronous listeners via
CacheBuilder::eviction_listenerinsrc/sync/builder.rsor async variants viaasync_eviction_listenerinsrc/future/builder.rs. - The
RemovalCauseenum distinguishes betweenExpired,Explicit,Replaced, andSizeremovals. - Panic safety is enforced by
RemovalNotifierinsrc/notification/notifier.rs, which catches panics and disables the listener to prevent cache crashes. - Async listeners return boxed futures that the cache awaits sequentially, ensuring background eviction synchronization.
- Listeners do not fire when the cache is dropped; explicitly call
invalidate_all()to trigger notifications for remaining entries.
Frequently Asked Questions
What is an eviction listener in Moka?
An eviction listener is a user-provided callback function that Moka executes whenever an entry is removed from the cache. According to the moka-rs/moka source code, the listener receives the key as an Arc<K>, the value as an owned V, and a RemovalCause enum indicating whether the removal was due to expiration, explicit invalidation, replacement, or size constraints.
How do I ensure my eviction listener fires for all entries?
To guarantee your eviction listener processes every removal, explicitly call invalidate_all() before the cache is dropped. Simply dropping the cache destroys entries without triggering listeners. For synchronous caches, you may also need to loop run_pending_tasks() until entry_count() reaches zero to flush pending background evictions.
Can eviction listeners panic without crashing the cache?
Yes. The RemovalNotifier implementation in src/notification/notifier.rs wraps listener calls in catch_unwind. If your listener panics, Moka catches the panic, disables further listener invocations by setting is_enabled to false, and optionally logs the error. This ensures the cache remains operational even if the listener contains bugs.
What is the difference between sync and async eviction listeners?
Synchronous listeners implement Fn(Arc<K>, V, RemovalCause) and execute immediately on the current thread during removal operations. Async listeners, registered via async_eviction_listener, return a boxed ListenerFuture that the cache's internal housekeeping task awaits. This design allows async listeners to perform I/O or other asynchronous work without blocking cache operations, as implemented in src/future/cache.rs.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →