use crate::backend::CacheBackend;
use crate::entity::CacheEntity;
use crate::error::{Error, Result};
use crate::feed::CacheFeed;
use crate::key::CacheKeyBuilder;
use crate::observability::{CacheMetrics, NoOpMetrics, TtlPolicy};
use crate::repository::DataRepository;
use crate::strategy::CacheStrategy;
use std::str::FromStr;
use std::time::{Duration, Instant};
#[derive(Clone, Debug, Default)]
pub struct OperationConfig {
pub ttl_override: Option<Duration>,
pub retry_count: u32,
}
impl OperationConfig {
pub fn with_ttl(mut self, ttl: Duration) -> Self {
self.ttl_override = Some(ttl);
self
}
pub fn with_retry(mut self, count: u32) -> Self {
self.retry_count = count;
self
}
}
pub struct CacheExpander<B: CacheBackend> {
backend: B,
metrics: Box<dyn CacheMetrics>,
pub(crate) ttl_policy: TtlPolicy,
}
impl<B: CacheBackend> CacheExpander<B> {
pub fn new(backend: B) -> Self {
CacheExpander {
backend,
metrics: Box::new(NoOpMetrics),
ttl_policy: TtlPolicy::default(),
}
}
pub fn with_metrics(mut self, metrics: Box<dyn CacheMetrics>) -> Self {
self.metrics = metrics;
self
}
pub fn with_ttl_policy(mut self, policy: TtlPolicy) -> Self {
self.ttl_policy = policy;
self
}
pub async fn with<T, F, R>(
&self,
feeder: &mut F,
repository: &R,
strategy: CacheStrategy,
) -> Result<()>
where
T: CacheEntity,
F: CacheFeed<T>,
R: DataRepository<T>,
T::Key: FromStr,
{
self.with_config::<T, F, R>(feeder, repository, strategy, OperationConfig::default())
.await
}
pub async fn with_config<T, F, R>(
&self,
feeder: &mut F,
repository: &R,
strategy: CacheStrategy,
config: OperationConfig,
) -> Result<()>
where
T: CacheEntity,
F: CacheFeed<T>,
R: DataRepository<T>,
T::Key: FromStr,
{
let mut attempts = 0;
let max_attempts = config.retry_count + 1;
loop {
attempts += 1;
let result = self
.execute_operation::<T, F, R>(feeder, repository, strategy.clone(), &config)
.await;
match result {
Ok(()) => return Ok(()),
Err(e) => {
if attempts >= max_attempts {
return Err(e);
}
debug!(
"Cache operation failed (attempt {}/{}), retrying...",
attempts, max_attempts
);
if config.retry_count > 0 {
let delay =
tokio::time::Duration::from_millis(100 * 2_u64.pow(attempts - 1));
tokio::time::sleep(delay).await;
}
}
}
}
}
async fn execute_operation<T, F, R>(
&self,
feeder: &mut F,
repository: &R,
strategy: CacheStrategy,
config: &OperationConfig,
) -> Result<()>
where
T: CacheEntity,
F: CacheFeed<T>,
R: DataRepository<T>,
T::Key: FromStr,
{
let timer = Instant::now();
feeder.validate()?;
let entity_id = feeder.entity_id();
let cache_key = CacheKeyBuilder::build::<T>(&entity_id);
debug!(
"» Cache operation for key: {} (strategy: {})",
cache_key, strategy
);
let result = match strategy {
CacheStrategy::Fresh => {
self.strategy_fresh::<T, R>(&cache_key, repository, config)
.await
}
CacheStrategy::Refresh => {
self.strategy_refresh::<T, R>(&cache_key, repository, config)
.await
}
CacheStrategy::Invalidate => {
self.strategy_invalidate::<T, R>(&cache_key, repository, config)
.await
}
CacheStrategy::Bypass => {
self.strategy_bypass::<T, R>(&cache_key, repository, config)
.await
}
};
match result {
Ok(Some(entity)) => {
entity.validate()?;
feeder.on_hit(&cache_key)?;
feeder.on_loaded(&entity)?;
feeder.feed(Some(entity));
self.metrics.record_hit(&cache_key, timer.elapsed());
info!("✓ Cache operation succeeded in {:?}", timer.elapsed());
}
Ok(None) => {
feeder.on_miss(&cache_key)?;
feeder.feed(None);
self.metrics.record_miss(&cache_key, timer.elapsed());
debug!("Entity not found after cache operation for {}", cache_key);
}
Err(e) => {
self.metrics.record_error(&cache_key, &e.to_string());
return Err(e);
}
}
Ok(())
}
async fn strategy_fresh<T: CacheEntity, R: DataRepository<T>>(
&self,
cache_key: &str,
_repository: &R,
_config: &OperationConfig,
) -> Result<Option<T>> {
debug!("Executing Fresh strategy for {}", cache_key);
match self.backend.get(cache_key).await? {
Some(bytes) => {
debug!("✓ Cache hit (Fresh strategy)");
T::deserialize_from_cache(&bytes).map(Some)
}
None => {
debug!("✗ Cache miss (Fresh strategy) - no fallback");
Ok(None)
}
}
}
async fn strategy_refresh<T: CacheEntity, R: DataRepository<T>>(
&self,
cache_key: &str,
repository: &R,
config: &OperationConfig,
) -> Result<Option<T>>
where
T::Key: FromStr,
{
debug!("Executing Refresh strategy for {}", cache_key);
if let Some(bytes) = self.backend.get(cache_key).await? {
debug!("✓ Cache hit (Refresh strategy)");
return T::deserialize_from_cache(&bytes).map(Some);
}
debug!("Cache miss, falling back to database");
let id = self.extract_id_from_key::<T>(cache_key)?;
match repository.fetch_by_id(&id).await? {
Some(entity) => {
let ttl = config
.ttl_override
.or_else(|| self.ttl_policy.get_ttl(T::cache_prefix()));
let bytes = entity.serialize_for_cache()?;
let _ = self.backend.set(cache_key, bytes, ttl).await;
Ok(Some(entity))
}
None => Ok(None),
}
}
async fn strategy_invalidate<T: CacheEntity, R: DataRepository<T>>(
&self,
cache_key: &str,
repository: &R,
config: &OperationConfig,
) -> Result<Option<T>>
where
T::Key: FromStr,
{
debug!("Executing Invalidate strategy for {}", cache_key);
self.backend.delete(cache_key).await?;
debug!("✓ Cache invalidated for {}", cache_key);
let id = self.extract_id_from_key::<T>(cache_key)?;
match repository.fetch_by_id(&id).await? {
Some(entity) => {
let ttl = config
.ttl_override
.or_else(|| self.ttl_policy.get_ttl(T::cache_prefix()));
let bytes = entity.serialize_for_cache()?;
let _ = self.backend.set(cache_key, bytes, ttl).await;
Ok(Some(entity))
}
None => Ok(None),
}
}
async fn strategy_bypass<T: CacheEntity, R: DataRepository<T>>(
&self,
cache_key: &str,
repository: &R,
config: &OperationConfig,
) -> Result<Option<T>>
where
T::Key: FromStr,
{
debug!("Executing Bypass strategy for {}", cache_key);
debug!("Bypassing cache entirely for {}", cache_key);
let id = self.extract_id_from_key::<T>(cache_key)?;
match repository.fetch_by_id(&id).await? {
Some(entity) => {
let ttl = config
.ttl_override
.or_else(|| self.ttl_policy.get_ttl(T::cache_prefix()));
let bytes = entity.serialize_for_cache()?;
let _ = self.backend.set(cache_key, bytes, ttl).await;
Ok(Some(entity))
}
None => Ok(None),
}
}
fn extract_id_from_key<T: CacheEntity>(&self, cache_key: &str) -> Result<T::Key>
where
T::Key: FromStr,
{
let parts: Vec<&str> = cache_key.split(':').collect();
if parts.len() > 1 {
let id_str = parts[1..].join(":");
id_str.parse().ok().ok_or_else(|| {
Error::ValidationError(format!("Failed to parse ID from cache key: {}", cache_key))
})
} else {
Err(Error::ValidationError(format!(
"Invalid cache key format: {}",
cache_key
)))
}
}
pub fn backend(&self) -> &B {
&self.backend
}
pub fn backend_mut(&mut self) -> &mut B {
&mut self.backend
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::backend::InMemoryBackend;
use crate::feed::GenericFeeder;
use crate::repository::InMemoryRepository;
use serde::{Deserialize, Serialize};
#[derive(Clone, Serialize, Deserialize)]
struct TestEntity {
id: String,
value: String,
}
impl CacheEntity for TestEntity {
type Key = String;
fn cache_key(&self) -> Self::Key {
self.id.clone()
}
fn cache_prefix() -> &'static str {
"test"
}
}
#[tokio::test]
async fn test_expander_with_fresh_strategy_hit() {
let backend = InMemoryBackend::new();
let expander = CacheExpander::new(backend.clone());
let entity = TestEntity {
id: "1".to_string(),
value: "data".to_string(),
};
let bytes = entity.serialize_for_cache().expect("Failed to serialize");
backend
.clone()
.set("test:1", bytes, None)
.await
.expect("Failed to set");
let mut feeder = GenericFeeder::new("1".to_string());
let repo = InMemoryRepository::new();
expander
.with::<TestEntity, _, _>(&mut feeder, &repo, CacheStrategy::Fresh)
.await
.expect("Failed to execute");
assert!(feeder.data.is_some());
}
#[tokio::test]
async fn test_expander_with_fresh_strategy_miss() {
let backend = InMemoryBackend::new();
let expander = CacheExpander::new(backend);
let mut feeder = GenericFeeder::new("1".to_string());
let repo = InMemoryRepository::new();
expander
.with::<TestEntity, _, _>(&mut feeder, &repo, CacheStrategy::Fresh)
.await
.expect("Failed to execute");
assert!(feeder.data.is_none());
}
#[tokio::test]
async fn test_expander_refresh_strategy_cache_hit() {
let backend = InMemoryBackend::new();
let expander = CacheExpander::new(backend.clone());
let entity = TestEntity {
id: "1".to_string(),
value: "cached_data".to_string(),
};
let bytes = entity.serialize_for_cache().expect("Failed to serialize");
backend
.clone()
.set("test:1", bytes, None)
.await
.expect("Failed to set");
let mut feeder = GenericFeeder::new("1".to_string());
let repo = InMemoryRepository::new();
expander
.with::<TestEntity, _, _>(&mut feeder, &repo, CacheStrategy::Refresh)
.await
.expect("Failed to execute");
assert!(feeder.data.is_some());
assert_eq!(feeder.data.expect("Data not found").value, "cached_data");
}
#[tokio::test]
async fn test_expander_refresh_strategy_cache_miss_db_hit() {
let backend = InMemoryBackend::new();
let expander = CacheExpander::new(backend.clone());
let mut repo = InMemoryRepository::new();
repo.insert(
"1".to_string(),
TestEntity {
id: "1".to_string(),
value: "db_data".to_string(),
},
);
let mut feeder = GenericFeeder::new("1".to_string());
expander
.with::<TestEntity, _, _>(&mut feeder, &repo, CacheStrategy::Refresh)
.await
.expect("Failed to execute");
assert!(feeder.data.is_some());
assert_eq!(feeder.data.expect("Data not found").value, "db_data");
let cached = backend
.clone()
.get("test:1")
.await
.expect("Failed to get from cache");
assert!(cached.is_some());
}
#[tokio::test]
async fn test_expander_refresh_strategy_complete_miss() {
let backend = InMemoryBackend::new();
let expander = CacheExpander::new(backend);
let mut feeder = GenericFeeder::new("nonexistent".to_string());
let repo = InMemoryRepository::new();
expander
.with::<TestEntity, _, _>(&mut feeder, &repo, CacheStrategy::Refresh)
.await
.expect("Failed to execute");
assert!(feeder.data.is_none());
}
#[tokio::test]
async fn test_expander_invalidate_strategy() {
let backend = InMemoryBackend::new();
let expander = CacheExpander::new(backend.clone());
let stale_entity = TestEntity {
id: "1".to_string(),
value: "stale_data".to_string(),
};
let bytes = stale_entity
.serialize_for_cache()
.expect("Failed to serialize");
backend
.clone()
.set("test:1", bytes, None)
.await
.expect("Failed to set");
let mut repo = InMemoryRepository::new();
repo.insert(
"1".to_string(),
TestEntity {
id: "1".to_string(),
value: "fresh_data".to_string(),
},
);
let mut feeder = GenericFeeder::new("1".to_string());
expander
.with::<TestEntity, _, _>(&mut feeder, &repo, CacheStrategy::Invalidate)
.await
.expect("Failed to execute");
assert!(feeder.data.is_some());
assert_eq!(feeder.data.expect("Data not found").value, "fresh_data");
let cached_bytes = backend
.clone()
.get("test:1")
.await
.expect("Failed to get")
.expect("Cache is empty");
let cached_entity =
TestEntity::deserialize_from_cache(&cached_bytes).expect("Failed to deserialize");
assert_eq!(cached_entity.value, "fresh_data");
}
#[tokio::test]
async fn test_expander_bypass_strategy() {
let backend = InMemoryBackend::new();
let expander = CacheExpander::new(backend.clone());
let cached_entity = TestEntity {
id: "1".to_string(),
value: "cached_data".to_string(),
};
let bytes = cached_entity
.serialize_for_cache()
.expect("Failed to serialize");
backend
.clone()
.set("test:1", bytes, None)
.await
.expect("Failed to set");
let mut repo = InMemoryRepository::new();
repo.insert(
"1".to_string(),
TestEntity {
id: "1".to_string(),
value: "db_data".to_string(),
},
);
let mut feeder = GenericFeeder::new("1".to_string());
expander
.with::<TestEntity, _, _>(&mut feeder, &repo, CacheStrategy::Bypass)
.await
.expect("Failed to execute");
assert!(feeder.data.is_some());
assert_eq!(feeder.data.expect("Data not found").value, "db_data");
}
#[tokio::test]
async fn test_expander_with_ttl_policy() {
use crate::observability::TtlPolicy;
use std::time::Duration;
let backend = InMemoryBackend::new();
let expander = CacheExpander::new(backend.clone())
.with_ttl_policy(TtlPolicy::Fixed(Duration::from_secs(300)));
let mut repo = InMemoryRepository::new();
repo.insert(
"1".to_string(),
TestEntity {
id: "1".to_string(),
value: "data".to_string(),
},
);
let mut feeder = GenericFeeder::new("1".to_string());
expander
.with::<TestEntity, _, _>(&mut feeder, &repo, CacheStrategy::Refresh)
.await
.expect("Failed to execute");
assert!(feeder.data.is_some());
}
#[tokio::test]
async fn test_expander_with_custom_metrics() {
use crate::observability::CacheMetrics;
use std::sync::{Arc, Mutex};
use std::time::Duration;
#[derive(Clone)]
struct TestMetrics {
hits: Arc<Mutex<usize>>,
misses: Arc<Mutex<usize>>,
}
impl CacheMetrics for TestMetrics {
fn record_hit(&self, _key: &str, _duration: Duration) {
*self.hits.lock().expect("Failed to lock hits") += 1;
}
fn record_miss(&self, _key: &str, _duration: Duration) {
*self.misses.lock().expect("Failed to lock misses") += 1;
}
}
let metrics = TestMetrics {
hits: Arc::new(Mutex::new(0)),
misses: Arc::new(Mutex::new(0)),
};
let backend = InMemoryBackend::new();
let expander = CacheExpander::new(backend.clone()).with_metrics(Box::new(metrics.clone()));
let mut repo = InMemoryRepository::new();
repo.insert(
"1".to_string(),
TestEntity {
id: "1".to_string(),
value: "data".to_string(),
},
);
let mut feeder = GenericFeeder::new("1".to_string());
expander
.with::<TestEntity, _, _>(&mut feeder, &repo, CacheStrategy::Refresh)
.await
.expect("Failed to execute");
assert_eq!(*metrics.hits.lock().expect("Failed to lock hits"), 1);
let mut feeder2 = GenericFeeder::new("1".to_string());
expander
.with::<TestEntity, _, _>(&mut feeder2, &repo, CacheStrategy::Refresh)
.await
.expect("Failed to execute");
assert_eq!(*metrics.hits.lock().expect("Failed to lock hits"), 2);
}
#[tokio::test]
async fn test_expander_error_on_missing_data() {
let backend = InMemoryBackend::new();
let expander = CacheExpander::new(backend);
let mut feeder = GenericFeeder::new("nonexistent".to_string());
let repo = InMemoryRepository::new();
let result = expander
.with::<TestEntity, _, _>(&mut feeder, &repo, CacheStrategy::Fresh)
.await;
assert!(result.is_ok());
assert!(feeder.data.is_none());
}
#[tokio::test]
async fn test_expander_backend_reference() {
let backend = InMemoryBackend::new();
let expander = CacheExpander::new(backend.clone());
let _backend_ref = expander.backend();
assert_eq!(backend.len().await, 0);
}
#[tokio::test]
async fn test_expander_with_config() {
let backend = InMemoryBackend::new();
let expander = CacheExpander::new(backend.clone())
.with_ttl_policy(TtlPolicy::Fixed(Duration::from_secs(60)));
let mut repo = InMemoryRepository::new();
repo.insert(
"1".to_string(),
TestEntity {
id: "1".to_string(),
value: "test_value".to_string(),
},
);
let mut feeder = GenericFeeder::new("1".to_string());
let config = OperationConfig::default()
.with_ttl(Duration::from_secs(300))
.with_retry(2);
expander
.with_config::<TestEntity, _, _>(&mut feeder, &repo, CacheStrategy::Refresh, config)
.await
.expect("Failed to execute with config");
assert!(feeder.data.is_some());
assert_eq!(feeder.data.expect("Data not found").value, "test_value");
match &expander.ttl_policy {
TtlPolicy::Fixed(duration) => assert_eq!(*duration, Duration::from_secs(60)),
_ => panic!("Expected Fixed TTL policy"),
}
}
}