use std::future::Future;
use std::hash::Hash;
use std::time::{Duration, Instant};
use dashmap::DashMap;
use dashmap::mapref::entry::Entry;
use tokio::sync::watch;
use super::FetchCompleteness;
use crate::cache_policy::evict_expired_then_oldest;
#[derive(Debug, Clone)]
pub(super) enum CallOutcome<V> {
Definitive(V),
Degraded(V),
}
impl<V> CallOutcome<V> {
pub(super) const fn completeness(&self) -> FetchCompleteness {
match self {
Self::Definitive(_) => FetchCompleteness::Complete,
Self::Degraded(_) => FetchCompleteness::Incomplete,
}
}
pub(super) fn into_value(self) -> V {
match self {
Self::Definitive(value) | Self::Degraded(value) => value,
}
}
pub(super) fn into_parts(self) -> (V, FetchCompleteness) {
let completeness = self.completeness();
(self.into_value(), completeness)
}
pub(super) const fn with_completeness(value: V, completeness: FetchCompleteness) -> Self {
match completeness {
FetchCompleteness::Complete => Self::Definitive(value),
FetchCompleteness::Incomplete => Self::Degraded(value),
}
}
}
#[derive(Debug, Clone, Copy)]
pub(super) struct TtlPolicy {
definitive: Duration,
degraded: Duration,
}
impl TtlPolicy {
pub(super) const fn new(definitive: Duration, degraded: Duration) -> Self {
Self {
definitive,
degraded,
}
}
pub(super) const fn ttl_for<V>(&self, outcome: &CallOutcome<V>) -> Duration {
match outcome {
CallOutcome::Definitive(_) => self.definitive,
CallOutcome::Degraded(_) => self.degraded,
}
}
}
pub(super) const DEPS_DEV_TTLS: TtlPolicy =
TtlPolicy::new(super::DEPS_DEV_SUCCESS_TTL, super::DEPS_DEV_ERROR_TTL);
pub(super) const GOSSIP_TTLS: TtlPolicy =
TtlPolicy::new(super::GOSSIP_SUCCESS_TTL, super::GOSSIP_ERROR_TTL);
const MAX_MEMO_ENTRIES: usize = super::MAX_MEMO_ENTRIES;
struct MemoSlot<V> {
fetched_at: Instant,
outcome: CallOutcome<V>,
}
pub(super) struct TtlMemo<K, V> {
entries: DashMap<K, MemoSlot<V>>,
policy: TtlPolicy,
}
impl<K, V> TtlMemo<K, V>
where
K: Hash + Eq + Clone,
V: Clone,
{
pub(super) fn new(policy: TtlPolicy) -> Self {
Self {
entries: DashMap::new(),
policy,
}
}
pub(super) fn get_fresh(&self, key: &K) -> Option<CallOutcome<V>> {
let entry = self.entries.get(key)?;
(entry.fetched_at.elapsed() < self.policy.ttl_for(&entry.outcome))
.then(|| entry.outcome.clone())
}
pub(super) fn insert(&self, key: K, outcome: CallOutcome<V>) {
if !self.entries.contains_key(&key) {
evict_expired_then_oldest(
&self.entries,
MAX_MEMO_ENTRIES,
|s| s.fetched_at,
|s| self.policy.ttl_for(&s.outcome),
);
}
self.entries.insert(
key,
MemoSlot {
fetched_at: Instant::now(),
outcome,
},
);
}
pub(super) fn remove(&self, key: &K) {
self.entries.remove(key);
}
#[cfg(test)]
pub(super) fn len(&self) -> usize {
self.entries.len()
}
#[cfg(test)]
pub(super) fn contains_key(&self, key: &K) -> bool {
self.entries.contains_key(key)
}
#[cfg(test)]
pub(super) fn ttl_of(&self, key: &K) -> Option<Duration> {
self.entries
.get(key)
.map(|e| self.policy.ttl_for(&e.outcome))
}
}
#[derive(Debug, Clone)]
pub(super) enum Slot<T> {
Pending,
Ready(T),
}
struct InFlightGuard<'a, K: Hash + Eq, W> {
map: &'a DashMap<K, W>,
key: K,
}
impl<K: Hash + Eq, W> Drop for InFlightGuard<'_, K, W> {
fn drop(&mut self) {
self.map.remove(&self.key);
}
}
fn claim_or_follow<K, T>(
in_flight: &DashMap<K, watch::Receiver<Slot<T>>>,
key: K,
) -> Result<watch::Sender<Slot<T>>, watch::Receiver<Slot<T>>>
where
K: Hash + Eq,
{
match in_flight.entry(key) {
Entry::Occupied(occupied) => Err(occupied.get().clone()),
Entry::Vacant(vacant) => {
let (tx, rx) = watch::channel(Slot::Pending);
vacant.insert(rx);
Ok(tx)
}
}
}
const MAX_COALESCE_TAKEOVER_ATTEMPTS: u8 = 2;
#[cfg(test)]
static COALESCE_FOLLOWER_JOINED: tokio::sync::Notify = tokio::sync::Notify::const_new();
async fn coalesce<K, T, F, Fut>(
in_flight: &DashMap<K, watch::Receiver<Slot<T>>>,
key: K,
fetch: F,
) -> Option<T>
where
K: Hash + Eq + Clone + Send + Sync,
T: Clone + Send + Sync,
F: Fn() -> Fut + Send,
Fut: Future<Output = T> + Send,
{
for attempt in 0..=MAX_COALESCE_TAKEOVER_ATTEMPTS {
match claim_or_follow(in_flight, key.clone()) {
Ok(tx) => {
let _guard = InFlightGuard {
map: in_flight,
key,
};
let value = fetch().await;
let _ = tx.send(Slot::Ready(value.clone()));
return Some(value);
}
Err(mut rx) => {
#[cfg(test)]
COALESCE_FOLLOWER_JOINED.notify_one();
loop {
let slot = rx.borrow_and_update().clone();
if let Slot::Ready(value) = slot {
return Some(value);
}
if rx.changed().await.is_err() {
tracing::debug!(
attempt,
"coalesce: in-flight leader was cancelled before completing; taking \
over as leader"
);
break;
}
}
}
}
}
tracing::debug!(
"coalesce: gave up after {} leader-takeover attempts",
MAX_COALESCE_TAKEOVER_ATTEMPTS + 1
);
None
}
pub(super) struct CoalescedMemo<K, V> {
memo: TtlMemo<K, V>,
in_flight: DashMap<K, watch::Receiver<Slot<CallOutcome<V>>>>,
}
impl<K, V> CoalescedMemo<K, V>
where
K: Hash + Eq + Clone + Send + Sync,
V: Clone + Default + Send + Sync,
{
pub(super) fn new(policy: TtlPolicy) -> Self {
Self {
memo: TtlMemo::new(policy),
in_flight: DashMap::new(),
}
}
pub(super) async fn get_or_fetch<F, Fut>(&self, key: K, fetch: F) -> CallOutcome<V>
where
F: Fn() -> Fut + Send + Sync,
Fut: Future<Output = CallOutcome<V>> + Send,
{
if let Some(outcome) = self.memo.get_fresh(&key) {
return outcome;
}
coalesce(&self.in_flight, key.clone(), || async {
if let Some(outcome) = self.memo.get_fresh(&key) {
return outcome;
}
let outcome = fetch().await;
self.memo.insert(key.clone(), outcome.clone());
outcome
})
.await
.unwrap_or_else(|| CallOutcome::Degraded(V::default()))
}
#[cfg(test)]
pub(super) const fn memo(&self) -> &TtlMemo<K, V> {
&self.memo
}
#[cfg(test)]
pub(super) fn in_flight_contains(&self, key: &K) -> bool {
self.in_flight.contains_key(key)
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use super::*;
async fn wait_for_claim<K, T>(map: &DashMap<K, watch::Receiver<Slot<T>>>, key: &K)
where
K: Hash + Eq + Sync,
T: Send + Sync,
{
tokio::time::timeout(Duration::from_secs(5), async {
while !map.contains_key(key) {
tokio::task::yield_now().await;
}
})
.await
.expect("leader must register the in-flight entry before the follower joins");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn coalesce_leader_panic_lets_follower_take_over_and_recover_real_value() {
let map: Arc<DashMap<u32, watch::Receiver<Slot<u32>>>> = Arc::new(DashMap::new());
let leader_map = Arc::clone(&map);
let leader = tokio::spawn(async move {
coalesce(&leader_map, 1u32, || async {
tokio::time::timeout(Duration::from_secs(5), COALESCE_FOLLOWER_JOINED.notified())
.await
.expect("follower must join before the leader panics");
panic!("leader fetch panics");
#[allow(unreachable_code)]
0u32
})
.await
});
wait_for_claim(&map, &1u32).await;
let follower = coalesce(&map, 1u32, || async { 99u32 }).await;
assert!(
leader.await.is_err(),
"the leader's own task must observe the panic"
);
assert_eq!(
follower,
Some(99),
"a follower whose leader panicked must take over and recover the real value from \
its own fetch, never deadlock, panic itself, or silently give up"
);
assert!(
!map.contains_key(&1u32),
"the in-flight entry must be cleaned up after the leader panic and the follower's \
own successful takeover"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn coalesce_aborted_leader_lets_follower_take_over_and_recover_real_value() {
let map: Arc<DashMap<u32, watch::Receiver<Slot<u32>>>> = Arc::new(DashMap::new());
let leader_map = Arc::clone(&map);
let leader = tokio::spawn(async move {
coalesce(&leader_map, 1u32, || async {
tokio::time::sleep(Duration::from_secs(10)).await;
0u32
})
.await
});
wait_for_claim(&map, &1u32).await;
let follower_map = Arc::clone(&map);
let follower =
tokio::spawn(async move { coalesce(&follower_map, 1u32, || async { 7u32 }).await });
tokio::time::timeout(Duration::from_secs(5), COALESCE_FOLLOWER_JOINED.notified())
.await
.expect("follower must join before the leader is aborted");
leader.abort();
let follower_result = follower.await.expect("follower task must not panic");
assert_eq!(
follower_result,
Some(7),
"a follower whose leader was aborted (not panicked) must take over and recover the \
real value from its own fetch, not silently give up"
);
assert!(
!map.contains_key(&1u32),
"the in-flight entry must be cleaned up after the leader's abort and the \
follower's own successful takeover"
);
}
#[tokio::test]
async fn get_or_fetch_memo_hit_skips_fetch() {
use std::sync::atomic::{AtomicUsize, Ordering};
let memo: CoalescedMemo<u32, u32> = CoalescedMemo::new(TtlPolicy::new(
Duration::from_secs(60),
Duration::from_secs(60),
));
let calls = Arc::new(AtomicUsize::new(0));
let call_a = Arc::clone(&calls);
let first = memo
.get_or_fetch(1u32, || {
let call_a = Arc::clone(&call_a);
async move {
call_a.fetch_add(1, Ordering::SeqCst);
CallOutcome::Definitive(42u32)
}
})
.await;
assert_eq!(first.into_value(), 42);
let call_b = Arc::clone(&calls);
let second = memo
.get_or_fetch(1u32, || {
let call_b = Arc::clone(&call_b);
async move {
call_b.fetch_add(1, Ordering::SeqCst);
CallOutcome::Definitive(0u32)
}
})
.await;
assert_eq!(
second.into_value(),
42,
"the memo hit must serve the cached value"
);
assert_eq!(
calls.load(Ordering::SeqCst),
1,
"a fresh memo hit must never call fetch again"
);
}
#[tokio::test]
async fn get_or_fetch_degraded_expires_on_the_shorter_degraded_ttl() {
let memo: CoalescedMemo<u32, u32> = CoalescedMemo::new(TtlPolicy::new(
Duration::from_secs(60),
Duration::from_millis(200),
));
let outcome = memo
.get_or_fetch(1u32, || async { CallOutcome::Degraded(0u32) })
.await;
assert!(matches!(outcome, CallOutcome::Degraded(0)));
assert!(memo.memo().get_fresh(&1u32).is_some());
tokio::time::sleep(Duration::from_millis(400)).await;
assert!(
memo.memo().get_fresh(&1u32).is_none(),
"a Degraded entry must expire on the shorter degraded TTL, not the definitive one"
);
}
#[tokio::test]
async fn get_or_fetch_exhaustion_returns_degraded_default() {
let in_flight: DashMap<u32, watch::Receiver<Slot<CallOutcome<u32>>>> = DashMap::new();
let (tx, rx) = watch::channel(Slot::<CallOutcome<u32>>::Pending);
drop(tx);
in_flight.insert(1u32, rx);
let memo = CoalescedMemo::<u32, u32> {
memo: TtlMemo::new(TtlPolicy::new(
Duration::from_secs(60),
Duration::from_secs(60),
)),
in_flight,
};
let outcome = memo
.get_or_fetch(1u32, || async { CallOutcome::Definitive(0u32) })
.await;
assert!(
matches!(outcome, CallOutcome::Degraded(0)),
"exhaustion must degrade to CallOutcome::Degraded(V::default()), never panic or hang"
);
}
}