Skip to main content

chronon_backend_redis/
queue.rs

1//! Redis sorted-set ready queue for run claims.
2
3use 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
12/// Redis ZSET layer for queued runs (`{prefix}:ready:{pool}` or hash-tagged for cluster).
13///
14/// Used only as the claim hot path inside [`crate::PostgresRedisSchedulerStore`] — not a
15/// standalone [`SchedulerStore`](chronon_core::SchedulerStore). Connect with [`Self::connect`]:
16///
17/// | Knob | Effect |
18/// |------|--------|
19/// | `url` / `CHRONON_REDIS_URL` | Standalone Redis |
20/// | `CHRONON_REDIS_CLUSTER_URLS` | Comma-separated cluster nodes |
21/// | `CHRONON_REDIS_HASH_TAGS=1` | Hash-tagged pool keys for slot affinity |
22/// | `key_prefix` (default `chronon`) | Key namespace |
23///
24/// # Examples
25///
26/// ```no_run
27/// use chronon_backend_redis::RedisQueueLayer;
28///
29/// # async fn demo() -> chronon_core::Result<()> {
30/// let redis = RedisQueueLayer::connect("redis://127.0.0.1:6379", Some("myapp")).await?;
31/// # let _ = redis;
32/// # Ok(())
33/// # }
34/// ```
35pub 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    /// Connect to Redis at `url` with optional key prefix (default `chronon`).
54    ///
55    /// When `CHRONON_REDIS_CLUSTER_URLS` is set (comma-separated), uses Redis Cluster.
56    /// When `CHRONON_REDIS_HASH_TAGS=1`, pool keys use hash tags for cluster slot affinity.
57    ///
58    /// # Errors
59    ///
60    /// Returns a storage error when the connection cannot be established.
61    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    /// Redis URL for tests (`CHRONON_TEST_REDIS_URL`, then `CHRONON_REDIS_URL`, or local default).
96    #[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    /// Enqueue a run id ordered by `scheduled_for` (ZADD score = epoch millis).
108    ///
109    /// # Errors
110    ///
111    /// Returns a storage error when Redis commands fail.
112    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    /// Atomically pop the earliest **due** run id (`score <= now`) from the pool queue.
131    ///
132    /// # Errors
133    ///
134    /// Returns a storage error when Redis commands fail.
135    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    /// Pop up to `count` earliest due run ids (`scheduled_for` score ≤ `now`).
145    ///
146    /// # Errors
147    ///
148    /// Returns a storage error when Redis commands fail.
149    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        // Atomic: take due members then remove them from the ZSET.
161        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    /// Remove a run from the ready queue (e.g. after cancellation).
195    ///
196    /// # Errors
197    ///
198    /// Returns a storage error when Redis commands fail.
199    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    /// Delete all keys with this layer's prefix (test isolation).
212    ///
213    /// # Errors
214    ///
215    /// Returns a storage error when Redis commands fail.
216    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}