Skip to main content

siderite_cache/
redis.rs

1//! [`RedisCache`]: a [`Cache`] over [`siderite_backends::redis::RedisStore`].
2
3use crate::cache::ttl_millis;
4use crate::{Admission, AdmitMarker, Cache, CacheError};
5use async_trait::async_trait;
6use siderite_backends::redis::{MarkerAdmission, MarkerReply};
7use siderite_backends::redis::{RedisError, RedisStore};
8use std::time::Duration;
9
10/// Default namespace used when wrapping a store whose prefix is empty.
11const DEFAULT_PREFIX: &str = "siderite-cache:";
12
13/// Redis-backed [`Cache`].
14///
15/// Wraps [`RedisStore`]. Values are stored as Redis strings. Arbitrary bytes
16/// are encoded as Latin-1 (each byte becomes `U+0000..=U+00FF`) so a round
17/// trip is lossless through the store's UTF-8 API. [`Cache::clear`] deletes
18/// only this cache's prefix (`SCAN` + `DEL`); it never sends `FLUSHDB`.
19///
20/// Construct with [`RedisCache::connect`] or [`RedisCache::new`]. An empty
21/// store prefix is replaced with `siderite-cache:` so [`clear`](Cache::clear)
22/// cannot wipe a whole database.
23#[derive(Clone)]
24pub struct RedisCache {
25    store: RedisStore,
26}
27
28impl std::fmt::Debug for RedisCache {
29    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30        f.debug_struct("RedisCache")
31            .field("store", &self.store)
32            .finish()
33    }
34}
35
36impl RedisCache {
37    /// Wrap `store`. An empty prefix becomes `siderite-cache:`.
38    #[must_use]
39    pub fn new(store: RedisStore) -> Self {
40        let store = if store.prefix().is_empty() {
41            store.with_prefix(DEFAULT_PREFIX)
42        } else {
43            store
44        };
45        Self { store }
46    }
47
48    /// Open `url` and namespace keys under `siderite-cache:`.
49    ///
50    /// # Errors
51    /// [`CacheError::Backend`] when the URL is rejected or Redis cannot be
52    /// reached.
53    pub async fn connect(url: &str) -> Result<Self, CacheError> {
54        let store = RedisStore::connect(url).await?;
55        Ok(Self::new(store))
56    }
57
58    /// The underlying store (already prefixed).
59    #[must_use]
60    pub fn store(&self) -> &RedisStore {
61        &self.store
62    }
63}
64
65impl From<RedisError> for CacheError {
66    fn from(err: RedisError) -> Self {
67        Self::Backend(err.to_string())
68    }
69}
70
71/// Map each byte to `U+0000..=U+00FF` so RedisStore's UTF-8 `SET` is lossless.
72fn encode_bytes(value: Vec<u8>) -> String {
73    value.into_iter().map(char::from).collect()
74}
75
76fn decode_bytes(key: &str, value: String) -> Result<Vec<u8>, CacheError> {
77    let mut out = Vec::with_capacity(value.len());
78    for ch in value.chars() {
79        match u8::try_from(u32::from(ch)) {
80            Ok(byte) => out.push(byte),
81            Err(_) => {
82                return Err(CacheError::Backend(format!(
83                    "cache key `{key}` is not a Latin-1 Redis string"
84                )));
85            }
86        }
87    }
88    Ok(out)
89}
90
91fn is_incr_value_error(message: &str) -> bool {
92    let lower = message.to_ascii_lowercase();
93    lower.contains("not an integer")
94        || lower.contains("would overflow")
95        || lower.contains("out of range")
96}
97
98#[async_trait]
99impl Cache for RedisCache {
100    async fn get(&self, key: &str) -> Result<Option<Vec<u8>>, CacheError> {
101        match self.store.get(key).await? {
102            None => Ok(None),
103            Some(value) => decode_bytes(key, value).map(Some),
104        }
105    }
106
107    async fn set(
108        &self,
109        key: &str,
110        value: Vec<u8>,
111        ttl: Option<Duration>,
112    ) -> Result<(), CacheError> {
113        self.store.set(key, &encode_bytes(value), ttl).await?;
114        Ok(())
115    }
116
117    async fn set_if_absent(&self, key: &str, value: Vec<u8>) -> Result<bool, CacheError> {
118        Ok(self.store.set_nx(key, &encode_bytes(value)).await?)
119    }
120
121    async fn set_if_absent_with_ttl(
122        &self,
123        key: &str,
124        value: Vec<u8>,
125        ttl: Duration,
126    ) -> Result<bool, CacheError> {
127        // One `SET key value NX PX ms`: creation and expiry are a single
128        // server-side step, so a dropped future leaves no key or an
129        // expiring one, never a non-expiring marker.
130        let ttl = Duration::from_millis(ttl_millis(ttl)?);
131        Ok(self.store.set_nx_px(key, &encode_bytes(value), ttl).await?)
132    }
133
134    async fn delete(&self, key: &str) -> Result<bool, CacheError> {
135        Ok(self.store.del(key).await? > 0)
136    }
137
138    async fn increment(&self, key: &str, by: i64) -> Result<i64, CacheError> {
139        match self.store.incr_by(key, by).await {
140            Ok(value) => Ok(value),
141            Err(RedisError::Command(message)) if is_incr_value_error(&message) => {
142                Err(CacheError::NotInteger {
143                    key: key.to_owned(),
144                })
145            }
146            Err(err) => Err(err.into()),
147        }
148    }
149
150    async fn increment_with_expiry(
151        &self,
152        key: &str,
153        by: i64,
154        ttl: Duration,
155    ) -> Result<i64, CacheError> {
156        // One server-side script: increment plus expiry-if-none.
157        let ttl = Duration::from_millis(ttl_millis(ttl)?);
158        match self.store.incr_by_expire(key, by, ttl).await {
159            Ok(value) => Ok(value),
160            Err(RedisError::Command(message)) if is_incr_value_error(&message) => {
161                Err(CacheError::NotInteger {
162                    key: key.to_owned(),
163                })
164            }
165            Err(err) => Err(err.into()),
166        }
167    }
168
169    async fn clear(&self) -> Result<(), CacheError> {
170        self.store.delete_namespace().await?;
171        Ok(())
172    }
173
174    async fn admit_marker(&self, req: &AdmitMarker<'_>) -> Result<Admission, CacheError> {
175        // One server-side script (EVALSHA): epoch check, marker find or
176        // create, and the budget counted in a window taken from the Redis
177        // clock, so processes with skewed clocks share one window.
178        let token = encode_bytes(req.token.to_vec());
179        let epoch = encode_bytes(req.epoch.to_vec());
180        let call = MarkerAdmission {
181            marker: req.marker,
182            token: &token,
183            epoch_key: req.epoch_key,
184            epoch: &epoch,
185            budget_prefix: req.budget_prefix,
186            max_new: req.max_new,
187            window: req.window,
188        };
189        match self.store.admit_marker(call).await? {
190            MarkerReply::Admitted(value) => decode_bytes(req.marker, value).map(Admission::Marker),
191            MarkerReply::Stale => Ok(Admission::Stale),
192            MarkerReply::Exhausted(resets_in) => Ok(Admission::Exhausted { resets_in }),
193        }
194    }
195
196    async fn invalidate_marker(
197        &self,
198        marker: &str,
199        epoch_key: &str,
200        epoch: &[u8],
201        ttl: Duration,
202    ) -> Result<(), CacheError> {
203        // `MULTI`/`EXEC`: the epoch replacement and marker delete are one
204        // round trip and one atomic step.
205        let epoch = encode_bytes(epoch.to_vec());
206        let ttl = Duration::from_millis(ttl_millis(ttl)?);
207        Ok(self
208            .store
209            .rotate_marker(marker, epoch_key, &epoch, ttl)
210            .await?)
211    }
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217
218    #[test]
219    fn latin1_roundtrip_covers_every_byte() {
220        let original: Vec<u8> = (0..=255).collect();
221        let encoded = encode_bytes(original.clone());
222        assert_eq!(decode_bytes("k", encoded).unwrap(), original);
223    }
224
225    #[test]
226    fn decode_rejects_non_latin1() {
227        let err = decode_bytes("k", "€".into()).unwrap_err();
228        assert!(matches!(err, CacheError::Backend(_)));
229    }
230
231    #[test]
232    fn incr_error_classifier() {
233        assert!(is_incr_value_error(
234            "ERR value is not an integer or out of range"
235        ));
236        assert!(is_incr_value_error(
237            "ERR increment or decrement would overflow"
238        ));
239        assert!(!is_incr_value_error("WRONGTYPE Operation against a key"));
240    }
241}