//! A distributed (L2) cache backed by Redis / Valkey (feature `l2-redis`). //! //! Wraps a `redis` async connection (multiplexed). Values are stored as raw //! Redis strings with an optional TTL (`SETEX` when a TTL is given). The //! caller provides the connection; this type only issues cache commands. use std::time::Duration; use async_trait::async_trait; use redis::aio::MultiplexedConnection; use redis::{AsyncCommands, RedisError}; use crate::traits::{Cache, CacheError}; /// Map a Redis error onto a [`CacheError`]. fn map_err(e: RedisError) -> CacheError { CacheError::Io(e.to_string()) } /// An L2 cache backed by a `redis` [`MultiplexedConnection`]. /// /// The connection is supplied by the caller; it is cheaply cloned (the /// multiplexed connection is `Arc`-backed internally), so one pool can drive /// both cache operations and other Redis usage. #[derive(Clone)] pub struct RedisCache { conn: MultiplexedConnection, /// Optional namespace prefix prepended to every key. prefix: String, } impl RedisCache { /// Wraps an existing connection. pub fn new(conn: MultiplexedConnection) -> Self { Self::with_prefix(conn, String::new()) } /// Wraps a connection and adds a namespace prefix to every key. pub fn with_prefix(conn: MultiplexedConnection, prefix: String) -> Self { Self { conn, prefix } } fn key(&self, key: &str) -> String { if self.prefix.is_empty() { key.to_string() } else { format!("{}{}", self.prefix, key) } } } #[async_trait] impl Cache for RedisCache { async fn get(&self, key: &str) -> Result>, CacheError> { // `get::<_, Option>>` returns `None` for a missing key. let mut c = self.conn.clone(); let k = self.key(key); let result: Result>, RedisError> = c.get(&k).await; result.map_err(map_err) } async fn set( &self, key: &str, value: Vec, ttl: Option, ) -> Result<(), CacheError> { let mut c = self.conn.clone(); let k = self.key(key); match ttl { Some(ttl) => { // Use millisecond precision (PSETEX) so sub-second TTLs are // honored faithfully. Previously `set_ex(seconds.max(1))` // rounded anything < 1s up to 1s, silently changing expiry // semantics for short-lived cache entries. let ms = ttl.as_millis(); if ms == 0 { return Err(CacheError::Key( "ttl of 0ms not allowed — pass None to store permanently".into(), )); } let ms = ms as u64; let result: Result<(), RedisError> = c.pset_ex(&k, value, ms).await; result.map_err(map_err) } None => { let result: Result<(), RedisError> = c.set(&k, value).await; result.map_err(map_err) } } } async fn invalidate(&self, key: &str) -> Result<(), CacheError> { let mut c = self.conn.clone(); let k = self.key(key); let result: Result = c.del(&k).await; result.map(|_| ()).map_err(map_err) } async fn clear(&self) -> Result<(), CacheError> { // Deliberately does nothing: a blind `FLUSHDB`/`FLUSHALL` on a shared // Redis instance would destroy keys owned by other consumers. // Consumers that need a true wipe must either (a) use a dedicated Redis // DB / namespace prefix they own exclusively, or (b) call // `invalidate` per-key for the keys they manage. // // See: https://redis.io/commands/flushdb/ (no key-scoping) Ok(()) } } #[cfg(test)] mod tests { use super::*; /// Integration test requiring a live Redis at `REDIS_URL` /// (e.g. `redis://127.0.0.1:6379`). Run with: /// `REDIS_URL=redis://127.0.0.1:6379 cargo test -p mytheclipse-cache --features l2-redis -- --ignored` . #[tokio::test] #[ignore = "requires a live Redis instance (REDIS_URL)"] async fn set_get_roundtrip_live() { let url = std::env::var("REDIS_URL").expect("set REDIS_URL"); let client = redis::Client::open(url).expect("valid redis url"); let conn = client .get_multiplexed_tokio_connection() .await .expect("connect"); let cache = RedisCache::with_prefix(conn, "mytheclipse_cache_test:".to_string()); cache .set("k", b"v".to_vec(), Some(Duration::from_secs(3600))) .await .unwrap(); assert_eq!(cache.get("k").await.unwrap(), Some(b"v".to_vec())); cache.invalidate("k").await.unwrap(); assert_eq!(cache.get("k").await.unwrap(), None); } }