1use std::sync::Arc;
4
5use chrono::{DateTime, Utc};
6use chronon_core::error::ChrononError;
7use chronon_core::Result;
8use redis::aio::ConnectionManager;
9use redis::AsyncCommands;
10use tokio::sync::Mutex;
11
12pub struct RedisQueueLayer {
36 single: Option<ConnectionManager>,
37 cluster: Option<Arc<Mutex<redis::cluster_async::ClusterConnection>>>,
38 key_prefix: String,
39 hash_tags: bool,
40}
41
42impl std::fmt::Debug for RedisQueueLayer {
43 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44 f.debug_struct("RedisQueueLayer")
45 .field("key_prefix", &self.key_prefix)
46 .field("hash_tags", &self.hash_tags)
47 .field("cluster", &self.cluster.is_some())
48 .finish_non_exhaustive()
49 }
50}
51
52impl RedisQueueLayer {
53 pub async fn connect(url: &str, key_prefix: Option<&str>) -> Result<Self> {
62 let hash_tags = std::env::var("CHRONON_REDIS_HASH_TAGS")
63 .is_ok_and(|v| matches!(v.as_str(), "1" | "true" | "yes"));
64 let prefix = key_prefix.unwrap_or("chronon").to_string();
65
66 if let Ok(urls) = std::env::var("CHRONON_REDIS_CLUSTER_URLS") {
67 let nodes: Vec<String> = urls
68 .split(',')
69 .map(str::trim)
70 .filter(|s| !s.is_empty())
71 .map(String::from)
72 .collect();
73 if !nodes.is_empty() {
74 let client = redis::cluster::ClusterClient::new(nodes).map_err(map_err)?;
75 let conn = client.get_async_connection().await.map_err(map_err)?;
76 return Ok(Self {
77 single: None,
78 cluster: Some(Arc::new(Mutex::new(conn))),
79 key_prefix: prefix,
80 hash_tags: true,
81 });
82 }
83 }
84
85 let client = redis::Client::open(url).map_err(map_err)?;
86 let conn = ConnectionManager::new(client).await.map_err(map_err)?;
87 Ok(Self {
88 single: Some(conn),
89 cluster: None,
90 key_prefix: prefix,
91 hash_tags,
92 })
93 }
94
95 #[must_use]
97 pub fn test_url() -> String {
98 std::env::var("CHRONON_TEST_REDIS_URL")
99 .or_else(|_| std::env::var("CHRONON_REDIS_URL"))
100 .unwrap_or_else(|_| "redis://127.0.0.1:6379".into())
101 }
102
103 fn ready_key(&self, pool_id: &str) -> String {
104 format_ready_key(&self.key_prefix, pool_id, self.hash_tags)
105 }
106
107 pub async fn enqueue_run(
113 &self,
114 pool_id: &str,
115 run_id: &str,
116 scheduled_for: DateTime<Utc>,
117 ) -> Result<()> {
118 let score = scheduled_for.timestamp_millis() as f64;
119 let key = self.ready_key(pool_id);
120 if let Some(conn) = &self.single {
121 let mut conn = conn.clone();
122 let _: () = conn.zadd(key, run_id, score).await.map_err(map_err)?;
123 } else if let Some(conn) = &self.cluster {
124 let mut conn = conn.lock().await;
125 let _: () = conn.zadd(key, run_id, score).await.map_err(map_err)?;
126 }
127 Ok(())
128 }
129
130 pub async fn claim_next_run_id(
136 &self,
137 pool_id: &str,
138 now: DateTime<Utc>,
139 ) -> Result<Option<String>> {
140 let ids = self.claim_next_run_ids(pool_id, 1, now).await?;
141 Ok(ids.into_iter().next())
142 }
143
144 pub async fn claim_next_run_ids(
150 &self,
151 pool_id: &str,
152 count: usize,
153 now: DateTime<Utc>,
154 ) -> Result<Vec<String>> {
155 if count == 0 {
156 return Ok(Vec::new());
157 }
158 let key = self.ready_key(pool_id);
159 let max_score = now.timestamp_millis() as f64;
160 let script = redis::Script::new(
162 r"
163 local ids = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', ARGV[1], 'LIMIT', 0, tonumber(ARGV[2]))
164 for _, id in ipairs(ids) do
165 redis.call('ZREM', KEYS[1], id)
166 end
167 return ids
168 ",
169 );
170 let ids: Vec<String> = if let Some(conn) = &self.single {
171 let mut conn = conn.clone();
172 script
173 .key(&key)
174 .arg(max_score)
175 .arg(count)
176 .invoke_async(&mut conn)
177 .await
178 .map_err(map_err)?
179 } else if let Some(conn) = &self.cluster {
180 let mut conn = conn.lock().await;
181 script
182 .key(&key)
183 .arg(max_score)
184 .arg(count)
185 .invoke_async(&mut *conn)
186 .await
187 .map_err(map_err)?
188 } else {
189 Vec::new()
190 };
191 Ok(ids)
192 }
193
194 pub async fn remove_run(&self, pool_id: &str, run_id: &str) -> Result<()> {
200 let key = self.ready_key(pool_id);
201 if let Some(conn) = &self.single {
202 let mut conn = conn.clone();
203 let _: () = conn.zrem(key, run_id).await.map_err(map_err)?;
204 } else if let Some(conn) = &self.cluster {
205 let mut conn = conn.lock().await;
206 let _: () = conn.zrem(key, run_id).await.map_err(map_err)?;
207 }
208 Ok(())
209 }
210
211 pub async fn flush_keys(&self) -> Result<()> {
217 if self.cluster.is_some() {
218 return Ok(());
219 }
220 let Some(single) = &self.single else {
221 return Ok(());
222 };
223 let pattern = format!("{}:*", self.key_prefix);
224 let mut conn = single.clone();
225 let mut cursor = 0_u64;
226 loop {
227 let (next, batch): (u64, Vec<String>) = redis::cmd("SCAN")
228 .arg(cursor)
229 .arg("MATCH")
230 .arg(&pattern)
231 .arg("COUNT")
232 .arg(500)
233 .query_async(&mut conn)
234 .await
235 .map_err(map_err)?;
236 if !batch.is_empty() {
237 let _: () = conn.del(batch).await.map_err(map_err)?;
238 }
239 cursor = next;
240 if cursor == 0 {
241 break;
242 }
243 }
244 Ok(())
245 }
246}
247
248fn format_ready_key(prefix: &str, pool_id: &str, hash_tags: bool) -> String {
249 if hash_tags {
250 format!("{prefix}:{{{pool_id}}}:ready")
251 } else {
252 format!("{prefix}:ready:{pool_id}")
253 }
254}
255
256fn map_err(e: impl std::fmt::Display) -> ChrononError {
257 ChrononError::StorageError(e.to_string())
258}
259
260#[cfg(test)]
261mod tests {
262 use chrono::Utc;
263
264 use super::{format_ready_key, RedisQueueLayer};
265
266 #[test]
267 fn ready_key_plain_and_hash_tagged() {
268 assert_eq!(
269 format_ready_key("chronon_test", "workers", false),
270 "chronon_test:ready:workers"
271 );
272 assert_eq!(
273 format_ready_key("chronon_test", "general-0", true),
274 "chronon_test:{general-0}:ready"
275 );
276 }
277
278 async fn layer() -> Option<RedisQueueLayer> {
279 let url = RedisQueueLayer::test_url();
280 let prefix = format!("chronon_test_{}", uuid_like());
281 let connect = RedisQueueLayer::connect(&url, Some(&prefix));
282 let layer = tokio::time::timeout(std::time::Duration::from_secs(2), connect)
283 .await
284 .ok()?
285 .ok()?;
286 tokio::time::timeout(std::time::Duration::from_secs(2), layer.flush_keys())
287 .await
288 .ok()?
289 .ok()?;
290 Some(layer)
291 }
292
293 fn uuid_like() -> String {
294 std::time::SystemTime::now()
295 .duration_since(std::time::UNIX_EPOCH)
296 .map_or_else(|_| "0".into(), |d| d.as_nanos().to_string())
297 }
298
299 #[tokio::test]
300 async fn enqueue_and_claim_orders_by_scheduled_for() {
301 let Some(layer) = layer().await else {
302 return;
303 };
304 let pool = "workers-order";
305 let now = Utc::now();
306 layer
307 .enqueue_run(pool, "run-late", now)
308 .await
309 .expect("enqueue");
310 layer
311 .enqueue_run(pool, "run-early", now - chrono::Duration::minutes(1))
312 .await
313 .expect("enqueue");
314
315 let first = layer.claim_next_run_id(pool, now).await.expect("claim");
316 assert_eq!(first.as_deref(), Some("run-early"));
317
318 let second = layer.claim_next_run_id(pool, now).await.expect("claim");
319 assert_eq!(second.as_deref(), Some("run-late"));
320
321 let empty = layer.claim_next_run_id(pool, now).await.expect("claim");
322 assert!(empty.is_none());
323 }
324
325 #[tokio::test]
326 async fn claim_skips_future_scheduled_for() {
327 let Some(layer) = layer().await else {
328 return;
329 };
330 let pool = "workers-future";
331 let now = Utc::now();
332 layer
333 .enqueue_run(pool, "run-future", now + chrono::Duration::minutes(5))
334 .await
335 .expect("enqueue");
336 let empty = layer.claim_next_run_id(pool, now).await.expect("claim");
337 assert!(empty.is_none());
338 }
339
340 #[tokio::test]
341 async fn claim_next_run_ids_batch() {
342 let Some(layer) = layer().await else {
343 return;
344 };
345 let pool = "workers-batch";
346 let now = Utc::now();
347 for i in 0..3_u64 {
348 layer
349 .enqueue_run(pool, &format!("run-{i}"), now)
350 .await
351 .expect("enqueue");
352 }
353 let batch = layer
354 .claim_next_run_ids(pool, 2, now)
355 .await
356 .expect("batch");
357 assert_eq!(batch.len(), 2);
358 }
359}