Skip to main content

shardline_cache/
redis.rs

1use std::{fmt, num::NonZeroU64, sync::Arc};
2
3use redis::AsyncCommands;
4use tokio::sync::Mutex;
5
6use crate::{
7    AsyncReconstructionCache, ReconstructionCacheError, ReconstructionCacheFuture,
8    ReconstructionCacheKey,
9};
10
11const RECONSTRUCTION_CACHE_PREFIX: &str = "shardline:reconstruction:v1";
12
13/// Redis-backed reconstruction cache adapter.
14pub struct RedisReconstructionCache {
15    client: redis::Client,
16    connection: Arc<Mutex<Option<redis::aio::MultiplexedConnection>>>,
17    ttl_seconds: NonZeroU64,
18}
19
20impl Clone for RedisReconstructionCache {
21    fn clone(&self) -> Self {
22        Self {
23            client: self.client.clone(),
24            connection: Arc::clone(&self.connection),
25            ttl_seconds: self.ttl_seconds,
26        }
27    }
28}
29
30impl fmt::Debug for RedisReconstructionCache {
31    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
32        formatter
33            .debug_struct("RedisReconstructionCache")
34            .field("client", &"***")
35            .field("ttl_seconds", &self.ttl_seconds)
36            .finish()
37    }
38}
39
40impl RedisReconstructionCache {
41    /// Creates a Redis-backed reconstruction cache adapter.
42    ///
43    /// # Errors
44    ///
45    /// Returns [`ReconstructionCacheError`] when the URL is empty or invalid.
46    pub fn new(redis_url: &str, ttl_seconds: NonZeroU64) -> Result<Self, ReconstructionCacheError> {
47        if redis_url.trim().is_empty() {
48            return Err(ReconstructionCacheError::EmptyRedisUrl);
49        }
50
51        Ok(Self {
52            client: redis::Client::open(redis_url)?,
53            connection: Arc::new(Mutex::new(None)),
54            ttl_seconds,
55        })
56    }
57
58    async fn get_connection(
59        &self,
60    ) -> Result<redis::aio::MultiplexedConnection, ReconstructionCacheError> {
61        let mut guard = self.connection.lock().await;
62        if let Some(ref conn) = *guard {
63            return Ok(conn.clone());
64        }
65        let conn = self.client.get_multiplexed_async_connection().await?;
66        *guard = Some(conn.clone());
67        Ok(conn)
68    }
69
70    fn redis_key(key: &ReconstructionCacheKey) -> String {
71        let scope = key.repository_scope().map_or_else(
72            || "global".to_owned(),
73            |scope| {
74                let revision = scope
75                    .revision()
76                    .map_or_else(|| "head".to_owned(), encode_component);
77                format!(
78                    "{}:{}:{}:{}",
79                    scope.provider(),
80                    encode_component(scope.owner()),
81                    encode_component(scope.repo()),
82                    revision
83                )
84            },
85        );
86        let content = key
87            .content_hash()
88            .map_or_else(|| "latest".to_owned(), encode_component);
89
90        format!(
91            "{RECONSTRUCTION_CACHE_PREFIX}:{scope}:{content}:{}",
92            encode_component(key.file_id())
93        )
94    }
95}
96
97impl AsyncReconstructionCache for RedisReconstructionCache {
98    fn ready(&self) -> ReconstructionCacheFuture<'_, ()> {
99        Box::pin(async move {
100            let mut connection = self.get_connection().await?;
101            let _pong: String = redis::cmd("PING").query_async(&mut connection).await?;
102            Ok(())
103        })
104    }
105
106    fn get<'operation>(
107        &'operation self,
108        key: &'operation ReconstructionCacheKey,
109    ) -> ReconstructionCacheFuture<'operation, Option<Vec<u8>>> {
110        Box::pin(async move {
111            let mut connection = self.get_connection().await?;
112            let redis_key = Self::redis_key(key);
113            let value: Option<Vec<u8>> = connection.get(redis_key).await?;
114            Ok(value)
115        })
116    }
117
118    fn put<'operation>(
119        &'operation self,
120        key: &'operation ReconstructionCacheKey,
121        payload: &'operation [u8],
122    ) -> ReconstructionCacheFuture<'operation, ()> {
123        Box::pin(async move {
124            let mut connection = self.get_connection().await?;
125            let redis_key = Self::redis_key(key);
126            let ttl_seconds = self.ttl_seconds.get();
127            let _: () = connection
128                .set_ex(redis_key, payload.to_vec(), ttl_seconds)
129                .await?;
130            Ok(())
131        })
132    }
133
134    fn delete<'operation>(
135        &'operation self,
136        key: &'operation ReconstructionCacheKey,
137    ) -> ReconstructionCacheFuture<'operation, bool> {
138        Box::pin(async move {
139            let mut connection = self.get_connection().await?;
140            let redis_key = Self::redis_key(key);
141            let deleted: usize = connection.del(redis_key).await?;
142            Ok(deleted > 0)
143        })
144    }
145}
146
147fn encode_component(value: &str) -> String {
148    hex::encode(value.as_bytes())
149}
150
151#[cfg(test)]
152mod tests {
153    use std::{env::var as env_var, error::Error as StdError, num::NonZeroU64};
154
155    use redis::AsyncCommands;
156
157    use super::{RECONSTRUCTION_CACHE_PREFIX, RedisReconstructionCache};
158    use crate::{AsyncReconstructionCache, ReconstructionCacheKey};
159
160    #[test]
161    fn redis_cache_debug_redacts_connection_url() {
162        let ttl_seconds = NonZeroU64::new(60).unwrap_or(NonZeroU64::MIN);
163        let cache = RedisReconstructionCache::new(
164            "redis://:cache-secret@cache.example.test:6379/0",
165            ttl_seconds,
166        );
167        assert!(cache.is_ok());
168        let Ok(cache) = cache else {
169            return;
170        };
171
172        let rendered = format!("{cache:?}");
173
174        assert!(!rendered.contains("cache-secret"));
175        assert!(rendered.contains("***"));
176    }
177
178    #[tokio::test]
179    async fn redis_cache_roundtrips_payload_when_live_url_is_available() {
180        let Some(redis_url) = env_var("STEXS_REDIS_CACHE_TEST_URL").ok() else {
181            return;
182        };
183
184        let ttl_seconds = NonZeroU64::new(60).unwrap_or(NonZeroU64::MIN);
185        let cache = RedisReconstructionCache::new(&redis_url, ttl_seconds);
186        assert!(cache.is_ok());
187        let Ok(cache) = cache else {
188            return;
189        };
190        let initial_flush = flush_matching_keys(&redis_url).await;
191        assert!(initial_flush.is_ok());
192        let key = ReconstructionCacheKey::latest("asset.bin", None);
193
194        let put = cache.put(&key, b"payload").await;
195        assert!(put.is_ok());
196        let value = cache.get(&key).await;
197        assert!(value.is_ok());
198        assert_eq!(value.ok().flatten(), Some(b"payload".to_vec()));
199
200        let final_flush = flush_matching_keys(&redis_url).await;
201        assert!(final_flush.is_ok());
202    }
203
204    async fn flush_matching_keys(redis_url: &str) -> Result<(), Box<dyn StdError>> {
205        let client = redis::Client::open(redis_url)?;
206        let mut connection = client.get_multiplexed_async_connection().await?;
207        let pattern = format!("{RECONSTRUCTION_CACHE_PREFIX}:*");
208        let keys: Vec<String> = redis::cmd("KEYS")
209            .arg(&pattern)
210            .query_async(&mut connection)
211            .await?;
212        if !keys.is_empty() {
213            let _: usize = connection.del(keys).await?;
214        }
215        Ok(())
216    }
217}