Skip to content

Commit c5f89f1

Browse files
authored
Add non-blocking (try_*) cache methods (#112)
### 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<bool, LockContention>` - `try_get` — returns `Result<Option<Val>, LockContention>` - `try_peek` — returns `Result<Option<Val>, LockContention>` - `try_remove` — returns `Result<Option<(Key, Val)>, LockContention>` - `try_insert` / `try_insert_with_lifecycle` — returns `Result<(), (Key, Val)>` / `Result<L::RequestState, (Key, Val)>` (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.
1 parent 8b0077f commit c5f89f1

3 files changed

Lines changed: 278 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ Lightweight and high performance concurrent cache optimized for low cache overhe
1212
* Scales well with the number of threads
1313
* Atomic operations with `get_or_insert` and `get_value_or_guard` functions
1414
* Atomic async operations with `get_or_insert_async` and `get_value_or_guard_async` functions
15+
* Non-blocking methods that return immediately on lock contention.
1516
* Closure-based `entry` API for atomic inspect-and-act patterns (keep, remove, replace)
1617
* Supports item pinning
1718
* Iteration and draining

src/rw_lock.rs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,46 @@ impl<T: ?Sized> RwLock<T> {
105105
})
106106
}
107107

108+
/// Attempts to acquire this `RwLock` with shared read access without blocking.
109+
///
110+
/// Returns `Some(guard)` if the lock was acquired, or `None` if it is already
111+
/// held by a writer.
112+
#[inline]
113+
pub fn try_read(&self) -> Option<RwLockReadGuard<'_, T>> {
114+
#[cfg(feature = "parking_lot")]
115+
{
116+
self.0.try_read().map(RwLockReadGuard)
117+
}
118+
#[cfg(not(feature = "parking_lot"))]
119+
{
120+
match self.0.try_read() {
121+
Ok(guard) => Some(RwLockReadGuard(guard)),
122+
Err(std::sync::TryLockError::WouldBlock) => None,
123+
Err(std::sync::TryLockError::Poisoned(err)) => panic!("{}", err),
124+
}
125+
}
126+
}
127+
128+
/// Attempts to acquire this `RwLock` with exclusive write access without blocking.
129+
///
130+
/// Returns `Some(guard)` if the lock was acquired, or `None` if it is already
131+
/// held by any readers or a writer.
132+
#[inline]
133+
pub fn try_write(&self) -> Option<RwLockWriteGuard<'_, T>> {
134+
#[cfg(feature = "parking_lot")]
135+
{
136+
self.0.try_write().map(RwLockWriteGuard)
137+
}
138+
#[cfg(not(feature = "parking_lot"))]
139+
{
140+
match self.0.try_write() {
141+
Ok(guard) => Some(RwLockWriteGuard(guard)),
142+
Err(std::sync::TryLockError::WouldBlock) => None,
143+
Err(std::sync::TryLockError::Poisoned(err)) => panic!("{}", err),
144+
}
145+
}
146+
}
147+
108148
/// Locks this `RwLock` with exclusive write access, blocking the current
109149
/// thread until it can be acquired.
110150
///

src/sync.rs

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,23 @@ use crate::shard::EntryOrPlaceholder;
1818
pub use crate::sync_placeholder::{EntryAction, EntryResult, GuardResult, PlaceholderGuard};
1919
use crate::sync_placeholder::{JoinFuture, JoinResult};
2020

21+
/// Error returned by non-blocking cache operations that do not consume their
22+
/// inputs when the relevant shard lock could not be acquired immediately.
23+
///
24+
/// This is used by borrowed-key/read-path operations. Non-blocking operations
25+
/// that consume owned inputs (e.g. `try_insert`) instead return those inputs
26+
/// on contention so the caller can retry or discard without losing data.
27+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
28+
pub struct LockContention;
29+
30+
impl std::fmt::Display for LockContention {
31+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32+
write!(f, "Lock Contention")
33+
}
34+
}
35+
36+
impl std::error::Error for LockContention {}
37+
2138
/// A concurrent cache
2239
///
2340
/// The concurrent cache is internally composed of equally sized shards, each of which is independently
@@ -274,6 +291,23 @@ impl<
274291
.is_some_and(|(shard, hash)| shard.read().contains(hash, key))
275292
}
276293

294+
/// Attempts to check if a key exists in the cache without blocking.
295+
/// Returns `Ok(true)` if present, `Ok(false)` if absent,
296+
/// or `Err(LockContention)` if the shard lock could not be acquired without blocking.
297+
pub fn try_contains_key<Q>(&self, key: &Q) -> Result<bool, LockContention>
298+
where
299+
Q: Hash + Equivalent<Key> + ?Sized,
300+
{
301+
let Some((shard, hash)) = self.shard_for(key) else {
302+
return Ok(false);
303+
};
304+
305+
match shard.try_read() {
306+
Some(guard) => Ok(guard.contains(hash, key)),
307+
None => Err(LockContention),
308+
}
309+
}
310+
277311
/// Fetches an item from the cache whose key is `key`.
278312
pub fn get<Q>(&self, key: &Q) -> Option<Val>
279313
where
@@ -283,6 +317,23 @@ impl<
283317
shard.read().get(hash, key).cloned()
284318
}
285319

320+
/// Attempts to fetch an item from the cache whose key is `key`.
321+
/// Returns `Ok(Some(val))` if the key is present, `Ok(None)` if absent,
322+
/// or `Err(LockContention)` if the shard lock could not be acquired without blocking.
323+
pub fn try_get<Q>(&self, key: &Q) -> Result<Option<Val>, LockContention>
324+
where
325+
Q: Hash + Equivalent<Key> + ?Sized,
326+
{
327+
let Some((shard, hash)) = self.shard_for(key) else {
328+
return Ok(None);
329+
};
330+
331+
match shard.try_read() {
332+
Some(guard) => Ok(guard.get(hash, key).cloned()),
333+
None => Err(LockContention),
334+
}
335+
}
336+
286337
/// Peeks an item from the cache whose key is `key`.
287338
/// Contrary to gets, peeks don't alter the key "hotness".
288339
pub fn peek<Q>(&self, key: &Q) -> Option<Val>
@@ -293,6 +344,23 @@ impl<
293344
shard.read().peek(hash, key).cloned()
294345
}
295346

347+
/// Attempts to peek an item from the cache whose key is `key`.
348+
/// Contrary to gets, peeks don't alter the key "hotness".
349+
/// Returns `Ok(Some(val))` if the key is present, `Ok(None)` if absent,
350+
/// or `Err(LockContention)` if the shard lock could not be acquired without blocking.
351+
pub fn try_peek<Q>(&self, key: &Q) -> Result<Option<Val>, LockContention>
352+
where
353+
Q: Hash + Equivalent<Key> + ?Sized,
354+
{
355+
let Some((shard, hash)) = self.shard_for(key) else {
356+
return Ok(None);
357+
};
358+
match shard.try_read() {
359+
Some(guard) => Ok(guard.peek(hash, key).cloned()),
360+
None => Err(LockContention),
361+
}
362+
}
363+
296364
/// Remove an item from the cache whose key is `key`.
297365
/// Returns the removed entry, if any.
298366
pub fn remove<Q>(&self, key: &Q) -> Option<(Key, Val)>
@@ -303,6 +371,23 @@ impl<
303371
shard.write().remove(hash, key)
304372
}
305373

374+
/// Attempts to remove an item from the cache whose key is `key`.
375+
/// Returns `Ok(Some(entry))` with the removed entry if present, `Ok(None)` if absent,
376+
/// or `Err(LockContention)` if the shard lock could not be acquired without blocking.
377+
pub fn try_remove<Q>(&self, key: &Q) -> Result<Option<(Key, Val)>, LockContention>
378+
where
379+
Q: Hash + Equivalent<Key> + ?Sized,
380+
{
381+
let Some((shard, hash)) = self.shard_for(key) else {
382+
return Ok(None);
383+
};
384+
385+
match shard.try_write() {
386+
Some(mut guard) => Ok(guard.remove(hash, key)),
387+
None => Err(LockContention),
388+
}
389+
}
390+
306391
/// Remove an item from the cache whose key is `key` if `f(&value)` returns `true` for that entry.
307392
/// Compared to peek and remove, this method guarantees that no new value was inserted in-between.
308393
///
@@ -364,6 +449,16 @@ impl<
364449
self.lifecycle.end_request(lcs);
365450
}
366451

452+
/// Attempts to insert an item in the cache with key `key` without blocking.
453+
/// Returns `Ok(())` if the item was inserted, or `Err((key, value))` if the shard lock
454+
/// could not be acquired without blocking. Lock contention is the only failure
455+
/// mode: the inputs are returned so the caller can retry or discard them.
456+
pub fn try_insert(&self, key: Key, value: Val) -> Result<(), (Key, Val)> {
457+
let lcs = self.try_insert_with_lifecycle(key, value)?;
458+
self.lifecycle.end_request(lcs);
459+
Ok(())
460+
}
461+
367462
/// Inserts an item in the cache with key `key`.
368463
pub fn insert_with_lifecycle(&self, key: Key, value: Val) -> L::RequestState {
369464
let mut lcs = self.lifecycle.begin_request();
@@ -376,6 +471,32 @@ impl<
376471
lcs
377472
}
378473

474+
/// Attempts to insert an item in the cache with key `key` without blocking.
475+
/// Returns `Ok(lcs)` with the lifecycle request state if the item was inserted,
476+
/// or `Err((key, value))` if the shard lock could not be acquired without blocking.
477+
/// Lock contention is the only failure mode: the inputs are returned so the
478+
/// caller can retry or discard them.
479+
pub fn try_insert_with_lifecycle(
480+
&self,
481+
key: Key,
482+
value: Val,
483+
) -> Result<L::RequestState, (Key, Val)> {
484+
// Tradeoff: begin_request is called before acquiring the shard lock to avoid holding
485+
// the lock during potentially expensive lifecycle initialization.
486+
let mut lcs = self.lifecycle.begin_request();
487+
let (shard, hash) = self.shard_for(&key).unwrap();
488+
489+
match shard.try_write() {
490+
Some(mut shard) => {
491+
let result = shard.insert(&mut lcs, hash, key, value, InsertStrategy::Insert);
492+
// result cannot err with the Insert strategy
493+
debug_assert!(result.is_ok());
494+
Ok(lcs)
495+
}
496+
_ => Err((key, value)),
497+
}
498+
}
499+
379500
/// Clear all items from the cache
380501
pub fn clear(&self) {
381502
for s in self.shards.iter() {
@@ -1526,4 +1647,120 @@ mod tests {
15261647
}
15271648
}
15281649
}
1650+
1651+
// --- Non-blocking method tests ---
1652+
#[test]
1653+
fn test_try_contains_key() {
1654+
let cache = Cache::new(100);
1655+
cache.insert(1, 10);
1656+
1657+
assert!(cache.try_contains_key(&1).is_ok_and(|v| v));
1658+
assert!(cache.try_contains_key(&2).is_ok_and(|v| !v));
1659+
}
1660+
1661+
#[test]
1662+
fn test_try_contains_key_contended() {
1663+
let cache = Cache::new(100);
1664+
cache.insert(1, 10);
1665+
// Hold write locks on all shards so try_read is blocked.
1666+
let _guards: Vec<_> = cache.shards.iter().map(|s| s.write()).collect();
1667+
assert!(cache.try_contains_key(&1).is_err());
1668+
}
1669+
1670+
#[test]
1671+
fn test_try_get() {
1672+
let cache = Cache::new(100);
1673+
cache.insert(1, 10);
1674+
1675+
assert!(cache.try_get(&1).is_ok_and(|v| matches!(v, Some(10))));
1676+
assert!(cache.try_get(&2).is_ok_and(|v| v.is_none()));
1677+
}
1678+
1679+
#[test]
1680+
fn test_try_get_contended() {
1681+
let cache = Cache::new(100);
1682+
cache.insert(1, 10);
1683+
let _guards: Vec<_> = cache.shards.iter().map(|s| s.write()).collect();
1684+
assert!(cache.try_get(&1).is_err());
1685+
}
1686+
1687+
#[test]
1688+
fn test_try_peek() {
1689+
let cache = Cache::new(100);
1690+
cache.insert(1, 10);
1691+
1692+
assert!(cache.try_peek(&1).is_ok_and(|v| matches!(v, Some(10))));
1693+
assert!(cache.try_peek(&2).is_ok_and(|v| v.is_none()));
1694+
}
1695+
1696+
#[test]
1697+
fn test_try_peek_contended() {
1698+
let cache = Cache::new(100);
1699+
cache.insert(1, 10);
1700+
let _guards: Vec<_> = cache.shards.iter().map(|s| s.write()).collect();
1701+
assert!(cache.try_peek(&1).is_err());
1702+
}
1703+
1704+
#[test]
1705+
fn test_try_remove() {
1706+
let cache = Cache::new(100);
1707+
cache.insert(1, 10);
1708+
1709+
assert!(cache
1710+
.try_remove(&1)
1711+
.is_ok_and(|v| matches!(v, Some((1, 10)))));
1712+
assert!(cache.try_remove(&1).is_ok_and(|v| v.is_none()));
1713+
assert!(cache.try_remove(&99).is_ok_and(|v| v.is_none()));
1714+
}
1715+
1716+
#[test]
1717+
fn test_try_remove_contended() {
1718+
let cache = Cache::new(100);
1719+
cache.insert(1, 10);
1720+
// Hold read locks on all shards so try_write is blocked.
1721+
let guards: Vec<_> = cache.shards.iter().map(|s| s.read()).collect();
1722+
assert!(cache.try_remove(&1).is_err());
1723+
drop(guards);
1724+
// Item must still be present since the remove did not happen.
1725+
assert_eq!(cache.get(&1), Some(10));
1726+
}
1727+
1728+
#[test]
1729+
fn test_try_insert() {
1730+
let cache = Cache::new(100);
1731+
1732+
assert_eq!(cache.try_insert(1, 10), Ok(()));
1733+
assert_eq!(cache.get(&1), Some(10));
1734+
1735+
// Insert same key overwrites the previous value.
1736+
assert_eq!(cache.try_insert(1, 20), Ok(()));
1737+
assert_eq!(cache.get(&1), Some(20));
1738+
}
1739+
1740+
#[test]
1741+
fn test_try_insert_contended() {
1742+
let cache = Cache::new(100);
1743+
let guards: Vec<_> = cache.shards.iter().map(|s| s.read()).collect();
1744+
assert_eq!(cache.try_insert(1, 10), Err((1, 10)));
1745+
drop(guards);
1746+
assert_eq!(cache.get(&1), None);
1747+
}
1748+
1749+
#[test]
1750+
fn test_try_insert_with_lifecycle() {
1751+
let cache = Cache::new(100);
1752+
1753+
// Successful insert returns the lifecycle request state.
1754+
let result = cache.try_insert_with_lifecycle(1, 10);
1755+
assert!(result.is_ok());
1756+
let lcs = result.ok().unwrap();
1757+
cache.lifecycle.end_request(lcs);
1758+
assert_eq!(cache.get(&1), Some(10));
1759+
1760+
// Contended when a read lock is held.
1761+
let guards: Vec<_> = cache.shards.iter().map(|s| s.read()).collect();
1762+
assert_eq!(cache.try_insert_with_lifecycle(2, 20), Err((2, 20)));
1763+
drop(guards);
1764+
assert_eq!(cache.get(&2), None);
1765+
}
15291766
}

0 commit comments

Comments
 (0)