use std::future::Future;
use chrono::{DateTime, Duration, Utc};
use tokio::sync::{Mutex, watch};
#[derive(Debug, Clone)]
pub struct CachedToken<T> {
pub value: T,
pub expires_at: DateTime<Utc>,
}
impl<T> CachedToken<T> {
pub fn new(value: T, expires_at: DateTime<Utc>) -> Self {
Self { value, expires_at }
}
}
const EXPIRY_SKEW: Duration = Duration::minutes(1);
type FetchResult<T, E> = Result<CachedToken<T>, E>;
enum Slot<T, E> {
Empty,
Fetching(watch::Receiver<Option<FetchResult<T, E>>>),
Ready(CachedToken<T>),
}
pub struct ExpiryTokenCache<T, E> {
slot: Mutex<Slot<T, E>>,
}
impl<T, E> Default for ExpiryTokenCache<T, E> {
fn default() -> Self {
Self::new()
}
}
impl<T, E> ExpiryTokenCache<T, E> {
pub fn new() -> Self {
Self {
slot: Mutex::new(Slot::Empty),
}
}
}
impl<T, E> ExpiryTokenCache<T, E>
where
T: Clone + Send + Sync + 'static,
E: Clone + Send + Sync + 'static,
{
pub async fn get_or_refresh<F, Fut>(&self, mut generate: F) -> Result<T, E>
where
F: FnMut() -> Fut,
Fut: Future<Output = FetchResult<T, E>>,
{
loop {
let mut receiver = {
let mut guard = self.slot.lock().await;
match &*guard {
Slot::Ready(cached) if !is_stale(cached) => return Ok(cached.value.clone()),
Slot::Fetching(rx) => rx.clone(),
_ => {
let (tx, rx) = watch::channel(None);
*guard = Slot::Fetching(rx);
drop(guard);
let result = generate().await;
let mut guard = self.slot.lock().await;
*guard = match &result {
Ok(cached) => Slot::Ready(cached.clone()),
Err(_) => Slot::Empty,
};
drop(guard);
let _ = tx.send(Some(result.clone()));
return result.map(|cached| cached.value);
}
}
};
loop {
if let Some(result) = receiver.borrow().clone() {
return result.map(|cached| cached.value);
}
if receiver.changed().await.is_err() {
break;
}
}
}
}
pub async fn get_existing(&self) -> Option<T> {
let guard = self.slot.lock().await;
match &*guard {
Slot::Ready(cached) if !is_stale(cached) => Some(cached.value.clone()),
_ => None,
}
}
pub async fn invalidate(&self) {
let mut guard = self.slot.lock().await;
*guard = Slot::Empty;
}
}
fn is_stale<T>(cached: &CachedToken<T>) -> bool {
cached.expires_at <= Utc::now() + EXPIRY_SKEW
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
#[derive(Debug, Clone, PartialEq, Eq)]
struct TestError(String);
#[tokio::test]
async fn returns_fresh_value_without_refetch() {
let cache: ExpiryTokenCache<i32, TestError> = ExpiryTokenCache::new();
let calls = Arc::new(AtomicUsize::new(0));
let c = calls.clone();
let v = cache
.get_or_refresh(|| {
let c = c.clone();
async move {
c.fetch_add(1, Ordering::SeqCst);
Ok(CachedToken::new(42, Utc::now() + Duration::hours(1)))
}
})
.await
.unwrap();
assert_eq!(v, 42);
let c = calls.clone();
let v = cache
.get_or_refresh(|| {
let c = c.clone();
async move {
c.fetch_add(1, Ordering::SeqCst);
Ok(CachedToken::new(99, Utc::now() + Duration::hours(1)))
}
})
.await
.unwrap();
assert_eq!(v, 42, "second call should return the cached value");
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn refreshes_after_expiry() {
let cache: ExpiryTokenCache<i32, TestError> = ExpiryTokenCache::new();
cache
.get_or_refresh(|| async { Ok(CachedToken::new(1, Utc::now() - Duration::seconds(1))) })
.await
.unwrap();
let v = cache
.get_or_refresh(|| async { Ok(CachedToken::new(2, Utc::now() + Duration::hours(1))) })
.await
.unwrap();
assert_eq!(v, 2);
}
#[tokio::test]
async fn invalidate_forces_refetch() {
let cache: ExpiryTokenCache<i32, TestError> = ExpiryTokenCache::new();
cache
.get_or_refresh(|| async { Ok(CachedToken::new(1, Utc::now() + Duration::hours(1))) })
.await
.unwrap();
cache.invalidate().await;
let v = cache
.get_or_refresh(|| async { Ok(CachedToken::new(2, Utc::now() + Duration::hours(1))) })
.await
.unwrap();
assert_eq!(v, 2);
}
#[tokio::test]
async fn concurrent_callers_single_flight() {
let cache: Arc<ExpiryTokenCache<i32, TestError>> = Arc::new(ExpiryTokenCache::new());
let calls = Arc::new(AtomicUsize::new(0));
let mut handles = vec![];
for _ in 0..16 {
let cache = cache.clone();
let calls = calls.clone();
handles.push(tokio::spawn(async move {
cache
.get_or_refresh(|| {
let calls = calls.clone();
async move {
calls.fetch_add(1, Ordering::SeqCst);
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
Ok(CachedToken::new(7, Utc::now() + Duration::hours(1)))
}
})
.await
}));
}
for handle in handles {
assert_eq!(handle.await.unwrap().unwrap(), 7);
}
assert_eq!(
calls.load(Ordering::SeqCst),
1,
"only one fetch should have run for all concurrent callers"
);
}
#[tokio::test]
async fn error_does_not_poison_the_cache() {
let cache: ExpiryTokenCache<i32, TestError> = ExpiryTokenCache::new();
let err = cache
.get_or_refresh(|| async { Err::<CachedToken<i32>, _>(TestError("boom".into())) })
.await
.unwrap_err();
assert_eq!(err, TestError("boom".into()));
let v = cache
.get_or_refresh(|| async { Ok(CachedToken::new(5, Utc::now() + Duration::hours(1))) })
.await
.unwrap();
assert_eq!(v, 5);
}
#[tokio::test]
async fn get_existing_returns_none_when_empty() {
let cache: ExpiryTokenCache<i32, TestError> = ExpiryTokenCache::new();
assert_eq!(cache.get_existing().await, None);
}
#[tokio::test]
async fn get_existing_returns_cached_value() {
let cache: ExpiryTokenCache<i32, TestError> = ExpiryTokenCache::new();
cache
.get_or_refresh(|| async { Ok(CachedToken::new(3, Utc::now() + Duration::hours(1))) })
.await
.unwrap();
assert_eq!(cache.get_existing().await, Some(3));
}
}