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
13pub 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 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}