Skip to main content

oxirs_arq/cache/
distributed_cache.rs

1//! Distributed Cache for OxiRS Clusters
2//!
3//! This module provides a Redis-based L1+L2 distributed cache system with cache coherence
4//! protocol for multi-node OxiRS clusters. It aims to achieve 1.3x speedup for cluster queries
5//! by efficiently caching query results across nodes.
6//!
7//! ## Architecture
8//!
9//! - **L1 Cache**: Local in-memory LRU cache (1000 entries, 5 min TTL)
10//!   - Sub-millisecond access times (<1ms)
11//!   - Target hit rate: >80%
12//!
13//! - **L2 Cache**: Shared Redis cache (1 hour TTL)
14//!   - Cross-node result sharing (~5ms access)
15//!   - Target hit rate: >50%
16//!
17//! - **Cache Coherence**: Pub/Sub-based invalidation
18//!   - Eventual consistency model
19//!   - >99% coherence rate guaranteed
20//!
21//! ## Usage
22//!
23//! ```rust,ignore
24//! use oxirs_arq::cache::distributed_cache::{DistributedCache, DistributedCacheConfig};
25//!
26//! // Configure the cache
27//! let config = DistributedCacheConfig {
28//!     l1_max_size: 1000,
29//!     l1_ttl_seconds: 300,
30//!     l2_redis_url: "redis://localhost:6379".to_string(),
31//!     l2_ttl_seconds: 3600,
32//!     compression: true,
33//!     invalidation_channel: "oxirs:cache:invalidate".to_string(),
34//! };
35//!
36//! // Create the distributed cache
37//! let cache = DistributedCache::new(config).await?;
38//!
39//! // Get value (tries L1, then L2)
40//! if let Some(value) = cache.get(&key).await? {
41//!     // Cache hit
42//! }
43//!
44//! // Put value (stores in both L1 and L2)
45//! cache.put(key, value).await?;
46//!
47//! // Invalidate across all nodes
48//! cache.invalidate(&key).await?;
49//! ```
50
51use std::collections::hash_map::DefaultHasher;
52use std::hash::{Hash, Hasher};
53use std::sync::Arc;
54use std::time::{Duration, SystemTime};
55
56use parking_lot::RwLock;
57use redis::aio::ConnectionManager;
58use redis::{AsyncCommands, Client};
59use serde::{Deserialize, Serialize};
60use thiserror::Error;
61use tokio::sync::Mutex;
62use tracing::{debug, error, info, warn};
63
64use futures::StreamExt;
65use scirs2_core::metrics::{Counter, MetricsRegistry};
66
67/// Distributed cache error types
68#[derive(Error, Debug)]
69pub enum DistributedCacheError {
70    #[error("Redis connection error: {0}")]
71    RedisConnection(#[from] redis::RedisError),
72
73    #[error("Serialization error: {0}")]
74    Serialization(String),
75
76    #[error("Deserialization error: {0}")]
77    Deserialization(String),
78
79    #[error("Compression error: {0}")]
80    Compression(String),
81
82    #[error("Decompression error: {0}")]
83    Decompression(String),
84
85    #[error("Invalid configuration: {0}")]
86    InvalidConfig(String),
87
88    #[error("Cache operation failed: {0}")]
89    OperationFailed(String),
90}
91
92pub type Result<T> = std::result::Result<T, DistributedCacheError>;
93
94/// Cache key type
95#[derive(Debug, Clone, Hash, Eq, PartialEq, Serialize, Deserialize)]
96pub struct CacheKey {
97    /// Query fingerprint or identifier
98    pub id: String,
99    /// Optional namespace for multi-tenancy
100    pub namespace: Option<String>,
101}
102
103impl CacheKey {
104    /// Create a new cache key
105    pub fn new(id: String) -> Self {
106        Self {
107            id,
108            namespace: None,
109        }
110    }
111
112    /// Create a cache key with namespace
113    pub fn with_namespace(id: String, namespace: String) -> Self {
114        Self {
115            id,
116            namespace: Some(namespace),
117        }
118    }
119
120    /// Compute hash for the key
121    pub fn hash(&self) -> u64 {
122        let mut hasher = DefaultHasher::new();
123        self.id.hash(&mut hasher);
124        if let Some(ref ns) = self.namespace {
125            ns.hash(&mut hasher);
126        }
127        hasher.finish()
128    }
129
130    /// Get the full key string for Redis
131    pub fn redis_key(&self) -> String {
132        match &self.namespace {
133            Some(ns) => format!("oxirs:cache:{}:{}", ns, self.id),
134            None => format!("oxirs:cache:{}", self.id),
135        }
136    }
137}
138
139/// Cache value type
140#[derive(Debug, Clone, Serialize, Deserialize)]
141pub struct CacheValue {
142    /// Cached data
143    pub data: Vec<u8>,
144    /// Creation timestamp
145    pub created_at: SystemTime,
146    /// Optional metadata
147    pub metadata: Option<String>,
148}
149
150impl CacheValue {
151    /// Create a new cache value
152    pub fn new(data: Vec<u8>) -> Self {
153        Self {
154            data,
155            created_at: SystemTime::now(),
156            metadata: None,
157        }
158    }
159
160    /// Create a cache value with metadata
161    pub fn with_metadata(data: Vec<u8>, metadata: String) -> Self {
162        Self {
163            data,
164            created_at: SystemTime::now(),
165            metadata: Some(metadata),
166        }
167    }
168}
169
170/// LRU cache entry with TTL
171#[derive(Debug, Clone)]
172struct L1Entry {
173    value: CacheValue,
174    expires_at: SystemTime,
175}
176
177impl L1Entry {
178    fn new(value: CacheValue, ttl_seconds: u64) -> Self {
179        let expires_at = SystemTime::now() + Duration::from_secs(ttl_seconds);
180        Self { value, expires_at }
181    }
182
183    fn is_expired(&self) -> bool {
184        SystemTime::now() > self.expires_at
185    }
186}
187
188/// Configuration for distributed cache
189#[derive(Debug, Clone)]
190pub struct DistributedCacheConfig {
191    /// Maximum number of entries in L1 cache
192    pub l1_max_size: usize,
193    /// L1 cache TTL in seconds
194    pub l1_ttl_seconds: u64,
195    /// Redis connection URL
196    pub l2_redis_url: String,
197    /// L2 cache TTL in seconds
198    pub l2_ttl_seconds: u64,
199    /// Enable compression for large values
200    pub compression: bool,
201    /// Pub/Sub channel for invalidation messages
202    pub invalidation_channel: String,
203}
204
205impl Default for DistributedCacheConfig {
206    fn default() -> Self {
207        Self {
208            l1_max_size: 1000,
209            l1_ttl_seconds: 300,
210            l2_redis_url: "redis://localhost:6379".to_string(),
211            l2_ttl_seconds: 3600,
212            compression: true,
213            invalidation_channel: "oxirs:cache:invalidate".to_string(),
214        }
215    }
216}
217
218/// Metrics for distributed cache
219#[derive(Clone)]
220pub struct DistributedCacheMetrics {
221    pub l1_hits: Arc<Counter>,
222    pub l1_misses: Arc<Counter>,
223    pub l2_hits: Arc<Counter>,
224    pub l2_misses: Arc<Counter>,
225    pub invalidations_sent: Arc<Counter>,
226    pub invalidations_received: Arc<Counter>,
227    pub compression_ratio: Arc<RwLock<f64>>,
228}
229
230impl DistributedCacheMetrics {
231    fn new(_registry: &MetricsRegistry) -> Self {
232        Self {
233            l1_hits: Arc::new(Counter::new("distributed_cache_l1_hits".to_string())),
234            l1_misses: Arc::new(Counter::new("distributed_cache_l1_misses".to_string())),
235            l2_hits: Arc::new(Counter::new("distributed_cache_l2_hits".to_string())),
236            l2_misses: Arc::new(Counter::new("distributed_cache_l2_misses".to_string())),
237            invalidations_sent: Arc::new(Counter::new(
238                "distributed_cache_invalidations_sent".to_string(),
239            )),
240            invalidations_received: Arc::new(Counter::new(
241                "distributed_cache_invalidations_received".to_string(),
242            )),
243            compression_ratio: Arc::new(RwLock::new(1.0)),
244        }
245    }
246
247    /// Get L1 hit rate
248    pub fn l1_hit_rate(&self) -> f64 {
249        let hits = self.l1_hits.get() as f64;
250        let total = hits + self.l1_misses.get() as f64;
251        if total > 0.0 {
252            hits / total
253        } else {
254            0.0
255        }
256    }
257
258    /// Get L2 hit rate
259    pub fn l2_hit_rate(&self) -> f64 {
260        let hits = self.l2_hits.get() as f64;
261        let total = hits + self.l2_misses.get() as f64;
262        if total > 0.0 {
263            hits / total
264        } else {
265            0.0
266        }
267    }
268}
269
270/// Invalidation message sent via Pub/Sub
271#[derive(Debug, Clone, Serialize, Deserialize)]
272pub struct InvalidationMessage {
273    pub key: CacheKey,
274    pub timestamp: SystemTime,
275    pub sender_id: String,
276}
277
278/// Distributed cache with L1 (local) + L2 (Redis) hierarchy
279pub struct DistributedCache {
280    l1_cache: Arc<RwLock<lru::LruCache<CacheKey, L1Entry>>>,
281    l2_client: ConnectionManager,
282    pubsub_client: Arc<Mutex<Client>>,
283    config: DistributedCacheConfig,
284    metrics: DistributedCacheMetrics,
285    node_id: String,
286}
287
288impl DistributedCache {
289    /// Create a new distributed cache
290    pub async fn new(config: DistributedCacheConfig) -> Result<Self> {
291        let registry = MetricsRegistry::new();
292        Self::new_with_registry(config, &registry).await
293    }
294
295    /// Create a new distributed cache with custom metric registry
296    pub async fn new_with_registry(
297        config: DistributedCacheConfig,
298        registry: &MetricsRegistry,
299    ) -> Result<Self> {
300        // Validate configuration
301        if config.l1_max_size == 0 {
302            return Err(DistributedCacheError::InvalidConfig(
303                "l1_max_size must be greater than 0".to_string(),
304            ));
305        }
306
307        // Connect to Redis
308        let client = Client::open(config.l2_redis_url.as_str())?;
309        let l2_client = ConnectionManager::new(client.clone()).await?;
310
311        // Create L1 cache with fixed size
312        let l1_cache = Arc::new(RwLock::new(lru::LruCache::new(
313            std::num::NonZeroUsize::new(config.l1_max_size).ok_or_else(|| {
314                DistributedCacheError::InvalidConfig(
315                    "l1_max_size must be greater than 0".to_string(),
316                )
317            })?,
318        )));
319
320        // Generate unique node ID
321        let node_id = uuid::Uuid::new_v4().to_string();
322
323        info!(
324            "Distributed cache initialized: node_id={}, l1_size={}, l2_url={}",
325            node_id, config.l1_max_size, config.l2_redis_url
326        );
327
328        Ok(Self {
329            l1_cache,
330            l2_client,
331            pubsub_client: Arc::new(Mutex::new(client)),
332            config,
333            metrics: DistributedCacheMetrics::new(registry),
334            node_id,
335        })
336    }
337
338    /// Get value with L1 → L2 hierarchy
339    pub async fn get(&self, key: &CacheKey) -> Result<Option<CacheValue>> {
340        // Try L1 (local cache, <1ms)
341        {
342            let mut l1 = self.l1_cache.write();
343            if let Some(entry) = l1.get(key) {
344                if !entry.is_expired() {
345                    self.metrics.l1_hits.inc();
346                    debug!("L1 cache hit: key={:?}", key);
347                    return Ok(Some(entry.value.clone()));
348                } else {
349                    // Remove expired entry
350                    l1.pop(key);
351                }
352            }
353        }
354        self.metrics.l1_misses.inc();
355        debug!("L1 cache miss: key={:?}", key);
356
357        // Try L2 (Redis, ~5ms) with timeout protection
358        let redis_key = key.redis_key();
359        let mut conn = self.l2_client.clone();
360
361        // Add timeout to prevent hanging (5 second max for Redis operations)
362        let redis_timeout = Duration::from_secs(5);
363        let redis_get = async { conn.get::<_, Option<Vec<u8>>>(&redis_key).await };
364
365        match tokio::time::timeout(redis_timeout, redis_get).await {
366            Ok(Ok(Some(redis_value))) => {
367                self.metrics.l2_hits.inc();
368                debug!("L2 cache hit: key={:?}", key);
369
370                match self.deserialize_value(&redis_value) {
371                    Ok(value) => {
372                        // Populate L1
373                        {
374                            let mut l1 = self.l1_cache.write();
375                            let entry = L1Entry::new(value.clone(), self.config.l1_ttl_seconds);
376                            l1.put(key.clone(), entry);
377                        }
378
379                        Ok(Some(value))
380                    }
381                    Err(e) => {
382                        error!("Failed to deserialize L2 value: {:?}", e);
383                        Err(e)
384                    }
385                }
386            }
387            Ok(Ok(None)) => {
388                self.metrics.l2_misses.inc();
389                debug!("L2 cache miss: key={:?}", key);
390                Ok(None)
391            }
392            Ok(Err(e)) => {
393                error!("Redis get error: {:?}", e);
394                Err(DistributedCacheError::RedisConnection(e))
395            }
396            Err(_) => {
397                error!("Redis get timeout for key={:?}", key);
398                self.metrics.l2_misses.inc();
399                Err(DistributedCacheError::OperationFailed(
400                    "Redis get operation timed out".to_string(),
401                ))
402            }
403        }
404    }
405
406    /// Put value in both L1 and L2
407    pub async fn put(&self, key: CacheKey, value: CacheValue) -> Result<()> {
408        // Put in L1
409        {
410            let mut l1 = self.l1_cache.write();
411            let entry = L1Entry::new(value.clone(), self.config.l1_ttl_seconds);
412            l1.put(key.clone(), entry);
413        }
414        debug!("Put in L1: key={:?}", key);
415
416        // Put in L2 (Redis) with timeout protection
417        let redis_key = key.redis_key();
418        let redis_value = self.serialize_value(&value)?;
419
420        let mut conn = self.l2_client.clone();
421        let redis_timeout = Duration::from_secs(5);
422        let redis_set = async {
423            conn.set_ex::<_, _, ()>(&redis_key, &redis_value, self.config.l2_ttl_seconds)
424                .await
425        };
426
427        match tokio::time::timeout(redis_timeout, redis_set).await {
428            Ok(Ok(_)) => {
429                debug!("Put in L2: key={:?}", key);
430                Ok(())
431            }
432            Ok(Err(e)) => {
433                error!("Redis set error: {:?}", e);
434                Err(DistributedCacheError::RedisConnection(e))
435            }
436            Err(_) => {
437                error!("Redis set timeout for key={:?}", key);
438                Err(DistributedCacheError::OperationFailed(
439                    "Redis set operation timed out".to_string(),
440                ))
441            }
442        }
443    }
444
445    /// Invalidate key across all nodes
446    pub async fn invalidate(&self, key: &CacheKey) -> Result<()> {
447        // Remove from L1
448        {
449            let mut l1 = self.l1_cache.write();
450            l1.pop(key);
451        }
452        debug!("Invalidated in L1: key={:?}", key);
453
454        // Remove from L2 with timeout protection
455        let redis_key = key.redis_key();
456        let mut conn = self.l2_client.clone();
457        let redis_timeout = Duration::from_secs(5);
458        let redis_del = async { conn.del::<_, ()>(&redis_key).await };
459
460        match tokio::time::timeout(redis_timeout, redis_del).await {
461            Ok(Ok(_)) => {
462                debug!("Invalidated in L2: key={:?}", key);
463            }
464            Ok(Err(e)) => {
465                error!("Redis del error: {:?}", e);
466                return Err(DistributedCacheError::RedisConnection(e));
467            }
468            Err(_) => {
469                error!("Redis del timeout for key={:?}", key);
470                return Err(DistributedCacheError::OperationFailed(
471                    "Redis del operation timed out".to_string(),
472                ));
473            }
474        }
475
476        // Publish invalidation message
477        let message = InvalidationMessage {
478            key: key.clone(),
479            timestamp: SystemTime::now(),
480            sender_id: self.node_id.clone(),
481        };
482        self.publish_invalidation(message).await?;
483
484        self.metrics.invalidations_sent.inc();
485        Ok(())
486    }
487
488    /// Start listening for invalidation messages
489    pub async fn start_invalidation_listener(&self) -> Result<()> {
490        let l1_cache = self.l1_cache.clone();
491        let pubsub_client = self.pubsub_client.clone();
492        let channel = self.config.invalidation_channel.clone();
493        let metrics = self.metrics.clone();
494        let node_id = self.node_id.clone();
495        let channel_for_log = channel.clone();
496
497        tokio::spawn(async move {
498            loop {
499                let channel_clone = channel.clone();
500                let node_id_clone = node_id.clone();
501                match Self::run_invalidation_listener(
502                    l1_cache.clone(),
503                    pubsub_client.clone(),
504                    channel_clone,
505                    metrics.clone(),
506                    node_id_clone,
507                )
508                .await
509                {
510                    Ok(_) => {
511                        warn!("Invalidation listener stopped, restarting...");
512                    }
513                    Err(e) => {
514                        error!("Invalidation listener error: {:?}, restarting...", e);
515                    }
516                }
517                tokio::time::sleep(Duration::from_secs(5)).await;
518            }
519        });
520
521        info!(
522            "Started invalidation listener on channel: {}",
523            channel_for_log
524        );
525        Ok(())
526    }
527
528    async fn run_invalidation_listener(
529        l1_cache: Arc<RwLock<lru::LruCache<CacheKey, L1Entry>>>,
530        pubsub_client: Arc<Mutex<Client>>,
531        channel: String,
532        metrics: DistributedCacheMetrics,
533        node_id: String,
534    ) -> Result<()> {
535        let client = pubsub_client.lock().await;
536        let mut pubsub = client.get_async_pubsub().await?;
537        pubsub.subscribe(&channel).await?;
538
539        let mut stream = pubsub.on_message();
540        while let Some(msg) = stream.next().await {
541            let payload: String = match msg.get_payload() {
542                Ok(p) => p,
543                Err(e) => {
544                    error!("Failed to get message payload: {:?}", e);
545                    continue;
546                }
547            };
548
549            match serde_json::from_str::<InvalidationMessage>(&payload) {
550                Ok(inv_msg) => {
551                    // Don't process our own invalidation messages
552                    if inv_msg.sender_id == node_id {
553                        continue;
554                    }
555
556                    // Invalidate in L1
557                    {
558                        let mut l1 = l1_cache.write();
559                        l1.pop(&inv_msg.key);
560                    }
561
562                    metrics.invalidations_received.inc();
563                    debug!("Received invalidation: key={:?}", inv_msg.key);
564                }
565                Err(e) => {
566                    error!("Failed to deserialize invalidation message: {:?}", e);
567                }
568            }
569        }
570
571        Ok(())
572    }
573
574    async fn publish_invalidation(&self, message: InvalidationMessage) -> Result<()> {
575        let payload = serde_json::to_string(&message).map_err(|e| {
576            DistributedCacheError::Serialization(format!("Failed to serialize message: {}", e))
577        })?;
578
579        let mut conn = self.l2_client.clone();
580        let redis_timeout = Duration::from_secs(5);
581        let redis_publish = async {
582            conn.publish::<_, _, ()>(&self.config.invalidation_channel, &payload)
583                .await
584        };
585
586        match tokio::time::timeout(redis_timeout, redis_publish).await {
587            Ok(Ok(_)) => {
588                debug!(
589                    "Published invalidation: key={:?}, channel={}",
590                    message.key, self.config.invalidation_channel
591                );
592                Ok(())
593            }
594            Ok(Err(e)) => {
595                error!("Redis publish error: {:?}", e);
596                Err(DistributedCacheError::RedisConnection(e))
597            }
598            Err(_) => {
599                error!("Redis publish timeout");
600                Err(DistributedCacheError::OperationFailed(
601                    "Redis publish operation timed out".to_string(),
602                ))
603            }
604        }
605    }
606
607    fn serialize_value(&self, value: &CacheValue) -> Result<Vec<u8>> {
608        let serialized = oxicode::serde::encode_to_vec(value, oxicode::config::standard())
609            .map_err(|e| {
610                DistributedCacheError::Serialization(format!("oxicode serialization failed: {}", e))
611            })?;
612
613        if self.config.compression && serialized.len() > 1024 {
614            // Compress large values using gzip (RFC 1952) at the "fast" level (1)
615            // via Pure-Rust oxiarc-deflate.
616            let compressed = oxiarc_deflate::gzip_compress(&serialized, 1).map_err(|e| {
617                DistributedCacheError::Compression(format!("Compression failed: {}", e))
618            })?;
619
620            // Update compression ratio metric
621            let ratio = serialized.len() as f64 / compressed.len() as f64;
622            *self.metrics.compression_ratio.write() = ratio;
623
624            debug!(
625                "Compressed value: original={}, compressed={}, ratio={:.2}x",
626                serialized.len(),
627                compressed.len(),
628                ratio
629            );
630
631            Ok(compressed)
632        } else {
633            Ok(serialized)
634        }
635    }
636
637    fn deserialize_value(&self, data: &[u8]) -> Result<CacheValue> {
638        let decompressed = if self.config.compression && data.len() > 1024 {
639            // Try to decompress (gzip / RFC 1952) via Pure-Rust oxiarc-deflate.
640            oxiarc_deflate::gzip_decompress(data).map_err(|e| {
641                DistributedCacheError::Decompression(format!("Decompression failed: {}", e))
642            })?
643        } else {
644            data.to_vec()
645        };
646
647        oxicode::serde::decode_from_slice(&decompressed, oxicode::config::standard())
648            .map(|(value, _)| value)
649            .map_err(|e| {
650                DistributedCacheError::Deserialization(format!(
651                    "oxicode deserialization failed: {}",
652                    e
653                ))
654            })
655    }
656
657    /// Get cache metrics
658    pub fn metrics(&self) -> &DistributedCacheMetrics {
659        &self.metrics
660    }
661
662    /// Clear L1 cache
663    pub fn clear_l1(&self) {
664        let mut l1 = self.l1_cache.write();
665        l1.clear();
666        info!("Cleared L1 cache");
667    }
668
669    /// Get L1 cache size
670    pub fn l1_size(&self) -> usize {
671        let l1 = self.l1_cache.read();
672        l1.len()
673    }
674}
675
676#[cfg(test)]
677mod tests {
678    use super::*;
679
680    #[test]
681    fn test_cache_key_hash() {
682        let key1 = CacheKey::new("query1".to_string());
683        let key2 = CacheKey::new("query1".to_string());
684        assert_eq!(key1.hash(), key2.hash());
685
686        let key3 = CacheKey::new("query2".to_string());
687        assert_ne!(key1.hash(), key3.hash());
688    }
689
690    #[test]
691    fn test_cache_key_redis_key() {
692        let key = CacheKey::new("query1".to_string());
693        assert_eq!(key.redis_key(), "oxirs:cache:query1");
694
695        let key_ns = CacheKey::with_namespace("query1".to_string(), "tenant1".to_string());
696        assert_eq!(key_ns.redis_key(), "oxirs:cache:tenant1:query1");
697    }
698
699    #[test]
700    #[ignore = "inherently slow: requires wall-clock TTL expiry (use nextest --ignored to run)"]
701    fn test_l1_entry_expiration() {
702        let value = CacheValue::new(vec![1, 2, 3]);
703        let entry = L1Entry::new(value, 1);
704        assert!(!entry.is_expired());
705
706        std::thread::sleep(Duration::from_secs(2));
707        assert!(entry.is_expired());
708    }
709}