Per-Entry Expiration in Moka: Custom TTL Policies for Individual Cache Entries
Moka supports per-entry expiration through the Expiry trait, allowing custom TTL logic per key-value pair that combines with global cache policies.
The moka crate provides a high-performance concurrent cache for Rust with flexible expiration policies. While global TTL (time-to-live) and TTI (time-to-idle) settings apply uniformly to all entries, Moka also supports per-entry expiration through user-defined policies that can vary per key-value pair. This article examines the implementation details in the moka-rs/moka repository, covering the Expiry trait interface, builder integration, and runtime behavior.
Understanding the Expiry Trait in src/policy.rs
The foundation of per-entry expiration lies in the Expiry trait defined in src/policy.rs. This trait specifies three lifecycle callbacks that the cache invokes when an entry is created, read, or updated.
Trait Interface and Callback Methods
The trait definition at lines 154-188 exposes three methods:
pub trait Expiry<K, V> {
fn expire_after_create(
&self,
key: &K,
value: &V,
created_at: Instant,
) -> Option<Duration> { None }
fn expire_after_read(
&self,
key: &K,
value: &V,
read_at: Instant,
duration_until_expiry: Option<Duration>,
last_modified_at: Instant,
) -> Option<Duration> { duration_until_expiry }
fn expire_after_update(
&self,
key: &K,
value: &V,
updated_at: Instant,
duration_until_expiry: Option<Duration>,
) -> Option<Duration> { duration_until_expiry }
}
Each method returns Option<Duration>, where Some(duration) sets a custom expiration time and None clears the expiration (making the entry immortal). The default implementations provide sensible passthrough behavior: new entries never expire by default, while reads and updates preserve the existing duration.
Configuring Per-Entry Expiration via CacheBuilder
To attach a custom expiry policy, use the expire_after method on the cache builder, located in src/sync/builder.rs (lines 487-490):
pub fn expire_after(self, expiry: impl Expiry<K, V> + Send + Sync + 'static) -> Self {
let mut builder = self;
builder.expiration_policy.set_expiry(Arc::new(expiry));
builder
}
This method stores the user-provided Expiry implementation inside the cache's ExpirationPolicy as an Arc, enabling shared ownership across cache operations. The Send + Sync + 'static bounds ensure thread-safe access across the cache's concurrent internal structures.
Runtime Execution in src/sync/base_cache.rs
The cache core invokes the appropriate Expiry callbacks during entry lifecycle events. The implementation in src/sync/base_cache.rs handles three specific scenarios:
Entry Creation (Lines 654-669)
When an entry is inserted, the cache calls Self::expire_after_create, passing the key, value, and creation timestamp. The returned Option<Duration> becomes the entry's initial expiration deadline.
Entry Read and Update (Lines 314-329)
For reads and updates, the cache invokes Self::expire_after_read_or_update. These callbacks receive the current duration_until_expiry and can either preserve it, modify it, or clear it entirely by returning None.
The returned Option<Duration> converts to an absolute expiration timestamp stored in the entry's EntryInfo. If the timestamp changes, the cache (re)registers the entry with the internal timer wheel; if the result is None, the timer wheel entry is removed entirely, preventing automatic expiration.
Combining Per-Entry and Global Expiration Policies
Per-entry expiration does not override global TTL/TTI settings—it combines with them. According to the trait documentation in src/policy.rs (lines 176-184), an entry expires when the earliest of these deadlines is reached:
- Global TTL deadline (cache-level time-to-live)
- Global TTI deadline (cache-level time-to-idle)
- Per-entry custom deadline (from the
Expirytrait)
This behavior ensures that restrictive global policies act as safety bounds while per-entry policies provide granular control.
Practical Implementation Examples
Example 1: Enum-Based Expiration Strategy
This pattern from src/sync/cache.rs (lines 57-84) embeds expiration metadata within the value and extracts it during the creation callback:
use moka::{sync::Cache, Expiry};
use std::{time::{Duration, Instant}, sync::Arc};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Expiration {
Never,
Short,
Long,
}
impl Expiration {
fn as_duration(&self) -> Option<Duration> {
match self {
Expiration::Never => None,
Expiration::Short => Some(Duration::from_secs(5)),
Expiration::Long => Some(Duration::from_secs(15)),
}
}
}
struct MyExpiry;
impl Expiry<u32, (Expiration, String)> for MyExpiry {
fn expire_after_create(
&self,
_key: &u32,
value: &(Expiration, String),
_now: Instant,
) -> Option<Duration> {
value.0.as_duration()
}
}
fn main() {
let cache = Cache::builder()
.max_capacity(100)
.expire_after(MyExpiry)
.build();
cache.get_with(0, || (Expiration::Short, "a".into()));
cache.get_with(1, || (Expiration::Long, "b".into()));
cache.get_with(2, || (Expiration::Never, "c".into()));
std::thread::sleep(Duration::from_secs(6));
assert!(!cache.contains_key(&0)); // Expired
assert!(cache.contains_key(&1)); // Remains
assert!(cache.contains_key(&2)); // Never expires
}
Example 2: Hybrid Global and Per-Entry Configuration
You can layer per-entry logic atop global policies for comprehensive expiration control:
let cache = Cache::builder()
.max_capacity(200)
.time_to_live(Duration::from_secs(30 * 60)) // 30 min global TTL
.time_to_idle(Duration::from_secs(5 * 60)) // 5 min global TTI
.expire_after(MyExpiry) // Per-entry policy
.build();
In this configuration, an entry is evicted when any deadline triggers—whether from the 30-minute global TTL, 5 minutes of inactivity, or the custom per-entry duration calculated by MyExpiry.
Summary
- The
Expirytrait insrc/policy.rsdefines three callbacks (expire_after_create,expire_after_read,expire_after_update) for implementing custom expiration logic based on entry content or metadata. - Attach custom policies via
CacheBuilder::expire_afterinsrc/sync/builder.rs, which stores the implementation in the cache'sExpirationPolicy. - The cache core in
src/sync/base_cache.rsinvokes these callbacks during entry creation, reads, and updates, converting returnedOption<Duration>values into absolute timestamps for the timer wheel. - Per-entry deadlines combine with global TTL/TTI settings, with the entry expiring when the earliest of all applicable deadlines is reached.
- Return
Nonefrom any callback to clear expiration (making the entry immortal), or returnSome(Duration)to establish or extend a custom lifetime.
Frequently Asked Questions
How does per-entry expiration interact with Moka's global TTL settings?
Per-entry expiration works alongside global TTL and TTI settings rather than replacing them. According to the implementation in src/policy.rs, an entry expires when the earliest of three deadlines is reached: the global TTL, the global TTI, or the per-entry custom expiration. This allows per-entry policies to specify shorter lifetimes than the global default while maintaining global upper bounds as safety limits.
Can I modify an entry's expiration time after it has been created?
Yes. The expire_after_read and expire_after_update callbacks in the Expiry trait allow dynamic adjustment of expiration times during the entry's lifecycle. These methods receive the current duration_until_expiry and can return a new Duration to extend or shorten the remaining lifetime, or None to remove expiration entirely. The cache updates the timer wheel registration whenever these callbacks return a different value.
What happens if my Expiry callback returns None?
Returning None from any Expiry callback clears the expiration for that entry. In expire_after_create, this creates an immortal entry that never expires (unless evicted by capacity constraints). In expire_after_read or expire_after_update, returning None removes the existing timer wheel entry, effectively canceling any previously scheduled expiration. This behavior is implemented in src/sync/base_cache.rs where the cache removes the entry from the expiration wheel when the callback yields None.
Is per-entry expiration supported in both synchronous and asynchronous Moka caches?
Yes. The Expiry trait and expire_after builder method are available for both the synchronous Cache and asynchronous future::Cache implementations in the moka crate. The trait definition in src/policy.rs is generic over key and value types, and the builder integration in src/sync/builder.rs (and its async counterpart) uses the same underlying ExpirationPolicy structure, ensuring consistent behavior across both cache variants.
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 →