High-performance cache policies and supporting data structures.
Status: design rationale for the store trait family in
src/store/traits.rsand the concrete stores undersrc/store/. Companion todesign.md§7 (policy/storage separation),trait-hierarchy.md(the parallel cache-trait family),concurrency.md, andweighted-eviction.md.
cachekit splits caches into two layers: a policy that decides what
to evict and a store that owns the keys and values. This doc
covers the store side. It explains why the store traits look the way
they do, what each shipped concrete store is for, how the
sequential/concurrent split mirrors the cache-trait family, and why
WeightStore deliberately sits outside
the rest of the family.
The store layer is shaped around four things:
design.md §7.StoreFull rather
than silently evicting. The caller (policy or user) decides what to
evict. This is the core of the layering — without it, every store
would have to ship an eviction strategy.V and borrowed
&V; the concurrent traits return Arc<V>. The reasoning is the
same as for the cache trait family in
trait-hierarchy.md — references cannot
safely outlive lock guards.StoreMetrics ships unconditionally
with seven counters: hits, misses, inserts, updates,
removes, evictions, and expirations. None of them are
feature-gated; the richer per-policy metrics hierarchy is, but
the store-layer baseline is not. expirations stays at 0 on
stores that do not own a TTL surface — the TTL count for an
Expiring<…> decorator is exposed separately via
Expiring::expirations().Single-threaded (direct ownership) Concurrent (shared ownership)
──────────────────────────────── ───────────────────────────────
┌────────────────────┐ ┌──────────────────────────┐
│ StoreCore │ │ ConcurrentStoreRead │
│ get(&K) -> &V │ │ get(&K) -> Arc<V> │
│ contains, len, │ │ contains, len, │
│ capacity, metrics │ │ capacity, metrics │
└─────────┬──────────┘ └────────────┬─────────────┘
│ extends │ extends
▼ ▼
┌────────────────────┐ ┌──────────────────────────┐
│ StoreMut │ │ ConcurrentStore │
│ try_insert, │ │ try_insert (Arc<V>), │
│ remove, clear │ │ remove, clear │
└────────────────────┘ └──────────────────────────┘
┌────────────────────┐ ┌──────────────────────────┐
│ StoreFactory │ │ ConcurrentStoreFactory │
│ type Store: ... │ │ type Store: ... │
│ new(capacity) │ │ new(capacity) │
└────────────────────┘ └──────────────────────────┘
StoreMetrics (unconditional, 7 counters)
StoreFull (zero-sized error type)
Every concrete store implements exactly one column. The
single-threaded stores use direct ownership; the concurrent stores
use Arc<V> because borrowed references cannot outlive
RwLockReadGuard. See
concurrency.md
for the equivalent argument at the cache-trait level.
StoreCore / StoreMutThe sequential surface. Three design choices worth naming:
&V return positionStoreCore::get returns Option<&V>.
The borrow is tied to &self, so callers can read without cloning.
This is the right shape for a sequential trait because the alternative
(V by value) forces V: Clone everywhere or hands out Arc<V> on
every call.
The concurrent counterpart cannot do this — see Layer 2 below.
try_insert returns Result<Option<V>, StoreFull>Three independent failure modes hide in this one signature:
| Outcome | Return value | Meaning |
|---|---|---|
| New key fits | Ok(None) |
Inserted; no previous value |
| Existing key updated | Ok(Some(old)) |
Replaced; old value handed back |
| Store full and key is new | Err(StoreFull) |
Caller must evict and retry |
Updates to an existing key always succeed — they cannot push the
store past capacity by entry count, and the previous value is handed
back as Ok(Some(old)). Capacity refusal is a StoreFull only
when the key is new and inserting it would exceed the entry count.
WeightStore extends this to a
dual-limit model where updates can fail (a larger value replacing a
smaller one can exceed the weight budget). See
weighted-eviction.md for
the full table.
The store never evicts on its own. Returning StoreFull is the
signal to the caller that they must decide who to remove. This is
the layering rule from design.md §7 made concrete:
StoreMut evicts its chosen victim, then
retries try_insert.StoreMut directly evicts a key they pick (random,
oldest-by-some-criterion, etc.), then retries.A store that evicted on its own would lock a single eviction strategy into every consumer, and would prevent layering a better eviction policy on top.
ConcurrentStoreRead / ConcurrentStoreThe concurrent surface mirrors the sequential one with two substitutions:
Arc<V> rather than &V / V. References cannot
outlive the lock guard they were extracted from; Arc<V> carries
ownership safely past lock release.&self. Internal synchronization (almost always
parking_lot::RwLock) is the store’s responsibility, not the
caller’s. The wrapper does not require &mut self for
mutation.Implementors must be Send + Sync. The trait bound is on the trait
declaration itself
(pub trait ConcurrentStoreRead<K, V>: Send + Sync), so any code
generic over ConcurrentStoreRead automatically requires thread
safety from the implementor.
This is the same shape Concurrent* cache wrappers use — see
concurrency.md
for the parallel reasoning.
pub trait StoreFactory<K, V> {
type Store: StoreMut<K, V>;
fn new(capacity: usize) -> Self::Store;
}
pub trait ConcurrentStoreFactory<K, V> {
type Store: ConcurrentStore<K, V>;
fn new(capacity: usize) -> Self::Store;
}
Factory traits exist so generic code can construct a store without
naming the concrete type. They mirror CacheFactory in
trait-hierarchy.md.
In practice most code constructs stores directly; the factories are
used by test harnesses and benchmark runners that want to
parameterise across store implementations.
StoreMetrics: the always-on baseline#[non_exhaustive]
pub struct StoreMetrics {
pub hits: u64,
pub misses: u64,
pub inserts: u64,
pub updates: u64,
pub removes: u64,
pub evictions: u64,
pub expirations: u64,
}
Two things distinguish this from the policy-layer metrics in
metrics.md:
#[cfg(feature = "metrics")]
gate. The seven counters here are universal enough to be a
baseline contract every store satisfies.StoreCore::metrics()
returns a snapshot StoreMetrics by value. How a store records
the increments (plain u64, AtomicU64, MetricsCell,
StoreCounters in
src/store/weight.rs) is an
implementation detail. Concurrent stores typically use
AtomicU64; single-threaded stores use plain u64 or Cell<u64>.StoreMetrics is #[non_exhaustive], so adding a new universal
counter is a minor version bump. The expirations field landed
this way (added when the TTL surface needed time-driven removals
distinguished from capacity-driven evictions).
For per-policy detail (recency rank reads, LFU bucket promotions,
S3-FIFO ghost hits) see the policy-layer metrics behind the
metrics feature.
StoreFull error semanticsStoreFull is a zero-sized type that
carries no data. The caller already knows what they tried to insert;
attaching the key/value to the error would force K: Clone /
V: Clone on the error path for no information gain.
The error is co-located with the trait that returns it
(src/store/traits.rs) rather than in src/error.rs. The reasoning
matches the broader error model
(error-model.md):
each error type lives near the surface that produces it.
Four concrete store types ship today, plus their concurrent counterparts. Each picks a different point in the memory-layout space.
| Store | Backing | Key shape | Threading | When to use |
|---|---|---|---|---|
HashMapStore |
HashMap<K, V, S> |
K: Eq + Hash |
sequential | Default; any cache where the key drives layout |
ConcurrentHashMapStore |
RwLock<HashMap<…>> |
same | concurrent | Default concurrent shape |
ShardedHashMapStore |
N RwLock<HashMap<…>> shards |
same | concurrent, contention-aware | When one RwLock is the bottleneck |
SlabStore |
slab arena with EntryId handles |
K: Eq + Hash |
sequential | Policies that need stable EntryIds for intrusive metadata |
ConcurrentSlabStore |
RwLock<SlabStore> |
same | concurrent | Concurrent slab access |
HandleStore |
HashMap<H, Arc<V>> |
opaque handle H |
sequential | When keys are pre-interned and only the handle is in the hot path |
ConcurrentHandleStore |
RwLock<HashMap<H, Arc<V>>> |
same | concurrent | Concurrent variant of the above |
WeightStore |
FxHashMap + per-entry weight |
K: Eq + Hash |
sequential | Variable-size values; byte-budgeted caches |
ConcurrentWeightStore |
RwLock<WeightStore> |
same | concurrent | Concurrent variant of the above |
HashMapStoreThe default public store. Uses std::collections::hash_map::RandomState
by default for HashDoS-resistant hashing on the public surface; users
who control the key source can opt into a faster hasher via
with_hasher. See hashing.md for the threat model.
This is the right choice when keys are typed
(String, u64, (TenantId, ResourceId), …) and the policy does
not need stable per-entry handles. Most caches built through
CacheBuilder end up here either directly or indirectly.
SlabStoreBacks stores in a slab arena. Each entry has a stable EntryId
handle that survives mutations to other entries. This is essential
for policies that thread intrusive metadata through entries — LRU’s
recency list, S3-FIFO’s small/main queues, NRU’s reference bit
ring — because pointer chasing without stable indirection makes the
borrow checker rejection-prone and pointer chasing hostile to the
cache hierarchy.
Use SlabStore directly when building a policy that wants slot
handles. Most users reach it indirectly through the policy types
that consume it.
HandleStoreA specialised shape: keys are stored elsewhere (typically a
KeyInterner) and the store maps
Handle -> Arc<V>. The motivation is to avoid cloning large keys
on every operation when many policies (LFU bucket maps,
frequency-bucket arrays, ARC ghost lists) need a compact key proxy
anyway.
HandleStore returns Arc<V> even in the single-threaded variant.
This is the same divergence
WeightStore takes, and for
the same reason: the values targeted by this shape (interned blobs,
deduplicated payloads) benefit from cheap shared ownership.
WeightStore’s deliberate divergenceWeightStore does not implement StoreCore / StoreMut. It is
a sibling of the trait family, not a subtype. The reasons live in
weighted-eviction.md but worth recapping
here:
Arc<V> (not &V) even in the single-threaded
variant. This is necessary for the concurrent variant and the
single-threaded variant inherits the same shape so users can swap
between them by changing one type.try_insert enforces a dual limit (entry count and weight
budget). Updates can fail when the weight delta would exceed
budget. StoreMut::try_insert’s contract is “updates always
succeed,” which WeightStore cannot honour.F: Fn(&V) -> usize weight function. Carrying that
third type parameter would propagate through every layer of
StoreMut-generic code unnecessarily.The concurrent variant does implement ConcurrentStoreRead /
ConcurrentStore because those return Arc<V> and accept Arc<V>
on insert. The asymmetry is awkward but honest — the concurrent
trait family already has the shape WeightStore needs.
ShardedHashMapStore<K, V, S> is the only sharded store that ships
today. It owns N independent RwLock<HashMap<…>> shards, each
addressed by hashing the key through a
ShardSelector.
| Property | Single concurrent | Sharded |
|---|---|---|
| Lock acquisition | One global RwLock per op |
One shard RwLock per op |
| Hot key contention | Yes — all readers/writers compete | Only readers/writers on the same shard |
| Capacity model | Single global cap | Per-shard caps that sum to global cap |
| Eviction quality | Global victim picking | Per-shard victim picking |
| Implementation complexity | Low | Medium |
See sharding.md for the full discussion. Note that
the sharded primitive lives at the data-structure / store layer;
a sharded cache policy (e.g. ShardedLruCache) is roadmap.
Store trait?StoreCore could in principle subsume StoreMut (just make all
methods &mut self-or-&self). It doesn’t, for the same reason
Cache<K, V> separates peek from get: a read-only surface lets
concurrent wrappers acquire only the read lock.
StoreCore + StoreMut could in principle merge with
ConcurrentStoreRead + ConcurrentStore via an Arc<V>-returning
universal variant. That collapses the sequential &V fast path into
an unnecessary Arc<V> round-trip, which is exactly what the
sequential Cache::get -> Option<&V> shape is trying to avoid.
Two parallel families is the cost of letting both shapes pay only for what they use.
Checklist for landing a new store implementation:
StoreCore / StoreMut) or
concurrent (ConcurrentStoreRead / ConcurrentStore). Usually
both, with the concurrent variant wrapping the sequential one in
RwLock.get, contains, len,
capacity, metrics. Override metrics() to expose your
counters rather than the default-zero implementation.try_insert, remove, clear.
try_insert must return Err(StoreFull) for new keys at
capacity; updates to existing keys must not fail (unless the
store has additional invariants like WeightStore’s weight
budget — document the divergence at the module level).StoreFactory impl if the store has a stable
new(capacity) constructor and is likely to be parameterised
over in generic code.Send + Sync for the concurrent variant. The
sequential variant typically is not Sync (because it holds
Cell<u64> for MetricsCell counters or RefCell for any
interior state).hashing.md discipline.docs/stores/<name>.md following the
doc style guide. Link the new
doc from docs/stores/README.md.len == sum(entries), metric counters monotonic,
try_insert(k, v) followed by remove(k) round-trips. See
docs/testing/testing.md for the
conventions.The store layer is small on purpose. Before adding a new store, check:
HashMapStore::with_hasher(FxBuildHasher) and
ShardedHashMapStore cover most of the obvious knobs.WeightStore is the precedent for diverging — variable weights
forced a dual-limit model that StoreMut cannot express. New
stores that fit StoreMut’s contract should implement it rather
than introduce a sibling.StoreFull from try_insert on a WeightStore-style store
with weight budget remaining. Caller should consult both
len() and (for weight-aware stores) total_weight() to know
which budget bit. The error type is the same; the resolution
differs.ConcurrentWeightStore::try_insert. The weight function runs
inside the write lock. Under panic = "unwind" the lock is
released (parking_lot doesn’t poison), but the inner state is
whatever the panicking weight function left it in. Under the
crate’s release-default panic = "abort", the process exits
before any observer can see partial state. See
error-model.md.FxHashMap-backed stores under adversarial
keys. WeightStore and policy-internal maps are the targets;
see hashing.md for
the trade-off and the user-facing escape hatches.&V vs Arc<V> reasoning is
sharedConcurrent* wrapper pattern
applied at the store layerWeightStore’s
dual-limit model and deliberate divergence from StoreMutShardedHashMapStore and the roadmap
for sharded cache policiesStoreMetrics baseline and the feature-gated policy-layer
metricsStoreFull semantics, panic
behaviour during user-supplied callbackssrc/store/traits.rs — canonical
trait definitions