Skip to main content

shaperail_runtime/cache/
store.rs

1use std::collections::HashMap;
2use std::sync::Arc;
3
4use redis::AsyncCommands;
5use sha2::{Digest, Sha256};
6
7/// Redis-backed response cache for GET endpoints.
8///
9/// Cache keys follow the pattern: `shaperail:<resource>:<endpoint>:<query_hash>:<user_role>`
10/// Auto-invalidation deletes all keys for a resource when a write occurs.
11#[derive(Clone)]
12pub struct RedisCache {
13    pool: Arc<deadpool_redis::Pool>,
14}
15
16impl RedisCache {
17    /// Creates a new cache backed by the given Redis pool.
18    pub fn new(pool: Arc<deadpool_redis::Pool>) -> Self {
19        Self { pool }
20    }
21
22    /// Builds a cache key from components.
23    ///
24    /// Format: `shaperail:<resource>:<endpoint>:<query_hash>:<tenant_id>:<role>`
25    /// The tenant_id segment ensures cache entries never leak across tenants (M18).
26    pub fn build_key(
27        resource: &str,
28        endpoint: &str,
29        query_params: &HashMap<String, String>,
30        user_role: &str,
31    ) -> String {
32        Self::build_key_with_tenant(resource, endpoint, query_params, user_role, "_")
33    }
34
35    /// Builds a tenant-scoped cache key (M18).
36    pub fn build_key_with_tenant(
37        resource: &str,
38        endpoint: &str,
39        query_params: &HashMap<String, String>,
40        user_role: &str,
41        tenant_id: &str,
42    ) -> String {
43        let query_hash = Self::hash_query(query_params);
44        format!("shaperail:{resource}:{endpoint}:{query_hash}:{tenant_id}:{user_role}")
45    }
46
47    /// Hashes query parameters into a deterministic short string.
48    fn hash_query(params: &HashMap<String, String>) -> String {
49        let mut sorted: Vec<(&String, &String)> = params.iter().collect();
50        sorted.sort_by_key(|(k, _)| k.as_str());
51
52        let mut hasher = Sha256::new();
53        for (k, v) in sorted {
54            hasher.update(k.as_bytes());
55            hasher.update(b"=");
56            hasher.update(v.as_bytes());
57            hasher.update(b"&");
58        }
59        let result = hasher.finalize();
60        // 16 hex chars from first 8 bytes — enough for cache keys
61        result[..8]
62            .iter()
63            .fold(String::with_capacity(16), |mut s, b| {
64                use std::fmt::Write;
65                let _ = write!(s, "{b:02x}");
66                s
67            })
68    }
69
70    /// Attempts to retrieve a cached response.
71    ///
72    /// Returns `None` on cache miss or Redis errors (fail-open).
73    pub async fn get(&self, key: &str) -> Option<String> {
74        let _span = crate::observability::telemetry::cache_span("get", key).entered();
75        let mut conn = self.pool.get().await.ok()?;
76        let result: Option<String> = conn.get(key).await.ok()?;
77        result
78    }
79
80    /// Stores a response in the cache with the given TTL.
81    ///
82    /// Silently ignores Redis errors (fail-open).
83    pub async fn set(&self, key: &str, value: &str, ttl_secs: u64) {
84        let _span = crate::observability::telemetry::cache_span("set", key).entered();
85        let Ok(mut conn) = self.pool.get().await else {
86            return;
87        };
88        let _: Result<(), _> = conn.set_ex(key, value, ttl_secs).await;
89    }
90
91    /// Invalidates all cache keys for a given resource.
92    ///
93    /// Uses SCAN to find matching keys and DEL to remove them.
94    /// Silently ignores Redis errors (fail-open).
95    pub async fn invalidate_resource(&self, resource: &str) {
96        let Ok(mut conn) = self.pool.get().await else {
97            return;
98        };
99        let pattern = format!("shaperail:{resource}:*");
100        let keys: Vec<String> = match redis::cmd("KEYS")
101            .arg(&pattern)
102            .query_async(&mut *conn)
103            .await
104        {
105            Ok(keys) => keys,
106            Err(_) => return,
107        };
108        if keys.is_empty() {
109            return;
110        }
111        let _: Result<(), _> = conn.del(keys).await;
112    }
113
114    /// Invalidates cache for a resource only if the action matches
115    /// the endpoint's `invalidate_on` list.
116    ///
117    /// If `invalidate_on` is `None`, all writes invalidate.
118    pub async fn invalidate_if_needed(
119        &self,
120        resource: &str,
121        action: &str,
122        invalidate_on: Option<&[String]>,
123    ) {
124        let should_invalidate = match invalidate_on {
125            Some(actions) => actions.iter().any(|a| a == action),
126            None => true, // No explicit list = invalidate on all writes
127        };
128        if should_invalidate {
129            self.invalidate_resource(resource).await;
130        }
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137
138    #[test]
139    fn build_key_format() {
140        let mut params = HashMap::new();
141        params.insert("filter[role]".to_string(), "admin".to_string());
142        let key = RedisCache::build_key("users", "list", &params, "member");
143        assert!(key.starts_with("shaperail:users:list:"));
144        assert!(key.ends_with(":member"));
145    }
146
147    #[test]
148    fn build_key_empty_params() {
149        let params = HashMap::new();
150        let key = RedisCache::build_key("users", "list", &params, "admin");
151        assert!(key.starts_with("shaperail:users:list:"));
152        assert!(key.ends_with(":admin"));
153    }
154
155    #[test]
156    fn hash_query_deterministic() {
157        let mut params1 = HashMap::new();
158        params1.insert("a".to_string(), "1".to_string());
159        params1.insert("b".to_string(), "2".to_string());
160
161        let mut params2 = HashMap::new();
162        params2.insert("b".to_string(), "2".to_string());
163        params2.insert("a".to_string(), "1".to_string());
164
165        // Same params in different insertion order should produce same hash
166        assert_eq!(
167            RedisCache::hash_query(&params1),
168            RedisCache::hash_query(&params2)
169        );
170    }
171
172    #[test]
173    fn hash_query_different_params() {
174        let mut params1 = HashMap::new();
175        params1.insert("a".to_string(), "1".to_string());
176
177        let mut params2 = HashMap::new();
178        params2.insert("a".to_string(), "2".to_string());
179
180        assert_ne!(
181            RedisCache::hash_query(&params1),
182            RedisCache::hash_query(&params2)
183        );
184    }
185
186    #[test]
187    fn build_key_with_tenant_includes_tenant_id() {
188        let params = HashMap::new();
189        let key = RedisCache::build_key_with_tenant("users", "list", &params, "member", "org-a");
190        assert!(key.contains(":org-a:"));
191        assert!(key.ends_with(":member"));
192    }
193
194    #[test]
195    fn cache_keys_differ_by_tenant() {
196        let params = HashMap::new();
197        let key_a = RedisCache::build_key_with_tenant("users", "list", &params, "member", "org-a");
198        let key_b = RedisCache::build_key_with_tenant("users", "list", &params, "member", "org-b");
199        assert_ne!(key_a, key_b, "Cache keys for different tenants must differ");
200    }
201
202    #[test]
203    fn build_key_backwards_compatible() {
204        // build_key (no tenant) should use "_" as placeholder
205        let params = HashMap::new();
206        let key = RedisCache::build_key("users", "list", &params, "admin");
207        assert!(key.contains(":_:"));
208    }
209}