Describe the bug
pingora-memory-cache::RTCache::get() can leak a per-key lookup-coalescing lock if the cache-miss writer future is cancelled while it is awaiting the user-provided Lookup::lookup() callback.
The vulnerable state transition is:
cache miss
-> insert zero-permit CacheLock into RTCache.lockers for this key
-> await user async callback CB::lookup(key, extra)
-> caller cancels/drops the RTCache::get() future before lookup returns
-> cleanup after the await is skipped
-> later same-key requests observe the stale lock as an active writer
With lock_age = None and lock_timeout = None, later same-key callers wait forever on the leaked zero-permit semaphore. With lock_timeout = Some(...), callers can eventually escape by doing a direct lookup, but the stale lock is not repaired and the timeout path returns without populating the cache. With lock_age = Some(...), aged locks may be bypassed, but the stale map entry can still remain and later lookups may continue to lose normal cache/coalescing behavior.
This is a cancellation-correctness issue because shared in-progress state is published before an arbitrary external async callback, while the only cleanup and waiter wakeup path remains after that cancellation point.
Root cause
The root cause is visible in pingora-memory-cache/src/read_through.rs. All source links below are pinned to commit e6e677fe9b58555140ab7bd14feff035392b3530.
RTCache::get() is a normal caller-owned async future. A caller can drop it through a timeout, a losing select! branch, task abort, parent future cancellation, or runtime shutdown:
pingora-memory-cache/src/read_through.rs#L150-L155
pub async fn get(
&self,
key: &K,
ttl: Option<Duration>,
extra: Option<&S>,
) -> (Result<T, Box<Error>>, CacheStatus) {
- On a miss with no existing lock,
RTCache::get() inserts a new per-key CacheLock into the shared lockers map before it awaits the user callback:
pingora-memory-cache/src/read_through.rs#L193-L198
None => {
let new_lock = CacheLock::new_arc();
let new_lock2 = new_lock.clone();
lockers.insert(hashed_key, new_lock2);
(Some(new_lock), None)
}
The inserted lock starts with zero permits:
pingora-memory-cache/src/read_through.rs#L36-L42
pub fn new_arc() -> Arc<Self> {
Arc::new(CacheLock {
lock: Semaphore::new(0),
lock_start: Instant::now(),
})
}
- Readers that find the lock treat it as an active writer and wait for a permit. If no
lock_timeout is configured, this await has no completion source once the writer has been cancelled:
pingora-memory-cache/src/read_through.rs#L203-L228
if let Some(my_lock) = my_read {
/* another task will do the lookup */
/* if available_permits > 0, writer is done */
if my_lock.lock.available_permits() == 0 {
/* block here to wait for writer to finish lookup */
let lock_fut = my_lock.lock.acquire();
let timed_out = match self.lock_timeout {
Some(t) => pingora_timeout::timeout(t, lock_fut).await.is_err(),
None => {
let _ = lock_fut.await;
false
}
};
- The writer crosses the cancellation point while the shared lock is already published:
pingora-memory-cache/src/read_through.rs#L252-L255
} else {
/* this one will do the look up, either because it gets the write lock or the read
* lock age is reached */
let value = CB::lookup(key, extra).await;
Lookup::lookup() is implemented by the caller and can be arbitrary async work, including DNS, database, or backend I/O:
pingora-memory-cache/src/read_through.rs#L75-L80
pub trait Lookup<K, T, S> {
/// Return a value and an optional TTL for the given key.
async fn lookup(
key: &K,
extra: Option<&S>,
) -> Result<(T, Option<Duration>), Box<dyn ErrorTrait + Send + Sync>>
- The only normal release and removal path runs after that await. If the future is dropped while
CB::lookup() is pending, this code is skipped:
pingora-memory-cache/src/read_through.rs#L270-L279
if let Some(my_write) = my_write {
/* add permit so that reader can start. Any number of permits will do,
* since readers will return permits right away. */
my_write.lock.add_permits(10);
{
// remove the lock from locker
let mut lockers = self.lockers.write();
lockers.remove(&hashed_key);
} // write lock dropped here
}
This creates the leaked state:
lockers[hashed_key] still present
Semaphore permits still 0
no cache value inserted
no live writer future remains
no live future remains that will call add_permits(10)
no live future remains that will remove lockers[hashed_key]
- Timeout and lock-age settings are partial escape hatches, not full repairs. If
lock_timeout fires, the caller performs a direct lookup and returns without inserting into the cache:
pingora-memory-cache/src/read_through.rs#L217-L226
if timed_out {
let value = CB::lookup(key, extra).await;
return match value {
Ok((v, _ttl)) => (Ok(v), cache_state),
Err(e) => {
let mut err = Error::new_str(LOOKUP_ERR_MSG);
err.set_cause(e);
(Err(err), cache_state)
}
};
}
If lock_age treats the stale lock as too old, my_write remains None, and successful lookup results are not inserted because cache insertion is gated on my_write.is_some():
pingora-memory-cache/src/read_through.rs#L174-L180
Some(lock) => {
/* There is an ongoing lookup to the same key */
if lock.too_old(self.lock_age.as_ref()) {
(None, None)
} else {
(None, Some(lock))
}
}
pingora-memory-cache/src/read_through.rs#L257-L261
Ok((v, new_ttl)) => {
/* Don't put() if lock ago too old, to avoid too many concurrent writes */
if my_write.is_some() {
self.inner.force_put(key, v.clone(), new_ttl.or(ttl));
}
Pingora info
Please include the following information about your environment:
Pingora version: audited and reproduced against commit e6e677fe9b58555140ab7bd14feff035392b3530
Rust version: not captured in the shared reproduction log
Operating system version: reproduced as a Rust unit test on Linux x86 (server36)
Steps to reproduce
The following is a focused whitebox reproduction. It intentionally asserts the current bad state, so the test passing means the cancellation window is reachable in the current implementation. After a fix, the core assertions should be inverted into a regression property such as: a cancelled writer removes or invalidates the coalescing lock, wakes waiters, and lets a later same-key request become a new writer or otherwise make bounded progress.
Steps:
- Apply the test-only code below inside the existing
#[cfg(test)] mod tests in pingora-memory-cache/src/read_through.rs.
- Run the targeted test command shown below.
- Observe that the cancelled writer leaves a stale zero-permit lock in
RTCache.lockers, and the next same-key get() remains blocked on that leaked lock.
Full whitebox test code for pingora-memory-cache/src/read_through.rs
#[derive(Clone, Debug)]
struct CancelledLookupOpt {
entered: Arc<tokio::sync::Notify>,
used: Arc<AtomicI32>,
}
struct CancelledLookupCB();
#[async_trait]
impl Lookup<i32, i32, CancelledLookupOpt> for CancelledLookupCB {
async fn lookup(
_key: &i32,
extra: Option<&CancelledLookupOpt>,
) -> Result<(i32, Option<Duration>), Box<dyn ErrorTrait + Send + Sync>> {
let extra = extra.expect("test lookup must receive coordination state");
let used = extra.used.fetch_add(1, atomic::Ordering::SeqCst) + 1;
if used == 1 {
extra.entered.notify_one();
std::future::pending::<()>().await;
unreachable!("first lookup is cancelled while pending");
}
Ok((used, None))
}
}
#[tokio::test]
async fn test_cancelled_lookup_keeps_coalescing_lock_blocking_next_get() {
let cache: Arc<RTCache<i32, i32, CancelledLookupCB, CancelledLookupOpt>> =
Arc::new(RTCache::new(10, None, None));
let opt = CancelledLookupOpt {
entered: Arc::new(tokio::sync::Notify::new()),
used: Arc::new(AtomicI32::new(0)),
};
assert!(cache.lockers.read().is_empty());
let writer_cache = cache.clone();
let writer_opt = opt.clone();
let writer = tokio::spawn(async move {
writer_cache.get(&1, None, Some(&writer_opt)).await
});
opt.entered.notified().await;
assert_eq!(
opt.used.load(atomic::Ordering::SeqCst),
1,
"the first cache miss writer must enter lookup before cancellation"
);
writer.abort();
assert!(
writer.await.unwrap_err().is_cancelled(),
"aborting the writer task is the cancellation source"
);
let hashed_key = cache.inner.hasher.hash_one(&1);
let leaked_lock = cache
.lockers
.read()
.get(&hashed_key)
.cloned()
.expect("cancelled writer leaves the coalescing lock installed");
assert_eq!(
leaked_lock.lock.available_permits(),
0,
"cancelled writer never reaches add_permits(), so waiters cannot be released"
);
let next_get = tokio::time::timeout(
Duration::from_millis(50),
cache.get(&1, None, Some(&opt)),
)
.await;
assert!(
next_get.is_err(),
"the next same-key get is still waiting on the leaked coalescing lock"
);
assert_eq!(
opt.used.load(atomic::Ordering::SeqCst),
1,
"the second get never becomes a replacement writer, so lookup is not called again"
);
}
Targeted command:
cargo test -p pingora-memory-cache test_cancelled_lookup_keeps_coalescing_lock_blocking_next_get -- --nocapture
Observed result:
Finished `test` profile [unoptimized + debuginfo] target(s) in 14.48s
Running unittests src/lib.rs (target/debug/deps/pingora_memory_cache-32c430ff3d060b71)
running 1 test
test read_through::tests::test_cancelled_lookup_keeps_coalescing_lock_blocking_next_get ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 20 filtered out; finished in 0.05s
Why this reproduces the issue:
- The test starts from a healthy
RTCache with an empty lockers map.
- The first same-key miss becomes the writer and enters
CB::lookup().
- The test callback uses
Notify to deterministically prove the writer is suspended inside the lookup callback, which is the cancellation window after lock insertion and before cleanup.
writer.abort() is the real cancellation source. It drops the in-flight RTCache::get() future while that future owns the only remaining cleanup path.
- The test then reads
cache.lockers directly and proves the bad shared state: the per-key lock is still installed and has 0 available permits.
- The next same-key
get() does not become a replacement writer. It waits on the stale zero-permit lock and is observed with a short timeout.
- The callback counter remains
1, proving that the second request never reached CB::lookup() and therefore could not repopulate or repair the key.
This timeout is only used as a deadlock guard around the final observation. The bad state itself is proven by direct whitebox assertions on RTCache.lockers and the leaked semaphore permits.
Expected results
Cancelling a cache-miss writer should not leave a shared "lookup in progress" marker that no live future can complete.
More specifically, after a writer is cancelled while populating a key, later same-key callers should still have a bounded-progress path. They should either:
- observe a cached value from a completed writer;
- observe a completed error/failure state;
- become a new writer and retry the lookup;
- or wait on a lock that is guaranteed to be released by a live owner.
The cache should not keep a stale zero-permit CacheLock in RTCache.lockers after the future responsible for releasing and removing that lock has been cancelled.
Observed results
The whitebox test observes this bad state after cancelling the cache-miss writer:
RTCache.lockers[hashed_key] exists
leaked_lock.lock.available_permits() == 0
second same-key RTCache::get() does not call CB::lookup()
second same-key RTCache::get() remains blocked on the leaked lock
For RTCache::new(size, None, None), this is a per-key liveness failure: future same-key requests can wait indefinitely.
For configurations that use lock_timeout or lock_age, the impact is different but still problematic. The affected key can repeatedly bypass normal coalescing/cache insertion or remain associated with a stale lock entry, defeating the load-shedding behavior that RTCache is intended to provide for async external lookups.
Additional context
The cancellation-safety invariant I expected is:
A cancellable RTCache writer must not be the only owner of cleanup for a
shared per-key in-progress marker that can block future same-key readers.
A robust fix could use a small writer guard that owns the inserted lock entry and removes or marks it failed on drop unless the writer reaches the normal completion path. To avoid a cancelled old writer deleting a newer writer's state, removal should compare the stored Arc<CacheLock> identity or use a generation token. The same guard should also wake waiters on cancellation or failure so they can retry instead of waiting on a zero-permit lock with no owner.
Describe the bug
pingora-memory-cache::RTCache::get()can leak a per-key lookup-coalescing lock if the cache-miss writer future is cancelled while it is awaiting the user-providedLookup::lookup()callback.The vulnerable state transition is:
With
lock_age = Noneandlock_timeout = None, later same-key callers wait forever on the leaked zero-permit semaphore. Withlock_timeout = Some(...), callers can eventually escape by doing a direct lookup, but the stale lock is not repaired and the timeout path returns without populating the cache. Withlock_age = Some(...), aged locks may be bypassed, but the stale map entry can still remain and later lookups may continue to lose normal cache/coalescing behavior.This is a cancellation-correctness issue because shared in-progress state is published before an arbitrary external async callback, while the only cleanup and waiter wakeup path remains after that cancellation point.
Root cause
The root cause is visible in
pingora-memory-cache/src/read_through.rs. All source links below are pinned to commite6e677fe9b58555140ab7bd14feff035392b3530.RTCache::get()is a normal caller-owned async future. A caller can drop it through a timeout, a losingselect!branch, task abort, parent future cancellation, or runtime shutdown:pingora-memory-cache/src/read_through.rs#L150-L155RTCache::get()inserts a new per-keyCacheLockinto the sharedlockersmap before it awaits the user callback:pingora-memory-cache/src/read_through.rs#L193-L198The inserted lock starts with zero permits:
pingora-memory-cache/src/read_through.rs#L36-L42lock_timeoutis configured, this await has no completion source once the writer has been cancelled:pingora-memory-cache/src/read_through.rs#L203-L228pingora-memory-cache/src/read_through.rs#L252-L255Lookup::lookup()is implemented by the caller and can be arbitrary async work, including DNS, database, or backend I/O:pingora-memory-cache/src/read_through.rs#L75-L80CB::lookup()is pending, this code is skipped:pingora-memory-cache/src/read_through.rs#L270-L279This creates the leaked state:
lock_timeoutfires, the caller performs a direct lookup and returns without inserting into the cache:pingora-memory-cache/src/read_through.rs#L217-L226If
lock_agetreats the stale lock as too old,my_writeremainsNone, and successful lookup results are not inserted because cache insertion is gated onmy_write.is_some():pingora-memory-cache/src/read_through.rs#L174-L180pingora-memory-cache/src/read_through.rs#L257-L261Pingora info
Please include the following information about your environment:
Pingora version: audited and reproduced against commit
e6e677fe9b58555140ab7bd14feff035392b3530Rust version: not captured in the shared reproduction log
Operating system version: reproduced as a Rust unit test on Linux x86 (
server36)Steps to reproduce
The following is a focused whitebox reproduction. It intentionally asserts the current bad state, so the test passing means the cancellation window is reachable in the current implementation. After a fix, the core assertions should be inverted into a regression property such as: a cancelled writer removes or invalidates the coalescing lock, wakes waiters, and lets a later same-key request become a new writer or otherwise make bounded progress.
Steps:
#[cfg(test)] mod testsinpingora-memory-cache/src/read_through.rs.RTCache.lockers, and the next same-keyget()remains blocked on that leaked lock.Full whitebox test code for
pingora-memory-cache/src/read_through.rsTargeted command:
cargo test -p pingora-memory-cache test_cancelled_lookup_keeps_coalescing_lock_blocking_next_get -- --nocaptureObserved result:
Why this reproduces the issue:
RTCachewith an emptylockersmap.CB::lookup().Notifyto deterministically prove the writer is suspended inside the lookup callback, which is the cancellation window after lock insertion and before cleanup.writer.abort()is the real cancellation source. It drops the in-flightRTCache::get()future while that future owns the only remaining cleanup path.cache.lockersdirectly and proves the bad shared state: the per-key lock is still installed and has0available permits.get()does not become a replacement writer. It waits on the stale zero-permit lock and is observed with a short timeout.1, proving that the second request never reachedCB::lookup()and therefore could not repopulate or repair the key.This timeout is only used as a deadlock guard around the final observation. The bad state itself is proven by direct whitebox assertions on
RTCache.lockersand the leaked semaphore permits.Expected results
Cancelling a cache-miss writer should not leave a shared "lookup in progress" marker that no live future can complete.
More specifically, after a writer is cancelled while populating a key, later same-key callers should still have a bounded-progress path. They should either:
The cache should not keep a stale zero-permit
CacheLockinRTCache.lockersafter the future responsible for releasing and removing that lock has been cancelled.Observed results
The whitebox test observes this bad state after cancelling the cache-miss writer:
For
RTCache::new(size, None, None), this is a per-key liveness failure: future same-key requests can wait indefinitely.For configurations that use
lock_timeoutorlock_age, the impact is different but still problematic. The affected key can repeatedly bypass normal coalescing/cache insertion or remain associated with a stale lock entry, defeating the load-shedding behavior thatRTCacheis intended to provide for async external lookups.Additional context
The cancellation-safety invariant I expected is:
A robust fix could use a small writer guard that owns the inserted lock entry and removes or marks it failed on drop unless the writer reaches the normal completion path. To avoid a cancelled old writer deleting a newer writer's state, removal should compare the stored
Arc<CacheLock>identity or use a generation token. The same guard should also wake waiters on cancellation or failure so they can retry instead of waiting on a zero-permit lock with no owner.