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>:<role>`
25    pub fn build_key(
26        resource: &str,
27        endpoint: &str,
28        query_params: &HashMap<String, String>,
29        user_role: &str,
30    ) -> String {
31        let query_hash = Self::hash_query(query_params);
32        format!("shaperail:{resource}:{endpoint}:{query_hash}:{user_role}")
33    }
34
35    /// Hashes query parameters into a deterministic short string.
36    fn hash_query(params: &HashMap<String, String>) -> String {
37        let mut sorted: Vec<(&String, &String)> = params.iter().collect();
38        sorted.sort_by_key(|(k, _)| k.as_str());
39
40        let mut hasher = Sha256::new();
41        for (k, v) in sorted {
42            hasher.update(k.as_bytes());
43            hasher.update(b"=");
44            hasher.update(v.as_bytes());
45            hasher.update(b"&");
46        }
47        let result = hasher.finalize();
48        // 16 hex chars from first 8 bytes — enough for cache keys
49        result[..8]
50            .iter()
51            .fold(String::with_capacity(16), |mut s, b| {
52                use std::fmt::Write;
53                let _ = write!(s, "{b:02x}");
54                s
55            })
56    }
57
58    /// Attempts to retrieve a cached response.
59    ///
60    /// Returns `None` on cache miss or Redis errors (fail-open).
61    pub async fn get(&self, key: &str) -> Option<String> {
62        let _span = crate::observability::telemetry::cache_span("get", key).entered();
63        let mut conn = self.pool.get().await.ok()?;
64        let result: Option<String> = conn.get(key).await.ok()?;
65        result
66    }
67
68    /// Stores a response in the cache with the given TTL.
69    ///
70    /// Silently ignores Redis errors (fail-open).
71    pub async fn set(&self, key: &str, value: &str, ttl_secs: u64) {
72        let _span = crate::observability::telemetry::cache_span("set", key).entered();
73        let Ok(mut conn) = self.pool.get().await else {
74            return;
75        };
76        let _: Result<(), _> = conn.set_ex(key, value, ttl_secs).await;
77    }
78
79    /// Invalidates all cache keys for a given resource.
80    ///
81    /// Uses SCAN to find matching keys and DEL to remove them.
82    /// Silently ignores Redis errors (fail-open).
83    pub async fn invalidate_resource(&self, resource: &str) {
84        let Ok(mut conn) = self.pool.get().await else {
85            return;
86        };
87        let pattern = format!("shaperail:{resource}:*");
88        let keys: Vec<String> = match redis::cmd("KEYS")
89            .arg(&pattern)
90            .query_async(&mut *conn)
91            .await
92        {
93            Ok(keys) => keys,
94            Err(_) => return,
95        };
96        if keys.is_empty() {
97            return;
98        }
99        let _: Result<(), _> = conn.del(keys).await;
100    }
101
102    /// Invalidates cache for a resource only if the action matches
103    /// the endpoint's `invalidate_on` list.
104    ///
105    /// If `invalidate_on` is `None`, all writes invalidate.
106    pub async fn invalidate_if_needed(
107        &self,
108        resource: &str,
109        action: &str,
110        invalidate_on: Option<&[String]>,
111    ) {
112        let should_invalidate = match invalidate_on {
113            Some(actions) => actions.iter().any(|a| a == action),
114            None => true, // No explicit list = invalidate on all writes
115        };
116        if should_invalidate {
117            self.invalidate_resource(resource).await;
118        }
119    }
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125
126    #[test]
127    fn build_key_format() {
128        let mut params = HashMap::new();
129        params.insert("filter[role]".to_string(), "admin".to_string());
130        let key = RedisCache::build_key("users", "list", &params, "member");
131        assert!(key.starts_with("shaperail:users:list:"));
132        assert!(key.ends_with(":member"));
133    }
134
135    #[test]
136    fn build_key_empty_params() {
137        let params = HashMap::new();
138        let key = RedisCache::build_key("users", "list", &params, "admin");
139        assert!(key.starts_with("shaperail:users:list:"));
140        assert!(key.ends_with(":admin"));
141    }
142
143    #[test]
144    fn hash_query_deterministic() {
145        let mut params1 = HashMap::new();
146        params1.insert("a".to_string(), "1".to_string());
147        params1.insert("b".to_string(), "2".to_string());
148
149        let mut params2 = HashMap::new();
150        params2.insert("b".to_string(), "2".to_string());
151        params2.insert("a".to_string(), "1".to_string());
152
153        // Same params in different insertion order should produce same hash
154        assert_eq!(
155            RedisCache::hash_query(&params1),
156            RedisCache::hash_query(&params2)
157        );
158    }
159
160    #[test]
161    fn hash_query_different_params() {
162        let mut params1 = HashMap::new();
163        params1.insert("a".to_string(), "1".to_string());
164
165        let mut params2 = HashMap::new();
166        params2.insert("a".to_string(), "2".to_string());
167
168        assert_ne!(
169            RedisCache::hash_query(&params1),
170            RedisCache::hash_query(&params2)
171        );
172    }
173}