use crate::result::{ResultStore, TaskResultValue};
use crate::state::TaskState;
use crate::{Result, TaskId};
use std::collections::HashMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant};
use tokio::sync::Mutex;
const NIL: usize = usize::MAX;
#[derive(Debug, Clone)]
struct CachedResult {
value: Option<TaskResultValue>,
inserted_at: Instant,
}
#[derive(Debug)]
struct LruNode {
key: TaskId,
value: CachedResult,
prev: usize,
next: usize,
}
#[derive(Debug)]
struct LruCache {
capacity: usize,
ttl: Option<Duration>,
nodes: Vec<LruNode>,
index: HashMap<TaskId, usize>,
head: usize,
tail: usize,
free: Vec<usize>,
}
impl LruCache {
fn new(capacity: usize, ttl: Option<Duration>) -> Self {
let capacity = capacity.max(1);
Self {
capacity,
ttl,
nodes: Vec::with_capacity(capacity),
index: HashMap::with_capacity(capacity),
head: NIL,
tail: NIL,
free: Vec::new(),
}
}
fn len(&self) -> usize {
self.index.len()
}
fn unlink(&mut self, idx: usize) {
let (prev, next) = {
let node = &self.nodes[idx];
(node.prev, node.next)
};
if prev != NIL {
self.nodes[prev].next = next;
} else {
self.head = next;
}
if next != NIL {
self.nodes[next].prev = prev;
} else {
self.tail = prev;
}
self.nodes[idx].prev = NIL;
self.nodes[idx].next = NIL;
}
fn push_front(&mut self, idx: usize) {
let old_head = self.head;
self.nodes[idx].prev = NIL;
self.nodes[idx].next = old_head;
if old_head != NIL {
self.nodes[old_head].prev = idx;
}
self.head = idx;
if self.tail == NIL {
self.tail = idx;
}
}
fn touch(&mut self, idx: usize) {
if self.head == idx {
return;
}
self.unlink(idx);
self.push_front(idx);
}
fn evict_lru(&mut self) {
let lru = self.tail;
if lru == NIL {
return;
}
self.unlink(lru);
let key = self.nodes[lru].key;
self.index.remove(&key);
self.free.push(lru);
}
fn is_expired(&self, idx: usize) -> bool {
match self.ttl {
Some(ttl) => self.nodes[idx].value.inserted_at.elapsed() >= ttl,
None => false,
}
}
fn get(&mut self, key: &TaskId) -> Option<CachedResult> {
let idx = *self.index.get(key)?;
if self.is_expired(idx) {
self.unlink(idx);
self.index.remove(key);
self.free.push(idx);
return None;
}
self.touch(idx);
Some(self.nodes[idx].value.clone())
}
fn put(&mut self, key: TaskId, value: CachedResult) {
if let Some(&idx) = self.index.get(&key) {
self.nodes[idx].value = value;
self.touch(idx);
return;
}
if self.len() >= self.capacity {
self.evict_lru();
}
let idx = if let Some(reused) = self.free.pop() {
self.nodes[reused] = LruNode {
key,
value,
prev: NIL,
next: NIL,
};
reused
} else {
self.nodes.push(LruNode {
key,
value,
prev: NIL,
next: NIL,
});
self.nodes.len() - 1
};
self.index.insert(key, idx);
self.push_front(idx);
}
fn remove(&mut self, key: &TaskId) {
if let Some(idx) = self.index.remove(key) {
self.unlink(idx);
self.free.push(idx);
}
}
fn clear(&mut self) {
self.nodes.clear();
self.index.clear();
self.free.clear();
self.head = NIL;
self.tail = NIL;
}
}
#[derive(Debug)]
pub struct CachingResultBackend<B: ResultStore> {
inner: B,
cache: Mutex<LruCache>,
hits: AtomicU64,
misses: AtomicU64,
}
impl<B: ResultStore> CachingResultBackend<B> {
#[must_use]
pub fn new(inner: B, capacity: usize) -> Self {
Self::build(inner, capacity, None)
}
#[must_use]
pub fn with_ttl(inner: B, capacity: usize, ttl: Duration) -> Self {
Self::build(inner, capacity, Some(ttl))
}
fn build(inner: B, capacity: usize, ttl: Option<Duration>) -> Self {
Self {
inner,
cache: Mutex::new(LruCache::new(capacity, ttl)),
hits: AtomicU64::new(0),
misses: AtomicU64::new(0),
}
}
pub const fn inner(&self) -> &B {
&self.inner
}
#[allow(clippy::missing_const_for_fn)]
pub fn into_inner(self) -> B {
self.inner
}
#[must_use]
pub fn cache_hits(&self) -> u64 {
self.hits.load(Ordering::Relaxed)
}
#[must_use]
pub fn cache_misses(&self) -> u64 {
self.misses.load(Ordering::Relaxed)
}
pub async fn cache_len(&self) -> usize {
self.cache.lock().await.len()
}
pub async fn capacity(&self) -> usize {
self.cache.lock().await.capacity
}
pub async fn invalidate(&self, task_id: TaskId) {
self.cache.lock().await.remove(&task_id);
}
pub async fn clear_cache(&self) {
self.cache.lock().await.clear();
}
fn record_hit(&self) {
self.hits.fetch_add(1, Ordering::Relaxed);
}
fn record_miss(&self) {
self.misses.fetch_add(1, Ordering::Relaxed);
}
}
#[async_trait::async_trait]
impl<B: ResultStore> ResultStore for CachingResultBackend<B> {
async fn store_result(&self, task_id: TaskId, result: TaskResultValue) -> Result<()> {
self.inner.store_result(task_id, result.clone()).await?;
self.cache.lock().await.put(
task_id,
CachedResult {
value: Some(result),
inserted_at: Instant::now(),
},
);
Ok(())
}
async fn get_result(&self, task_id: TaskId) -> Result<Option<TaskResultValue>> {
if let Some(cached) = self.cache.lock().await.get(&task_id) {
self.record_hit();
return Ok(cached.value);
}
self.record_miss();
let value = self.inner.get_result(task_id).await?;
self.cache.lock().await.put(
task_id,
CachedResult {
value: value.clone(),
inserted_at: Instant::now(),
},
);
Ok(value)
}
async fn get_state(&self, task_id: TaskId) -> Result<TaskState> {
if let Some(cached) = self.cache.lock().await.get(&task_id) {
self.record_hit();
return Ok(state_for_cached(cached.value.as_ref()));
}
self.record_miss();
self.inner.get_state(task_id).await
}
async fn forget(&self, task_id: TaskId) -> Result<()> {
self.cache.lock().await.remove(&task_id);
self.inner.forget(task_id).await
}
async fn has_result(&self, task_id: TaskId) -> Result<bool> {
if let Some(cached) = self.cache.lock().await.get(&task_id) {
self.record_hit();
return Ok(cached.value.is_some());
}
self.record_miss();
let value = self.inner.get_result(task_id).await?;
let present = value.is_some();
self.cache.lock().await.put(
task_id,
CachedResult {
value,
inserted_at: Instant::now(),
},
);
Ok(present)
}
async fn store_tombstone(&self, tombstone: crate::ResultTombstone) -> Result<()> {
self.inner.store_tombstone(tombstone).await
}
async fn get_tombstone(&self, task_id: TaskId) -> Result<Option<crate::ResultTombstone>> {
self.inner.get_tombstone(task_id).await
}
async fn apply_result_ttl(
&self,
task_id: TaskId,
config: &crate::result_ttl::ResultTtlConfig,
task_name: &str,
) -> Result<bool> {
self.inner
.apply_result_ttl(task_id, config, task_name)
.await
}
}
fn state_for_cached(value: Option<&TaskResultValue>) -> TaskState {
match value {
None | Some(TaskResultValue::Pending) => TaskState::Pending,
Some(TaskResultValue::Received) => TaskState::Received,
Some(TaskResultValue::Started) => TaskState::Running,
Some(TaskResultValue::Success(v)) => {
TaskState::Succeeded(serde_json::to_vec(v).unwrap_or_default())
}
Some(TaskResultValue::Failure { error, .. }) => TaskState::Failed(error.clone()),
Some(TaskResultValue::Revoked) => TaskState::Revoked,
Some(TaskResultValue::Retry { attempt, .. }) => TaskState::Retrying(*attempt),
Some(TaskResultValue::Rejected { .. }) => TaskState::Rejected,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::in_memory_broker::InMemoryResultBackend;
use async_trait::async_trait;
use serde_json::json;
use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering};
use std::sync::Arc;
use uuid::Uuid;
#[derive(Debug, Default)]
struct CountingBackend {
inner: InMemoryResultBackend,
get_calls: AtomicUsize,
}
impl CountingBackend {
fn get_calls(&self) -> usize {
self.get_calls.load(AtomicOrdering::Relaxed)
}
}
#[async_trait]
impl ResultStore for CountingBackend {
async fn store_result(&self, task_id: TaskId, result: TaskResultValue) -> Result<()> {
self.inner.store_result(task_id, result).await
}
async fn get_result(&self, task_id: TaskId) -> Result<Option<TaskResultValue>> {
self.get_calls.fetch_add(1, AtomicOrdering::Relaxed);
self.inner.get_result(task_id).await
}
async fn get_state(&self, task_id: TaskId) -> Result<TaskState> {
self.inner.get_state(task_id).await
}
async fn forget(&self, task_id: TaskId) -> Result<()> {
self.inner.forget(task_id).await
}
async fn has_result(&self, task_id: TaskId) -> Result<bool> {
self.inner.has_result(task_id).await
}
}
#[tokio::test]
async fn hit_skips_inner_backend() {
let counting = CountingBackend::default();
let cached = CachingResultBackend::new(counting, 16);
let id = Uuid::new_v4();
assert!(cached.get_result(id).await.unwrap().is_none());
assert_eq!(cached.cache_misses(), 1);
assert_eq!(cached.inner().get_calls(), 1);
assert!(cached.get_result(id).await.unwrap().is_none());
assert_eq!(cached.cache_hits(), 1);
assert_eq!(cached.inner().get_calls(), 1);
}
#[tokio::test]
async fn store_then_get_is_hit() {
let cached = CachingResultBackend::new(InMemoryResultBackend::new(), 16);
let id = Uuid::new_v4();
cached
.store_result(id, TaskResultValue::Success(json!(123)))
.await
.unwrap();
let v = cached.get_result(id).await.unwrap().unwrap();
assert!(v.is_successful());
assert_eq!(cached.cache_hits(), 1);
assert_eq!(cached.cache_misses(), 0);
}
#[tokio::test]
async fn eviction_at_capacity() {
let cached = CachingResultBackend::new(InMemoryResultBackend::new(), 2);
let a = Uuid::new_v4();
let b = Uuid::new_v4();
let c = Uuid::new_v4();
for id in [a, b] {
cached
.store_result(id, TaskResultValue::Success(json!(1)))
.await
.unwrap();
}
assert_eq!(cached.cache_len().await, 2);
let _ = cached.get_result(a).await.unwrap();
cached
.store_result(c, TaskResultValue::Success(json!(1)))
.await
.unwrap();
assert_eq!(cached.cache_len().await, 2);
let hits_before = cached.cache_hits();
let _ = cached.get_result(a).await.unwrap();
let _ = cached.get_result(c).await.unwrap();
assert_eq!(cached.cache_hits(), hits_before + 2);
let misses_before = cached.cache_misses();
let _ = cached.get_result(b).await.unwrap();
assert_eq!(cached.cache_misses(), misses_before + 1);
}
#[tokio::test]
async fn ttl_expiry_is_a_miss() {
let counting = CountingBackend::default();
let cached = CachingResultBackend::with_ttl(counting, 16, Duration::from_millis(30));
let id = Uuid::new_v4();
cached
.store_result(id, TaskResultValue::Success(json!(1)))
.await
.unwrap();
assert!(cached.get_result(id).await.unwrap().is_some());
assert_eq!(cached.inner().get_calls(), 0);
tokio::time::sleep(Duration::from_millis(50)).await;
assert!(cached.get_result(id).await.unwrap().is_some());
assert_eq!(cached.inner().get_calls(), 1);
assert!(cached.cache_misses() >= 1);
}
#[tokio::test]
async fn forget_invalidates_cache_and_inner() {
let cached = CachingResultBackend::new(InMemoryResultBackend::new(), 16);
let id = Uuid::new_v4();
cached
.store_result(id, TaskResultValue::Success(json!(1)))
.await
.unwrap();
assert!(cached.has_result(id).await.unwrap());
cached.forget(id).await.unwrap();
assert!(!cached.inner().has_result(id).await.unwrap());
let misses_before = cached.cache_misses();
assert!(cached.get_result(id).await.unwrap().is_none());
assert_eq!(cached.cache_misses(), misses_before + 1);
}
#[tokio::test]
async fn overwrite_refreshes_cache() {
let cached = CachingResultBackend::new(InMemoryResultBackend::new(), 16);
let id = Uuid::new_v4();
cached
.store_result(id, TaskResultValue::Success(json!(1)))
.await
.unwrap();
cached
.store_result(id, TaskResultValue::Success(json!(2)))
.await
.unwrap();
let v = cached.get_result(id).await.unwrap().unwrap();
assert_eq!(v.success_value().cloned(), Some(json!(2)));
}
#[tokio::test]
async fn invalidate_removes_single_entry() {
let cached = CachingResultBackend::new(InMemoryResultBackend::new(), 16);
let id = Uuid::new_v4();
cached
.store_result(id, TaskResultValue::Success(json!(1)))
.await
.unwrap();
assert_eq!(cached.cache_len().await, 1);
cached.invalidate(id).await;
assert_eq!(cached.cache_len().await, 0);
assert!(cached.inner().has_result(id).await.unwrap());
}
#[tokio::test]
async fn get_state_served_from_cache() {
let cached = CachingResultBackend::new(InMemoryResultBackend::new(), 16);
let id = Uuid::new_v4();
cached
.store_result(
id,
TaskResultValue::Failure {
error: "x".to_string(),
traceback: None,
},
)
.await
.unwrap();
let hits_before = cached.cache_hits();
assert_eq!(
cached.get_state(id).await.unwrap(),
TaskState::Failed("x".to_string())
);
assert_eq!(cached.cache_hits(), hits_before + 1);
}
#[tokio::test]
async fn capacity_clamped_to_one() {
let cached = CachingResultBackend::new(InMemoryResultBackend::new(), 0);
assert_eq!(cached.capacity().await, 1);
let a = Uuid::new_v4();
let b = Uuid::new_v4();
cached
.store_result(a, TaskResultValue::Success(json!(1)))
.await
.unwrap();
cached
.store_result(b, TaskResultValue::Success(json!(1)))
.await
.unwrap();
assert_eq!(cached.cache_len().await, 1);
}
#[tokio::test]
async fn wraps_in_memory_broker_backend_concurrently() {
let cached = Arc::new(CachingResultBackend::new(InMemoryResultBackend::new(), 64));
let id = Uuid::new_v4();
cached
.store_result(id, TaskResultValue::Success(json!(1)))
.await
.unwrap();
let mut handles = Vec::new();
for _ in 0..8 {
let c = cached.clone();
handles.push(tokio::spawn(async move {
c.get_result(id).await.unwrap().is_some()
}));
}
for h in handles {
assert!(h.await.unwrap());
}
}
#[tokio::test]
async fn into_inner_returns_backend() {
let cached = CachingResultBackend::new(InMemoryResultBackend::new(), 4);
let id = Uuid::new_v4();
cached
.store_result(id, TaskResultValue::Success(json!(9)))
.await
.unwrap();
let inner = cached.into_inner();
assert!(inner.has_result(id).await.unwrap());
}
}