Rust cache structures and easy function memoization
cached
Caching structures and simplified function memoization
cached provides implementations of several caching structures as well as macros for defining memoized functions.
Requires Rust >= 1.89.
Memoized functions defined using #[cached]/#[once] macros are thread-safe with the backing function-cache wrapped in a mutex/rwlock. #[concurrent_cached] functions are thread-safe via the store's own internal synchronization: sharded stores use per-shard parking_lot::RwLock; Redis and disk stores rely on their respective server/file-system concurrency. By default, #[cached] uses no write synchronization: concurrent uncached calls for the same key may each compute independently and overwrite each other, matching the 2.x behavior and Python's functools.lrucache. Set syncwrites = "by_key" to deduplicate concurrent first calls for the same key through bucketed per-key locks. Set sync_writes = true (or "default") to hold the whole-cache lock for the duration of each miss. Note: "by_key" holds the per-key bucket lock across the entire function body, so it must not be used on recursive or re-entrant memoized functions (deadlock risk when keys in the active call chain share a bucket). #[once] defaults to no synchronization (add syncwrites = true to serialize concurrent first-calls); #[concurrentcached] does not support syncwrites. The number of per-key lock buckets for "bykey" is tunable with syncwritesbuckets = N (default 64).
- See
cached::storesdocs for the available cache stores. - See
macrosdocs for more macro examples.
Upgrading from 2.x? See the
migration guide
for a step-by-step walkthrough, or the
agent-oriented guide
for the complete breaking-change list.>
Upgrading from 1.x? 2.0 contains breaking changes (new cacheremoveentry required method,
Result/Optioncaching behavior flipped to smart-by-default,result/optionattributes
removed, and more). See the
2.0 migration guide
for a step-by-step walkthrough.>
Upgrading from a pre-1.0 release? 1.0 contains breaking changes (store
renames, removed declarative macros, renamed macro/builder attributes, and a
changed Redis key format). See the
1.0 migration guide
for a step-by-step walkthrough, or the
agent-oriented guide
for automated migration tooling.
Method naming
Every synchronous cache operation has a short alias (get/set/remove/clear/len/...) and a cache-prefixed form (cacheget/cacheset/cacheremove/cacheclear/cachesize/...). The short aliases are the preferred spelling. Use the cache_-prefixed names when a short alias would collide with another in-scope trait's method of the same name (for example, your type also implements a trait with its own get).
The get/set/remove short aliases for Cached stores live on CachedExt; those for ConcurrentCached stores live on ConcurrentCachedExt. Both extension traits have blanket implementations, so the short names are always available when the extension trait is in scope. The simplest way to get them is use cached::prelude::*;, which re-exports both extension traits. Alternatively, import them directly: use cached::{Cached, CachedExt};. Custom store implementations only need to implement the cache_-prefixed required methods on the core trait; the short aliases come for free via the blanket extension trait impl.
For Cached stores, len/is_empty are also on CachedExt. For ConcurrentCached stores, size introspection is cachesize and cacheis_empty on ConcurrentCacheBase (the shared base trait), not on ConcurrentCachedExt: bring ConcurrentCacheBase into scope to call them on a generic bound. (The sharded stores keep their inherent infallible len/is_empty too.)
Both async traits use the asynccache* spelling. ConcurrentCachedAsync mirrors the sync ConcurrentCached surface (asynccacheget, asynccacheset, asynccacheremove, ...) for concurrent stores that manage their own synchronization (the in-memory sharded stores, plus the IO-backed redis and redb stores). CachedGetOrSetAsync is narrower: it only memoizes an async closure over a synchronous in-memory Cached store, via the asynccachegetorsetwith family (asynccachegetorsetwith, asynccachetrygetorsetwith, and their _mut variants). Neither trait has a short alias; the async_ prefix already prevents collisions with the sync methods.
Features
default: Includeprocmacro,ahash, andtimestoresfeaturesproc_macro: Include proc macrosahash: Enable the optionalahashhasher as default hashing algorithm.async_core: Async trait definitions (the runtime-agnostic async cache traits) without theasync-lockdependency. Enabled byasync.async: Include support for async functions and async cache stores (runtime-agnostic; no tokio dependency; usesasync-lock)serde: MessagePack serialization support (serde+rmp-serde) for implementing a custom [SerializeCached] store without enabling a full IO-store feature. Theredisstoreandredbstorefeatures enable it transitively.redis_store: Include Redis cache storeredissmol: Include async Redis support usingsmol(no TLS); impliesredisstoreandasyncredissmolnativetls:redissmol+ TLS vianative-tls(system TLS library)redissmolrustls:redis_smol+ TLS viarustls(pure-Rust TLS)redistokio: Include async Redis support usingtokio(no TLS); impliesredisstoreandasyncredistokionativetls:redistokio+ TLS vianative-tls(system TLS library)redistokiorustls:redis_tokio+ TLS viarustls(pure-Rust TLS)redisconnectionmanager: Enable the optionalconnection-managercapability ofredis. Additive: async redis
MultiplexedConnection by default; opt a specific cache into the auto-reconnecting connection
manager with .connectionmanager(true) on its builder. This capability feature pulls in redisstore and async
itself, but it is runtime-agnostic (redis/connection-manager needs only redis/aio), so it carries no runtime:
pair it with a runtime feature (redistokio or redissmol) to actually connect; enabling it alone leaves you
without a runtime. Does not enable TLS.
redisasynccache: Enable Redis client-side caching over RESP3 for async Redis caches.
async and redis_store, but is runtime-agnostic (redis/cache-aio needs only redis/aio): pair it with a
runtime feature (redistokio or redissmol) or the build has no runtime to connect with. Does not enable TLS.
redb_store: Include disk cache storetimestores: Include time-based cache stores (TtlCache,LruTtlCache,TtlSortedCache,ShardedTtlCache, andShardedLruTtlCache).
#[cached(ttlsecs = ...)], #[cached(ttl = ...)], #[cached(ttlmillis = ...)], #[concurrentcached(ttlsecs = ...)], #[concurrentcached(ttl = ...)], or #[concurrentcached(ttlmillis = ...)] on the default in-memory path. (#[once] has its own ungated timer, so #[once(ttlsecs = ...)] does NOT require this feature.)
Disable this feature when targeting environments without system time support (e.g. wasm32-unknown-unknown without WASI or JS).
The procedural macros (#[cached], #[once], #[concurrent_cached]) offer a number of features, including async support. See the macros module for more samples, and the examples directory for runnable snippets.
Any custom cache that implements cached::Cached can be used with the #[cached]/#[once] macros in place of the built-ins (cached::CachedGetOrSetAsync additionally memoizes an async closure over such a store). Any custom cache that implements cached::ConcurrentCached/cached::ConcurrentCachedAsync can be used with the #[concurrent_cached] macro.
Macro quick reference
| Use case | Annotated signature | |---|---| | #[cached] | | | Unbounded memoize (default; concurrent misses each compute independently) | #[cached] fn fib(n: u64) -> u64 | | Unbounded memoize, explicit no-sync (same as default) | #[cached(sync_writes = false)] fn fib(n: u64) -> u64 | | LRU-bounded โ evict past N entries | #[cached(maxsize = 1000)] fn lookup(id: u32) -> Row | | TTL โ expire results after N whole seconds | #[cached(ttl_secs = 60)] fn config() -> Config | | TTL as a Duration expression (inlined verbatim, so Duration must be in scope; see note below) | #[cached(ttl = "Duration::from_secs(60)")] fn config() -> Config | | TTL in milliseconds (sub-second capable; Redis honors millisecond TTL via PSETEX/PEXPIRE) | #[cached(ttl_millis = 500)] fn poll(id: u64) -> Status | | LRU + TTL | #[cached(maxsize = 500, ttlsecs = 300)] fn search(q: String) -> Vec<Hit> | | Don't cache None returns (implicit for Option<T>) | #[cached] fn find(id: u64) -> Option<User> | | Don't cache Err returns (implicit for Result<T, E>) | #[cached] fn load(id: u64) -> Result<Data, E> | | Force-cache None returns | #[cached(cache_none = true)] fn find(id: u64) -> Option<User> | | Force-cache Err returns | #[cached(cache_err = true)] fn load(id: u64) -> Result<Data, E> | | Serve stale value when function returns Err | #[cached(resultfallback = true, ttlsecs = 60)] fn fetch(id: u64) -> Result<Data, E> | | Per-value / dynamic per-entry TTL (value carries its own expiry) | #[cached(expires = true)] fn token(scope: String) -> Token | | Deduplicate concurrent first calls per key (opt-in; do not use on recursive functions) | #[cached(ttlsecs = 30, syncwrites = "by_key")] fn expensive(id: u64) -> Payload | | Recompute when an expression over the args is true | #[cached(force_refresh = { id == 0 })] fn fetch(id: u64) -> Data | | Force-refresh via a dedicated flag (exclude it from the key) | #[cached(key = "u64", convert = { id }, forcerefresh = { refresh })] fn fetch(id: u64, refresh: bool) -> Data { let = refresh; โฆ } โ the generated guard reads refresh to decide whether to bypass the cache; the function body still receives refresh as a normal parameter, so if your body does not otherwise use it, add let = refresh; (or #[allow(unusedvariables)]) to silence the unused-variable warning | | Cache a method inside an impl block (one cache shared across all instances) | #[cached(in_impl = true)] fn load(&self, id: u64) -> Data | | Control visibility of generated nocache / primecache companions | #[cached(companions_vis = "pub(crate)")] pub fn compute(x: u64) -> u64 | | Async | #[cached(max_size = 100)] async fn remote(id: u64) -> Data | | #[once] | | | Compute and cache a global value forever | #[once] fn app_config() -> Config | | Refresh a global value periodically | #[once(ttlsecs = 300, syncwrites = true)] fn pubkey() -> Key | | TTL in milliseconds (sub-second capable) | #[once(ttl_millis = 500)] fn pubkey() -> Key | | Optional global โ skip caching if None (implicit) | #[once] fn feature_flag() -> Option<Flag> | | Recompute when an expression is true | #[once(force_refresh = { flag })] fn config(flag: bool) -> Config | | Cache a method inside an impl block (one value shared across all instances) | #[once(in_impl = true)] fn config(&self) -> Config | | #[concurrent_cached] | | | Thread-safe sharded memoize (no global lock per call) | #[concurrent_cached] fn compute(x: u64) -> u64 | | Sharded with LRU | #[concurrentcached(maxsize = 1_000)] fn lookup(id: u64) -> Row | | Sharded with TTL | #[concurrentcached(ttlsecs = 60)] fn fetch(url: String) -> Body | | Sharded LRU + TTL with custom shard count | #[concurrentcached(maxsize = 1000, ttlsecs = 60, shards = 32)] fn query(id: u64) -> Row | | TTL in milliseconds (sub-second; Redis honors millisecond TTL via PSETEX/PEXPIRE) | #[concurrentcached(ttlmillis = 500)] fn poll(id: u64) -> Status | | Per-value expiry, thread-safe | #[concurrent_cached(expires = true)] fn session(id: u32) -> Token | | Per-value expiry with LRU bound | #[concurrentcached(expires = true, maxsize = 1_000)] fn session(id: u32) -> Token | | Cache only successful results (implicit for Result<T, E>) | #[concurrent_cached] fn load(id: u64) -> Result<Row, DbError> | | Don't cache None returns (implicit for Option<T>) | #[concurrent_cached] fn find(id: u64) -> Option<Row> | | Serve stale value when function returns Err | #[concurrentcached(resultfallback = true, ttl_secs = 60)] fn fetch(id: u64) -> Result<Data, E> | | Recompute when an expression over the args is true | #[concurrentcached(forcerefresh = { id == 0 })] fn fetch(id: u64) -> Data | | Force-refresh via a dedicated flag (exclude it from the key) | #[concurrentcached(key = "u64", convert = { id }, forcerefresh = { refresh })] fn fetch(id: u64, refresh: bool) -> Data { let = refresh; โฆ } โ the generated guard reads refresh to decide whether to bypass the cache; the body still receives it as a normal parameter, so add let = refresh; (or #[allow(unused_variables)]) if your body does not otherwise use it | | Cache a method inside an impl block (one cache shared across all instances) | #[concurrentcached(inimpl = true)] fn load(&self, id: u64) -> Data | | Persist results to disk (with maperror; or omit when E: From<RedbCacheError>) | #[concurrentcached(disk = true, map_error = \|e\| MyErr(e))] fn crunch(n: u64) -> Result<Data, MyErr> | | Redis-backed async cache (quoted or unquoted create/maperror) | #[concurrentcached(ty = "AsyncRedisCache<u64, String>", create = { ... }, map_error = \|e\| MyErr(e))] async fn api(id: u64) -> Result<Resp, MyErr> |
On #[cached] and #[concurrentcached], the LRU bound is set with maxsize = N (mirroring the maxsize builder/constructor methods on the stores). The size = N spelling โ a deprecated alias in 2.x โ has been removed; only maxsize = N is accepted.
The ttl attribute accepts a Duration expression as a quoted string: ttl = "Duration::fromsecs(60)". The expression is inlined verbatim, so Duration must be in scope at the call site (e.g. use cached::time::Duration;); the ttlsecs / ttlmillis forms need no import. For whole seconds, the shorter ttlsecs = N form is preferred. ttlmillis = N sets a TTL in milliseconds. The three attributes ttl, ttlsecs, and ttlmillis are mutually exclusive; using more than one is a compile error. All three are mutually exclusive with expires. Sub-second precision for ttlmillis is honored by the in-memory, disk (redb), and Redis stores; Redis applies the TTL with millisecond precision via PSETEX/PEXPIRE.
For the default in-memory sharded stores, #[concurrent_cached] accepts any return type โ plain values, Option<T>, or Result<T, E>. Plain values are always cached as-is. Option<T> returns skip caching None by default; use cachenone = true to also cache None values. Result<T, E> only caches Ok values; Err is returned without being stored. Use cacheerr = true to also cache Err values. The macro detects Result<T, E> by matching the exact identifier Result (including fully-qualified paths such as std::result::Result<T, E>). Type aliases are not resolved at macro-expansion time, so any alias โ even one whose name ends with Result (e.g. type MyResult<T> = Result<T, E>) โ is treated as a plain value and its Err variant is cached. Use Result<T, E> directly when you need Ok-only caching behavior. The same applies to Option<T> detection: a type alias such as type MaybeRow<T> = Option<T> is treated as a plain value and its None variant is cached. Use Option<T> directly when you need None-skipping behavior. On the default in-memory path, do not specify map_error -- the sharded stores are infallible and supplying it is a compile error. For disk and redis stores, Result<T, E> is required. maperror is optional: when supplied it converts the store error into your E; when omitted the generated code uses .maperr(Into::into)?, so E must implement From<RedbCacheError> (disk) or From<RedisCacheError> (Redis). Both quoted-string and unquoted forms are accepted: maperror = |e| MyErr(e) and maperror = "|e| MyErr(e)" are equivalent.
Store comparison
| Store | Eviction policy | Size limit | TTL | Refresh on hit | on_evict | Concurrent | Async | |---|---|---|---|---|---|---|---| | UnboundCache | None (unbounded) | No | No | N/A | On explicit remove | No | Yes | | LruCache | LRU | Yes | No | N/A | Yes | No | Yes | | TtlCache | TTL (insert time) | No | Global | Optional | Yes | No | Yes | | LruTtlCache | LRU + TTL | Yes | Global | Optional | Yes | No | Yes | | TtlSortedCache | TTL (expiry-ordered) | Optional | Global | No | Yes | No | Yes | | ExpiringLruCache | LRU + value-defined | Yes | Per-value | N/A | Yes | No | Yes | | ExpiringCache | Value-defined | No | Per-value | N/A | Yes | No | Yes | | ShardedUnboundCache | None (unbounded) | No | No | N/A | On explicit remove | Yes (Arc) | Yes | | ShardedLruCache | LRU | Yes | No | N/A | Yes | Yes (Arc) | Yes | | ShardedTtlCache | TTL (insert time) | No | Global | Optional | Yes | Yes (Arc) | Yes | | ShardedLruTtlCache | LRU + TTL | Yes | Global | Optional | Yes (โ ) | Yes (Arc) | Yes | | ShardedExpiringCache | Value-defined | No | Per-value | N/A | Yes | Yes (Arc) | Yes | | ShardedExpiringLruCache | LRU + value-defined | Yes | Per-value | N/A | Yes | Yes (Arc) | Yes |
"On explicit remove" โonevictfires only oncacheremove; there is no capacity eviction or TTL expiry trigger for these stores.
โShardedLruTtlCacheBuilder::on_evictrequiresK: 'static + V: 'static; see the builder docs for details.
TtlCache/LruTtlCache/TtlSortedCache/ShardedTtlCache/ShardedLruTtlCache require the time_stores feature.
ShardedUnboundCache and its variants are partitioned across power-of-two shards (default: availableparallelism() ร 4, clamped to 8โ1024; the 8โ1024 clamp applies only to this computed default โ an explicit shards = N is rounded up to a power of two but never clamped) each protected by a parkinglot::RwLock. Shard structs are padded to 128-byte alignment (covering Intel adjacent-line prefetch and Apple Silicon 128-byte L1 lines) to eliminate false sharing; on a 64-shard deployment this amounts to ~8 KB of padding overhead per cache array. The outer type is an Arc โ cloning is a reference share, not a deep copy (use deepclone() for an independent copy; note that deepclone() is an inherent method on each concrete sharded type, not part of any trait). They implement ConcurrentCached/ConcurrentCachedAsync and are the default store selected by #[concurrent_cached]. For sharded LRU variants, eviction is enforced independently per shard. maxsize = N is divided across shards with ceiling division. Use the builder's pershardmaxsize method for an exact per-shard cap (builder-only; #[concurrentcached] does not expose a pershardmaxsize attribute โ use shards to control parallelism and maxsize for total capacity). Capacity Fragmentation Warning: To protect against premature evictions due to hash collisions in extremely small caches (where a shard capacity could drop to 1-2 entries), when sharding is active (shards > 1) we enforce a minimum capacity of 16 entries per shard (e.g., minimum total capacity of 128 on a single-core machine with 8 shards, or 256 on a 4-core machine with 16 shards). If you require smaller, strict limits under low capacities, configure shards = 1 or specify pershardmaxsize directly (builder-only; not available via #[concurrent_cached]). Because LRU caches require updating access recency, ShardedLruCache, ShardedLruTtlCache, and ShardedExpiringLruCache must acquire an exclusive write lock on accessed shards during read hits, which can lead to contention under highly concurrent read-heavy workloads. Unbounded ShardedUnboundCache, time-only ShardedTtlCache (when refreshonhit is disabled -- enabling it promotes read hits to exclusive write locks), and expiring ShardedExpiringCache require only a shared read lock on read hits, avoiding this contention. To mitigate contention on LRU variants, consider increasing the number of shards to distribute writes. Note: this write-lock-on-read behavior is a known limitation of the strict-LRU sharded stores. A future read-optimized variant that relaxes strict recency ordering will ship as a separate store type; the existing stores will not change semantics.
Basetypes: Each sharded store has a correspondingBasegeneric (ShardedUnboundCacheBase<K, V, H>,ShardedLruCacheBase<K, V, H>, etc.) parameterized on a custom [ShardHasher]. The named aliases (ShardedUnboundCache,ShardedLruCache, โฆ) use the default hasher and are what most users should reach for. Use theBasetypes only when implementing a customShardHasherfor non-standard shard routing. Construct a custom-hasher cache through the alias builder and itshashermethod:ShardedLruCache::builder().hasher(myhasher)switches the builder's hasher type andbuildyields aBase<K, V, H>overmyhasher.new/builderare defined only on the default-hasher alias, so a custom hasher is always introduced throughhasher, never a*Base::<, , H>turbofish (which would otherwise silently drop the hasher).
Behavioral guarantees
- Non-sharded in-memory stores (
UnboundCache,LruCache,TtlCache, etc.) are not internally
#[cached]/#[once] functions wrap them in locks; users
managing these stores directly must add their own synchronization when sharing across threads.
Sharded* stores are internally synchronized (per-shard parking_lot::RwLock) and implement
ConcurrentCached/ConcurrentCachedAsync โ no external lock is needed.
The synchronous get / set / remove short aliases come from the ConcurrentCachedExt
extension trait (bring it into scope with use cached::prelude::*; or
use cached::{ConcurrentCached, ConcurrentCachedExt};); the cacheget / cacheset /
cache_remove spellings come from ConcurrentCached directly. For sharded stores, inherent
methods with the same names take priority at the call site. The async trait operations are
async-prefixed, so they never collide (e.g., STORE.asynccache_get(&key).await.expect("ShardedUnboundCache is infallible")).
CachedExt::get(and theCached::cache_getrequired method it wraps) requires mutable access
cacheget/cachesetshapes differ per family, by design. Single-owner:Cached::cache_get
&mut self -> Option<&V> and Cached::cache_set returns Option<V> (the displaced value).
Concurrent: ConcurrentCached::cache_get is &self -> Result<Option<V>, Error> (owned value,
fallible) and ConcurrentCached::cacheset returns a #[mustuse] Result<Option<V>, Error>.
The concurrent family returns owned values because its implementors include IO stores that
serialize entries and cannot hand out a borrow into the store, and it is fallible because those
stores can fail; the single-owner family stays infallible and borrow-returning. A prelude glob
can bring both families into scope without collision.
- The inherent-method asymmetry between the two families is deliberate. On a sharded store the
set/get/len calls resolve to inherent methods (infallible, &self), so
ShardedLruCache::new(100) is usable bare. A single-owner LruCache::new(100) has no such
inherent shims: c.set/c.get/c.len need the CachedExt extension trait in scope
(use cached::CachedExt;, or the prelude). This mirrors each family's ownership model (sharded
stores are self-synchronized and infallible; single-owner stores are &mut self) and is not an
oversight.
len/sizevsitervsevictcontract for timed and expiring stores:
len() (and cachesize(), isempty()) return the raw stored entry count without
scanning for expiry. On lazy-eviction stores (TtlCache, LruTtlCache,
TtlSortedCache, ExpiringCache, ExpiringLruCache, and their sharded equivalents)
this count may include entries that have expired but not yet been swept, so
len() can be greater than iter().count(). iter() (from [CachedIter]) omits
expired entries from the yielded view but does not remove them from the store - it
stays &self. Call evict() (via [CacheEvict] for single-owner stores or
[ConcurrentCacheEvict] for sharded stores) to physically remove expired entries,
reclaim memory, and obtain an accurate live count.
- Expired values can remain allocated until a mutating operation,
evict, or
cacheremovefires theonevictcallback (if set) and counts as an eviction for
ShardedUnboundCache is the
exception: it has no evictions counter and always returns None from
metrics().evictions, though its onevict callback still fires. The onevict column
above marks the unbounded stores where explicit removal is the only eviction trigger. For stores with
expiry, removing a present-but-already-expired entry still evicts and fires on_evict,
but cacheremove returns None; use cachedelete or cacheremoveentry when you
need to know whether an entry was physically removed.
cacheclear()is fast and side-effect-free: it does not fireonevictand does
cacheclearwithonevict() when you need the
callback to fire for every removed entry (e.g., to release resources tracked via on_evict).
Note: cacheclear is a required method on ConcurrentCached (and asynccache_clear on
the async counterpart), with the short clear() alias on ConcurrentCachedExt, so generic
code over ConcurrentCached can clear. cacheclearwithonevict() is the exception: it is
inherent-only on each concrete sharded store type and is not callable through the trait.
- Bounded caches enforce capacity on insertion. Time-bounded caches enforce freshness on lookup.
- Redis and disk stores serialize values and return owned values. Non-sharded in-memory stores
Option<V> values
(cloned under a shard lock). Macro-generated functions clone cached return values in all cases.
- Macro-generated
#[cached]/#[once]cache statics useRwLockby default. Named cache
.read() or .write() unless
synclock = "mutex" is set. Named #[concurrentcached] statics hold a self-synchronizing
store directly: sync functions use LazyLock<Store>, and async functions use
OnceCell<Store>.
CachedPeekprovides non-mutating lookups that do not update recency, refresh TTLs, or record
CachedRead is narrower and is only implemented where shared-lock lookups can preserve
normal read-side semantics without recency or refresh mutation.
- Sharded stores implement
ConcurrentCached/ConcurrentCachedAsyncinstead of
Cached/CachedGetOrSetAsync. Generic code parameterized over Cached<K, V> cannot accept sharded
stores; use a ConcurrentCached<K, V> bound or a concrete type instead.
Sharded stores also do not implement CachedIter or CachedPeek. Code that is generic over
CachedIter<K, V> or uses .iter() / cache_peek must use non-sharded stores instead.
The four expiry-capable sharded stores ([ShardedTtlCache], [ShardedLruTtlCache],
[ShardedExpiringCache], [ShardedExpiringLruCache]) implement [ConcurrentCloneCached],
which provides cachegetwithexpirystatus for reading stale entries without evicting them, and
cachepeekwithexpirystatus as a side-effect-free counterpart (a read with no hit/miss
counting, LRU promotion, or TTL renewal).
Per-Value Expiry via the Expires Trait
While standard timed stores (TtlCache, LruTtlCache, TtlSortedCache) enforce a single, global Time-To-Live (TTL) duration applied to all entries in the cache, [ExpiringLruCache] and [ExpiringCache] let each individual value determine its own expiration. This is accomplished by storing values that implement the [Expires] trait.
This approach is highly useful when caching payloads like OAuth tokens, HTTP responses with varying Cache-Control headers, or database records that contain their own absolute expiration timestamps.
It is also the idiomatic way to give entries a dynamic, per-entry TTL โ a lifetime computed at call time rather than the single uniform duration that ttl = N applies to every entry. Because the value carries its own expiry, each entry can be given a different lifetime derived from a function argument, runtime configuration, or a response header. (expires = true is mutually exclusive with ttl.) See the expiresper_key example for a runnable demonstration.
When using the #[cached] or #[once] proc macros, add expires = true to opt into per-value expiry automatically. For #[cached], this selects ExpiringCache (unbounded) by default or ExpiringLruCache when max_size is also specified. For #[once], this stores a single value whose expiry is polled on each call.
The macro form below derives each entry's TTL from a function argument โ key/convert keep the TTL out of the cache key so it influences only the entry's lifetime, not which slot it occupies (ignored as a doctest because it requires the default procmacro feature; the same code runs in the expiresperkey example):
use cached::macros::cached;
use cached::Expires;
use cached::time::{Duration, Instant};
#[derive(Clone)] struct Token { value: String, expires_at: Instant }
impl Expires for Token { fn isexpired(&self) -> bool { Instant::now() >= self.expiresat } }
// ttl_secs is a runtime argument โ each user's token expires on its own schedule. #[cached(expires = true, key = "u64", convert = "{ user_id }")] fn fetchtoken(userid: u64, ttl_secs: u64) -> Token { Token { value: format!("token-{user_id}"), expiresat: Instant::now() + Duration::fromsecs(ttl_secs), } }
For concurrent (multi-thread, no external lock) use, the sharded equivalents [ShardedExpiringCache] and [ShardedExpiringLruCache] provide the same per-value expiry with internally-synchronized sharded storage. Use #[concurrent_cached(expires = true)] to select them automatically.
Memory note:ExpiringCacheandShardedExpiringCacheare unbounded and only remove
expired entries when the same key is accessed again. CachedIter::iter() (implemented on the
non-shardedExpiringCache/ExpiringLruCacheonly, not on the sharded variants) filters
expired entries from the iterator but does not remove them from the map. For high-cardinality workloads,
callevict()periodically โ on the single-ownerExpiringCachevia [CacheEvict]
(use cached::CacheEvict;,&mut self), and on the shardedShardedExpiringCachevia
[ConcurrentCacheEvict] (use cached::ConcurrentCacheEvict;,&self) or its inherent
evict(&self)method; note thatevict()on sharded TTL and expiring stores requires
K: Clone. Alternatively, preferExpiringLruCache/ShardedExpiringLruCachewith a
max_size bound.
use cached::{CachedExt, Expires, ExpiringCache, ExpiringLruCache};
use cached::time::{Duration, Instant};
#[derive(Clone)] struct Response { payload: String, expires_at: Instant, }
impl Expires for Response { fn is_expired(&self) -> bool { Instant::now() >= self.expires_at } }
let now = Instant::now();
// ExpiringCache โ unbounded, default for #[cached(expires = true)] let mut cache = ExpiringCache::builder().build().unwrap(); cache.set("key1", Response { payload: "a".to_string(), expiresat: now + Duration::fromsecs(1), }); cache.set("key2", Response { payload: "b".to_string(), expiresat: now + Duration::fromsecs(3600), });
// ExpiringLruCache โ LRU-bounded, used with #[cached(expires = true, max_size = N)] let mut lru = ExpiringLruCache::builder().max_size(10).build().unwrap(); lru.set("key1", Response { payload: "a".to_string(), expiresat: now + Duration::fromsecs(1), });
The basic usage looks like:
,no_run,ignore
use cached::macros::cached;
/// Defines a function named fib that uses a cache implicitly named FIB. /// By default, the cache will be the function's name in all caps. /// The following line is equivalent to #[cached(name = "FIB")] #[cached] fn fib(n: u64) -> u64 { if n == 0 || n == 1 { return n } fib(n-1) + fib(n-2) }
pub fn main() { }
,no_run,ignore
use std::thread::sleep;
use cached::time::Duration;
use cached::macros::cached;
use cached::LruCache;
/// Use an explicit cache-type with a custom creation block and custom cache-key generating block #[cached( ty = "LruCache<String, usize>", create = "{ LruCache::builder().max_size(100).build().unwrap() }", convert = r#"{ format!("{}{}", a, b) }"# )] fn keyed(a: &str, b: &str) -> usize { let size = a.len() + b.len(); sleep(Duration::new(size as u64, 0)); size }
pub fn main() { }
,no_run,ignore
use cached::macros::once;
/// Only cache the initial function call. /// Function will be re-executed after the cache /// expires (according to ttl_secs). /// When no (or expired) cache, concurrent calls /// will synchronize (sync_writes) so the function /// is only executed once. #[once(ttlsecs=10, syncwrites = true)] fn keyed(a: String) -> Option<usize> { if a == "a" { Some(a.len()) } else { None } }
pub fn main() { }
use cached::macros::cached;
/// Cannot use syncwrites and resultfallback together #[cached( ttl_secs = 1, sync_writes = "default", result_fallback = true )] fn doesnt_compile() -> Result<String, ()> { Ok("a".to_string()) }
cachegetorsetwith returns a shared reference (&V); binding it as &mut V no longer compiles. Use cachegetorsetwith_mut when you need a mutable reference.
use cached::{Cached, UnboundCache};
let mut cache: UnboundCache<u32, u32> = UnboundCache::builder().build().unwrap(); let : &mut u32 = cache.cachegetorset_with(1, || 2);
,no_run,ignore
use cached::macros::concurrent_cached;
use cached::AsyncRedisCache;
use cached::time::Duration;
use thiserror::Error;
#[derive(Error, Debug, PartialEq, Clone)] enum ExampleError { #[error("error with redis cache {0}")] RedisError(String), }
/// Cache the results of an async function in redis. Redis keys are laid out as /// {namespace}:{prefix}:{key}, where namespace defaults to cached-redis-store: /// and prefix is required (here cachedredisprefix). The prefix is what scopes /// cache_clear to this logical cache, so give each cache a distinct prefix. /// Redis and disk stores require Result<T, E>; supply a map_error closure /// to convert store errors into your error type. #[concurrent_cached( map_error = r##"|e| ExampleError::RedisError(format!("{:?}", e))"##, ty = "AsyncRedisCache<u64, String>", create = r##" { AsyncRedisCache::builder("cachedredisprefix") .ttl(Duration::from_secs(1)) .refreshonhit(true) .build() .await .expect("error building example redis cache") } "## )] async fn asynccachedsleep_secs(secs: u64) -> Result<String, ExampleError> { std::thread::sleep(cached::time::Duration::from_secs(secs)); Ok(secs.to_string()) }
,no_run,ignore
use cached::macros::concurrent_cached;
use cached::RedbCache;
use thiserror::Error;
#[derive(Error, Debug, PartialEq, Clone)] enum ExampleError { #[error("error with disk cache {0}")] DiskError(String), }
/// Cache the results of a function on disk. /// Cache files will be stored under the system cache dir /// unless otherwise specified with disk_dir or the create argument. /// Disk stores require Result<T, E>; supply a map_error closure /// to convert store errors into your error type. #[concurrent_cached( map_error = r##"|e| ExampleError::DiskError(format!("{:?}", e))"##, disk = true )] fn cachedsleepsecs(secs: u64) -> Result<String, ExampleError> { std::thread::sleep(cached::time::Duration::from_secs(secs)); Ok(secs.to_string()) }
,no_run,ignore
use cached::macros::concurrent_cached;
/// Memoize with the default in-memory sharded store โ no map_error, ty, /// or create needed. Add max_size for LRU eviction or ttl for time-based /// expiry (requires the time_stores feature). /// /// #[concurrentcached] does not support syncwrites. /// For Option<T> returns, None is skipped by default (use cache_none = true to cache it). /// For Result<T, E> returns, only Ok values are cached by default (use cache_err = true /// to also cache Err). resultfallback = true is supported (requires ttlsecs, ttl_millis, or ttl = "<Duration expr>"): on an Err /// return, the last cached Ok value for the same key is returned instead. The stale value /// is held in the primary cache slot and re-cached with a fresh TTL window on Err; no /// secondary store is created. #[concurrent_cached] fn slow_double(x: u64) -> u64 { std::thread::sleep(cached::time::Duration::from_millis(10)); x * 2 }
/// LRU capacity of 1 000 entries spread across shards. #[concurrentcached(maxsize = 1000)] fn slow_triple(x: u64) -> u64 { x * 3 }
/// Only cache successful lookups โ Err is returned but not stored. #[concurrent_cached] fn load_user(id: u64) -> Result<String, std::io::Error> { Ok(format!("user_{id}")) }
Functions defined via macros will have their results cached using the function's arguments as a key, or a convert expression specified on the macro.
When a macro-defined function is called, the function's cache is first checked for an already computed (and still valid) value before evaluating the function body.
Due to the requirements of storing arguments and return values in a global cache:
- Function return types:
#[cached] / #[once]), must be owned and implement Clone
- For in-memory #[concurrent_cached] (sharded stores โ the default), must implement Clone.
Any return type is accepted: plain T, Option<T>, or Result<T, E>. Option<T> skips
caching None by default; use cache_none = true to also cache None. When the
return type is Result<T, E>, only Ok(v) is stored โ Err values are returned but not cached.
Use cache_err = true to also cache Err values.
- For I/O-backed stores used by #[concurrent_cached] (Redis and disk), must be Result<T, E>
where T: Clone + serde::Serialize + serde::DeserializeOwned (the store serializes it).
map_error is optional: supply it to convert the store's error into E, or omit it when
E: From<RedisCacheError> (Redis) or E: From<RedbCacheError> (disk).
- Function arguments:
#[cached] / #[once]), must either be owned and implement Hash + Eq + Clone,
or a convert expression must be specified on the macro to produce a key of a Hash + Eq + Clone type.
- For in-memory #[concurrent_cached] (sharded stores), must implement Hash + Eq + Clone. The
macro's default key construction always clones function arguments, so K: Clone is required on
every in-memory path. (When using convert to supply an already-owned key, only the store's
own bounds apply: K: Hash + Eq for unbounded/TTL-only variants, K: Hash + Eq + Clone for LRU
variants โ except when result_fallback = true is also set, which always requires K: Clone
regardless of store variant because the generated code clones the key into the fallback store.)
- For I/O-backed stores used by #[concurrent_cached] (Redis and disk), must either be owned and
implement Display + Clone, or a convert expression must be used to produce a key of a
Display + Clone type. Clone is needed so removal APIs can return the stored key.
- Floats (f32 / f64), and any type containing them (e.g. a struct with float fields), do not
implement Hash / Eq, so they are the canonical case that requires a convert expression to
produce a hashable key. For example key = "String", convert = r#"{ format!("{:.6}", x) }"#, or
wrap the value with a crate such as ordered-float.
- Arguments and return values will be
clonedin the process of insertion and retrieval. For Redis and
Strings and values are de/serialized.
- Macro-defined functions should not be used to produce side-effectual results!
- Macro-defined functions live at module scope by default (the macro expands to a static plus
impl block, set in_impl = true, which
emits the cache static inside the generated method body instead. A {fn}nocache sibling
method is generated at the same visibility, calling the original body directly and bypassing
the cache. The primecache companion is not generated for in_impl methods (a
function-local static cannot be shared between two sibling methods, so priming would silently
do nothing; calling a non-existent prime function is a clear compile error instead).
- Macro-defined methods may take a
selfreceiver only whenin_impl = true;selfis excluded
self-receiver methods are rejected with a compile error
(a convert block alone does not make them valid: off the in_impl path the cache static is
emitted at impl scope, where a static is not a legal item).
Footgun: because self is excluded, two instances with different internal state but identical
arguments share one cache entry, so a.load(5) and b.load(5) return the same cached value even
when a and b differ. The cache is process-global, not per-instance. If a method's result
depends on self's fields, fold them into the key with a convert expression (e.g.
convert = r#"{ format!("{}:{}", self.id, id) }"#), or keep the logic in a free function keyed on
those fields.
- Macro-defined functions can be generic over type parameters only when a
key+convertis
convert), #[cached] /
#[concurrent_cached] reject generic functions, since each monomorphization would need its own
static cache: write a concrete monomorphic wrapper per type instead. (#[once] caches a single
concrete value and is unaffected.)
License: MIT