cachekit/backend/
redis.rs1use std::collections::HashMap;
2use std::time::Duration;
3
4use async_trait::async_trait;
5use fred::interfaces::LuaInterface;
6use fred::prelude::*;
7use fred::types::ConnectHandle;
8
9use crate::backend::{Backend, HealthStatus, LockableBackend, TtlInspectable};
10use crate::error::{BackendError, BackendErrorKind};
11
12fn sanitize_redis_message(msg: &str) -> String {
16 if let Some(proto_end) = msg.find("://") {
19 if let Some(at_pos) = msg[proto_end..].find('@') {
20 let mut sanitized = String::with_capacity(msg.len());
21 sanitized.push_str(&msg[..proto_end + 3]);
22 sanitized.push_str("[REDACTED]");
23 sanitized.push_str(&msg[proto_end + at_pos..]);
24 return sanitized;
25 }
26 }
27 msg.to_string()
28}
29
30fn redis_err(e: RedisError) -> BackendError {
31 let kind = match e.kind() {
32 RedisErrorKind::Auth => BackendErrorKind::Authentication,
33 RedisErrorKind::IO => BackendErrorKind::Transient,
34 RedisErrorKind::Timeout => BackendErrorKind::Timeout,
35 RedisErrorKind::Canceled => BackendErrorKind::Transient,
36 _ => BackendErrorKind::Permanent,
37 };
38 BackendError {
39 kind,
40 message: sanitize_redis_message(&e.to_string()),
41 source: Some(Box::new(e)),
42 }
43}
44
45pub struct RedisBackend {
51 client: RedisClient,
52}
53
54impl std::fmt::Debug for RedisBackend {
55 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56 f.debug_struct("RedisBackend").finish_non_exhaustive()
57 }
58}
59
60impl RedisBackend {
61 pub fn builder() -> RedisBackendBuilder {
63 RedisBackendBuilder::default()
64 }
65
66 pub async fn connect(&self) -> Result<ConnectHandle, BackendError> {
72 self.client.init().await.map_err(redis_err)
73 }
74}
75
76#[cfg(not(target_arch = "wasm32"))]
79#[cfg_attr(not(feature = "unsync"), async_trait)]
80#[cfg_attr(feature = "unsync", async_trait(?Send))]
81impl Backend for RedisBackend {
82 async fn get(&self, key: &str) -> Result<Option<Vec<u8>>, BackendError> {
83 let result: Option<bytes::Bytes> = self.client.get(key).await.map_err(redis_err)?;
84 Ok(result.map(|b| b.to_vec()))
85 }
86
87 async fn set(
88 &self,
89 key: &str,
90 value: Vec<u8>,
91 ttl: Option<Duration>,
92 ) -> Result<(), BackendError> {
93 let expiration = ttl.map(|d| {
94 let secs = i64::try_from(d.as_secs().max(1)).unwrap_or(i64::MAX);
95 Expiration::EX(secs)
96 });
97 self.client
98 .set::<(), _, _>(key, value.as_slice(), expiration, None, false)
99 .await
100 .map_err(redis_err)
101 }
102
103 async fn delete(&self, key: &str) -> Result<bool, BackendError> {
104 let removed: i64 = self.client.del(key).await.map_err(redis_err)?;
105 Ok(removed > 0)
106 }
107
108 async fn exists(&self, key: &str) -> Result<bool, BackendError> {
109 let count: i64 = self.client.exists(key).await.map_err(redis_err)?;
110 Ok(count > 0)
111 }
112
113 async fn health(&self) -> Result<HealthStatus, BackendError> {
114 let start = std::time::Instant::now();
115 let _pong: String = self.client.ping().await.map_err(redis_err)?;
116 let latency = start.elapsed();
117
118 let mut details = HashMap::new();
119 details.insert("latency_ms".to_string(), latency.as_millis().to_string());
120 Ok(HealthStatus {
121 is_healthy: true,
122 latency_ms: latency.as_secs_f64() * 1000.0,
123 backend_type: "redis".to_string(),
124 details,
125 })
126 }
127
128 fn as_lockable(&self) -> Option<&dyn LockableBackend> {
129 Some(self)
130 }
131}
132
133#[cfg(not(target_arch = "wasm32"))]
136#[cfg_attr(not(feature = "unsync"), async_trait)]
137#[cfg_attr(feature = "unsync", async_trait(?Send))]
138impl TtlInspectable for RedisBackend {
139 async fn ttl(&self, key: &str) -> Result<Option<Duration>, BackendError> {
140 let secs: i64 = self.client.ttl(key).await.map_err(redis_err)?;
145 match secs {
146 ..0 => Ok(None),
147 n => Ok(Some(Duration::from_secs(n.unsigned_abs()))),
148 }
149 }
150}
151
152fn lock_key(key: &str) -> String {
158 format!("{key}:lock")
159}
160
161const RELEASE_LOCK_SCRIPT: &str = r#"if redis.call("GET", KEYS[1]) == ARGV[1] then return redis.call("DEL", KEYS[1]) else return 0 end"#;
165
166#[cfg(not(target_arch = "wasm32"))]
167#[cfg_attr(not(feature = "unsync"), async_trait)]
168#[cfg_attr(feature = "unsync", async_trait(?Send))]
169impl LockableBackend for RedisBackend {
170 async fn acquire_lock(
171 &self,
172 key: &str,
173 timeout_ms: u64,
174 ) -> Result<Option<String>, BackendError> {
175 let lock_id = uuid::Uuid::new_v4().to_string();
176 let px = i64::try_from(timeout_ms.max(1)).unwrap_or(i64::MAX);
178
179 let result: Option<String> = self
181 .client
182 .set(
183 lock_key(key),
184 lock_id.as_str(),
185 Some(Expiration::PX(px)),
186 Some(SetOptions::NX),
187 false,
188 )
189 .await
190 .map_err(redis_err)?;
191
192 Ok(result.map(|_| lock_id))
193 }
194
195 async fn release_lock(&self, key: &str, lock_id: &str) -> Result<bool, BackendError> {
196 let deleted: i64 = self
197 .client
198 .eval(RELEASE_LOCK_SCRIPT, vec![lock_key(key)], vec![lock_id])
199 .await
200 .map_err(redis_err)?;
201 Ok(deleted == 1)
202 }
203}
204
205#[derive(Default)]
209#[must_use]
210pub struct RedisBackendBuilder {
211 url: Option<String>,
212 reconnect: Option<ReconnectPolicy>,
213}
214
215impl RedisBackendBuilder {
216 pub fn url(mut self, url: impl Into<String>) -> Self {
221 self.url = Some(url.into());
222 self
223 }
224
225 pub(crate) fn auto_reconnect(mut self) -> Self {
233 self.reconnect = Some(ReconnectPolicy::new_exponential(0, 100, 30_000, 2));
234 self
235 }
236
237 pub fn build(self) -> Result<RedisBackend, crate::error::CachekitError> {
244 use crate::error::CachekitError;
245
246 let url = self
247 .url
248 .filter(|u| !u.is_empty())
249 .ok_or_else(|| CachekitError::Config("url is required".to_string()))?;
250
251 let config = RedisConfig::from_url(&url).map_err(|e| {
252 CachekitError::Config(format!(
253 "invalid Redis URL: {}",
254 sanitize_redis_message(&e.to_string())
255 ))
256 })?;
257
258 let client = RedisClient::new(config, None, None, self.reconnect);
259 Ok(RedisBackend { client })
260 }
261}
262
263#[cfg(test)]
264mod tests {
265 use super::*;
266
267 fn _assert_lockable(_b: &dyn LockableBackend) {}
269
270 #[test]
271 fn redis_is_lockable() {
272 fn _check(backend: &RedisBackend) {
273 _assert_lockable(backend);
274 }
275 }
276
277 #[test]
278 fn lock_key_derives_py_compatible_namespace() {
279 assert_eq!(
282 lock_key("ns:app:func:m.f:args:abc:v1"),
283 "ns:app:func:m.f:args:abc:v1:lock"
284 );
285 }
286}