featherbit/stores/
redis_cache.rs1use std::collections::HashMap;
9use std::sync::Arc;
10use std::time::Duration;
11
12use async_trait::async_trait;
13use bytes::Bytes;
14use redis::AsyncCommands;
15
16use crate::stores::namespaces;
17use crate::stores::redis_store::RedisStoreClient;
18use crate::traffic::{CacheError, CachedResponse, ResponseCache};
19
20pub(crate) fn cache_key(prefix: &str, key: &str) -> String {
22 format!("{}:{}:{}", prefix, namespaces::CACHE, key)
23}
24
25pub(crate) fn escape_glob(s: &str) -> String {
30 let mut out = String::with_capacity(s.len());
31 for c in s.chars() {
32 if matches!(c, '*' | '?' | '[' | ']' | '\\') {
33 out.push('\\');
34 }
35 out.push(c);
36 }
37 out
38}
39
40const UNLINK_BATCH: usize = 200;
43
44pub struct RedisResponseCache {
45 pub(crate) client: Arc<RedisStoreClient>,
46}
47
48impl RedisResponseCache {
49 pub fn new(client: Arc<RedisStoreClient>) -> Self {
50 Self { client }
51 }
52
53 pub(crate) fn redis_key(&self, key: &str) -> String {
54 cache_key(self.client.key_prefix(), key)
55 }
56}
57
58#[async_trait]
59impl ResponseCache for RedisResponseCache {
60 async fn get(&self, key: &str) -> Result<Option<CachedResponse>, CacheError> {
61 let mut conn = self.client.conn().await.map_err(CacheError)?;
62 let fields: HashMap<String, Vec<u8>> = conn
63 .hgetall(self.redis_key(key))
64 .await
65 .map_err(|e| CacheError(e.to_string()))?;
66
67 if fields.is_empty() {
68 return Ok(None);
69 }
70
71 let (Some(status), Some(headers), Some(body)) = (
75 fields.get("status"),
76 fields.get("headers"),
77 fields.get("body"),
78 ) else {
79 return Ok(None);
80 };
81 let Ok(status) = String::from_utf8_lossy(status).parse::<u16>() else {
82 return Ok(None);
83 };
84 let Ok(headers) = serde_json::from_slice::<HashMap<String, Vec<String>>>(headers) else {
85 return Ok(None);
86 };
87
88 Ok(Some(CachedResponse {
89 status,
90 headers,
91 body: Bytes::copy_from_slice(body),
92 }))
93 }
94
95 async fn put(
96 &self,
97 key: &str,
98 entry: &CachedResponse,
99 ttl: Duration,
100 ) -> Result<(), CacheError> {
101 let mut conn = self.client.conn().await.map_err(CacheError)?;
102 let redis_key = self.redis_key(key);
103 let headers = serde_json::to_vec(&entry.headers).map_err(|e| CacheError(e.to_string()))?;
104
105 redis::pipe()
108 .atomic()
109 .hset(&redis_key, "status", entry.status.to_string())
110 .hset(&redis_key, "headers", headers)
111 .hset(&redis_key, "body", entry.body.as_ref())
112 .expire(&redis_key, ttl.as_secs().max(1) as i64)
113 .query_async::<()>(&mut conn)
114 .await
115 .map_err(|e| CacheError(e.to_string()))
116 }
117
118 async fn purge(&self, id: &str) -> Result<u64, CacheError> {
119 let mut conn = self.client.conn().await.map_err(CacheError)?;
120 let literal = self.redis_key(&crate::traffic::cache::pair_prefix(id));
126 let pattern = format!("{}*", escape_glob(&literal));
127
128 let mut cursor: u64 = 0;
132 let mut removed: u64 = 0;
133 loop {
134 let (next, keys): (u64, Vec<String>) = redis::cmd("SCAN")
135 .arg(cursor)
136 .arg("MATCH")
137 .arg(&pattern)
138 .arg("COUNT")
139 .arg(UNLINK_BATCH)
140 .query_async(&mut conn)
141 .await
142 .map_err(|e| CacheError(e.to_string()))?;
143
144 for chunk in keys.chunks(UNLINK_BATCH) {
145 let n: u64 = redis::cmd("UNLINK")
146 .arg(chunk)
147 .query_async(&mut conn)
148 .await
149 .map_err(|e| CacheError(e.to_string()))?;
150 removed += n;
151 }
152
153 if next == 0 {
154 break;
155 }
156 cursor = next;
157 }
158 Ok(removed)
159 }
160}
161
162#[cfg(all(test, feature = "redis-store"))]
163mod tests {
164 use super::*;
165
166 #[test]
170 fn test_escape_glob_neutralises_every_metacharacter() {
171 assert_eq!(escape_glob("plain"), "plain");
172 assert_eq!(escape_glob("a*b?c[d]e\\f"), "a\\*b\\?c\\[d\\]e\\\\f");
173 }
174
175 fn store_url() -> Option<String> {
176 std::env::var("FEATHERBIT_TEST_REDIS_URL")
177 .ok()
178 .filter(|s| !s.is_empty())
179 }
180
181 fn client(url: &str) -> Arc<crate::stores::redis_store::RedisStoreClient> {
182 let cfg: crate::config::StoreConfig = serde_yaml::from_str(&format!(
183 "name: cache-test\ntype: redis\nurl: {url}\nkey_prefix: fbtest\n"
184 ))
185 .unwrap();
186 Arc::new(crate::stores::redis_store::RedisStoreClient::build(&cfg).unwrap())
187 }
188
189 fn response(body: &[u8]) -> CachedResponse {
190 let mut headers = HashMap::new();
191 headers.insert(
192 "x-multi".to_string(),
193 vec!["a".to_string(), "b".to_string()],
194 );
195 CachedResponse {
196 status: 203,
197 headers,
198 body: Bytes::copy_from_slice(body),
199 }
200 }
201
202 #[tokio::test]
206 async fn test_an_entry_written_by_one_handle_is_read_by_another() {
207 let Some(url) = store_url() else { return };
208 let writer = RedisResponseCache::new(client(&url));
209 let reader = RedisResponseCache::new(client(&url));
210 let key = format!("share-{}", uuid::Uuid::new_v4());
211
212 writer
213 .put(&key, &response(b"shared"), Duration::from_secs(60))
214 .await
215 .unwrap();
216
217 let got = reader
218 .get(&key)
219 .await
220 .unwrap()
221 .expect("the second handle must see it");
222 assert_eq!(got.body, Bytes::from_static(b"shared"));
223 }
224
225 #[tokio::test]
228 async fn test_a_binary_body_and_multi_value_header_round_trip() {
229 let Some(url) = store_url() else { return };
230 let cache = RedisResponseCache::new(client(&url));
231 let key = format!("bin-{}", uuid::Uuid::new_v4());
232 let body = vec![0u8, 159, 146, 150, 255];
233
234 cache
235 .put(&key, &response(&body), Duration::from_secs(60))
236 .await
237 .unwrap();
238
239 let got = cache.get(&key).await.unwrap().unwrap();
240 assert_eq!(got.status, 203);
241 assert_eq!(got.body.as_ref(), body.as_slice());
242 assert_eq!(
243 got.headers.get("x-multi").unwrap(),
244 &vec!["a".to_string(), "b".to_string()]
245 );
246 }
247
248 #[tokio::test]
249 async fn test_a_missing_key_is_ok_none() {
250 let Some(url) = store_url() else { return };
251 let cache = RedisResponseCache::new(client(&url));
252 assert!(cache
253 .get(&format!("absent-{}", uuid::Uuid::new_v4()))
254 .await
255 .unwrap()
256 .is_none());
257 }
258
259 #[tokio::test]
262 async fn test_the_key_carries_a_ttl() {
263 let Some(url) = store_url() else { return };
264 let cache = RedisResponseCache::new(client(&url));
265 let key = format!("ttl-{}", uuid::Uuid::new_v4());
266 cache
267 .put(&key, &response(b"x"), Duration::from_secs(30))
268 .await
269 .unwrap();
270
271 let mut conn = cache.client.conn().await.unwrap();
272 let ttl: i64 = redis::cmd("TTL")
273 .arg(cache.redis_key(&key))
274 .query_async(&mut conn)
275 .await
276 .unwrap();
277 assert!(
278 ttl > 0,
279 "the entry must expire on redis's clock, not ours: {ttl}"
280 );
281 }
282
283 #[tokio::test]
287 async fn test_redis_purge_removes_only_the_named_pair_across_scan_pages() {
288 let Some(url) = store_url() else { return };
289 let cache = RedisResponseCache::new(client(&url));
290 let run = uuid::Uuid::new_v4();
291 let target = format!("purge-{run}");
292 let sibling = format!("purge-{run}-v2");
293 let ttl = Duration::from_secs(60);
294
295 for i in 0..250 {
296 cache
297 .put(&format!("{target}\u{1}/{i}"), &response(b"x"), ttl)
298 .await
299 .unwrap();
300 }
301 cache
302 .put(&format!("{sibling}\u{1}/0"), &response(b"keep"), ttl)
303 .await
304 .unwrap();
305
306 let removed = cache.purge(&target).await.unwrap();
307
308 assert_eq!(
309 removed, 250,
310 "every entry of the pair, across more than one SCAN page"
311 );
312 assert!(cache
313 .get(&format!("{target}\u{1}/0"))
314 .await
315 .unwrap()
316 .is_none());
317 assert!(
318 cache
319 .get(&format!("{sibling}\u{1}/0"))
320 .await
321 .unwrap()
322 .is_some(),
323 "the sibling pair must be untouched"
324 );
325 }
326}