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/// Per-command deadline. fred's default is 0 (no timeout), so a stalled or
208/// black-holed server would hold every op until TCP gives up and the circuit
209/// breaker could never open. 5 s matches cachekit-py and cachekit-ts.
210pub(crate) const COMMAND_TIMEOUT: Duration = Duration::from_secs(5);
211
212/// fred client options for [`RedisBackendBuilder::build`].
213///
214/// The unresponsive-connection check is enabled only with a reconnect policy:
215/// fred closes a connection it deems unresponsive, and without a policy it is
216/// never re-established, so one stall past the threshold would leave the
217/// client failing permanently.
218pub(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/// Builder for [`RedisBackend`].
231#[derive(Default)]
232#[must_use]
233pub struct RedisBackendBuilder {
234    url: Option<String>,
235    reconnect: Option<ReconnectPolicy>,
236}
237
238impl RedisBackendBuilder {
239    /// Set the Redis connection URL (required).
240    ///
241    /// Accepts standard Redis URL formats, e.g. `redis://localhost:6379` or
242    /// `redis://:password@host:6379/0`.
243    pub fn url(mut self, url: impl Into<String>) -> Self {
244        self.url = Some(url.into());
245        self
246    }
247
248    /// Automatically re-establish dropped connections with exponential
249    /// backoff (100 ms → 30 s, retrying indefinitely).
250    ///
251    /// Initial connections still fail fast — fred's `fail_fast` default stays
252    /// on, so the policy only governs reconnecting a connection that was
253    /// previously established. Used by the reliability-first intent presets
254    /// (`CacheKit::production`, `CacheKit::secure`).
255    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    /// Consume the builder and construct a [`RedisBackend`].
261    ///
262    /// # Errors
263    ///
264    /// Returns an error if `url` was not provided or is not a valid Redis URL.
265    /// The connection itself is lazy — call [`RedisBackend::connect`] to establish it.
266    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    /// Compile-time proof that RedisBackend implements LockableBackend.
292    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        // Bare-key contract: callers never build ":lock" themselves; the
304        // backend appends it, byte-identical to cachekit-py's Redis lock.
305        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}