Skip to main content

oxirs_gql/
distributed_cache.rs

1//! Distributed Caching with Redis Integration
2//!
3//! This module provides high-performance distributed caching for GraphQL queries
4//! with Redis backend, intelligent cache strategies, and federation support.
5
6use anyhow::{anyhow, Result};
7use async_trait::async_trait;
8use redis::{cmd, Client};
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11use std::sync::Arc;
12use std::time::{Duration, SystemTime};
13use tokio::sync::RwLock;
14use tracing::{debug, info};
15
16/// A 256-bit symmetric key used to encrypt cache entries at rest / on the wire.
17///
18/// The key material is redacted from `Debug` output so it never leaks into logs
19/// when a [`CacheConfig`] is printed.
20#[derive(Clone, PartialEq, Eq)]
21pub struct EncryptionKey(Vec<u8>);
22
23impl EncryptionKey {
24    /// Wrap raw key bytes. AES-256-GCM requires exactly 32 bytes; other lengths
25    /// are accepted here and validated when the cache is constructed so callers
26    /// get an explicit error rather than a silent weak key.
27    pub fn new(bytes: impl Into<Vec<u8>>) -> Self {
28        Self(bytes.into())
29    }
30
31    /// Borrow the raw key bytes.
32    pub fn as_bytes(&self) -> &[u8] {
33        &self.0
34    }
35}
36
37impl std::fmt::Debug for EncryptionKey {
38    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39        write!(f, "EncryptionKey([REDACTED; {} bytes])", self.0.len())
40    }
41}
42
43/// Cache configuration
44#[derive(Debug, Clone)]
45pub struct CacheConfig {
46    pub redis_urls: Vec<String>,
47    pub default_ttl: Duration,
48    pub max_cache_size: u64,
49    pub compression_enabled: bool,
50    pub encryption_enabled: bool,
51    /// 256-bit key used when `encryption_enabled` is true. Constructing a cache
52    /// with `encryption_enabled = true` but no (or an incorrectly-sized) key is
53    /// a hard error — the flag must never silently degrade to plaintext.
54    pub encryption_key: Option<EncryptionKey>,
55    pub cluster_mode: bool,
56    pub sharding_strategy: ShardingStrategy,
57    pub eviction_policy: EvictionPolicy,
58    pub consistency_level: ConsistencyLevel,
59    pub replication_factor: usize,
60    pub local_cache_size: usize,
61    pub prefetch_enabled: bool,
62}
63
64impl Default for CacheConfig {
65    fn default() -> Self {
66        Self {
67            redis_urls: vec!["redis://localhost:6379".to_string()],
68            default_ttl: Duration::from_secs(3600),
69            max_cache_size: 1024 * 1024 * 1024, // 1GB
70            compression_enabled: true,
71            encryption_enabled: false,
72            encryption_key: None,
73            cluster_mode: false,
74            sharding_strategy: ShardingStrategy::ConsistentHashing,
75            eviction_policy: EvictionPolicy::LRU,
76            consistency_level: ConsistencyLevel::Eventual,
77            replication_factor: 2,
78            local_cache_size: 10000,
79            prefetch_enabled: true,
80        }
81    }
82}
83
84/// Sharding strategies for distributed cache
85#[derive(Debug, Clone)]
86pub enum ShardingStrategy {
87    ConsistentHashing,
88    Range,
89    ModuloHash,
90    QueryType,
91    ServiceAffinity,
92}
93
94/// Cache eviction policies
95#[derive(Debug, Clone)]
96pub enum EvictionPolicy {
97    LRU,
98    LFU,
99    FIFO,
100    TTL,
101    Adaptive,
102}
103
104/// Consistency levels for distributed caching
105#[derive(Debug, Clone)]
106pub enum ConsistencyLevel {
107    Strong,
108    Eventual,
109    Session,
110    Bounded,
111}
112
113/// Cache entry metadata
114#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct CacheEntry {
116    pub key: String,
117    pub value: Vec<u8>,
118    pub created_at: SystemTime,
119    pub expires_at: SystemTime,
120    pub access_count: u64,
121    pub last_accessed: SystemTime,
122    pub size_bytes: usize,
123    pub tags: Vec<String>,
124    pub metadata: HashMap<String, String>,
125}
126
127/// Cache operation statistics
128#[derive(Debug, Clone, Default)]
129pub struct CacheStats {
130    pub hits: u64,
131    pub misses: u64,
132    pub sets: u64,
133    pub deletes: u64,
134    pub evictions: u64,
135    pub total_size_bytes: u64,
136    pub entry_count: u64,
137    pub average_response_time: Duration,
138}
139
140/// Cache invalidation event
141#[derive(Debug, Clone, Serialize, Deserialize)]
142pub struct InvalidationEvent {
143    pub keys: Vec<String>,
144    pub tags: Vec<String>,
145    pub timestamp: SystemTime,
146    pub source: String,
147    pub reason: InvalidationReason,
148}
149
150/// Reasons for cache invalidation
151#[derive(Debug, Clone, Serialize, Deserialize)]
152pub enum InvalidationReason {
153    SchemaChange,
154    DataUpdate,
155    Manual,
156    TTLExpired,
157    MemoryPressure,
158    ErrorRecovery,
159}
160
161/// GraphQL query context for caching
162#[derive(Debug, Clone, Serialize, Deserialize)]
163pub struct QueryContext {
164    pub query_hash: String,
165    pub variables_hash: String,
166    pub operation_name: Option<String>,
167    pub user_id: Option<String>,
168    pub service_ids: Vec<String>,
169    pub schema_version: String,
170    pub requested_fields: Vec<String>,
171}
172
173impl QueryContext {
174    /// Generate cache key from query context
175    pub fn cache_key(&self) -> String {
176        format!(
177            "gql:{}:{}:{}:{}",
178            self.query_hash,
179            self.variables_hash,
180            self.schema_version,
181            self.service_ids.join(",")
182        )
183    }
184
185    /// Generate tags for cache invalidation
186    pub fn tags(&self) -> Vec<String> {
187        let mut tags = vec![
188            format!("query:{}", self.query_hash),
189            format!("schema:{}", self.schema_version),
190        ];
191
192        for service_id in &self.service_ids {
193            tags.push(format!("service:{service_id}"));
194        }
195
196        for field in &self.requested_fields {
197            tags.push(format!("field:{field}"));
198        }
199
200        if let Some(user_id) = &self.user_id {
201            tags.push(format!("user:{user_id}"));
202        }
203
204        tags
205    }
206}
207
208/// Distributed cache trait
209#[async_trait]
210pub trait DistributedCache: Send + Sync {
211    async fn get(&self, key: &str) -> Result<Option<Vec<u8>>>;
212    async fn set(&self, key: &str, value: Vec<u8>, ttl: Option<Duration>) -> Result<()>;
213    async fn delete(&self, key: &str) -> Result<()>;
214    async fn exists(&self, key: &str) -> Result<bool>;
215    async fn invalidate_by_tags(&self, tags: &[String]) -> Result<u64>;
216    async fn get_stats(&self) -> Result<CacheStats>;
217    async fn health_check(&self) -> Result<bool>;
218    async fn clear(&self) -> Result<()>;
219}
220
221/// Redis-based distributed cache implementation
222pub struct RedisDistributedCache {
223    config: CacheConfig,
224    redis_pool: Arc<RwLock<Vec<Client>>>,
225    local_cache: Arc<RwLock<lru::LruCache<String, CacheEntry>>>,
226    stats: Arc<RwLock<CacheStats>>,
227    compression: Option<Arc<dyn CompressionStrategy>>,
228    encryption: Option<Arc<dyn EncryptionStrategy>>,
229}
230
231impl RedisDistributedCache {
232    /// Create a new Redis-based distributed cache
233    pub async fn new(config: CacheConfig) -> Result<Self> {
234        let mut redis_clients = Vec::new();
235
236        for redis_url in &config.redis_urls {
237            let client = Client::open(redis_url.as_str())
238                .map_err(|e| anyhow!("Failed to create Redis client: {}", e))?;
239            redis_clients.push(client);
240        }
241
242        let local_cache = lru::LruCache::new(
243            std::num::NonZeroUsize::new(config.local_cache_size).unwrap_or(
244                std::num::NonZeroUsize::new(1000).expect("1000 is a valid NonZeroUsize"),
245            ),
246        );
247
248        let compression = if config.compression_enabled {
249            Some(Arc::new(GzipCompressionStrategy::new()) as Arc<dyn CompressionStrategy>)
250        } else {
251            None
252        };
253
254        let encryption = if config.encryption_enabled {
255            // Fail loud: encryption was requested but no usable key was provided.
256            // Never silently fall back to a plaintext passthrough.
257            let key = config.encryption_key.as_ref().ok_or_else(|| {
258                anyhow!(
259                    "CacheConfig.encryption_enabled = true but no encryption_key was provided; \
260                     refusing to start a cache that would store data in plaintext"
261                )
262            })?;
263            Some(Arc::new(AesEncryptionStrategy::new(key.as_bytes())?)
264                as Arc<dyn EncryptionStrategy>)
265        } else {
266            None
267        };
268
269        Ok(Self {
270            config,
271            redis_pool: Arc::new(RwLock::new(redis_clients)),
272            local_cache: Arc::new(RwLock::new(local_cache)),
273            stats: Arc::new(RwLock::new(CacheStats::default())),
274            compression,
275            encryption,
276        })
277    }
278
279    /// Get Redis client for a given key
280    async fn get_redis_client(&self, key: &str) -> Result<Client> {
281        let clients = self.redis_pool.read().await;
282
283        if clients.is_empty() {
284            return Err(anyhow!("No Redis clients available"));
285        }
286
287        let index = match self.config.sharding_strategy {
288            ShardingStrategy::ConsistentHashing => self.consistent_hash(key, clients.len()),
289            ShardingStrategy::ModuloHash => self.modulo_hash(key, clients.len()),
290            _ => 0, // Default to first client for other strategies
291        };
292
293        Ok(clients[index].clone())
294    }
295
296    /// Consistent hashing for key distribution
297    fn consistent_hash(&self, key: &str, num_nodes: usize) -> usize {
298        use std::collections::hash_map::DefaultHasher;
299        use std::hash::{Hash, Hasher};
300
301        let mut hasher = DefaultHasher::new();
302        key.hash(&mut hasher);
303        (hasher.finish() as usize) % num_nodes
304    }
305
306    /// Simple modulo hashing
307    fn modulo_hash(&self, key: &str, num_nodes: usize) -> usize {
308        use std::collections::hash_map::DefaultHasher;
309        use std::hash::{Hash, Hasher};
310
311        let mut hasher = DefaultHasher::new();
312        key.hash(&mut hasher);
313        (hasher.finish() as usize) % num_nodes
314    }
315
316    /// Process data through compression/encryption pipeline
317    async fn process_data(&self, data: &[u8], encode: bool) -> Result<Vec<u8>> {
318        let mut processed_data = data.to_vec();
319
320        if encode {
321            // Apply compression first
322            if let Some(compression) = &self.compression {
323                processed_data = compression.compress(&processed_data).await?;
324            }
325
326            // Then encryption
327            if let Some(encryption) = &self.encryption {
328                processed_data = encryption.encrypt(&processed_data).await?;
329            }
330        } else {
331            // Reverse order for decoding: decrypt first
332            if let Some(encryption) = &self.encryption {
333                processed_data = encryption.decrypt(&processed_data).await?;
334            }
335
336            // Then decompress
337            if let Some(compression) = &self.compression {
338                processed_data = compression.decompress(&processed_data).await?;
339            }
340        }
341
342        Ok(processed_data)
343    }
344
345    /// Update cache statistics
346    async fn update_stats<F>(&self, update_fn: F)
347    where
348        F: FnOnce(&mut CacheStats),
349    {
350        let mut stats = self.stats.write().await;
351        update_fn(&mut stats);
352    }
353}
354
355#[async_trait]
356impl DistributedCache for RedisDistributedCache {
357    async fn get(&self, key: &str) -> Result<Option<Vec<u8>>> {
358        let start_time = std::time::Instant::now();
359
360        // Check local cache first
361        {
362            let mut local_cache = self.local_cache.write().await;
363            if let Some(entry) = local_cache.get(key) {
364                if entry.expires_at > SystemTime::now() {
365                    self.update_stats(|stats| {
366                        stats.hits += 1;
367                        stats.average_response_time =
368                            (stats.average_response_time + start_time.elapsed()) / 2;
369                    })
370                    .await;
371
372                    return Ok(Some(entry.value.clone()));
373                } else {
374                    // Entry expired, remove it
375                    local_cache.pop(key);
376                }
377            }
378        }
379
380        // Check Redis
381        let client = self.get_redis_client(key).await?;
382        let mut connection = client
383            .get_multiplexed_async_connection()
384            .await
385            .map_err(|e| anyhow!("Failed to get Redis connection: {}", e))?;
386
387        let redis_result: Option<Vec<u8>> = cmd("GET")
388            .arg(key)
389            .query_async(&mut connection)
390            .await
391            .map_err(|e| anyhow!("Redis GET failed: {}", e))?;
392
393        if let Some(raw_data) = redis_result {
394            // Process data (decrypt/decompress)
395            let processed_data = self.process_data(&raw_data, false).await?;
396
397            // Store in local cache
398            let entry = CacheEntry {
399                key: key.to_string(),
400                value: processed_data.clone(),
401                created_at: SystemTime::now(),
402                expires_at: SystemTime::now() + self.config.default_ttl,
403                access_count: 1,
404                last_accessed: SystemTime::now(),
405                size_bytes: processed_data.len(),
406                tags: Vec::new(),
407                metadata: HashMap::new(),
408            };
409
410            {
411                let mut local_cache = self.local_cache.write().await;
412                local_cache.put(key.to_string(), entry);
413            }
414
415            self.update_stats(|stats| {
416                stats.hits += 1;
417                stats.average_response_time =
418                    (stats.average_response_time + start_time.elapsed()) / 2;
419            })
420            .await;
421
422            Ok(Some(processed_data))
423        } else {
424            self.update_stats(|stats| {
425                stats.misses += 1;
426                stats.average_response_time =
427                    (stats.average_response_time + start_time.elapsed()) / 2;
428            })
429            .await;
430
431            Ok(None)
432        }
433    }
434
435    async fn set(&self, key: &str, value: Vec<u8>, ttl: Option<Duration>) -> Result<()> {
436        let ttl = ttl.unwrap_or(self.config.default_ttl);
437
438        // Process data (compress/encrypt)
439        let processed_data = self.process_data(&value, true).await?;
440
441        // Store in Redis
442        let client = self.get_redis_client(key).await?;
443        let mut connection = client
444            .get_multiplexed_async_connection()
445            .await
446            .map_err(|e| anyhow!("Failed to get Redis connection: {}", e))?;
447
448        cmd("SETEX")
449            .arg(key)
450            .arg(ttl.as_secs())
451            .arg(&processed_data)
452            .exec_async(&mut connection)
453            .await
454            .map_err(|e| anyhow!("Redis SETEX failed: {}", e))?;
455
456        // Store in local cache
457        let entry = CacheEntry {
458            key: key.to_string(),
459            value,
460            created_at: SystemTime::now(),
461            expires_at: SystemTime::now() + ttl,
462            access_count: 0,
463            last_accessed: SystemTime::now(),
464            size_bytes: processed_data.len(),
465            tags: Vec::new(),
466            metadata: HashMap::new(),
467        };
468
469        {
470            let mut local_cache = self.local_cache.write().await;
471            local_cache.put(key.to_string(), entry);
472        }
473
474        self.update_stats(|stats| {
475            stats.sets += 1;
476            stats.total_size_bytes += processed_data.len() as u64;
477            stats.entry_count += 1;
478        })
479        .await;
480
481        Ok(())
482    }
483
484    async fn delete(&self, key: &str) -> Result<()> {
485        // Remove from local cache
486        {
487            let mut local_cache = self.local_cache.write().await;
488            local_cache.pop(key);
489        }
490
491        // Remove from Redis
492        let client = self.get_redis_client(key).await?;
493        let mut connection = client
494            .get_multiplexed_async_connection()
495            .await
496            .map_err(|e| anyhow!("Failed to get Redis connection: {}", e))?;
497
498        cmd("DEL")
499            .arg(key)
500            .query_async::<()>(&mut connection)
501            .await
502            .map_err(|e| anyhow!("Redis DEL failed: {}", e))?;
503
504        self.update_stats(|stats| {
505            stats.deletes += 1;
506        })
507        .await;
508
509        Ok(())
510    }
511
512    async fn exists(&self, key: &str) -> Result<bool> {
513        // Check local cache first
514        {
515            let mut local_cache = self.local_cache.write().await;
516            if let Some(entry) = local_cache.get(key) {
517                if entry.expires_at > SystemTime::now() {
518                    return Ok(true);
519                } else {
520                    local_cache.pop(key);
521                }
522            }
523        }
524
525        // Check Redis
526        let client = self.get_redis_client(key).await?;
527        let mut connection = client
528            .get_multiplexed_async_connection()
529            .await
530            .map_err(|e| anyhow!("Failed to get Redis connection: {}", e))?;
531
532        let exists: bool = cmd("EXISTS")
533            .arg(key)
534            .query_async(&mut connection)
535            .await
536            .map_err(|e| anyhow!("Redis EXISTS failed: {}", e))?;
537
538        Ok(exists)
539    }
540
541    async fn invalidate_by_tags(&self, tags: &[String]) -> Result<u64> {
542        // This is a simplified implementation
543        // A production implementation would use Redis sets to track keys by tags
544        let mut invalidated = 0;
545
546        for tag in tags {
547            // Create a pattern to match keys with this tag
548            let pattern = format!("*{tag}*");
549
550            let clients = self.redis_pool.read().await;
551            for client in clients.iter() {
552                let mut connection = client.get_multiplexed_async_connection().await?;
553
554                let keys: Vec<String> = cmd("KEYS")
555                    .arg(&pattern)
556                    .query_async(&mut connection)
557                    .await?;
558
559                for key in keys {
560                    self.delete(&key).await?;
561                    invalidated += 1;
562                }
563            }
564        }
565
566        Ok(invalidated)
567    }
568
569    async fn get_stats(&self) -> Result<CacheStats> {
570        Ok(self.stats.read().await.clone())
571    }
572
573    async fn health_check(&self) -> Result<bool> {
574        let clients = self.redis_pool.read().await;
575
576        for client in clients.iter() {
577            match client.get_multiplexed_async_connection().await {
578                Ok(mut connection) => {
579                    let result: Result<String, _> = cmd("PING").query_async(&mut connection).await;
580                    if result.is_err() {
581                        return Ok(false);
582                    }
583                }
584                Err(_) => return Ok(false),
585            }
586        }
587
588        Ok(true)
589    }
590
591    async fn clear(&self) -> Result<()> {
592        // Clear local cache
593        {
594            let mut local_cache = self.local_cache.write().await;
595            local_cache.clear();
596        }
597
598        // Clear Redis
599        let clients = self.redis_pool.read().await;
600        for client in clients.iter() {
601            let mut connection = client.get_multiplexed_async_connection().await?;
602            cmd("FLUSHDB").query_async::<()>(&mut connection).await?;
603        }
604
605        // Reset stats
606        {
607            let mut stats = self.stats.write().await;
608            *stats = CacheStats::default();
609        }
610
611        Ok(())
612    }
613}
614
615/// Compression strategy trait
616#[async_trait]
617pub trait CompressionStrategy: Send + Sync {
618    async fn compress(&self, data: &[u8]) -> Result<Vec<u8>>;
619    async fn decompress(&self, data: &[u8]) -> Result<Vec<u8>>;
620}
621
622/// Gzip compression strategy
623pub struct GzipCompressionStrategy;
624
625impl Default for GzipCompressionStrategy {
626    fn default() -> Self {
627        Self::new()
628    }
629}
630
631impl GzipCompressionStrategy {
632    pub fn new() -> Self {
633        Self
634    }
635}
636
637#[async_trait]
638impl CompressionStrategy for GzipCompressionStrategy {
639    async fn compress(&self, data: &[u8]) -> Result<Vec<u8>> {
640        // Gzip (RFC 1952) compression via Pure-Rust oxiarc-deflate.
641        // Level 6 is the balanced default.
642        Ok(oxiarc_deflate::gzip_compress(data, 6)?)
643    }
644
645    async fn decompress(&self, data: &[u8]) -> Result<Vec<u8>> {
646        // Gzip (RFC 1952) decompression via Pure-Rust oxiarc-deflate.
647        Ok(oxiarc_deflate::gzip_decompress(data)?)
648    }
649}
650
651/// Encryption strategy trait
652#[async_trait]
653pub trait EncryptionStrategy: Send + Sync {
654    async fn encrypt(&self, data: &[u8]) -> Result<Vec<u8>>;
655    async fn decrypt(&self, data: &[u8]) -> Result<Vec<u8>>;
656}
657
658/// Length, in bytes, of the AES-256 key.
659const AES256_KEY_LEN: usize = 32;
660/// Length, in bytes, of the 96-bit AES-GCM nonce.
661const AES_GCM_NONCE_LEN: usize = 12;
662
663/// AES-256-GCM authenticated-encryption strategy (Pure Rust, via OxiCrypto).
664///
665/// On-the-wire layout of an encrypted entry is `nonce(12) || ciphertext || tag(16)`.
666/// A fresh random nonce is generated for every `encrypt` call, so identical
667/// plaintexts do not produce identical ciphertexts and nonces never repeat under
668/// the same key (a hard requirement for GCM security).
669pub struct AesEncryptionStrategy {
670    key: [u8; AES256_KEY_LEN],
671}
672
673impl AesEncryptionStrategy {
674    /// Build a strategy from a 256-bit key. Returns an error for any other length
675    /// so a misconfigured key cannot silently weaken or disable encryption.
676    pub fn new(key: &[u8]) -> Result<Self> {
677        if key.len() != AES256_KEY_LEN {
678            return Err(anyhow!(
679                "AES-256-GCM requires a {}-byte key, got {} bytes",
680                AES256_KEY_LEN,
681                key.len()
682            ));
683        }
684        let mut key_arr = [0u8; AES256_KEY_LEN];
685        key_arr.copy_from_slice(key);
686        Ok(Self { key: key_arr })
687    }
688}
689
690#[async_trait]
691impl EncryptionStrategy for AesEncryptionStrategy {
692    async fn encrypt(&self, data: &[u8]) -> Result<Vec<u8>> {
693        use oxicrypto_core::Aead;
694
695        // Fresh random 96-bit nonce per message (never reuse under the same key).
696        let nonce: [u8; AES_GCM_NONCE_LEN] = oxicrypto_rand::random_nonce()
697            .map_err(|e| anyhow!("Failed to generate AES-GCM nonce: {e}"))?;
698
699        // seal_to_vec returns `ciphertext || tag` (16-byte GCM tag appended).
700        let sealed = oxicrypto_aead::Aes256Gcm
701            .seal_to_vec(&self.key, &nonce, &[], data)
702            .map_err(|e| anyhow!("AES-256-GCM encryption failed: {e}"))?;
703
704        // Prepend the nonce so decrypt is self-describing.
705        let mut out = Vec::with_capacity(AES_GCM_NONCE_LEN + sealed.len());
706        out.extend_from_slice(&nonce);
707        out.extend_from_slice(&sealed);
708        Ok(out)
709    }
710
711    async fn decrypt(&self, data: &[u8]) -> Result<Vec<u8>> {
712        use oxicrypto_core::Aead;
713
714        if data.len() < AES_GCM_NONCE_LEN {
715            return Err(anyhow!(
716                "Ciphertext too short: {} bytes, expected at least {} (nonce)",
717                data.len(),
718                AES_GCM_NONCE_LEN
719            ));
720        }
721        let (nonce, sealed) = data.split_at(AES_GCM_NONCE_LEN);
722
723        oxicrypto_aead::Aes256Gcm
724            .open_to_vec(&self.key, nonce, &[], sealed)
725            .map_err(|e| anyhow!("AES-256-GCM decryption/authentication failed: {e}"))
726    }
727}
728
729/// GraphQL query cache manager
730#[allow(dead_code)]
731pub struct GraphQLQueryCache {
732    cache: Arc<dyn DistributedCache>,
733    config: CacheConfig,
734}
735
736impl GraphQLQueryCache {
737    /// Create a new GraphQL query cache
738    pub async fn new(config: CacheConfig) -> Result<Self> {
739        let cache = Arc::new(RedisDistributedCache::new(config.clone()).await?);
740
741        Ok(Self { cache, config })
742    }
743
744    /// Cache a GraphQL query result
745    pub async fn cache_query_result(
746        &self,
747        context: &QueryContext,
748        result: &serde_json::Value,
749        ttl: Option<Duration>,
750    ) -> Result<()> {
751        let key = context.cache_key();
752        let value = serde_json::to_vec(result)?;
753
754        self.cache.set(&key, value, ttl).await?;
755
756        info!("Cached GraphQL query result: {}", key);
757        Ok(())
758    }
759
760    /// Get cached GraphQL query result
761    pub async fn get_cached_result(
762        &self,
763        context: &QueryContext,
764    ) -> Result<Option<serde_json::Value>> {
765        let key = context.cache_key();
766
767        if let Some(cached_data) = self.cache.get(&key).await? {
768            let result: serde_json::Value = serde_json::from_slice(&cached_data)?;
769            debug!("Cache hit for GraphQL query: {}", key);
770            return Ok(Some(result));
771        }
772
773        debug!("Cache miss for GraphQL query: {}", key);
774        Ok(None)
775    }
776
777    /// Invalidate cache entries based on schema changes
778    pub async fn invalidate_on_schema_change(&self, schema_version: &str) -> Result<u64> {
779        let tags = vec![format!("schema:{}", schema_version)];
780        self.cache.invalidate_by_tags(&tags).await
781    }
782
783    /// Invalidate cache entries for specific services
784    pub async fn invalidate_for_services(&self, service_ids: &[String]) -> Result<u64> {
785        let tags: Vec<String> = service_ids
786            .iter()
787            .map(|id| format!("service:{id}"))
788            .collect();
789        self.cache.invalidate_by_tags(&tags).await
790    }
791
792    /// Get cache statistics
793    pub async fn get_stats(&self) -> Result<CacheStats> {
794        self.cache.get_stats().await
795    }
796
797    /// Health check
798    pub async fn health_check(&self) -> Result<bool> {
799        self.cache.health_check().await
800    }
801
802    /// Raw cache get for internal use
803    pub async fn raw_get(&self, key: &str) -> Result<Option<Vec<u8>>> {
804        self.cache.get(key).await
805    }
806
807    /// Raw cache set for internal use  
808    pub async fn raw_set(&self, key: &str, value: Vec<u8>, ttl: Option<Duration>) -> Result<()> {
809        self.cache.set(key, value, ttl).await
810    }
811}
812
813#[cfg(test)]
814mod tests {
815    use super::*;
816
817    #[tokio::test]
818    async fn test_query_context_cache_key() {
819        let context = QueryContext {
820            query_hash: "abc123".to_string(),
821            variables_hash: "def456".to_string(),
822            operation_name: Some("GetUser".to_string()),
823            user_id: Some("user123".to_string()),
824            service_ids: vec!["service1".to_string(), "service2".to_string()],
825            schema_version: "v1.0".to_string(),
826            requested_fields: vec!["name".to_string(), "email".to_string()],
827        };
828
829        let cache_key = context.cache_key();
830        assert!(cache_key.contains("abc123"));
831        assert!(cache_key.contains("def456"));
832        assert!(cache_key.contains("v1.0"));
833    }
834
835    #[tokio::test]
836    async fn test_gzip_compression() {
837        let compression = GzipCompressionStrategy::new();
838        // Use a larger, more repetitive string that will actually compress well
839        let original_data = b"This is a test string for compression. ".repeat(100);
840
841        let compressed = compression
842            .compress(&original_data)
843            .await
844            .expect("should succeed");
845        let decompressed = compression
846            .decompress(&compressed)
847            .await
848            .expect("should succeed");
849
850        assert_eq!(original_data.as_slice(), decompressed.as_slice());
851        assert!(compressed.len() < original_data.len()); // Should be compressed
852    }
853
854    #[tokio::test]
855    async fn regression_aes_encryption_roundtrip_is_not_plaintext() {
856        let key = [7u8; AES256_KEY_LEN];
857        let strategy = AesEncryptionStrategy::new(&key).expect("32-byte key is valid");
858        let plaintext = b"sensitive GraphQL query result with PII".to_vec();
859
860        let ciphertext = strategy.encrypt(&plaintext).await.expect("encrypt");
861        // The ciphertext must NOT contain the plaintext (the old stub returned it verbatim).
862        assert_ne!(ciphertext, plaintext);
863        assert!(
864            ciphertext
865                .windows(plaintext.len())
866                .all(|w| w != plaintext.as_slice()),
867            "ciphertext must not embed the plaintext"
868        );
869        // Nonce(12) + tag(16) overhead present.
870        assert!(ciphertext.len() >= plaintext.len() + AES_GCM_NONCE_LEN + 16);
871
872        let recovered = strategy.decrypt(&ciphertext).await.expect("decrypt");
873        assert_eq!(recovered, plaintext);
874    }
875
876    #[tokio::test]
877    async fn regression_aes_encryption_fresh_nonce_per_message() {
878        let key = [3u8; AES256_KEY_LEN];
879        let strategy = AesEncryptionStrategy::new(&key).expect("valid key");
880        let plaintext = b"same input".to_vec();
881        let c1 = strategy.encrypt(&plaintext).await.expect("encrypt 1");
882        let c2 = strategy.encrypt(&plaintext).await.expect("encrypt 2");
883        // Random nonce => identical plaintext yields distinct ciphertexts.
884        assert_ne!(c1, c2);
885    }
886
887    #[tokio::test]
888    async fn regression_aes_tamper_detection_fails_loud() {
889        let key = [9u8; AES256_KEY_LEN];
890        let strategy = AesEncryptionStrategy::new(&key).expect("valid key");
891        let mut ciphertext = strategy.encrypt(b"payload").await.expect("encrypt");
892        // Flip a bit in the tag/ciphertext region.
893        let last = ciphertext.len() - 1;
894        ciphertext[last] ^= 0x01;
895        assert!(
896            strategy.decrypt(&ciphertext).await.is_err(),
897            "tampered ciphertext must be rejected, not silently returned"
898        );
899    }
900
901    #[test]
902    fn regression_bad_key_length_rejected() {
903        assert!(AesEncryptionStrategy::new(&[0u8; 16]).is_err());
904        assert!(AesEncryptionStrategy::new(&[0u8; 32]).is_ok());
905    }
906
907    #[tokio::test]
908    async fn regression_encryption_enabled_without_key_fails_loud() {
909        let config = CacheConfig {
910            encryption_enabled: true,
911            encryption_key: None,
912            ..Default::default()
913        };
914        // Must not silently construct a plaintext cache.
915        let result = RedisDistributedCache::new(config).await;
916        assert!(
917            result.is_err(),
918            "encryption_enabled without a key must be a hard error"
919        );
920    }
921
922    #[test]
923    fn regression_encryption_key_debug_is_redacted() {
924        let key = EncryptionKey::new(vec![0xAB; 32]);
925        let dbg = format!("{key:?}");
926        assert!(dbg.contains("REDACTED"));
927        assert!(!dbg.contains("171")); // 0xAB
928    }
929}