use redis::Commands;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct RedisCacheConfig {
pub enabled: bool,
pub url: String,
pub max_memory_mb: usize,
pub default_ttl_secs: u64,
pub key_prefix: String,
}
impl Default for RedisCacheConfig {
fn default() -> Self {
Self {
enabled: true,
url: "redis://127.0.0.1:6379".into(),
max_memory_mb: 2048, default_ttl_secs: 3600,
key_prefix: "portail:app:".into(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct CacheEntry<T: Serialize> {
pub key: String,
pub value: T,
pub created_at: u64,
pub ttl_secs: u64,
pub hits: u64,
}
pub struct RedisCache {
config: RedisCacheConfig,
fallback: std::sync::RwLock<rustc_hash::FxHashMap<String, (String, u64)>>,
}
impl RedisCache {
pub fn new(config: RedisCacheConfig) -> Self {
Self {
config,
fallback: std::sync::RwLock::new(rustc_hash::FxHashMap::default()),
}
}
pub fn full_key(&self, key: &str) -> String {
format!("{}{}", self.config.key_prefix, key)
}
pub async fn get(&self, key: &str) -> Option<String> {
let full_key = self.full_key(key);
if let Ok(client) = redis::Client::open(self.config.url.as_str()) {
if let Ok(mut conn) = client.get_connection() {
if let Ok(value) = conn.get::<_, String>(&full_key) {
return Some(value);
}
}
}
let fallback = self.fallback.read().unwrap();
if let Some((value, expires_at)) = fallback.get(&full_key) {
if now_secs() < *expires_at {
return Some(value.clone());
}
}
None
}
pub async fn set(&self, key: &str, value: &str, ttl_secs: Option<u64>) -> bool {
let full_key = self.full_key(key);
let ttl = ttl_secs.unwrap_or(self.config.default_ttl_secs);
if let Ok(client) = redis::Client::open(self.config.url.as_str()) {
if let Ok(mut conn) = client.get_connection() {
let result = conn.set_ex::<_, _, ()>(&full_key, value, ttl);
if result.is_ok() {
return true;
}
}
}
let mut fallback = self.fallback.write().unwrap();
fallback.insert(full_key, (value.to_string(), now_secs() + ttl));
true
}
pub async fn delete(&self, key: &str) -> bool {
let full_key = self.full_key(key);
if let Ok(client) = redis::Client::open(self.config.url.as_str()) {
if let Ok(mut conn) = client.get_connection() {
let result = conn.del::<_, ()>(&full_key);
if result.is_ok() {
return true;
}
}
}
let mut fallback = self.fallback.write().unwrap();
fallback.remove(&full_key);
true
}
pub async fn exists(&self, key: &str) -> bool {
self.get(key).await.is_some()
}
pub async fn stats(&self) -> CacheStats {
if let Ok(client) = redis::Client::open(self.config.url.as_str()) {
if let Ok(mut conn) = client.get_connection() {
if let Ok(info) = redis::cmd("INFO").arg("memory").query::<String>(&mut conn) {
let used_memory = parse_info_value(&info, "used_memory").unwrap_or(0);
let max_memory = parse_info_value(&info, "maxmemory")
.unwrap_or(self.config.max_memory_mb as u64 * 1024 * 1024);
let keys = redis::cmd("DBSIZE").query::<u64>(&mut conn).unwrap_or(0);
return CacheStats {
connected: true,
used_memory_bytes: used_memory,
max_memory_bytes: max_memory,
keys,
hits: parse_info_value(&info, "keyspace_hits").unwrap_or(0),
misses: parse_info_value(&info, "keyspace_misses").unwrap_or(0),
};
}
}
}
let fallback = self.fallback.read().unwrap();
CacheStats {
connected: false,
used_memory_bytes: 0,
max_memory_bytes: self.config.max_memory_mb as u64 * 1024 * 1024,
keys: fallback.len() as u64,
hits: 0,
misses: 0,
}
}
pub async fn flush(&self) -> bool {
if let Ok(client) = redis::Client::open(self.config.url.as_str()) {
if let Ok(mut conn) = client.get_connection() {
let result = redis::cmd("FLUSHDB").query::<()>(&mut conn);
if result.is_ok() {
return true;
}
}
}
let mut fallback = self.fallback.write().unwrap();
fallback.clear();
true
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct CacheStats {
pub connected: bool,
pub used_memory_bytes: u64,
pub max_memory_bytes: u64,
pub keys: u64,
pub hits: u64,
pub misses: u64,
}
fn now_secs() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
fn parse_info_value(info: &str, key: &str) -> Option<u64> {
for line in info.lines() {
if let Some(value) = line.strip_prefix(&format!("{}:", key)) {
return value.trim().parse().ok();
}
}
None
}
pub async fn handle_cache_stats(
axum::extract::State(state): axum::extract::State<Arc<crate::AppState>>,
) -> axum::Json<CacheStats> {
axum::Json(state.redis_cache.stats().await)
}
pub async fn handle_cache_get(
axum::extract::State(state): axum::extract::State<Arc<crate::AppState>>,
axum::extract::Path(key): axum::extract::Path<String>,
) -> impl axum::response::IntoResponse {
match state.redis_cache.get(&key).await {
Some(value) => (axum::http::StatusCode::OK, value),
None => (axum::http::StatusCode::NOT_FOUND, "not found".to_string()),
}
}
pub async fn handle_cache_set(
axum::extract::State(state): axum::extract::State<Arc<crate::AppState>>,
axum::Json(req): axum::Json<serde_json::Value>,
) -> impl axum::response::IntoResponse {
let key = match req["key"].as_str() {
Some(k) => k,
None => return (axum::http::StatusCode::BAD_REQUEST, "missing key"),
};
let value = match req["value"].as_str() {
Some(v) => v,
None => return (axum::http::StatusCode::BAD_REQUEST, "missing value"),
};
let ttl = req["ttl"].as_u64();
if state.redis_cache.set(key, value, ttl).await {
(axum::http::StatusCode::OK, "ok")
} else {
(axum::http::StatusCode::INTERNAL_SERVER_ERROR, "failed")
}
}
pub async fn handle_cache_flush(
axum::extract::State(state): axum::extract::State<Arc<crate::AppState>>,
) -> impl axum::response::IntoResponse {
if state.redis_cache.flush().await {
(axum::http::StatusCode::OK, "flushed")
} else {
(axum::http::StatusCode::INTERNAL_SERVER_ERROR, "failed")
}
}
pub fn router() -> axum::Router<Arc<crate::AppState>> {
axum::Router::new()
.route("/cache/stats", axum::routing::get(handle_cache_stats))
.route("/cache/{key}", axum::routing::get(handle_cache_get))
.route("/cache", axum::routing::post(handle_cache_set))
.route("/cache/flush", axum::routing::post(handle_cache_flush))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn config_defaults() {
let config = RedisCacheConfig::default();
assert!(config.enabled);
assert_eq!(config.max_memory_mb, 2048);
assert_eq!(config.default_ttl_secs, 3600);
assert_eq!(config.key_prefix, "portail:app:");
}
#[test]
fn full_key_generation() {
let cache = RedisCache::new(RedisCacheConfig::default());
assert_eq!(cache.full_key("test"), "portail:app:test");
assert_eq!(cache.full_key("llm:gpt4"), "portail:app:llm:gpt4");
}
#[tokio::test]
async fn fallback_cache_operations() {
let cache = RedisCache::new(RedisCacheConfig {
url: "redis://invalid:6379".into(), ..Default::default()
});
cache.set("test_key", "test_value", Some(60)).await;
let value = cache.get("test_key").await;
assert_eq!(value, Some("test_value".to_string()));
cache.delete("test_key").await;
assert!(cache.get("test_key").await.is_none());
}
#[test]
fn parse_info() {
let info = "used_memory:12345\nmaxmemory:2048000\n";
assert_eq!(parse_info_value(info, "used_memory"), Some(12345));
assert_eq!(parse_info_value(info, "maxmemory"), Some(2048000));
assert_eq!(parse_info_value(info, "nonexistent"), None);
}
}