Skip to main content

cachekit/backend/
redis.rs

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
12// ── Error mapping ─────────────────────────────────────────────────────────────
13
14/// Sanitize Redis error messages to avoid leaking connection URL passwords.
15fn sanitize_redis_message(msg: &str) -> String {
16    // Redis URLs may contain passwords: redis://:password@host:6379
17    // Strip anything between :// and @ to remove embedded credentials.
18    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
45// ── RedisBackend ──────────────────────────────────────────────────────────────
46
47/// Redis backend powered by the `fred` client.
48///
49/// Call [`connect`](RedisBackend::connect) before issuing any cache operations.
50pub 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    /// Start building a [`RedisBackend`].
62    pub fn builder() -> RedisBackendBuilder {
63        RedisBackendBuilder::default()
64    }
65
66    /// Connect to Redis and wait until the connection is established.
67    ///
68    /// This must be called before any cache operations. The returned
69    /// `ConnectHandle` drives the connection task — hold onto it for the
70    /// lifetime of the backend (or join it on shutdown).
71    pub async fn connect(&self) -> Result<ConnectHandle, BackendError> {
72        self.client.init().await.map_err(redis_err)
73    }
74}
75
76// ── Backend impl ──────────────────────────────────────────────────────────────
77
78#[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// ── TtlInspectable impl ───────────────────────────────────────────────────────
134
135#[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        // Redis TTL return values:
141        //   -2 → key does not exist
142        //   -1 → key exists but has no TTL
143        //   N  → remaining seconds
144        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
152// ── LockableBackend impl ──────────────────────────────────────────────────────
153
154/// Callers pass the bare cache key; the backend derives the lock key on the
155/// wire. `<key>:lock` matches cachekit-py's Redis lock namespace so py and rs
156/// workloads contend on the same lock.
157fn lock_key(key: &str) -> String {
158    format!("{key}:lock")
159}
160
161/// Compare-and-delete: only the holder of the lock_id token may release.
162/// Atomic via Lua — a GET-then-DEL from the client would race with expiry
163/// and delete a lock some other client has since acquired.
164const 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        // PX must be >= 1 (Redis rejects 0); clamp like Backend::set does for EX.
177        let px = i64::try_from(timeout_ms.max(1)).unwrap_or(i64::MAX);
178
179        // SET NX PX: returns OK when the lock was free, nil when contested.
180        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// ── Builder ───────────────────────────────────────────────────────────────────
206
207/// Builder for [`RedisBackend`].
208#[derive(Default)]
209#[must_use]
210pub struct RedisBackendBuilder {
211    url: Option<String>,
212    reconnect: Option<ReconnectPolicy>,
213}
214
215impl RedisBackendBuilder {
216    /// Set the Redis connection URL (required).
217    ///
218    /// Accepts standard Redis URL formats, e.g. `redis://localhost:6379` or
219    /// `redis://:password@host:6379/0`.
220    pub fn url(mut self, url: impl Into<String>) -> Self {
221        self.url = Some(url.into());
222        self
223    }
224
225    /// Automatically re-establish dropped connections with exponential
226    /// backoff (100 ms → 30 s, retrying indefinitely).
227    ///
228    /// Initial connections still fail fast — fred's `fail_fast` default stays
229    /// on, so the policy only governs reconnecting a connection that was
230    /// previously established. Used by the reliability-first intent presets
231    /// (`CacheKit::production`, `CacheKit::secure`).
232    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    /// Consume the builder and construct a [`RedisBackend`].
238    ///
239    /// # Errors
240    ///
241    /// Returns an error if `url` was not provided or is not a valid Redis URL.
242    /// The connection itself is lazy — call [`RedisBackend::connect`] to establish it.
243    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    /// Compile-time proof that RedisBackend implements LockableBackend.
268    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        // Bare-key contract: callers never build ":lock" themselves; the
280        // backend appends it, byte-identical to cachekit-py's Redis lock.
281        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}