Skip to main content

kaccy_db/
cache.rs

1//! Redis caching layer for high-performance data access
2//!
3//! Provides caching for:
4//! - User sessions and profiles
5//! - Token prices and metadata
6//! - User balances (hot data)
7//! - Rate limiting counters
8
9use std::sync::Arc;
10
11use redis::aio::ConnectionManager;
12use redis::{AsyncCommands, Client};
13use serde::{de::DeserializeOwned, Serialize};
14use uuid::Uuid;
15
16use crate::error::{DbError, Result};
17
18/// Cache key prefixes for namespacing
19pub mod keys {
20    /// Key prefix for user session data.
21    pub const USER_SESSION: &str = "session:";
22    /// Key prefix for user profile data.
23    pub const USER_PROFILE: &str = "user:";
24    /// Key prefix for token price data.
25    pub const TOKEN_PRICE: &str = "price:";
26    /// Key prefix for token metadata.
27    pub const TOKEN_META: &str = "token:";
28    /// Key prefix for user balance data.
29    pub const USER_BALANCE: &str = "balance:";
30    /// Key prefix for rate limiting counters.
31    pub const RATE_LIMIT: &str = "ratelimit:";
32    /// Key prefix for distributed locks.
33    pub const LOCK: &str = "lock:";
34}
35
36/// Cache configuration
37#[derive(Debug, Clone)]
38pub struct CacheConfig {
39    /// Redis connection URL
40    pub redis_url: String,
41    /// Default TTL for cached items (seconds)
42    pub default_ttl_secs: u64,
43    /// TTL for user sessions (seconds)
44    pub session_ttl_secs: u64,
45    /// TTL for price cache (seconds) - should be short
46    pub price_ttl_secs: u64,
47    /// TTL for user profiles (seconds)
48    pub profile_ttl_secs: u64,
49    /// TTL for balance cache (seconds)
50    pub balance_ttl_secs: u64,
51    /// Key prefix for this application instance
52    pub key_prefix: String,
53}
54
55impl Default for CacheConfig {
56    fn default() -> Self {
57        Self {
58            redis_url: "redis://127.0.0.1:6379".to_string(),
59            default_ttl_secs: 3600,  // 1 hour
60            session_ttl_secs: 86400, // 24 hours
61            price_ttl_secs: 5,       // 5 seconds (volatile)
62            profile_ttl_secs: 300,   // 5 minutes
63            balance_ttl_secs: 30,    // 30 seconds
64            key_prefix: "kaccy:".to_string(),
65        }
66    }
67}
68
69impl CacheConfig {
70    /// Create config from environment variable
71    pub fn from_env() -> Self {
72        let redis_url =
73            std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string());
74
75        Self {
76            redis_url,
77            ..Default::default()
78        }
79    }
80
81    /// Set custom TTLs
82    pub fn with_ttls(
83        mut self,
84        default: u64,
85        session: u64,
86        price: u64,
87        profile: u64,
88        balance: u64,
89    ) -> Self {
90        self.default_ttl_secs = default;
91        self.session_ttl_secs = session;
92        self.price_ttl_secs = price;
93        self.profile_ttl_secs = profile;
94        self.balance_ttl_secs = balance;
95        self
96    }
97}
98
99/// Redis cache client with connection management
100#[derive(Clone)]
101pub struct RedisCache {
102    conn: ConnectionManager,
103    config: CacheConfig,
104}
105
106impl RedisCache {
107    /// Create a new Redis cache connection
108    pub async fn new(config: CacheConfig) -> Result<Self> {
109        let client = Client::open(config.redis_url.as_str())
110            .map_err(|e| DbError::Connection(format!("Redis client error: {}", e)))?;
111
112        let conn = ConnectionManager::new(client)
113            .await
114            .map_err(|e| DbError::Connection(format!("Redis connection error: {}", e)))?;
115
116        tracing::info!("Redis cache connected to {}", config.redis_url);
117
118        Ok(Self { conn, config })
119    }
120
121    /// Get the full key with prefix
122    fn full_key(&self, key: &str) -> String {
123        format!("{}{}", self.config.key_prefix, key)
124    }
125
126    /// Get a value from cache
127    pub async fn get<T: DeserializeOwned>(&self, key: &str) -> Result<Option<T>> {
128        let full_key = self.full_key(key);
129        let mut conn = self.conn.clone();
130
131        let value: Option<String> = conn
132            .get(&full_key)
133            .await
134            .map_err(|e| DbError::Cache(format!("Redis GET error: {}", e)))?;
135
136        match value {
137            Some(json) => {
138                let parsed: T = serde_json::from_str(&json)
139                    .map_err(|e| DbError::Cache(format!("Deserialization error: {}", e)))?;
140                Ok(Some(parsed))
141            }
142            None => Ok(None),
143        }
144    }
145
146    /// Set a value in cache with TTL
147    pub async fn set<T: Serialize>(&self, key: &str, value: &T, ttl_secs: u64) -> Result<()> {
148        let full_key = self.full_key(key);
149        let json = serde_json::to_string(value)
150            .map_err(|e| DbError::Cache(format!("Serialization error: {}", e)))?;
151
152        let mut conn = self.conn.clone();
153        let _: () = conn
154            .set_ex(&full_key, json, ttl_secs)
155            .await
156            .map_err(|e| DbError::Cache(format!("Redis SET error: {}", e)))?;
157
158        Ok(())
159    }
160
161    /// Set a value with default TTL
162    pub async fn set_default<T: Serialize>(&self, key: &str, value: &T) -> Result<()> {
163        self.set(key, value, self.config.default_ttl_secs).await
164    }
165
166    /// Delete a key from cache
167    pub async fn delete(&self, key: &str) -> Result<bool> {
168        let full_key = self.full_key(key);
169        let mut conn = self.conn.clone();
170
171        let deleted: i64 = conn
172            .del(&full_key)
173            .await
174            .map_err(|e| DbError::Cache(format!("Redis DEL error: {}", e)))?;
175
176        Ok(deleted > 0)
177    }
178
179    /// Delete multiple keys matching a pattern
180    pub async fn delete_pattern(&self, pattern: &str) -> Result<u64> {
181        let full_pattern = self.full_key(pattern);
182        let mut conn = self.conn.clone();
183
184        let keys: Vec<String> = conn
185            .keys(&full_pattern)
186            .await
187            .map_err(|e| DbError::Cache(format!("Redis KEYS error: {}", e)))?;
188
189        if keys.is_empty() {
190            return Ok(0);
191        }
192
193        let deleted: i64 = conn
194            .del(&keys)
195            .await
196            .map_err(|e| DbError::Cache(format!("Redis DEL error: {}", e)))?;
197
198        Ok(deleted as u64)
199    }
200
201    /// Check if a key exists
202    pub async fn exists(&self, key: &str) -> Result<bool> {
203        let full_key = self.full_key(key);
204        let mut conn = self.conn.clone();
205
206        let exists: bool = conn
207            .exists(&full_key)
208            .await
209            .map_err(|e| DbError::Cache(format!("Redis EXISTS error: {}", e)))?;
210
211        Ok(exists)
212    }
213
214    /// Increment a counter (for rate limiting)
215    pub async fn incr(&self, key: &str) -> Result<i64> {
216        let full_key = self.full_key(key);
217        let mut conn = self.conn.clone();
218
219        let value: i64 = conn
220            .incr(&full_key, 1)
221            .await
222            .map_err(|e| DbError::Cache(format!("Redis INCR error: {}", e)))?;
223
224        Ok(value)
225    }
226
227    /// Increment with expiry (for rate limiting windows)
228    pub async fn incr_with_expiry(&self, key: &str, ttl_secs: u64) -> Result<i64> {
229        let full_key = self.full_key(key);
230        let mut conn = self.conn.clone();
231
232        // Use MULTI/EXEC for atomicity
233        let (value,): (i64,) = redis::pipe()
234            .atomic()
235            .incr(&full_key, 1)
236            .expire(&full_key, ttl_secs as i64)
237            .ignore()
238            .query_async(&mut conn)
239            .await
240            .map_err(|e| DbError::Cache(format!("Redis INCR/EXPIRE error: {}", e)))?;
241
242        Ok(value)
243    }
244
245    /// Set TTL on existing key
246    pub async fn expire(&self, key: &str, ttl_secs: u64) -> Result<bool> {
247        let full_key = self.full_key(key);
248        let mut conn = self.conn.clone();
249
250        let set: bool = conn
251            .expire(&full_key, ttl_secs as i64)
252            .await
253            .map_err(|e| DbError::Cache(format!("Redis EXPIRE error: {}", e)))?;
254
255        Ok(set)
256    }
257
258    /// Get remaining TTL for a key
259    pub async fn ttl(&self, key: &str) -> Result<i64> {
260        let full_key = self.full_key(key);
261        let mut conn = self.conn.clone();
262
263        let ttl: i64 = conn
264            .ttl(&full_key)
265            .await
266            .map_err(|e| DbError::Cache(format!("Redis TTL error: {}", e)))?;
267
268        Ok(ttl)
269    }
270
271    /// Try to acquire a distributed lock
272    pub async fn try_lock(&self, resource: &str, ttl_secs: u64) -> Result<Option<String>> {
273        let key = format!("{}{}:{}", keys::LOCK, resource, Uuid::new_v4());
274        let full_key = self.full_key(&key);
275        let lock_id = Uuid::new_v4().to_string();
276        let mut conn = self.conn.clone();
277
278        let set: bool = conn
279            .set_nx(&full_key, &lock_id)
280            .await
281            .map_err(|e| DbError::Cache(format!("Redis SETNX error: {}", e)))?;
282
283        if set {
284            let _: () = conn
285                .expire(&full_key, ttl_secs as i64)
286                .await
287                .map_err(|e| DbError::Cache(format!("Redis EXPIRE error: {}", e)))?;
288            Ok(Some(lock_id))
289        } else {
290            Ok(None)
291        }
292    }
293
294    /// Release a distributed lock
295    pub async fn release_lock(&self, resource: &str, lock_id: &str) -> Result<bool> {
296        let key = format!("{}{}:{}", keys::LOCK, resource, lock_id);
297        self.delete(&key).await
298    }
299
300    /// Health check
301    pub async fn health_check(&self) -> Result<bool> {
302        let mut conn = self.conn.clone();
303        let pong: String = redis::cmd("PING")
304            .query_async(&mut conn)
305            .await
306            .map_err(|e| DbError::Cache(format!("Redis PING error: {}", e)))?;
307
308        Ok(pong == "PONG")
309    }
310}
311
312// ============== Specialized Cache Operations ==============
313
314/// User session cache operations
315impl RedisCache {
316    /// Cache a user session
317    pub async fn set_session(&self, session_id: &str, user_id: Uuid) -> Result<()> {
318        let key = format!("{}{}", keys::USER_SESSION, session_id);
319        self.set(&key, &user_id, self.config.session_ttl_secs).await
320    }
321
322    /// Get user ID from session
323    pub async fn get_session(&self, session_id: &str) -> Result<Option<Uuid>> {
324        let key = format!("{}{}", keys::USER_SESSION, session_id);
325        self.get(&key).await
326    }
327
328    /// Invalidate a session
329    pub async fn invalidate_session(&self, session_id: &str) -> Result<bool> {
330        let key = format!("{}{}", keys::USER_SESSION, session_id);
331        self.delete(&key).await
332    }
333
334    /// Invalidate all sessions for a user
335    #[allow(dead_code)]
336    pub async fn invalidate_user_sessions(&self, user_id: Uuid) -> Result<u64> {
337        // This requires scanning - consider using a SET per user for production
338        // Note: This is a simplified implementation
339        // In production, maintain a set of session IDs per user
340        tracing::warn!(
341            "invalidate_user_sessions: scanning all sessions for user {}",
342            user_id
343        );
344        Ok(0) // Placeholder - implement with user session tracking
345    }
346}
347
348/// User profile cache operations
349impl RedisCache {
350    /// Cache user profile
351    pub async fn set_user_profile<T: Serialize>(&self, user_id: Uuid, profile: &T) -> Result<()> {
352        let key = format!("{}{}", keys::USER_PROFILE, user_id);
353        self.set(&key, profile, self.config.profile_ttl_secs).await
354    }
355
356    /// Get cached user profile
357    pub async fn get_user_profile<T: DeserializeOwned>(&self, user_id: Uuid) -> Result<Option<T>> {
358        let key = format!("{}{}", keys::USER_PROFILE, user_id);
359        self.get(&key).await
360    }
361
362    /// Invalidate user profile cache
363    pub async fn invalidate_user_profile(&self, user_id: Uuid) -> Result<bool> {
364        let key = format!("{}{}", keys::USER_PROFILE, user_id);
365        self.delete(&key).await
366    }
367}
368
369/// Token price cache operations
370impl RedisCache {
371    /// Cache token price
372    pub async fn set_token_price(&self, token_id: Uuid, price_btc: f64) -> Result<()> {
373        let key = format!("{}{}", keys::TOKEN_PRICE, token_id);
374        self.set(&key, &price_btc, self.config.price_ttl_secs).await
375    }
376
377    /// Get cached token price
378    pub async fn get_token_price(&self, token_id: Uuid) -> Result<Option<f64>> {
379        let key = format!("{}{}", keys::TOKEN_PRICE, token_id);
380        self.get(&key).await
381    }
382
383    /// Cache multiple token prices
384    pub async fn set_token_prices(&self, prices: &[(Uuid, f64)]) -> Result<()> {
385        for (token_id, price) in prices {
386            self.set_token_price(*token_id, *price).await?;
387        }
388        Ok(())
389    }
390}
391
392/// Token metadata cache operations
393impl RedisCache {
394    /// Cache token metadata
395    pub async fn set_token_meta<T: Serialize>(&self, token_id: Uuid, meta: &T) -> Result<()> {
396        let key = format!("{}{}", keys::TOKEN_META, token_id);
397        self.set(&key, meta, self.config.default_ttl_secs).await
398    }
399
400    /// Get cached token metadata
401    pub async fn get_token_meta<T: DeserializeOwned>(&self, token_id: Uuid) -> Result<Option<T>> {
402        let key = format!("{}{}", keys::TOKEN_META, token_id);
403        self.get(&key).await
404    }
405
406    /// Invalidate token metadata cache
407    pub async fn invalidate_token_meta(&self, token_id: Uuid) -> Result<bool> {
408        let key = format!("{}{}", keys::TOKEN_META, token_id);
409        self.delete(&key).await
410    }
411}
412
413/// User balance cache operations
414impl RedisCache {
415    /// Cache user balance for a token
416    pub async fn set_balance(&self, user_id: Uuid, token_id: Uuid, balance: f64) -> Result<()> {
417        let key = format!("{}{}:{}", keys::USER_BALANCE, user_id, token_id);
418        self.set(&key, &balance, self.config.balance_ttl_secs).await
419    }
420
421    /// Get cached balance
422    pub async fn get_balance(&self, user_id: Uuid, token_id: Uuid) -> Result<Option<f64>> {
423        let key = format!("{}{}:{}", keys::USER_BALANCE, user_id, token_id);
424        self.get(&key).await
425    }
426
427    /// Invalidate all balances for a user
428    pub async fn invalidate_user_balances(&self, user_id: Uuid) -> Result<u64> {
429        let pattern = format!("{}{}:*", keys::USER_BALANCE, user_id);
430        self.delete_pattern(&pattern).await
431    }
432
433    /// Invalidate all balances for a token
434    pub async fn invalidate_token_balances(&self, token_id: Uuid) -> Result<u64> {
435        let pattern = format!("{}*:{}", keys::USER_BALANCE, token_id);
436        self.delete_pattern(&pattern).await
437    }
438}
439
440/// Rate limiting operations
441impl RedisCache {
442    /// Check and increment rate limit
443    /// Returns (current_count, is_allowed)
444    pub async fn check_rate_limit(
445        &self,
446        identifier: &str,
447        limit: u64,
448        window_secs: u64,
449    ) -> Result<(u64, bool)> {
450        let key = format!("{}{}", keys::RATE_LIMIT, identifier);
451        let count = self.incr_with_expiry(&key, window_secs).await? as u64;
452        Ok((count, count <= limit))
453    }
454
455    /// Get current rate limit count
456    pub async fn get_rate_limit_count(&self, identifier: &str) -> Result<u64> {
457        let key = format!("{}{}", keys::RATE_LIMIT, identifier);
458        let count: Option<u64> = self.get(&key).await?;
459        Ok(count.unwrap_or(0))
460    }
461
462    /// Reset rate limit for identifier
463    pub async fn reset_rate_limit(&self, identifier: &str) -> Result<bool> {
464        let key = format!("{}{}", keys::RATE_LIMIT, identifier);
465        self.delete(&key).await
466    }
467}
468
469/// Cached repository wrapper for read-through caching
470pub struct CachedRepository<R> {
471    cache: Arc<RedisCache>,
472    repo: R,
473}
474
475impl<R> CachedRepository<R> {
476    /// Create a new cached repository wrapping the given repo and cache.
477    pub fn new(cache: Arc<RedisCache>, repo: R) -> Self {
478        Self { cache, repo }
479    }
480
481    /// Get the underlying repository
482    pub fn repo(&self) -> &R {
483        &self.repo
484    }
485
486    /// Get the cache
487    pub fn cache(&self) -> &RedisCache {
488        &self.cache
489    }
490}
491
492/// Cache statistics
493#[derive(Debug, Clone, Default, serde::Serialize)]
494pub struct CacheStats {
495    /// Number of successful cache lookups.
496    pub hits: u64,
497    /// Number of cache lookups that did not find an entry.
498    pub misses: u64,
499    /// Number of values written to the cache.
500    pub sets: u64,
501    /// Number of values removed from the cache.
502    pub deletes: u64,
503}
504
505impl CacheStats {
506    /// Calculate the cache hit rate as a fraction between 0.0 and 1.0.
507    pub fn hit_rate(&self) -> f64 {
508        let total = self.hits + self.misses;
509        if total == 0 {
510            0.0
511        } else {
512            self.hits as f64 / total as f64
513        }
514    }
515}