use crate::api::ExchangeRateResponse;
use chrono::{DateTime, Utc};
use moka::future::Cache;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use std::time::Duration;
#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)]
pub struct CacheKey {
pub base_currency: String,
pub date: Option<String>,
}
impl CacheKey {
pub fn latest(base_currency: &str) -> Self {
Self {
base_currency: base_currency.to_uppercase(),
date: None,
}
}
pub fn historical(base_currency: &str, date: &str) -> Self {
Self {
base_currency: base_currency.to_uppercase(),
date: Some(date.to_string()),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CachedRates {
pub data: ExchangeRateResponse,
pub cached_at: DateTime<Utc>,
pub expires_at: DateTime<Utc>,
pub access_count: u64,
}
impl CachedRates {
pub fn new(data: ExchangeRateResponse, ttl_seconds: u64) -> Self {
let now = Utc::now();
Self {
data,
cached_at: now,
expires_at: now + chrono::Duration::seconds(ttl_seconds as i64),
access_count: 0,
}
}
pub fn is_valid(&self) -> bool {
Utc::now() < self.expires_at
}
pub fn age_seconds(&self) -> i64 {
(Utc::now() - self.cached_at).num_seconds()
}
pub fn record_access(&mut self) {
self.access_count += 1;
}
}
#[derive(Debug, Clone)]
pub struct CacheConfig {
pub max_capacity: u64,
pub latest_ttl: u64,
pub historical_ttl: u64,
pub enable_stats: bool,
}
impl Default for CacheConfig {
fn default() -> Self {
Self {
max_capacity: 1000,
latest_ttl: 300, historical_ttl: 3600, enable_stats: true,
}
}
}
#[derive(Debug, Clone)]
pub struct ExchangeRateCache {
cache: Cache<CacheKey, Arc<CachedRates>>,
config: CacheConfig,
stats: Arc<dashmap::DashMap<String, u64>>,
}
impl ExchangeRateCache {
pub fn new() -> Self {
Self::with_config(CacheConfig::default())
}
pub fn with_config(config: CacheConfig) -> Self {
let cache = Cache::builder()
.max_capacity(config.max_capacity)
.time_to_live(Duration::from_secs(
config.latest_ttl.max(config.historical_ttl),
))
.build();
Self {
cache,
config,
stats: Arc::new(dashmap::DashMap::new()),
}
}
pub async fn get(&self, key: &CacheKey) -> Option<ExchangeRateResponse> {
self.increment_stat("requests_total");
if let Some(cached) = self.cache.get(key).await {
if cached.is_valid() {
self.increment_stat("hits");
let mut updated = (*cached).clone();
updated.record_access();
self.cache.insert(key.clone(), Arc::new(updated)).await;
return Some(cached.data.clone());
} else {
self.cache.remove(key).await;
self.increment_stat("expired");
}
}
self.increment_stat("misses");
None
}
pub async fn put(&self, key: CacheKey, data: ExchangeRateResponse) {
let ttl = if key.date.is_some() {
self.config.historical_ttl
} else {
self.config.latest_ttl
};
let cached_rates = Arc::new(CachedRates::new(data, ttl));
self.cache.insert(key, cached_rates).await;
self.increment_stat("insertions");
}
pub fn get_stats(&self) -> CacheStats {
let total_requests = self.get_stat("requests_total");
let hits = self.get_stat("hits");
let misses = self.get_stat("misses");
let expired = self.get_stat("expired");
let hit_rate = if total_requests > 0 {
(hits as f64 / total_requests as f64) * 100.0
} else {
0.0
};
CacheStats {
total_requests,
hits,
misses,
expired,
hit_rate,
cached_entries: self.cache.entry_count(),
weighted_size: self.cache.weighted_size(),
}
}
pub async fn clear(&self) {
self.cache.invalidate_all();
if self.config.enable_stats {
self.stats.clear();
}
}
pub async fn cleanup_expired(&self) {
self.cache.run_pending_tasks().await;
}
pub async fn get_keys(&self) -> Vec<CacheKey> {
vec![]
}
fn increment_stat(&self, key: &str) {
if self.config.enable_stats {
self.stats
.entry(key.to_string())
.and_modify(|v| *v += 1)
.or_insert(1);
}
}
fn get_stat(&self, key: &str) -> u64 {
self.stats.get(key).map(|v| *v).unwrap_or(0)
}
}
impl Default for ExchangeRateCache {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CacheStats {
pub total_requests: u64,
pub hits: u64,
pub misses: u64,
pub expired: u64,
pub hit_rate: f64,
pub cached_entries: u64,
pub weighted_size: u64,
}
impl CacheStats {
pub fn format_summary(&self) -> String {
format!(
"Cache Stats: {:.1}% hit rate ({}/{} requests), {} entries, {} expired",
self.hit_rate, self.hits, self.total_requests, self.cached_entries, self.expired
)
}
}