From c5f89f146be06a0a9720afd520063ecfef6a717b Mon Sep 17 00:00:00 2001 From: Faiaz Sanaulla <105630300+fsdvh@users.noreply.github.com> Date: Tue, 12 May 2026 16:11:35 +0200 Subject: [PATCH 01/17] Add non-blocking (`try_*`) cache methods (#112) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Summary - Adds `try_read` / `try_write` to the internal `RwLock` wrapper, normalizing `WouldBlock` to `None` and panicking on poison (matching the blocking variants' behavior). - Introduces a `LockContention` error type returned by all non-blocking operations when the target shard lock cannot be acquired immediately. - Adds non-blocking counterparts for all major cache operations on `Cache`: - `try_contains_key` — returns `Result` - `try_get` — returns `Result, LockContention>` - `try_peek` — returns `Result, LockContention>` - `try_remove` — returns `Result, LockContention>` - `try_insert` / `try_insert_with_lifecycle` — returns `Result<(), (Key, Val)>` / `Result` (key+value returned on contention so the caller can retry or discard) - All read-path methods return `Err(LockContention)` on contention; write-path insert methods return `Err((key, val))` so the caller can retry or discard without losing data. --- README.md | 1 + src/rw_lock.rs | 40 +++++++++ src/sync.rs | 237 +++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 278 insertions(+) diff --git a/README.md b/README.md index 8c986e8..4174151 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ Lightweight and high performance concurrent cache optimized for low cache overhe * Scales well with the number of threads * Atomic operations with `get_or_insert` and `get_value_or_guard` functions * Atomic async operations with `get_or_insert_async` and `get_value_or_guard_async` functions +* Non-blocking methods that return immediately on lock contention. * Closure-based `entry` API for atomic inspect-and-act patterns (keep, remove, replace) * Supports item pinning * Iteration and draining diff --git a/src/rw_lock.rs b/src/rw_lock.rs index 278f5b7..cc8e81c 100644 --- a/src/rw_lock.rs +++ b/src/rw_lock.rs @@ -105,6 +105,46 @@ impl RwLock { }) } + /// Attempts to acquire this `RwLock` with shared read access without blocking. + /// + /// Returns `Some(guard)` if the lock was acquired, or `None` if it is already + /// held by a writer. + #[inline] + pub fn try_read(&self) -> Option> { + #[cfg(feature = "parking_lot")] + { + self.0.try_read().map(RwLockReadGuard) + } + #[cfg(not(feature = "parking_lot"))] + { + match self.0.try_read() { + Ok(guard) => Some(RwLockReadGuard(guard)), + Err(std::sync::TryLockError::WouldBlock) => None, + Err(std::sync::TryLockError::Poisoned(err)) => panic!("{}", err), + } + } + } + + /// Attempts to acquire this `RwLock` with exclusive write access without blocking. + /// + /// Returns `Some(guard)` if the lock was acquired, or `None` if it is already + /// held by any readers or a writer. + #[inline] + pub fn try_write(&self) -> Option> { + #[cfg(feature = "parking_lot")] + { + self.0.try_write().map(RwLockWriteGuard) + } + #[cfg(not(feature = "parking_lot"))] + { + match self.0.try_write() { + Ok(guard) => Some(RwLockWriteGuard(guard)), + Err(std::sync::TryLockError::WouldBlock) => None, + Err(std::sync::TryLockError::Poisoned(err)) => panic!("{}", err), + } + } + } + /// Locks this `RwLock` with exclusive write access, blocking the current /// thread until it can be acquired. /// diff --git a/src/sync.rs b/src/sync.rs index 52cd3a4..55b1580 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -18,6 +18,23 @@ use crate::shard::EntryOrPlaceholder; pub use crate::sync_placeholder::{EntryAction, EntryResult, GuardResult, PlaceholderGuard}; use crate::sync_placeholder::{JoinFuture, JoinResult}; +/// Error returned by non-blocking cache operations that do not consume their +/// inputs when the relevant shard lock could not be acquired immediately. +/// +/// This is used by borrowed-key/read-path operations. Non-blocking operations +/// that consume owned inputs (e.g. `try_insert`) instead return those inputs +/// on contention so the caller can retry or discard without losing data. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct LockContention; + +impl std::fmt::Display for LockContention { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Lock Contention") + } +} + +impl std::error::Error for LockContention {} + /// A concurrent cache /// /// The concurrent cache is internally composed of equally sized shards, each of which is independently @@ -274,6 +291,23 @@ impl< .is_some_and(|(shard, hash)| shard.read().contains(hash, key)) } + /// Attempts to check if a key exists in the cache without blocking. + /// Returns `Ok(true)` if present, `Ok(false)` if absent, + /// or `Err(LockContention)` if the shard lock could not be acquired without blocking. + pub fn try_contains_key(&self, key: &Q) -> Result + where + Q: Hash + Equivalent + ?Sized, + { + let Some((shard, hash)) = self.shard_for(key) else { + return Ok(false); + }; + + match shard.try_read() { + Some(guard) => Ok(guard.contains(hash, key)), + None => Err(LockContention), + } + } + /// Fetches an item from the cache whose key is `key`. pub fn get(&self, key: &Q) -> Option where @@ -283,6 +317,23 @@ impl< shard.read().get(hash, key).cloned() } + /// Attempts to fetch an item from the cache whose key is `key`. + /// Returns `Ok(Some(val))` if the key is present, `Ok(None)` if absent, + /// or `Err(LockContention)` if the shard lock could not be acquired without blocking. + pub fn try_get(&self, key: &Q) -> Result, LockContention> + where + Q: Hash + Equivalent + ?Sized, + { + let Some((shard, hash)) = self.shard_for(key) else { + return Ok(None); + }; + + match shard.try_read() { + Some(guard) => Ok(guard.get(hash, key).cloned()), + None => Err(LockContention), + } + } + /// Peeks an item from the cache whose key is `key`. /// Contrary to gets, peeks don't alter the key "hotness". pub fn peek(&self, key: &Q) -> Option @@ -293,6 +344,23 @@ impl< shard.read().peek(hash, key).cloned() } + /// Attempts to peek an item from the cache whose key is `key`. + /// Contrary to gets, peeks don't alter the key "hotness". + /// Returns `Ok(Some(val))` if the key is present, `Ok(None)` if absent, + /// or `Err(LockContention)` if the shard lock could not be acquired without blocking. + pub fn try_peek(&self, key: &Q) -> Result, LockContention> + where + Q: Hash + Equivalent + ?Sized, + { + let Some((shard, hash)) = self.shard_for(key) else { + return Ok(None); + }; + match shard.try_read() { + Some(guard) => Ok(guard.peek(hash, key).cloned()), + None => Err(LockContention), + } + } + /// Remove an item from the cache whose key is `key`. /// Returns the removed entry, if any. pub fn remove(&self, key: &Q) -> Option<(Key, Val)> @@ -303,6 +371,23 @@ impl< shard.write().remove(hash, key) } + /// Attempts to remove an item from the cache whose key is `key`. + /// Returns `Ok(Some(entry))` with the removed entry if present, `Ok(None)` if absent, + /// or `Err(LockContention)` if the shard lock could not be acquired without blocking. + pub fn try_remove(&self, key: &Q) -> Result, LockContention> + where + Q: Hash + Equivalent + ?Sized, + { + let Some((shard, hash)) = self.shard_for(key) else { + return Ok(None); + }; + + match shard.try_write() { + Some(mut guard) => Ok(guard.remove(hash, key)), + None => Err(LockContention), + } + } + /// Remove an item from the cache whose key is `key` if `f(&value)` returns `true` for that entry. /// Compared to peek and remove, this method guarantees that no new value was inserted in-between. /// @@ -364,6 +449,16 @@ impl< self.lifecycle.end_request(lcs); } + /// Attempts to insert an item in the cache with key `key` without blocking. + /// Returns `Ok(())` if the item was inserted, or `Err((key, value))` if the shard lock + /// could not be acquired without blocking. Lock contention is the only failure + /// mode: the inputs are returned so the caller can retry or discard them. + pub fn try_insert(&self, key: Key, value: Val) -> Result<(), (Key, Val)> { + let lcs = self.try_insert_with_lifecycle(key, value)?; + self.lifecycle.end_request(lcs); + Ok(()) + } + /// Inserts an item in the cache with key `key`. pub fn insert_with_lifecycle(&self, key: Key, value: Val) -> L::RequestState { let mut lcs = self.lifecycle.begin_request(); @@ -376,6 +471,32 @@ impl< lcs } + /// Attempts to insert an item in the cache with key `key` without blocking. + /// Returns `Ok(lcs)` with the lifecycle request state if the item was inserted, + /// or `Err((key, value))` if the shard lock could not be acquired without blocking. + /// Lock contention is the only failure mode: the inputs are returned so the + /// caller can retry or discard them. + pub fn try_insert_with_lifecycle( + &self, + key: Key, + value: Val, + ) -> Result { + // Tradeoff: begin_request is called before acquiring the shard lock to avoid holding + // the lock during potentially expensive lifecycle initialization. + let mut lcs = self.lifecycle.begin_request(); + let (shard, hash) = self.shard_for(&key).unwrap(); + + match shard.try_write() { + Some(mut shard) => { + let result = shard.insert(&mut lcs, hash, key, value, InsertStrategy::Insert); + // result cannot err with the Insert strategy + debug_assert!(result.is_ok()); + Ok(lcs) + } + _ => Err((key, value)), + } + } + /// Clear all items from the cache pub fn clear(&self) { for s in self.shards.iter() { @@ -1526,4 +1647,120 @@ mod tests { } } } + + // --- Non-blocking method tests --- + #[test] + fn test_try_contains_key() { + let cache = Cache::new(100); + cache.insert(1, 10); + + assert!(cache.try_contains_key(&1).is_ok_and(|v| v)); + assert!(cache.try_contains_key(&2).is_ok_and(|v| !v)); + } + + #[test] + fn test_try_contains_key_contended() { + let cache = Cache::new(100); + cache.insert(1, 10); + // Hold write locks on all shards so try_read is blocked. + let _guards: Vec<_> = cache.shards.iter().map(|s| s.write()).collect(); + assert!(cache.try_contains_key(&1).is_err()); + } + + #[test] + fn test_try_get() { + let cache = Cache::new(100); + cache.insert(1, 10); + + assert!(cache.try_get(&1).is_ok_and(|v| matches!(v, Some(10)))); + assert!(cache.try_get(&2).is_ok_and(|v| v.is_none())); + } + + #[test] + fn test_try_get_contended() { + let cache = Cache::new(100); + cache.insert(1, 10); + let _guards: Vec<_> = cache.shards.iter().map(|s| s.write()).collect(); + assert!(cache.try_get(&1).is_err()); + } + + #[test] + fn test_try_peek() { + let cache = Cache::new(100); + cache.insert(1, 10); + + assert!(cache.try_peek(&1).is_ok_and(|v| matches!(v, Some(10)))); + assert!(cache.try_peek(&2).is_ok_and(|v| v.is_none())); + } + + #[test] + fn test_try_peek_contended() { + let cache = Cache::new(100); + cache.insert(1, 10); + let _guards: Vec<_> = cache.shards.iter().map(|s| s.write()).collect(); + assert!(cache.try_peek(&1).is_err()); + } + + #[test] + fn test_try_remove() { + let cache = Cache::new(100); + cache.insert(1, 10); + + assert!(cache + .try_remove(&1) + .is_ok_and(|v| matches!(v, Some((1, 10))))); + assert!(cache.try_remove(&1).is_ok_and(|v| v.is_none())); + assert!(cache.try_remove(&99).is_ok_and(|v| v.is_none())); + } + + #[test] + fn test_try_remove_contended() { + let cache = Cache::new(100); + cache.insert(1, 10); + // Hold read locks on all shards so try_write is blocked. + let guards: Vec<_> = cache.shards.iter().map(|s| s.read()).collect(); + assert!(cache.try_remove(&1).is_err()); + drop(guards); + // Item must still be present since the remove did not happen. + assert_eq!(cache.get(&1), Some(10)); + } + + #[test] + fn test_try_insert() { + let cache = Cache::new(100); + + assert_eq!(cache.try_insert(1, 10), Ok(())); + assert_eq!(cache.get(&1), Some(10)); + + // Insert same key overwrites the previous value. + assert_eq!(cache.try_insert(1, 20), Ok(())); + assert_eq!(cache.get(&1), Some(20)); + } + + #[test] + fn test_try_insert_contended() { + let cache = Cache::new(100); + let guards: Vec<_> = cache.shards.iter().map(|s| s.read()).collect(); + assert_eq!(cache.try_insert(1, 10), Err((1, 10))); + drop(guards); + assert_eq!(cache.get(&1), None); + } + + #[test] + fn test_try_insert_with_lifecycle() { + let cache = Cache::new(100); + + // Successful insert returns the lifecycle request state. + let result = cache.try_insert_with_lifecycle(1, 10); + assert!(result.is_ok()); + let lcs = result.ok().unwrap(); + cache.lifecycle.end_request(lcs); + assert_eq!(cache.get(&1), Some(10)); + + // Contended when a read lock is held. + let guards: Vec<_> = cache.shards.iter().map(|s| s.read()).collect(); + assert_eq!(cache.try_insert_with_lifecycle(2, 20), Err((2, 20))); + drop(guards); + assert_eq!(cache.get(&2), None); + } } From 649720dd1eb8931d846c296f05bf3ae906fd3cb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20U=C4=9Fur?= <7467169+DenizUgur@users.noreply.github.com> Date: Fri, 15 May 2026 15:41:20 -0700 Subject: [PATCH 02/17] Extend lifecycle trait with `on_evict_(cold|hot)` (#117) * extend lifecycle trait with on_evict_(cold|hot) * default impl for on_evict * Address Copilot review on PR #117 - Fix hot/cold misclassification in handle_insert_overweight: capture the evicted entry's ResidentState before remove_internal so cold evictions aren't reported as hot. - Reword on_evict deprecation note to accurately describe per-method fallback (each new method delegates to on_evict independently). - Document rejection-style behavior on on_evict_hot: items that never enter the cache (oversized inserts and oversized placeholder values) are reported as hot. - Migrate DefaultLifecycle (sync and unsync) and the eviction_listener example to implement on_evict_hot/on_evict_cold directly. * Lifecycle: drop on_evict deprecation; route overweight rejections to cold - on_evict stays as a provided method with an empty default body (no deprecation). on_evict_hot/on_evict_cold default to delegating to it, so existing impls keep working and new impls can override just the hot/cold methods when they want to distinguish. - Route oversized inserts and oversized placeholder rejections through on_evict_cold (they never entered any queue). - Revert DefaultLifecycle (sync/unsync) and the eviction_listener example to the simple on_evict form. * Cross-reference rejection routing on on_evict and on_evict_hot docs --------- Co-authored-by: Deniz Co-authored-by: Arthur Silva --- src/lib.rs | 38 +++++++++++++++++++++++++++++++++++++- src/shard.rs | 27 ++++++++++++++++++++------- src/unsync.rs | 3 --- 3 files changed, 57 insertions(+), 11 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 7840608..433d0f0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -196,7 +196,43 @@ pub trait Lifecycle { fn before_evict(&self, state: &mut Self::RequestState, key: &Key, val: &mut Val) {} /// Called when an item is evicted. - fn on_evict(&self, state: &mut Self::RequestState, key: Key, val: Val); + /// + /// To distinguish evictions from the hot vs cold queues, override + /// [`Lifecycle::on_evict_hot`] and/or [`Lifecycle::on_evict_cold`] instead; + /// they default to delegating here. + /// + /// If none of `on_evict`, `on_evict_hot`, or `on_evict_cold` is overridden, + /// eviction notifications are silently dropped. + /// + /// Note: items that are rejected without ever being admitted to the cache + /// (oversized inserts and oversized placeholder values) are routed through + /// [`Lifecycle::on_evict_cold`], which by default reaches this method. + #[allow(unused_variables)] + #[inline] + fn on_evict(&self, state: &mut Self::RequestState, key: Key, val: Val) {} + + /// Called when an item is evicted from the cold queue. + /// + /// By default delegates to [`Lifecycle::on_evict`]. + /// + /// Note: items that are rejected without ever being admitted to the cache + /// (oversized inserts and oversized placeholder values) are also reported + /// via this method. + #[inline] + fn on_evict_cold(&self, state: &mut Self::RequestState, key: Key, val: Val) { + self.on_evict(state, key, val) + } + + /// Called when an item is evicted from the hot queue. + /// + /// By default delegates to [`Lifecycle::on_evict`]. + /// + /// Note: rejected (never-admitted) items are reported via + /// [`Lifecycle::on_evict_cold`], not this method. + #[inline] + fn on_evict_hot(&self, state: &mut Self::RequestState, key: Key, val: Val) { + self.on_evict(state, key, val) + } /// Called after a request finishes, e.g.: insert, replace. /// diff --git a/src/shard.rs b/src/shard.rs index ec40b94..bf01c02 100644 --- a/src/shard.rs +++ b/src/shard.rs @@ -783,7 +783,8 @@ impl< if self.num_non_resident > self.capacity_non_resident { self.advance_ghost(); } - self.lifecycle.on_evict(lcs, evicted.key, evicted.value); + self.lifecycle + .on_evict_cold(lcs, evicted.key, evicted.value); return true; } } @@ -835,7 +836,7 @@ impl< unsafe { core::hint::unreachable_unchecked() }; }; self.hot_head = next; - self.lifecycle.on_evict(lcs, evicted.key, evicted.value); + self.lifecycle.on_evict_hot(lcs, evicted.key, evicted.value); self.map_remove(hash, idx); } return true; @@ -920,7 +921,15 @@ impl< } else if evicted_weight != 0 && weight == 0 { *list_head = self.entries.unlink(idx); } - self.lifecycle.on_evict(lcs, evicted.key, evicted.value); + match enter_state { + ResidentState::Hot => { + self.lifecycle.on_evict_hot(lcs, evicted.key, evicted.value) + } + ResidentState::Cold => { + self.lifecycle + .on_evict_cold(lcs, evicted.key, evicted.value) + } + } } Entry::Ghost(_) => { self.weight_hot += weight; @@ -1052,7 +1061,7 @@ impl< ) -> Result<(), Val> { self.entries.remove(placeholder.idx()); self.map_remove(placeholder.hash(), placeholder.idx()); - self.lifecycle.on_evict(lcs, key, value); + self.lifecycle.on_evict_cold(lcs, key, value); Ok(()) } @@ -1120,15 +1129,19 @@ impl< strategy: InsertStrategy, ) -> Result<(), (Key, Val)> { // Make sure to remove any existing entry - if let Some((idx, _)) = self.search_resident(hash, &key) { + if let Some((idx, resident)) = self.search_resident(hash, &key) { + let prev_state = resident.state; if let Some((ek, ev)) = self.remove_internal(hash, idx) { - self.lifecycle.on_evict(lcs, ek, ev); + match prev_state { + ResidentState::Hot => self.lifecycle.on_evict_hot(lcs, ek, ev), + ResidentState::Cold => self.lifecycle.on_evict_cold(lcs, ek, ev), + } } } if matches!(strategy, InsertStrategy::Replace { .. }) { return Err((key, value)); } - self.lifecycle.on_evict(lcs, key, value); + self.lifecycle.on_evict_cold(lcs, key, value); Ok(()) } diff --git a/src/unsync.rs b/src/unsync.rs index 66ee954..ae7f1d5 100644 --- a/src/unsync.rs +++ b/src/unsync.rs @@ -443,9 +443,6 @@ impl Lifecycle for DefaultLifecycle { #[inline] fn begin_request(&self) -> Self::RequestState {} - - #[inline] - fn on_evict(&self, _state: &mut Self::RequestState, _key: Key, _val: Val) {} } #[derive(Debug, Clone)] From 79eb9e64f9482611f1f53e0f93cc83eddd79145b Mon Sep 17 00:00:00 2001 From: Arthur Silva Date: Sun, 17 May 2026 19:08:47 +0200 Subject: [PATCH 03/17] 0.6.22 --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index f6a8eef..8dc07e5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "quick_cache" -version = "0.6.21" +version = "0.6.22" edition = "2021" description = "Lightweight and high performance concurrent cache" repository = "https://github.com/arthurprs/quick-cache" From 52e42fb2de5afee4bbb6736a1539d8d9cea882a4 Mon Sep 17 00:00:00 2001 From: hanabi1224 Date: Fri, 5 Jun 2026 04:58:51 +0800 Subject: [PATCH 04/17] fix: memory leak in dropping uninserted guard (#120) * fix: memory leak in dropping uninserted guard * fix * Add regression tests for placeholder slot reuse on guard drop Cover the two documented cases where a placeholder is removed/replaced while a guard is still outstanding (overwrite insert, and remove + slot reuse). Both panicked under the original unconditional entries.remove and pass with the fix that only frees the slot when the placeholder is still present. --------- Co-authored-by: Arthur Silva --- src/shard.rs | 1 + src/sync.rs | 52 +++++++++++++++++++++++++++++++++++++++++ src/sync_placeholder.rs | 7 ++++++ 3 files changed, 60 insertions(+) diff --git a/src/shard.rs b/src/shard.rs index bf01c02..c493371 100644 --- a/src/shard.rs +++ b/src/shard.rs @@ -223,6 +223,7 @@ impl CacheShard Lifecycle for DefaultLifecycle { #[cfg(test)] mod tests { use super::*; + use crate::shard::SharedPlaceholder as _; use std::{ sync::{Arc, Barrier}, thread, @@ -1763,4 +1764,55 @@ mod tests { drop(guards); assert_eq!(cache.get(&2), None); } + + #[test] + fn test_guard_leak() { + let cache: Cache = Cache::new(8); + let guard1 = match cache.get_value_or_guard(&1, None) { + GuardResult::Guard(g) => g, + _ => panic!("expected guard"), + }; + let idx1 = guard1.shared().idx(); + drop(guard1); + let guard2 = match cache.get_value_or_guard(&1, None) { + GuardResult::Guard(g) => g, + _ => panic!("expected guard"), + }; + let idx2 = guard2.shared().idx(); + drop(guard2); + assert_eq!(idx1, idx2); + } + + // A real insert overwrites the placeholder in place, reusing its slab slot as + // a Resident. Dropping the now-stale guard must not free that slot, otherwise + // the live entry is evicted while the map still references it. + #[test] + fn test_guard_drop_after_overwrite_insert() { + let cache: Cache = Cache::new(8); + let guard = match cache.get_value_or_guard(&1, None) { + GuardResult::Guard(g) => g, + _ => panic!("expected guard"), + }; + cache.insert(1, 100); + assert_eq!(cache.get(&1), Some(100)); + drop(guard); + assert_eq!(cache.get(&1), Some(100)); + } + + // A remove frees the placeholder's slab slot, which a later insert reuses for a + // different key. Dropping the original guard must not free that slot again, or + // it evicts the unrelated key. + #[test] + fn test_guard_drop_after_remove_and_reuse() { + let cache: Cache = Cache::new(8); + let guard = match cache.get_value_or_guard(&1, None) { + GuardResult::Guard(g) => g, + _ => panic!("expected guard"), + }; + cache.remove(&1); + cache.insert(2, 222); + assert_eq!(cache.get(&2), Some(222)); + drop(guard); + assert_eq!(cache.get(&2), Some(222)); + } } diff --git a/src/sync_placeholder.rs b/src/sync_placeholder.rs index 8f09d11..5da45e2 100644 --- a/src/sync_placeholder.rs +++ b/src/sync_placeholder.rs @@ -93,6 +93,13 @@ pub struct PlaceholderGuard<'a, Key, Val, We, B, L> { inserted: bool, } +#[cfg(test)] +impl<'a, Key, Val, We, B, L> PlaceholderGuard<'a, Key, Val, We, B, L> { + pub fn shared(&self) -> &SharedPlaceholder { + &self.shared + } +} + #[derive(Debug)] enum Waiter { Thread { From 4cb2092591b652acb34c457137d2bbc4637bd0b4 Mon Sep 17 00:00:00 2001 From: Arthur Silva Date: Thu, 4 Jun 2026 23:07:45 +0200 Subject: [PATCH 05/17] Fix MSRV build: qualify size_of, add cargo-msrv CI check (#121) `std::mem::size_of` was only added to the prelude in Rust 1.80, but the declared MSRV is 1.71. It was used unqualified in linked_slab.rs, breaking builds for downstream crates on Rust < 1.80. Qualify the call as `std::mem::size_of` (matching shard.rs) and add an `msrv` CI job that runs `cargo msrv verify` so this regression is caught in the future. Fixes #118 --- .github/workflows/ci.yml | 21 +++++++++++++++++++++ src/linked_slab.rs | 2 +- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3a6a5e2..a48224b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,6 +29,27 @@ jobs: - run: cargo check --all-targets --no-default-features --features sharded-lock - run: cargo check --all-targets --features shuttle + msrv: + name: MSRV + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions-rs/toolchain@v1 + with: + profile: minimal + toolchain: stable + override: true + - uses: taiki-e/install-action@v2 + with: + tool: cargo-msrv + # `cargo msrv verify` checks that the crate compiles with the + # `rust-version` declared in Cargo.toml. The default `cargo check` skips + # dev-dependencies, which may require a newer Rust than downstream needs. + - run: cargo msrv verify + - run: cargo msrv verify -- cargo check --features stats + - run: cargo msrv verify -- cargo check --no-default-features + - run: cargo msrv verify -- cargo check --no-default-features --features sharded-lock + test: name: Tests strategy: diff --git a/src/linked_slab.rs b/src/linked_slab.rs index bd840da..7a252c6 100644 --- a/src/linked_slab.rs +++ b/src/linked_slab.rs @@ -247,6 +247,6 @@ impl LinkedSlab { /// It should be noted that if cache key or value is some type like `Vec`, /// the memory allocated in the heap will not be counted. pub fn memory_used(&self) -> usize { - self.entries.len() * size_of::>() + self.entries.len() * std::mem::size_of::>() } } From 95b0e4058f74a2acbc9efca21a79c713b9c257ff Mon Sep 17 00:00:00 2001 From: Arthur Silva Date: Thu, 4 Jun 2026 23:15:07 +0200 Subject: [PATCH 06/17] 0.6.23 --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 8dc07e5..eabaf0c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "quick_cache" -version = "0.6.22" +version = "0.6.23" edition = "2021" description = "Lightweight and high performance concurrent cache" repository = "https://github.com/arthurprs/quick-cache" From 54dacff7944109b99c293f1691bd591cb26a51e3 Mon Sep 17 00:00:00 2001 From: Faiaz Sanaulla Date: Wed, 24 Jun 2026 13:07:03 +0200 Subject: [PATCH 07/17] Add per item access count --- src/lib.rs | 16 +++++++++++++++ src/shard.rs | 56 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/sync.rs | 34 +++++++++++++++++++++++++++++++ src/unsync.rs | 10 +++++++++ 4 files changed, 116 insertions(+) diff --git a/src/lib.rs b/src/lib.rs index 7840608..09b8265 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -225,6 +225,22 @@ impl MemoryUsed { } } +/// Per-item statistics returned by `item_stats`. +/// +/// Only available with the `stats` feature enabled. +#[cfg(feature = "stats")] +#[non_exhaustive] +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub struct ItemStats { + /// Number of times the item has been accessed (read) since it became resident. + /// + /// Incremented on every cache hit (`get`/`get_mut`/`get_value_or_guard`/`entry`). + /// Unlike the internal eviction counter, this is monotonic per residency and is + /// not bounded by the eviction policy. It resets to zero if the slot is reused for + /// a new value (e.g. after eviction and re-insertion). + pub access_count: u64, +} + #[cfg(test)] mod tests { use std::{ diff --git a/src/shard.rs b/src/shard.rs index ec40b94..92be278 100644 --- a/src/shard.rs +++ b/src/shard.rs @@ -79,6 +79,11 @@ pub struct Resident { value: Val, state: ResidentState, referenced: AtomicU16, + /// Number of times this item has been accessed (read) since it became resident. + /// Incremented wherever a cache hit is recorded, unlike `referenced` which is + /// bounded by the eviction policy. Reset whenever the slot is reused for a new key. + #[cfg(feature = "stats")] + access_count: AtomicU64, } impl Clone for Resident { @@ -89,6 +94,8 @@ impl Clone for Resident { value: self.value.clone(), state: self.state, referenced: self.referenced.load(atomic::Ordering::Relaxed).into(), + #[cfg(feature = "stats")] + access_count: self.access_count.load(atomic::Ordering::Relaxed).into(), } } } @@ -195,6 +202,20 @@ macro_rules! record_miss_mut { *$self.misses.get_mut() += 1; }}; } +#[cfg(feature = "stats")] +macro_rules! record_item_hit { + ($resident: expr) => {{ + $resident + .access_count + .fetch_add(1, atomic::Ordering::Relaxed); + }}; +} +#[cfg(feature = "stats")] +macro_rules! record_item_hit_mut { + ($resident: expr) => {{ + *$resident.access_count.get_mut() += 1; + }}; +} #[cfg(not(feature = "stats"))] macro_rules! record_hit { @@ -212,6 +233,14 @@ macro_rules! record_miss { macro_rules! record_miss_mut { ($self: expr) => {{}}; } +#[cfg(not(feature = "stats"))] +macro_rules! record_item_hit { + ($resident: expr) => {{}}; +} +#[cfg(not(feature = "stats"))] +macro_rules! record_item_hit_mut { + ($resident: expr) => {{}}; +} impl CacheShard { pub fn remove_placeholder(&mut self, placeholder: &Plh) { @@ -563,6 +592,7 @@ impl< resident.referenced.fetch_add(1, atomic::Ordering::Relaxed); } record_hit!(self); + record_item_hit!(resident); Some((&resident.key, &resident.value)) } else { record_miss!(self); @@ -594,6 +624,7 @@ impl< *resident.referenced.get_mut() += 1; } record_hit_mut!(self); + record_item_hit_mut!(resident); let old_weight = self.weighter.weight(&resident.key, &resident.value); Some(RefMut { @@ -640,6 +671,19 @@ impl< Some(&resident.value) } + /// Returns per-item statistics for a resident key without affecting its hotness + /// or access count. Returns `None` if the key is not resident. + #[cfg(feature = "stats")] + pub fn item_stats(&self, hash: u64, key: &Q) -> Option + where + Q: Hash + Equivalent + ?Sized, + { + let (_, resident) = self.search_resident(hash, key)?; + Some(crate::ItemStats { + access_count: resident.access_count.load(atomic::Ordering::Relaxed), + }) + } + pub fn peek_mut(&mut self, hash: u64, key: &Q) -> Option> where Q: Hash + Equivalent + ?Sized, @@ -900,6 +944,8 @@ impl< value, state: enter_state, referenced: referenced.into(), + #[cfg(feature = "stats")] + access_count: Default::default(), }), ); match evicted { @@ -1020,6 +1066,8 @@ impl< value, state: placeholder_hot, referenced: (referenced as u16).into(), + #[cfg(feature = "stats")] + access_count: Default::default(), }); let list_head = if placeholder_hot == ResidentState::Hot { @@ -1102,6 +1150,8 @@ impl< value, state, referenced: Default::default(), + #[cfg(feature = "stats")] + access_count: Default::default(), })); if weight != 0 { *list_head = Some(self.entries.link(idx, *list_head)); @@ -1147,6 +1197,7 @@ impl< *resident.referenced.get_mut() += 1; } record_hit_mut!(self); + record_item_hit_mut!(resident); unsafe { // Rustc gets insanely confused returning references from mut borrows // Safety: value will have the same lifetime as `resident` @@ -1205,6 +1256,7 @@ impl< if *resident.referenced.get_mut() < MAX_F { *resident.referenced.get_mut() += 1; } + record_item_hit_mut!(resident); EntryOrPlaceholder::Kept(t) } EntryAction::Remove => { @@ -1404,8 +1456,12 @@ impl, B, L, Plh: SharedPlaceholder> #[cfg(test)] mod tests { + #[cfg(not(feature = "stats"))] use super::*; + // The tight entry overhead is only guaranteed without the `stats` feature, + // which adds a per-item `access_count` field to each `Resident`. + #[cfg(not(feature = "stats"))] #[test] fn entry_overhead() { use std::mem::size_of; diff --git a/src/sync.rs b/src/sync.rs index 3e0aa34..ab426ad 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -370,6 +370,17 @@ impl< } } + /// Returns per-item statistics for `key`, or `None` if the key is not present. + /// Like peeks, this does not alter the key "hotness" or its access count. + #[cfg(feature = "stats")] + pub fn item_stats(&self, key: &Q) -> Option + where + Q: Hash + Equivalent + ?Sized, + { + let (shard, hash) = self.shard_for(key)?; + shard.read().item_stats(hash, key) + } + /// Remove an item from the cache whose key is `key`. /// Returns the removed entry, if any. pub fn remove(&self, key: &Q) -> Option<(Key, Val)> @@ -1767,6 +1778,29 @@ mod tests { assert!(cache.try_peek(&1).is_err()); } + #[cfg(feature = "stats")] + #[test] + fn test_item_stats() { + let cache = Cache::new(100); + // Missing key has no stats. + assert!(cache.item_stats(&1).is_none()); + + cache.insert(1, 10); + // Insert alone is not a hit. + assert_eq!(cache.item_stats(&1).map(|s| s.access_count), Some(0)); + + // Each get increments the per-item access count. + cache.get(&1); + cache.get(&1); + cache.get(&1); + assert_eq!(cache.item_stats(&1).map(|s| s.access_count), Some(3)); + + // Peeking (including item_stats itself) does not alter the count. + cache.peek(&1); + let _ = cache.item_stats(&1); + assert_eq!(cache.item_stats(&1).map(|s| s.access_count), Some(3)); + } + #[test] fn test_try_remove() { let cache = Cache::new(100); diff --git a/src/unsync.rs b/src/unsync.rs index 66ee954..a187105 100644 --- a/src/unsync.rs +++ b/src/unsync.rs @@ -175,6 +175,16 @@ impl, B: BuildHasher, L: Lifecycle(&self, key: &Q) -> Option + where + Q: Hash + Equivalent + ?Sized, + { + self.shard.item_stats(self.shard.hash(key), key) + } + /// Peeks an item from the cache. Contrary to gets, peeks don't alter the key "hotness". /// /// Note: Leaking the returned RefMut might cause cache weight tracking to be inaccurate. From 993f4f3f5cc677bd1db3e021d7fb27fdcf128ec3 Mon Sep 17 00:00:00 2001 From: Arthur Silva Date: Wed, 24 Jun 2026 23:31:48 +0200 Subject: [PATCH 08/17] Pre-allocate the slab in reserve() to avoid reallocation stalls reserve() only grew the hash table, not the LinkedSlab Vec that holds the actual entries. Under a large insert burst, that Vec doubled and memcpy'd hundreds of MB while the shard write lock was held, stalling all concurrent readers on that shard (issue #119). Now reserve() also pre-allocates the slab, sized for the requested entries plus the bounded ghost-key space (capacity_non_resident) instead of a fixed 50% guess. --- src/linked_slab.rs | 30 ++++++++++++++++++++++++++++++ src/shard.rs | 6 ++++-- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/linked_slab.rs b/src/linked_slab.rs index 7a252c6..e927e16 100644 --- a/src/linked_slab.rs +++ b/src/linked_slab.rs @@ -26,6 +26,14 @@ impl LinkedSlab { } } + /// Reserves capacity for at least `additional` more entries to be inserted. + /// + /// This pre-allocates the backing storage so that subsequent `insert`s don't + /// trigger a reallocation (and the associated copy) of the whole slab. + pub fn reserve(&mut self, additional: usize) { + self.entries.reserve(additional); + } + #[cfg(fuzzing)] pub fn len(&self) -> usize { self.entries.iter().filter(|e| e.item.is_some()).count() @@ -250,3 +258,25 @@ impl LinkedSlab { self.entries.len() * std::mem::size_of::>() } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reserve_avoids_realloc() { + let mut slab: LinkedSlab = LinkedSlab::with_capacity(0); + slab.reserve(1000); + let cap = slab.entries.capacity(); + assert!(cap >= 1000); + // Inserting up to the reserved capacity must not reallocate. + for i in 0..cap as u64 { + slab.insert(i); + } + assert_eq!( + slab.entries.capacity(), + cap, + "slab reallocated despite reserve" + ); + } +} diff --git a/src/shard.rs b/src/shard.rs index c493371..17e7262 100644 --- a/src/shard.rs +++ b/src/shard.rs @@ -433,8 +433,10 @@ impl< /// Reserver additional space for `additional` entries. /// Note that this is counted in entries, and is not weighted. pub fn reserve(&mut self, additional: usize) { - // extra 50% for non-resident entries - let additional = additional.saturating_add(additional / 2); + // Ghost (non-resident) entries also occupy slab/map slots, and their + // count is hard-capped at `capacity_non_resident`, so account for them. + let additional = additional.saturating_add(self.capacity_non_resident); + self.entries.reserve(additional); self.map.reserve(additional, |&idx| { let (entry, _) = self.entries.get(idx).unwrap(); match entry { From 79fc81307f0e5a89af814d88e2531dafea3cf082 Mon Sep 17 00:00:00 2001 From: Arthur Silva Date: Wed, 24 Jun 2026 23:56:50 +0200 Subject: [PATCH 09/17] Bound reserve() ghost headroom by additional The previous reserve() unconditionally added the full per-shard capacity_non_resident as ghost headroom. On a cache with a large estimated_items_capacity, a small reserve(n) call would then allocate the entire ghost cap (potentially gigabytes), not space for n entries. Cap the ghost term at additional: n insertions evict at most n residents into ghosts, and ghosts are also bounded by capacity_non_resident, so min(additional, capacity_non_resident) is the true upper bound. Full pre-loads (additional >= cap) are unchanged; small reserves no longer over-allocate. --- src/linked_slab.rs | 6 ++++++ src/shard.rs | 40 +++++++++++++++++++++++++++++++++++++--- 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/src/linked_slab.rs b/src/linked_slab.rs index e927e16..728a25d 100644 --- a/src/linked_slab.rs +++ b/src/linked_slab.rs @@ -34,6 +34,12 @@ impl LinkedSlab { self.entries.reserve(additional); } + /// The number of entries the slab can hold without reallocating. + #[cfg(test)] + pub fn capacity(&self) -> usize { + self.entries.capacity() + } + #[cfg(fuzzing)] pub fn len(&self) -> usize { self.entries.iter().filter(|e| e.item.is_some()).count() diff --git a/src/shard.rs b/src/shard.rs index 17e7262..746e39d 100644 --- a/src/shard.rs +++ b/src/shard.rs @@ -433,9 +433,11 @@ impl< /// Reserver additional space for `additional` entries. /// Note that this is counted in entries, and is not weighted. pub fn reserve(&mut self, additional: usize) { - // Ghost (non-resident) entries also occupy slab/map slots, and their - // count is hard-capped at `capacity_non_resident`, so account for them. - let additional = additional.saturating_add(self.capacity_non_resident); + // Ghost (non-resident) entries also occupy slab/map slots. The number + // produced by `additional` insertions is bounded both by `additional` + // (each insert evicts at most one resident into a ghost) and by the + // shard-wide cap `capacity_non_resident`, so reserve for the smaller. + let additional = additional.saturating_add(additional.min(self.capacity_non_resident)); self.entries.reserve(additional); self.map.reserve(additional, |&idx| { let (entry, _) = self.entries.get(idx).unwrap(); @@ -1422,6 +1424,38 @@ impl, B, L, Plh: SharedPlaceholder> mod tests { use super::*; + #[test] + fn reserve_caps_ghost_headroom() { + // A small reserve on a shard with a large estimated capacity (hence a + // large `capacity_non_resident`) must not over-allocate the slab by the + // full ghost cap; the ghost headroom is bounded by `additional`. + let mut shard = CacheShard::< + u64, + u64, + crate::UnitWeighter, + crate::DefaultHashBuilder, + crate::sync::DefaultLifecycle, + crate::sync_placeholder::SharedPlaceholder, + >::new( + DEFAULT_HOT_ALLOCATION, + 0.5, // ghost_allocation -> capacity_non_resident = 500_000 + 1_000_000, // estimated_items_capacity + u64::MAX, // weight_capacity + crate::UnitWeighter, + crate::DefaultHashBuilder::default(), + crate::sync::DefaultLifecycle::default(), + ); + assert_eq!(shard.capacity_non_resident, 500_000); + shard.reserve(100); + // Ghost headroom is min(additional, capacity_non_resident) = 100, so the + // slab reserves ~200 entries, not 500_000+. + assert!( + shard.entries.capacity() < 1_000, + "slab over-allocated: {}", + shard.entries.capacity() + ); + } + #[test] fn entry_overhead() { use std::mem::size_of; From 6555ffba87acb2385d261bfebc6d42e7b0002d5f Mon Sep 17 00:00:00 2001 From: Faiaz Sanaulla Date: Thu, 25 Jun 2026 15:03:29 +0200 Subject: [PATCH 10/17] cleanup --- src/sync.rs | 100 +++++++--------------------------------------------- 1 file changed, 12 insertions(+), 88 deletions(-) diff --git a/src/sync.rs b/src/sync.rs index 624b517..118b9ca 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -76,15 +76,6 @@ impl Cache { } impl + Clone> Cache { - /// Creates a new cache with a custom [`Weighter`]. - /// - /// - `estimated_items_capacity` — expected number of items the cache will hold, - /// roughly `weight_capacity / average_item_weight`. - /// - `weight_capacity` — total weight the cache may hold across all shards. - /// - `weighter` — determines the weight of each key–value pair. - /// - /// Use [`Cache::new`] when each item has unit weight (i.e. when you only care - /// about item count, not size). pub fn with_weighter( estimated_items_capacity: usize, weight_capacity: u64, @@ -432,19 +423,11 @@ impl< Ok(()) } - /// Inserts an item in the cache, but _only_ if an entry with key `key` already exists, - /// returning the lifecycle request state. - /// + /// Inserts an item in the cache, but _only_ if an entry with key `key` already exists. /// If `soft` is set, the replace operation won't affect the "hotness" of the entry, /// even if the value is replaced. /// - /// Returns `Ok(lcs)` with the lifecycle request state if the entry was replaced. - /// Returns `Err((key, value))` if no entry existed for `key` (inputs are returned - /// so the caller can retry or discard them). The caller is responsible for passing - /// the returned state to [`Lifecycle::end_request`]. - /// - /// Prefer [`replace`](Self::replace) unless you need manual control over lifecycle - /// request lifetime. + /// Returns `Ok` if the entry was admitted and `Err(_)` if it wasn't. pub fn replace_with_lifecycle( &self, key: Key, @@ -487,10 +470,16 @@ impl< Ok(()) } + /// Inserts an item in the cache with key `key`. /// Inserts an item in the cache with key `key`. pub fn insert_with_lifecycle(&self, key: Key, value: Val) -> L::RequestState { let mut lcs = self.lifecycle.begin_request(); - self.insert_with_state(key, value, &mut lcs); + let (shard, hash) = self.shard_for(&key).unwrap(); + let result = shard + .write() + .insert(&mut lcs, hash, key, value, InsertStrategy::Insert); + // result cannot err with the Insert strategy + debug_assert!(result.is_ok()); lcs } @@ -512,57 +501,6 @@ impl< debug_assert!(result.is_ok()); } - /// Attempts to insert an item in the cache with key `key` without blocking. - /// Returns `Ok(())` if the item was inserted, or `Err((key, value))` if the shard lock - /// could not be acquired without blocking. Lock contention is the only failure - /// mode: the inputs are returned so the caller can retry or discard them. - pub fn try_insert(&self, key: Key, value: Val) -> Result<(), (Key, Val)> { - let lcs = self.try_insert_with_lifecycle(key, value)?; - self.lifecycle.end_request(lcs); - Ok(()) - } - - /// Attempts to insert an item in the cache with key `key` without blocking. - /// Returns `Ok(lcs)` with the lifecycle request state if the item was inserted, - /// or `Err((key, value))` if the shard lock could not be acquired without blocking. - /// Lock contention is the only failure mode: the inputs are returned so the - /// caller can retry or discard them. - pub fn try_insert_with_lifecycle( - &self, - key: Key, - value: Val, - ) -> Result { - // Tradeoff: begin_request is called before acquiring the shard lock to avoid holding - // the lock during potentially expensive lifecycle initialization. - let mut lcs = self.lifecycle.begin_request(); - self.try_insert_with_state(key, value, &mut lcs)?; - Ok(lcs) - } - - /// Attempts to insert an item in the cache with key `key` without blocking. - /// Returns `Ok(lcs)` with the lifecycle request state if the item was inserted, - /// or `Err((key, value))` if the shard lock could not be acquired without blocking. - /// Lock contention is the only failure mode: the inputs are returned so the - /// caller can retry or discard them. - pub fn try_insert_with_state( - &self, - key: Key, - value: Val, - lcs: &mut L::RequestState, - ) -> Result<(), (Key, Val)> { - let (shard, hash) = self.shard_for(&key).unwrap(); - - match shard.try_write() { - Some(mut shard) => { - let result = shard.insert(lcs, hash, key, value, InsertStrategy::Insert); - // result cannot err with the Insert strategy - debug_assert!(result.is_ok()); - Ok(()) - } - _ => Err((key, value)), - } - } - /// Attempts to insert an item in the cache with key `key` without blocking. /// Returns `Ok(lcs)` with the lifecycle request state if the item was inserted, /// or `Err((key, value))` if the shard lock could not be acquired without blocking. @@ -669,18 +607,9 @@ impl< PlaceholderGuard::join(&self.lifecycle, shard, hash, key, timeout) } - /// Gets an item from the cache with key `key`, or inserts one produced by `with`. + /// Gets or inserts an item in the cache with key `key`. /// - /// If the key is already present, the cached value is returned without calling `with`. - /// Otherwise, the cache is locked for this key (other callers to `get_value_or_guard` - /// or the `get_or_insert` family will block until the value is populated), `with` is - /// called to produce the value, and the result is inserted and returned. - /// - /// `with` may return an error, in which case nothing is inserted and the error is - /// propagated. The placeholder is dropped so waiting callers can retry. - /// - /// See also [`get_value_or_guard`](Self::get_value_or_guard) for more control over - /// the placeholder lifecycle. + /// See also `get_value_or_guard` and `get_value_or_guard_async`. pub fn get_or_insert_with( &self, key: &Q, @@ -731,12 +660,7 @@ impl< } } - /// Gets an item from the cache with key `key`, or inserts one produced by `with`. - /// - /// Async counterpart of [`get_or_insert_with`](Self::get_or_insert_with). The `with` - /// future is only polled when no value exists for `key`. While `with` is being awaited, - /// other tasks looking up the same key will suspend and resume once the value is - /// inserted (or the guard is dropped without inserting). + /// Gets or inserts an item in the cache with key `key`. pub async fn get_or_insert_async( &self, key: &Q, From e3bb3d013bd0f21444b30e68497a82648f207a44 Mon Sep 17 00:00:00 2001 From: Faiaz Sanaulla Date: Thu, 25 Jun 2026 15:08:13 +0200 Subject: [PATCH 11/17] cleanup --- src/sync.rs | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/src/sync.rs b/src/sync.rs index 118b9ca..38daf84 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -483,24 +483,6 @@ impl< lcs } - /// Inserts an item in the cache with key `key` using an existing lifecycle request state. - /// - /// `lcs` must have been obtained from a prior [`Lifecycle::begin_request`] call and - /// must **not** have been passed to [`Lifecycle::end_request`] yet. Any items evicted - /// by this insert are recorded into `lcs` and will be dropped when `end_request` is - /// eventually called. - /// - /// Prefer [`insert`](Self::insert) for the common case where you do not need to - /// manage the lifecycle state manually. - pub fn insert_with_state(&self, key: Key, value: Val, lcs: &mut L::RequestState) { - let (shard, hash) = self.shard_for(&key).unwrap(); - let result = shard - .write() - .insert(lcs, hash, key, value, InsertStrategy::Insert); - // result cannot err with the Insert strategy - debug_assert!(result.is_ok()); - } - /// Attempts to insert an item in the cache with key `key` without blocking. /// Returns `Ok(lcs)` with the lifecycle request state if the item was inserted, /// or `Err((key, value))` if the shard lock could not be acquired without blocking. From 313c4a107060abc21e6b4f25ce5abcbe0b675c1c Mon Sep 17 00:00:00 2001 From: Faiaz Sanaulla Date: Thu, 25 Jun 2026 16:00:41 +0200 Subject: [PATCH 12/17] empty commit --- src/sync.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/sync.rs b/src/sync.rs index 38daf84..b1b99ab 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -363,6 +363,7 @@ impl< /// Returns per-item statistics for `key`, or `None` if the key is not present. /// Like peeks, this does not alter the key "hotness" or its access count. + /// Only available when the `stats` feature is enabled. #[cfg(feature = "stats")] pub fn item_stats(&self, key: &Q) -> Option where From 3a1461084b86ce433198e44bcf16aa07837eaa6e Mon Sep 17 00:00:00 2001 From: Arthur Silva Date: Thu, 25 Jun 2026 19:25:24 +0200 Subject: [PATCH 13/17] 0.6.24 --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index eabaf0c..2d4a15b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "quick_cache" -version = "0.6.23" +version = "0.6.24" edition = "2021" description = "Lightweight and high performance concurrent cache" repository = "https://github.com/arthurprs/quick-cache" From 5ca720ad727f2474b27159b4a9fa92378ff64dff Mon Sep 17 00:00:00 2001 From: Arthur Silva Date: Thu, 25 Jun 2026 21:11:53 +0200 Subject: [PATCH 14/17] Reshape lifecycle API: Default RequestState threaded by &mut (0.7.0) (#124) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Reshape lifecycle API: Default-built RequestState threaded by &mut BREAKING (0.7.0). Collapses the growing per-operation variant cross-product. - `Lifecycle::RequestState` now requires `Default`; `begin_request` and `end_request` are removed. State is built via `Default` and finalized by its own `Drop` (which releases evicted items outside the shard lock). - The `*_with_lifecycle` methods now take `&mut L::RequestState` (returning `()` / `Result<(), _>`) instead of returning the state, so one state can be threaded through several ops to batch eviction work or inspect evicted items. This supersedes the `_with_state` proposal in #122. - Drops the now-vestigial `&L` plumbing from the placeholder machinery and the unused `Cache::lifecycle` field; shards keep their own clones for the hooks. * Drop RequestState outside the lock in set_capacity set_capacity created and dropped its own RequestState inside the shard method, so when called via Cache::set_capacity (shard.write().set_capacity) the evicted items were released while the write lock was still held — contradicting the documented Drop-after-lock contract. Take &mut lcs from the caller instead, so it drops after the lock guard. Also use Default::default() consistently instead of L::RequestState::default(). * Document lcs/lock drop ordering in set_capacity --- Cargo.toml | 2 +- examples/eviction_listener.rs | 2 - fuzz/Cargo.lock | 2 +- fuzz/fuzz_targets/fuzz_sync_cache.rs | 34 ++--- fuzz/fuzz_targets/fuzz_unsync_cache.rs | 45 ++++--- .../fuzz_unsync_cache_pinstate.rs | 45 ++++--- src/lib.rs | 38 +++--- src/shard.rs | 8 +- src/sync.rs | 117 +++++++++--------- src/sync_placeholder.rs | 60 ++++----- src/unsync.rs | 80 ++++++------ 11 files changed, 222 insertions(+), 211 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 2d4a15b..f3700d6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "quick_cache" -version = "0.6.24" +version = "0.7.0" edition = "2021" description = "Lightweight and high performance concurrent cache" repository = "https://github.com/arthurprs/quick-cache" diff --git a/examples/eviction_listener.rs b/examples/eviction_listener.rs index 4861993..e5e5725 100644 --- a/examples/eviction_listener.rs +++ b/examples/eviction_listener.rs @@ -7,8 +7,6 @@ struct EvictionListener(mpsc::Sender<(u64, u64)>); impl Lifecycle for EvictionListener { type RequestState = (); - fn begin_request(&self) -> Self::RequestState {} - fn on_evict(&self, _state: &mut Self::RequestState, key: u64, val: u64) { let _ = self.0.send((key, val)); } diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index 0f19630..3c864f2 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -162,7 +162,7 @@ dependencies = [ [[package]] name = "quick_cache" -version = "0.6.17" +version = "0.7.0" dependencies = [ "ahash", "equivalent", diff --git a/fuzz/fuzz_targets/fuzz_sync_cache.rs b/fuzz/fuzz_targets/fuzz_sync_cache.rs index d5765f1..16238ed 100644 --- a/fuzz/fuzz_targets/fuzz_sync_cache.rs +++ b/fuzz/fuzz_targets/fuzz_sync_cache.rs @@ -31,10 +31,6 @@ impl Weighter for MyWeighter { impl Lifecycle for MyLifecycle { type RequestState = Vec<(u16, Value)>; - fn begin_request(&self) -> Self::RequestState { - Default::default() - } - fn is_pinned(&self, _key: &u16, val: &Value) -> bool { val.pinned } @@ -104,13 +100,15 @@ fn run(input: Input) { match op { Op::Insert(k, v, pinned) => { // eprintln!("insert {k} {v}"); - let evicted = cache.insert_with_lifecycle( + let mut evicted = Vec::new(); + cache.insert_with_lifecycle( k, Value { original: v, current: v, pinned, }, + &mut evicted, ); placeholders.remove(&k); // if k is present it must have value v @@ -121,15 +119,20 @@ fn run(input: Input) { Op::Replace(k, v, pinned) => { // eprintln!("replace {k} {v}"); placeholders.remove(&k); - if let Ok(evicted) = cache.replace_with_lifecycle( - k, - Value { - original: v, - current: v, - pinned, - }, - false, - ) { + let mut evicted = Vec::new(); + if cache + .replace_with_lifecycle( + k, + Value { + original: v, + current: v, + pinned, + }, + false, + &mut evicted, + ) + .is_ok() + { // if k is present it must have value v let peek = cache.peek(&k); assert!(peek.is_none() || peek.unwrap().original == v); @@ -155,7 +158,8 @@ fn run(input: Input) { current: v, pinned, }; - if let Ok(evicted) = p.insert_with_lifecycle(value) { + let mut evicted = Vec::new(); + if p.insert_with_lifecycle(value, &mut evicted).is_ok() { let peek = cache.peek(&k); assert!(peek.is_none() || peek.unwrap().original == v); check_evicted(k, peek, evicted); diff --git a/fuzz/fuzz_targets/fuzz_unsync_cache.rs b/fuzz/fuzz_targets/fuzz_unsync_cache.rs index 6ead4d4..02dc73b 100644 --- a/fuzz/fuzz_targets/fuzz_unsync_cache.rs +++ b/fuzz/fuzz_targets/fuzz_unsync_cache.rs @@ -26,10 +26,6 @@ impl Weighter for MyWeighter { impl Lifecycle for MyLifecycle { type RequestState = Vec<(u16, Value)>; - fn begin_request(&self) -> Self::RequestState { - Default::default() - } - fn is_pinned(&self, _key: &u16, val: &Value) -> bool { val.pinned } @@ -99,13 +95,15 @@ fn run(input: Input) { match op { Op::Insert(k, v, pinned) => { // eprintln!("insert {k} {v}"); - let evicted = cache.insert_with_lifecycle( + let mut evicted = Vec::new(); + cache.insert_with_lifecycle( k, Value { original: v, current: v, pinned, }, + &mut evicted, ); // if k is present it must have value v let peek = cache.peek(&k).copied(); @@ -125,15 +123,20 @@ fn run(input: Input) { } Op::Replace(k, v, pinned) => { // eprintln!("replace {k} {v}"); - if let Ok(evicted) = cache.replace_with_lifecycle( - k, - Value { - original: v, - current: v, - pinned, - }, - false, - ) { + let mut evicted = Vec::new(); + if cache + .replace_with_lifecycle( + k, + Value { + original: v, + current: v, + pinned, + }, + false, + &mut evicted, + ) + .is_ok() + { // if k is present it must have value v let peek = cache.peek(&k).copied(); assert!(peek.is_none() || peek.unwrap().original == v); @@ -146,11 +149,15 @@ fn run(input: Input) { let (inserted, evicted) = match cache.get_ref_or_guard(&k) { Ok(_) => (false, Vec::new()), Err(g) => { - let evicted = g.insert_with_lifecycle(Value { - original: v, - current: v, - pinned, - }); + let mut evicted = Vec::new(); + g.insert_with_lifecycle( + Value { + original: v, + current: v, + pinned, + }, + &mut evicted, + ); (true, evicted) } }; diff --git a/fuzz/fuzz_targets/fuzz_unsync_cache_pinstate.rs b/fuzz/fuzz_targets/fuzz_unsync_cache_pinstate.rs index d26a3aa..68257c0 100644 --- a/fuzz/fuzz_targets/fuzz_unsync_cache_pinstate.rs +++ b/fuzz/fuzz_targets/fuzz_unsync_cache_pinstate.rs @@ -34,10 +34,6 @@ impl Weighter for MyWeighter { impl Lifecycle for MyLifecycle { type RequestState = Vec<(u16, Value)>; - fn begin_request(&self) -> Self::RequestState { - Default::default() - } - fn is_pinned(&self, _key: &u16, val: &Value) -> bool { let mut pinned = val.pinned.get(); if pinned.remaining != 0 { @@ -114,13 +110,15 @@ fn run(input: Input) { match op { Op::Insert(k, v, pinned) => { // eprintln!("insert {k} {v}"); - let evicted = cache.insert_with_lifecycle( + let mut evicted = Vec::new(); + cache.insert_with_lifecycle( k, Value { original: v, current: v, pinned: Cell::new(pinned), }, + &mut evicted, ); // if k is present it must have value v let peek = cache.peek(&k).cloned(); @@ -138,15 +136,20 @@ fn run(input: Input) { } Op::Replace(k, v, pinned) => { // eprintln!("replace {k} {v}"); - if let Ok(evicted) = cache.replace_with_lifecycle( - k, - Value { - original: v, - current: v, - pinned: Cell::new(pinned), - }, - false, - ) { + let mut evicted = Vec::new(); + if cache + .replace_with_lifecycle( + k, + Value { + original: v, + current: v, + pinned: Cell::new(pinned), + }, + false, + &mut evicted, + ) + .is_ok() + { // if k is present it must have value v let peek = cache.peek(&k).cloned(); assert!(peek.is_none() || peek.as_ref().unwrap().original == v); @@ -159,11 +162,15 @@ fn run(input: Input) { let (inserted, evicted) = match cache.get_ref_or_guard(&k) { Ok(_) => (false, Vec::new()), Err(g) => { - let evicted = g.insert_with_lifecycle(Value { - original: v, - current: v, - pinned: Cell::new(pinned), - }); + let mut evicted = Vec::new(); + g.insert_with_lifecycle( + Value { + original: v, + current: v, + pinned: Cell::new(pinned), + }, + &mut evicted, + ); (true, evicted) } }; diff --git a/src/lib.rs b/src/lib.rs index 433d0f0..4db978a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -164,8 +164,31 @@ impl Weighter for UnitWeighter { /// Hooks into the lifetime of the cache items. /// /// The functions should be small and very fast, otherwise the cache performance might be negatively affected. +/// +/// # Request state +/// +/// Operations that may evict items thread a [`RequestState`](Lifecycle::RequestState) +/// through the eviction hooks. It is a per-request accumulator: the cache constructs a +/// fresh one via [`Default`], the `on_evict*`/`before_evict` hooks record into it, and it +/// is finalized by its own [`Drop`] (which, for example, releases evicted items _after_ +/// the shard lock is dropped). +/// +/// The `_with_lifecycle` cache methods take `&mut RequestState`, letting a caller drive +/// several operations against a single state — e.g. to batch the eviction work or inspect +/// evicted items before dropping it: +/// +/// ```ignore +/// let mut lcs = Default::default(); +/// cache.insert_with_lifecycle(k1, v1, &mut lcs); +/// cache.insert_with_lifecycle(k2, v2, &mut lcs); +/// // inspect `lcs` here if desired; evicted items are released when it drops +/// ``` pub trait Lifecycle { - type RequestState; + /// Per-request accumulator threaded through the eviction hooks. + /// + /// Constructed via [`Default`] at the start of each request and finalized by its + /// [`Drop`]. Keep it cheap to create and drop. + type RequestState: Default; /// Returns whether the item is pinned. Items that are pinned can't be evicted. /// Note that a pinned item can still be replaced with get_mut, insert, replace and similar APIs. @@ -181,9 +204,6 @@ pub trait Lifecycle { false } - /// Called before the insert request starts, e.g.: insert, replace. - fn begin_request(&self) -> Self::RequestState; - /// Called when a cache item is about to be evicted. /// Note that value replacement (e.g. insertions for the same key) won't call this method. /// @@ -233,16 +253,6 @@ pub trait Lifecycle { fn on_evict_hot(&self, state: &mut Self::RequestState, key: Key, val: Val) { self.on_evict(state, key, val) } - - /// Called after a request finishes, e.g.: insert, replace. - /// - /// Notes: - /// This will _not_ be called when using `_with_lifecycle` apis, which will return the RequestState instead. - /// This will _not_ be called if the request errored (e.g. a replace didn't find a value to replace). - /// If needed, Drop for RequestState can be used to detect these cases. - #[allow(unused_variables)] - #[inline] - fn end_request(&self, state: Self::RequestState) {} } /// The memory used by the cache diff --git a/src/shard.rs b/src/shard.rs index 746e39d..11769fc 100644 --- a/src/shard.rs +++ b/src/shard.rs @@ -1325,7 +1325,7 @@ impl< } } - pub fn set_capacity(&mut self, new_weight_capacity: u64) { + pub fn set_capacity(&mut self, new_weight_capacity: u64, lcs: &mut L::RequestState) { // Guard against division by zero when old capacity is 0 (produces inf/NaN ratios) if self.weight_capacity == 0 { self.weight_capacity = new_weight_capacity; @@ -1344,11 +1344,7 @@ impl< } // Evict items if we're over the new capacity - let mut lcs = self.lifecycle.begin_request(); - while self.weight_hot + self.weight_cold > self.weight_capacity - && self.advance_cold(&mut lcs) - {} - self.lifecycle.end_request(lcs); + while self.weight_hot + self.weight_cold > self.weight_capacity && self.advance_cold(lcs) {} // Trim ghost entries if needed while self.num_non_resident > self.capacity_non_resident { self.advance_ghost(); diff --git a/src/sync.rs b/src/sync.rs index 8810ec2..363fde0 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -59,7 +59,6 @@ pub struct Cache< hash_builder: B, shards: Box<[RwLock>>]>, shards_mask: u64, - lifecycle: L, } impl Cache { @@ -170,7 +169,6 @@ impl< shards: shards.into_boxed_slice(), hash_builder, shards_mask: num_shards - 1, - lifecycle, } } @@ -407,28 +405,32 @@ impl< /// /// Returns `Ok` if the entry was admitted and `Err(_)` if it wasn't. pub fn replace(&self, key: Key, value: Val, soft: bool) -> Result<(), (Key, Val)> { - let lcs = self.replace_with_lifecycle(key, value, soft)?; - self.lifecycle.end_request(lcs); - Ok(()) + let mut lcs = Default::default(); + self.replace_with_lifecycle(key, value, soft, &mut lcs) } - /// Inserts an item in the cache, but _only_ if an entry with key `key` already exists. + /// Inserts an item in the cache, but _only_ if an entry with key `key` already exists, + /// recording any evicted items into the given lifecycle request state. /// If `soft` is set, the replace operation won't affect the "hotness" of the entry, /// even if the value is replaced. /// /// Returns `Ok` if the entry was admitted and `Err(_)` if it wasn't. + /// + /// `lcs` is a [`Lifecycle::RequestState`]; construct one with `Default::default()`. + /// The same `&mut lcs` can be threaded through multiple operations to batch the + /// eviction work. Evicted items are released when `lcs` is dropped. pub fn replace_with_lifecycle( &self, key: Key, value: Val, soft: bool, - ) -> Result { - let mut lcs = self.lifecycle.begin_request(); + lcs: &mut L::RequestState, + ) -> Result<(), (Key, Val)> { let (shard, hash) = self.shard_for(&key).unwrap(); shard .write() - .insert(&mut lcs, hash, key, value, InsertStrategy::Replace { soft })?; - Ok(lcs) + .insert(lcs, hash, key, value, InsertStrategy::Replace { soft })?; + Ok(()) } /// Retains only the items specified by the predicate. @@ -445,8 +447,8 @@ impl< /// Inserts an item in the cache with key `key`. pub fn insert(&self, key: Key, value: Val) { - let lcs = self.insert_with_lifecycle(key, value); - self.lifecycle.end_request(lcs); + let mut lcs = Default::default(); + self.insert_with_lifecycle(key, value, &mut lcs); } /// Attempts to insert an item in the cache with key `key` without blocking. @@ -454,44 +456,48 @@ impl< /// could not be acquired without blocking. Lock contention is the only failure /// mode: the inputs are returned so the caller can retry or discard them. pub fn try_insert(&self, key: Key, value: Val) -> Result<(), (Key, Val)> { - let lcs = self.try_insert_with_lifecycle(key, value)?; - self.lifecycle.end_request(lcs); - Ok(()) + let mut lcs = Default::default(); + self.try_insert_with_lifecycle(key, value, &mut lcs) } - /// Inserts an item in the cache with key `key`. - pub fn insert_with_lifecycle(&self, key: Key, value: Val) -> L::RequestState { - let mut lcs = self.lifecycle.begin_request(); + /// Inserts an item in the cache with key `key`, recording any evicted items into the + /// given lifecycle request state. + /// + /// `lcs` is a [`Lifecycle::RequestState`]; construct one with `Default::default()`. + /// The same `&mut lcs` can be threaded through multiple operations to batch the + /// eviction work. Evicted items are released when `lcs` is dropped. + pub fn insert_with_lifecycle(&self, key: Key, value: Val, lcs: &mut L::RequestState) { let (shard, hash) = self.shard_for(&key).unwrap(); let result = shard .write() - .insert(&mut lcs, hash, key, value, InsertStrategy::Insert); + .insert(lcs, hash, key, value, InsertStrategy::Insert); // result cannot err with the Insert strategy debug_assert!(result.is_ok()); - lcs } - /// Attempts to insert an item in the cache with key `key` without blocking. - /// Returns `Ok(lcs)` with the lifecycle request state if the item was inserted, - /// or `Err((key, value))` if the shard lock could not be acquired without blocking. - /// Lock contention is the only failure mode: the inputs are returned so the - /// caller can retry or discard them. + /// Attempts to insert an item in the cache with key `key` without blocking, recording + /// any evicted items into the given lifecycle request state. + /// Returns `Ok(())` if the item was inserted, or `Err((key, value))` if the shard lock + /// could not be acquired without blocking. Lock contention is the only failure mode: + /// the inputs are returned so the caller can retry or discard them. + /// + /// `lcs` is a [`Lifecycle::RequestState`]; construct one with `Default::default()`. + /// The same `&mut lcs` can be threaded through multiple operations to batch the + /// eviction work. Evicted items are released when `lcs` is dropped. pub fn try_insert_with_lifecycle( &self, key: Key, value: Val, - ) -> Result { - // Tradeoff: begin_request is called before acquiring the shard lock to avoid holding - // the lock during potentially expensive lifecycle initialization. - let mut lcs = self.lifecycle.begin_request(); + lcs: &mut L::RequestState, + ) -> Result<(), (Key, Val)> { let (shard, hash) = self.shard_for(&key).unwrap(); match shard.try_write() { Some(mut shard) => { - let result = shard.insert(&mut lcs, hash, key, value, InsertStrategy::Insert); + let result = shard.insert(lcs, hash, key, value, InsertStrategy::Insert); // result cannot err with the Insert strategy debug_assert!(result.is_ok()); - Ok(lcs) + Ok(()) } _ => Err((key, value)), } @@ -547,7 +553,9 @@ impl< let shard_weight_cap = new_weight_capacity.saturating_add(self.shards.len() as u64 - 1) / self.shards.len() as u64; for shard in &*self.shards { - shard.write().set_capacity(shard_weight_cap); + let mut lcs = Default::default(); + // `lcs` drops after this statement's lock guard, releasing evicted items outside the lock. + shard.write().set_capacity(shard_weight_cap, &mut lcs); } } @@ -574,7 +582,7 @@ impl< if let Some(v) = shard.read().get(hash, key) { return GuardResult::Value(v.clone()); } - PlaceholderGuard::join(&self.lifecycle, shard, hash, key, timeout) + PlaceholderGuard::join(shard, hash, key, timeout) } /// Gets or inserts an item in the cache with key `key`. @@ -618,7 +626,7 @@ impl< if let Some(v) = shard.read().get(hash, key) { return Ok(v.clone()); } - match JoinFuture::new(&self.lifecycle, shard, hash, key).await { + match JoinFuture::new(shard, hash, key).await { JoinResult::Filled(Some(shared)) => { // SAFETY: Filled means the value was set by the loader. return Ok(unsafe { shared.value().unwrap_unchecked().clone() }); @@ -724,21 +732,16 @@ impl< EntryOrPlaceholder::Replaced(shared, old_val) => { drop(shard_guard); return EntryResult::Replaced( - PlaceholderGuard::start_loading(&self.lifecycle, shard, shared), + PlaceholderGuard::start_loading(shard, shared), old_val, ); } EntryOrPlaceholder::NewPlaceholder(shared) => { drop(shard_guard); - return EntryResult::Vacant(PlaceholderGuard::start_loading( - &self.lifecycle, - shard, - shared, - )); + return EntryResult::Vacant(PlaceholderGuard::start_loading(shard, shared)); } EntryOrPlaceholder::ExistingPlaceholder(shared) => { match PlaceholderGuard::wait_for_placeholder( - &self.lifecycle, shard, shard_guard, shared, @@ -783,16 +786,14 @@ impl< EntryOrPlaceholder::Replaced(shared, old_val) => { drop(shard_guard); Ok(EntryResult::Replaced( - PlaceholderGuard::start_loading(&self.lifecycle, shard, shared), + PlaceholderGuard::start_loading(shard, shared), old_val, )) } EntryOrPlaceholder::NewPlaceholder(shared) => { drop(shard_guard); Ok(EntryResult::Vacant(PlaceholderGuard::start_loading( - &self.lifecycle, - shard, - shared, + shard, shared, ))) } EntryOrPlaceholder::ExistingPlaceholder(_) => Err(()), @@ -800,7 +801,7 @@ impl< }; match result { Ok(entry_result) => return entry_result, - Err(()) => match JoinFuture::new(&self.lifecycle, shard, hash, key).await { + Err(()) => match JoinFuture::new(shard, hash, key).await { JoinResult::Filled(_) => continue, JoinResult::Guard(g) => return EntryResult::Vacant(g), JoinResult::Timeout => unsafe { unreachable_unchecked() }, @@ -938,11 +939,6 @@ impl Lifecycle for DefaultLifecycle { // overhead (e.g. a vector) for this default lifecycle. type RequestState = [Option<(Key, Val)>; 2]; - #[inline] - fn begin_request(&self) -> Self::RequestState { - [None, None] - } - #[inline] fn on_evict(&self, state: &mut Self::RequestState, key: Key, val: Val) { if std::mem::needs_drop::<(Key, Val)>() { @@ -1750,19 +1746,24 @@ mod tests { #[test] fn test_try_insert_with_lifecycle() { let cache = Cache::new(100); + let mut lcs = Default::default(); - // Successful insert returns the lifecycle request state. - let result = cache.try_insert_with_lifecycle(1, 10); - assert!(result.is_ok()); - let lcs = result.ok().unwrap(); - cache.lifecycle.end_request(lcs); + // Successful insert records evictions into the provided request state. + assert_eq!(cache.try_insert_with_lifecycle(1, 10, &mut lcs), Ok(())); assert_eq!(cache.get(&1), Some(10)); + // The same request state can be threaded through several operations. + assert_eq!(cache.try_insert_with_lifecycle(2, 20, &mut lcs), Ok(())); + assert_eq!(cache.get(&2), Some(20)); + // Contended when a read lock is held. let guards: Vec<_> = cache.shards.iter().map(|s| s.read()).collect(); - assert_eq!(cache.try_insert_with_lifecycle(2, 20), Err((2, 20))); + assert_eq!( + cache.try_insert_with_lifecycle(3, 30, &mut lcs), + Err((3, 30)) + ); drop(guards); - assert_eq!(cache.get(&2), None); + assert_eq!(cache.get(&3), None); } #[test] diff --git a/src/sync_placeholder.rs b/src/sync_placeholder.rs index 5da45e2..23cb867 100644 --- a/src/sync_placeholder.rs +++ b/src/sync_placeholder.rs @@ -87,7 +87,6 @@ enum LoadingState { } pub struct PlaceholderGuard<'a, Key, Val, We, B, L> { - lifecycle: &'a L, shard: &'a RwLock>>, shared: SharedPlaceholder, inserted: bool, @@ -196,7 +195,6 @@ pub enum EntryResult<'a, Key, Val, We, B, L, T> { impl<'a, Key, Val, We, B, L> PlaceholderGuard<'a, Key, Val, We, B, L> { #[inline] pub fn start_loading( - lifecycle: &'a L, shard: &'a RwLock>>, shared: SharedPlaceholder, ) -> Self { @@ -205,7 +203,6 @@ impl<'a, Key, Val, We, B, L> PlaceholderGuard<'a, Key, Val, We, B, L> { LoadingState::Loading )); PlaceholderGuard { - lifecycle, shard, shared, inserted: false, @@ -216,7 +213,6 @@ impl<'a, Key, Val, We, B, L> PlaceholderGuard<'a, Key, Val, We, B, L> { // or a guard if the caller got the guard. #[inline] fn handle_notification( - lifecycle: &'a L, shard: &'a RwLock>>, shared: SharedPlaceholder, ) -> Result, PlaceholderGuard<'a, Key, Val, We, B, L>> { @@ -225,7 +221,7 @@ impl<'a, Key, Val, We, B, L> PlaceholderGuard<'a, Key, Val, We, B, L> { if shared.value().is_some() { Ok(shared) } else { - Err(PlaceholderGuard::start_loading(lifecycle, shard, shared)) + Err(PlaceholderGuard::start_loading(shard, shared)) } } @@ -265,7 +261,6 @@ impl< > PlaceholderGuard<'a, Key, Val, We, B, L> { pub fn join( - lifecycle: &'a L, shard: &'a RwLock>>, hash: u64, key: &Q, @@ -278,12 +273,12 @@ impl< let shared = match shard_guard.get_or_placeholder(hash, key) { Ok((_, v)) => return GuardResult::Value(v.clone()), Err((shared, true)) => { - return GuardResult::Guard(Self::start_loading(lifecycle, shard, shared)); + return GuardResult::Guard(Self::start_loading(shard, shared)); } Err((shared, false)) => shared, }; let mut deadline = timeout.map(Ok); - match Self::wait_for_placeholder(lifecycle, shard, shard_guard, shared, deadline.as_mut()) { + match Self::wait_for_placeholder(shard, shard_guard, shared, deadline.as_mut()) { JoinResult::Filled(shared) => unsafe { // SAFETY: Filled means the value was set by the loader. GuardResult::Value(shared.unwrap_unchecked().value().unwrap_unchecked().clone()) @@ -302,7 +297,6 @@ impl< /// call. On first use the duration is converted in-place to `Err(instant)` so that /// callers that retry (e.g. `entry`) preserve the original deadline across calls. pub(crate) fn wait_for_placeholder( - lifecycle: &'a L, shard: &'a RwLock>>, shard_guard: RwLockWriteGuard<'a, CacheShard>>, shared: SharedPlaceholder, @@ -345,7 +339,7 @@ impl< if let Some(instant) = deadline { let remaining = instant.saturating_duration_since(Instant::now()); if remaining.is_zero() { - return Self::join_timeout(lifecycle, shard, shared, parked_thread, ¬ified); + return Self::join_timeout(shard, shared, parked_thread, ¬ified); } #[cfg(not(fuzzing))] thread::park_timeout(remaining); @@ -354,7 +348,7 @@ impl< thread::park(); } if notified.load(Ordering::Acquire) { - return match Self::handle_notification(lifecycle, shard, shared) { + return match Self::handle_notification(shard, shared) { Ok(shared) => JoinResult::Filled(Some(shared)), Err(g) => JoinResult::Guard(g), }; @@ -364,7 +358,6 @@ impl< #[cold] fn join_timeout( - lifecycle: &'a L, shard: &'a RwLock>>>, shared: Arc>, // when timeout is zero, the thread may have not been added to the waiters list @@ -375,7 +368,7 @@ impl< match state.loading { LoadingState::Loading if notified.load(Ordering::Acquire) => { drop(state); // Drop state guard to avoid a deadlock with start_loading - JoinResult::Guard(PlaceholderGuard::start_loading(lifecycle, shard, shared)) + JoinResult::Guard(PlaceholderGuard::start_loading(shard, shared)) } LoadingState::Loading => { if parked_thread.is_some() { @@ -414,18 +407,24 @@ impl< /// A placeholder can be removed as a result of a `remove` call /// or a non-placeholder `insert` with the same key. pub fn insert(self, value: Val) -> Result<(), Val> { - let lifecycle = self.lifecycle; - let lcs = self.insert_with_lifecycle(value)?; - lifecycle.end_request(lcs); - Ok(()) + let mut lcs = Default::default(); + self.insert_with_lifecycle(value, &mut lcs) } - /// Inserts the value into the placeholder + /// Inserts the value into the placeholder, recording any evicted items into the given + /// lifecycle request state. /// /// Returns Err if the placeholder isn't in the cache anymore. /// A placeholder can be removed as a result of a `remove` call /// or a non-placeholder `insert` with the same key. - pub fn insert_with_lifecycle(mut self, value: Val) -> Result { + /// + /// `lcs` is a [`Lifecycle::RequestState`]; construct one with `Default::default()`. + /// Evicted items are released when `lcs` is dropped. + pub fn insert_with_lifecycle( + mut self, + value: Val, + lcs: &mut L::RequestState, + ) -> Result<(), Val> { unsafe { self.shared.value.set(value.clone()).unwrap_unchecked() }; let referenced; { @@ -446,11 +445,10 @@ impl< // - the placeholder will be removed here, if it still exists self.inserted = true; - let mut lcs = self.lifecycle.begin_request(); self.shard .write() - .replace_placeholder(&mut lcs, &self.shared, referenced, value)?; - Ok(lcs) + .replace_placeholder(lcs, &self.shared, referenced, value)?; + Ok(()) } } @@ -510,7 +508,6 @@ impl std::fmt::Debug for PlaceholderGuard<'_, Key, Val, We, /// be moved after the first poll, keeping that pointer valid. The pointer is /// cleaned up in `drop_pending_waiter` before the struct is destroyed. pub(crate) struct JoinFuture<'a, 'b, Q: ?Sized, Key, Val, We, B, L> { - lifecycle: &'a L, shard: &'a RwLock>>, hash: u64, key: &'b Q, @@ -530,13 +527,11 @@ enum JoinFutureState { impl<'a, 'b, Q: ?Sized, Key, Val, We, B, L> JoinFuture<'a, 'b, Q, Key, Val, We, B, L> { pub(crate) fn new( - lifecycle: &'a L, shard: &'a RwLock>>, hash: u64, key: &'b Q, ) -> Self { Self { - lifecycle, shard, hash, key, @@ -561,7 +556,7 @@ impl JoinFuture<'_, '_, Q, Key, Val, We, B, L> { // The write guard was abandoned elsewhere, this future was notified but didn't get polled. // So we get and drop the guard here to handle the side effects. drop(state); // Drop state guard to avoid a deadlock with start_loading - let _ = PlaceholderGuard::start_loading(self.lifecycle, self.shard, shared); + let _ = PlaceholderGuard::start_loading(self.shard, shared); } LoadingState::Loading => { // Remove ourselves from the waiters list @@ -607,7 +602,6 @@ impl< // fields. The `notified` field's address (registered in the waiter list) stays // stable because Pin guarantees the future won't be moved. let this = unsafe { self.get_unchecked_mut() }; - let lifecycle = this.lifecycle; let shard = this.shard; match &mut this.state { JoinFutureState::Created => { @@ -621,7 +615,7 @@ impl< this.state = JoinFutureState::Done; drop(shard_guard); Poll::Ready(JoinResult::Guard(PlaceholderGuard::start_loading( - lifecycle, shard, shared, + shard, shared, ))) } Err((shared, false)) => { @@ -680,12 +674,10 @@ impl< else { unsafe { unreachable_unchecked() } }; - Poll::Ready( - match PlaceholderGuard::handle_notification(lifecycle, shard, shared) { - Ok(shared) => JoinResult::Filled(Some(shared)), - Err(g) => JoinResult::Guard(g), - }, - ) + Poll::Ready(match PlaceholderGuard::handle_notification(shard, shared) { + Ok(shared) => JoinResult::Filled(Some(shared)), + Err(g) => JoinResult::Guard(g), + }) } JoinFutureState::Done => panic!("Polled after ready"), } diff --git a/src/unsync.rs b/src/unsync.rs index ae7f1d5..65af821 100644 --- a/src/unsync.rs +++ b/src/unsync.rs @@ -212,31 +212,35 @@ impl, B: BuildHasher, L: Lifecycle Result<(), (Key, Val)> { - let lcs = self.replace_with_lifecycle(key, value, soft)?; - self.shard.lifecycle.end_request(lcs); - Ok(()) + let mut lcs = Default::default(); + self.replace_with_lifecycle(key, value, soft, &mut lcs) } - /// Replaces an item in the cache, but only if it already exists. + /// Replaces an item in the cache, but only if it already exists, recording any evicted + /// items into the given lifecycle request state. /// If `soft` is set, the replace operation won't affect the "hotness" of the key, /// even if the value is replaced. /// /// Returns `Ok` if the entry was admitted and `Err(_)` if it wasn't. + /// + /// `lcs` is a [`Lifecycle::RequestState`]; construct one with `Default::default()`. + /// The same `&mut lcs` can be threaded through multiple operations to batch the + /// eviction work. Evicted items are released when `lcs` is dropped. pub fn replace_with_lifecycle( &mut self, key: Key, value: Val, soft: bool, - ) -> Result { - let mut lcs = self.shard.lifecycle.begin_request(); + lcs: &mut L::RequestState, + ) -> Result<(), (Key, Val)> { self.shard.insert( - &mut lcs, + lcs, self.shard.hash(&key), key, value, InsertStrategy::Replace { soft }, )?; - Ok(lcs) + Ok(()) } /// Retains only the items specified by the predicate. @@ -265,9 +269,8 @@ impl, B: BuildHasher, L: Lifecycle idx, Err((plh, _)) => { let v = with()?; - let mut lcs = self.shard.lifecycle.begin_request(); + let mut lcs = Default::default(); let replaced = self.shard.replace_placeholder(&mut lcs, &plh, false, v); - self.shard.lifecycle.end_request(lcs); debug_assert!(replaced.is_ok(), "unsync replace_placeholder can't fail"); plh.idx } @@ -291,10 +294,9 @@ impl, B: BuildHasher, L: Lifecycle idx, Err((plh, _)) => { let v = with()?; - let mut lcs = self.shard.lifecycle.begin_request(); + let mut lcs = Default::default(); let replaced = self.shard.replace_placeholder(&mut lcs, &plh, false, v); debug_assert!(replaced.is_ok(), "unsync replace_placeholder can't fail"); - self.shard.lifecycle.end_request(lcs); plh.idx } }; @@ -349,15 +351,19 @@ impl, B: BuildHasher, L: Lifecycle L::RequestState { - let mut lcs = self.shard.lifecycle.begin_request(); + /// Inserts an item in the cache with key `key`, recording any evicted items into the + /// given lifecycle request state. + /// + /// `lcs` is a [`Lifecycle::RequestState`]; construct one with `Default::default()`. + /// The same `&mut lcs` can be threaded through multiple operations to batch the + /// eviction work. Evicted items are released when `lcs` is dropped. + pub fn insert_with_lifecycle(&mut self, key: Key, value: Val, lcs: &mut L::RequestState) { let result = self.shard.insert( - &mut lcs, + lcs, self.shard.hash(&key), key, value, @@ -365,7 +371,6 @@ impl, B: BuildHasher, L: Lifecycle, B: BuildHasher, L: Lifecycle Clone for DefaultLifecycle { impl Lifecycle for DefaultLifecycle { type RequestState = (); - - #[inline] - fn begin_request(&self) -> Self::RequestState {} } #[derive(Debug, Clone)] @@ -462,29 +465,22 @@ impl, B: BuildHasher, L: Lifecycle L::RequestState { - self.insert_internal(value, true).unwrap() + let mut lcs = Default::default(); + self.insert_with_lifecycle(value, &mut lcs); } - #[inline] - fn insert_internal(mut self, value: Val, return_lcs: bool) -> Option { - let mut lcs = self.cache.shard.lifecycle.begin_request(); - let replaced = - self.cache - .shard - .replace_placeholder(&mut lcs, &self.placeholder, false, value); + /// Inserts the value into the placeholder, recording any evicted items into the given + /// lifecycle request state. + /// + /// `lcs` is a [`Lifecycle::RequestState`]; construct one with `Default::default()`. + /// Evicted items are released when `lcs` is dropped. + pub fn insert_with_lifecycle(mut self, value: Val, lcs: &mut L::RequestState) { + let replaced = self + .cache + .shard + .replace_placeholder(lcs, &self.placeholder, false, value); debug_assert!(replaced.is_ok(), "unsync replace_placeholder can't fail"); self.inserted = true; - if return_lcs { - Some(lcs) - } else { - self.cache.shard.lifecycle.end_request(lcs); - None - } } } From 7ad7fa40582ee47aef482ce461fbc0006c77dbc8 Mon Sep 17 00:00:00 2001 From: Faiaz Sanaulla Date: Fri, 26 Jun 2026 14:08:18 +0200 Subject: [PATCH 15/17] add try_* method --- src/shard.rs | 2 ++ src/sync.rs | 19 ++++++++++++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/shard.rs b/src/shard.rs index 0e4c2d5..2d2e52c 100644 --- a/src/shard.rs +++ b/src/shard.rs @@ -1474,6 +1474,8 @@ impl, B, L, Plh: SharedPlaceholder> #[cfg(test)] mod tests { + use crate::shard::Entry; + #[cfg(not(feature = "stats"))] use super::*; diff --git a/src/sync.rs b/src/sync.rs index b1b99ab..65ee4e5 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -363,7 +363,6 @@ impl< /// Returns per-item statistics for `key`, or `None` if the key is not present. /// Like peeks, this does not alter the key "hotness" or its access count. - /// Only available when the `stats` feature is enabled. #[cfg(feature = "stats")] pub fn item_stats(&self, key: &Q) -> Option where @@ -373,6 +372,24 @@ impl< shard.read().item_stats(hash, key) } + /// Attempts to return per-item statistics for `key`. + /// Like peeks, this does not alter the key "hotness" or its access count. + /// Returns `Ok(Some(stats))` if the key is present, `Ok(None)` if absent, + /// or `Err(LockContention)` if the shard lock could not be acquired without blocking. + #[cfg(feature = "stats")] + pub fn try_item_stats(&self, key: &Q) -> Result, LockContention> + where + Q: Hash + Equivalent + ?Sized, + { + let Some((shard, hash)) = self.shard_for(key) else { + return Ok(None); + }; + match shard.try_read() { + Some(guard) => Ok(guard.item_stats(hash, key)), + None => Err(LockContention), + } + } + /// Remove an item from the cache whose key is `key`. /// Returns the removed entry, if any. pub fn remove(&self, key: &Q) -> Option<(Key, Val)> From 6eb8e7c48af2f4cf79d332a46777659cedb0a10d Mon Sep 17 00:00:00 2001 From: Faiaz Sanaulla Date: Fri, 26 Jun 2026 14:22:07 +0200 Subject: [PATCH 16/17] cleanup --- src/shard.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/shard.rs b/src/shard.rs index 1256f9d..ee0a014 100644 --- a/src/shard.rs +++ b/src/shard.rs @@ -1470,9 +1470,6 @@ impl, B, L, Plh: SharedPlaceholder> #[cfg(test)] mod tests { - use crate::shard::Entry; - - #[cfg(not(feature = "stats"))] use super::*; // The tight entry overhead is only guaranteed without the `stats` feature, From 344004abca93831b2133f15d15ca46e5b7bcc06c Mon Sep 17 00:00:00 2001 From: Arthur Silva Date: Fri, 26 Jun 2026 19:00:03 +0200 Subject: [PATCH 17/17] Address review: unify stats hit macros, fix entry_overhead under stats, document stats overhead - Fold record_item_hit{,_mut} into arity-overloaded record_hit{,_mut} arms so each resident hit-site records both counters in one call. - Re-enable entry_overhead under the stats feature; assert the real per-cache sizes (sync unchanged, unsync +8 via discriminant niche). - Document the stats feature's per-entry size and per-hit/miss cost. --- src/lib.rs | 7 ++++-- src/shard.rs | 63 +++++++++++++++++++++++----------------------------- 2 files changed, 33 insertions(+), 37 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 5f08c72..f706b8b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -79,7 +79,7 @@ //! | `parking_lot` | ✓ | Use [parking_lot](https://crates.io/crates/parking_lot) for synchronization primitives. Mutually exclusive with `sharded-lock`. | //! | `sharded-lock` | | Use [`crossbeam_utils::sync::ShardedLock`](https://docs.rs/crossbeam-utils/latest/crossbeam_utils/sync/struct.ShardedLock.html) for synchronization primitives. Mutually exclusive with `parking_lot`. | //! | `shuttle` | | Enable [shuttle](https://crates.io/crates/shuttle) testing support for concurrency testing. | -//! | `stats` | | Enable cache statistics tracking via the `hits()` and `misses()` methods. | +//! | `stats` | | Enable cache statistics tracking via the `hits()`, `misses()`, and per-item `item_stats()` methods. Overhead: adds an 8-byte per-item access counter (`AtomicU64`) to each resident item — raising per-entry memory by up to 8 bytes, depending on layout — and performs two atomic increments per cache hit and one per miss. | #![allow(clippy::type_complexity)] #![cfg_attr(docsrs, feature(doc_cfg))] @@ -273,7 +273,10 @@ impl MemoryUsed { /// Per-item statistics returned by `item_stats`. /// -/// Only available with the `stats` feature enabled. +/// Only available with the `stats` feature enabled. Enabling that feature adds an +/// 8-byte per-item access counter to each resident item (raising per-entry memory +/// by up to 8 bytes, depending on layout) and performs two atomic increments per +/// cache hit and one per miss. #[cfg(feature = "stats")] #[non_exhaustive] #[derive(Debug, Copy, Clone, PartialEq, Eq)] diff --git a/src/shard.rs b/src/shard.rs index ee0a014..7b110a1 100644 --- a/src/shard.rs +++ b/src/shard.rs @@ -183,12 +183,22 @@ macro_rules! record_hit { ($self: expr) => {{ $self.hits.fetch_add(1, atomic::Ordering::Relaxed); }}; + ($self: expr, $resident: expr) => {{ + $self.hits.fetch_add(1, atomic::Ordering::Relaxed); + $resident + .access_count + .fetch_add(1, atomic::Ordering::Relaxed); + }}; } #[cfg(feature = "stats")] macro_rules! record_hit_mut { ($self: expr) => {{ *$self.hits.get_mut() += 1; }}; + ($self: expr, $resident: expr) => {{ + *$self.hits.get_mut() += 1; + *$resident.access_count.get_mut() += 1; + }}; } #[cfg(feature = "stats")] macro_rules! record_miss { @@ -202,28 +212,15 @@ macro_rules! record_miss_mut { *$self.misses.get_mut() += 1; }}; } -#[cfg(feature = "stats")] -macro_rules! record_item_hit { - ($resident: expr) => {{ - $resident - .access_count - .fetch_add(1, atomic::Ordering::Relaxed); - }}; -} -#[cfg(feature = "stats")] -macro_rules! record_item_hit_mut { - ($resident: expr) => {{ - *$resident.access_count.get_mut() += 1; - }}; -} - #[cfg(not(feature = "stats"))] macro_rules! record_hit { ($self: expr) => {{}}; + ($self: expr, $resident: expr) => {{}}; } #[cfg(not(feature = "stats"))] macro_rules! record_hit_mut { ($self: expr) => {{}}; + ($self: expr, $resident: expr) => {{}}; } #[cfg(not(feature = "stats"))] macro_rules! record_miss { @@ -233,14 +230,6 @@ macro_rules! record_miss { macro_rules! record_miss_mut { ($self: expr) => {{}}; } -#[cfg(not(feature = "stats"))] -macro_rules! record_item_hit { - ($resident: expr) => {{}}; -} -#[cfg(not(feature = "stats"))] -macro_rules! record_item_hit_mut { - ($resident: expr) => {{}}; -} impl CacheShard { pub fn remove_placeholder(&mut self, placeholder: &Plh) { @@ -596,8 +585,7 @@ impl< // Even if that happens there's no impact correctness wise. resident.referenced.fetch_add(1, atomic::Ordering::Relaxed); } - record_hit!(self); - record_item_hit!(resident); + record_hit!(self, resident); Some((&resident.key, &resident.value)) } else { record_miss!(self); @@ -628,8 +616,7 @@ impl< if *resident.referenced.get_mut() < MAX_F { *resident.referenced.get_mut() += 1; } - record_hit_mut!(self); - record_item_hit_mut!(resident); + record_hit_mut!(self, resident); let old_weight = self.weighter.weight(&resident.key, &resident.value); Some(RefMut { @@ -1214,8 +1201,7 @@ impl< if *resident.referenced.get_mut() < MAX_F { *resident.referenced.get_mut() += 1; } - record_hit_mut!(self); - record_item_hit_mut!(resident); + record_hit_mut!(self, resident); unsafe { // Rustc gets insanely confused returning references from mut borrows // Safety: value will have the same lifetime as `resident` @@ -1266,7 +1252,6 @@ impl< return match action { EntryAction::Retain(t) => { - record_hit_mut!(self); let Some((Entry::Resident(resident), _)) = self.entries.get_mut(idx) else { // SAFETY: we had a mut reference to the Resident under `idx` until the previous line unsafe { unreachable_unchecked() }; @@ -1274,7 +1259,7 @@ impl< if *resident.referenced.get_mut() < MAX_F { *resident.referenced.get_mut() += 1; } - record_item_hit_mut!(resident); + record_hit_mut!(self, resident); EntryOrPlaceholder::Kept(t) } EntryAction::Remove => { @@ -1472,8 +1457,6 @@ impl, B, L, Plh: SharedPlaceholder> mod tests { use super::*; - // The tight entry overhead is only guaranteed without the `stats` feature, - // which adds a per-item `access_count` field to each `Resident`. #[cfg(not(feature = "stats"))] #[test] fn reserve_caps_ghost_headroom() { @@ -1510,14 +1493,24 @@ mod tests { #[test] fn entry_overhead() { use std::mem::size_of; + // 8 bytes from the linked slab, 8 bytes from the entry enum. + // `stats` adds an 8-byte `access_count` to each `Resident` (24 -> 32 bytes). + // Whether the slab entry grows then depends on enum discriminant/niche layout: + // the sync entry's discriminant moves into the `Arc` niche, cancelling the + // growth (stays 32), while the unsync entry loses its niche and grows by 8. + // (Layout-dependent and not guaranteed stable across rustc versions.) assert_eq!( size_of::>>() - size_of::<[u64; 2]>(), - 16 // 8 bytes from linked slab, 8 bytes from entry + 16 ); + #[cfg(not(feature = "stats"))] + let unsync_overhead = 16; + #[cfg(feature = "stats")] + let unsync_overhead = 24; assert_eq!( size_of::>() - size_of::<[u64; 2]>(), - 16 // 8 bytes from linked slab, 8 bytes from entry + unsync_overhead ); } }