1use 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
205pub(crate) const COMMAND_TIMEOUT: Duration = Duration::from_secs(5);
211
212pub(crate) fn fred_options(reconnects: bool) -> (PerformanceConfig, ConnectionConfig) {
219 let perf = PerformanceConfig {
220 default_command_timeout: COMMAND_TIMEOUT,
221 ..Default::default()
222 };
223 let mut connection = ConnectionConfig::default();
224 if reconnects {
225 connection.unresponsive.max_timeout = Some(COMMAND_TIMEOUT);
226 }
227 (perf, connection)
228}
229
230#[derive(Default)]
232#[must_use]
233pub struct RedisBackendBuilder {
234 url: Option<String>,
235 reconnect: Option<ReconnectPolicy>,
236}
237
238impl RedisBackendBuilder {
239 pub fn url(mut self, url: impl Into<String>) -> Self {
244 self.url = Some(url.into());
245 self
246 }
247
248 pub(crate) fn auto_reconnect(mut self) -> Self {
256 self.reconnect = Some(ReconnectPolicy::new_exponential(0, 100, 30_000, 2));
257 self
258 }
259
260 pub fn build(self) -> Result<RedisBackend, crate::error::CachekitError> {
267 use crate::error::CachekitError;
268
269 let url = self
270 .url
271 .filter(|u| !u.is_empty())
272 .ok_or_else(|| CachekitError::Config("url is required".to_string()))?;
273
274 let config = RedisConfig::from_url(&url).map_err(|e| {
275 CachekitError::Config(format!(
276 "invalid Redis URL: {}",
277 sanitize_redis_message(&e.to_string())
278 ))
279 })?;
280
281 let (perf, connection) = fred_options(self.reconnect.is_some());
282 let client = RedisClient::new(config, Some(perf), Some(connection), self.reconnect);
283 Ok(RedisBackend { client })
284 }
285}
286
287#[cfg(test)]
288mod tests {
289 use super::*;
290
291 fn _assert_lockable(_b: &dyn LockableBackend) {}
293
294 #[test]
295 fn redis_is_lockable() {
296 fn _check(backend: &RedisBackend) {
297 _assert_lockable(backend);
298 }
299 }
300
301 #[test]
302 fn lock_key_derives_py_compatible_namespace() {
303 assert_eq!(
306 lock_key("ns:app:func:m.f:args:abc:v1"),
307 "ns:app:func:m.f:args:abc:v1:lock"
308 );
309 }
310
311 #[test]
312 fn command_timeout_is_five_seconds_on_every_build() {
313 assert_eq!(COMMAND_TIMEOUT, Duration::from_secs(5));
314 for reconnects in [false, true] {
315 let (perf, _) = fred_options(reconnects);
316 assert_eq!(perf.default_command_timeout, COMMAND_TIMEOUT);
317 }
318 }
319
320 #[test]
321 fn unresponsive_recycling_only_with_reconnect_policy() {
322 let (_, connection) = fred_options(false);
323 assert_eq!(connection.unresponsive.max_timeout, None);
324 let (_, connection) = fred_options(true);
325 assert_eq!(connection.unresponsive.max_timeout, Some(COMMAND_TIMEOUT));
326 }
327}