shaperail_runtime/cache/
store.rs1use std::collections::HashMap;
2use std::sync::Arc;
3
4use redis::AsyncCommands;
5use sha2::{Digest, Sha256};
6
7#[derive(Clone)]
12pub struct RedisCache {
13 pool: Arc<deadpool_redis::Pool>,
14}
15
16impl RedisCache {
17 pub fn new(pool: Arc<deadpool_redis::Pool>) -> Self {
19 Self { pool }
20 }
21
22 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 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 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 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 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 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 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 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, };
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", ¶ms, "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", ¶ms, "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 assert_eq!(
167 RedisCache::hash_query(¶ms1),
168 RedisCache::hash_query(¶ms2)
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(¶ms1),
182 RedisCache::hash_query(¶ms2)
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", ¶ms, "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", ¶ms, "member", "org-a");
198 let key_b = RedisCache::build_key_with_tenant("users", "list", ¶ms, "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 let params = HashMap::new();
206 let key = RedisCache::build_key("users", "list", ¶ms, "admin");
207 assert!(key.contains(":_:"));
208 }
209}