Skip to main content

azums_redis/
backend.rs

1use async_trait::async_trait;
2use azums_core::{
3    backend::{NotificationStream, StorageBackend, StreamBackend},
4    model::{ConsumerGroupStatus, Event, Job, JobListItem, JobStatus, NewEvent, NewJob},
5};
6use chrono::{DateTime, Utc};
7use redis::aio::ConnectionManager;
8use redis::AsyncCommands;
9use std::{
10    collections::HashMap,
11    sync::{Arc, RwLock},
12};
13use uuid::Uuid;
14
15/// Production-grade Redis implementation of [`StorageBackend`] and [`StreamBackend`].
16#[derive(Clone)]
17pub struct RedisBackend {
18    client: redis::Client,
19    conn_mgr: ConnectionManager,
20    notifiers: Arc<RwLock<HashMap<String, tokio::sync::broadcast::Sender<()>>>>,
21    stream_notifiers: Arc<RwLock<HashMap<String, tokio::sync::broadcast::Sender<()>>>>,
22}
23
24impl RedisBackend {
25    /// Creates a new `RedisBackend` from a Redis connection URL (e.g., `"redis://127.0.0.1:6379"`).
26    pub async fn new(redis_url: impl AsRef<str>) -> anyhow::Result<Self> {
27        let client = redis::Client::open(redis_url.as_ref())?;
28        let conn_mgr = ConnectionManager::new(client.clone()).await?;
29
30        Ok(Self {
31            client,
32            conn_mgr,
33            notifiers: Arc::new(RwLock::new(HashMap::new())),
34            stream_notifiers: Arc::new(RwLock::new(HashMap::new())),
35        })
36    }
37
38    /// Returns reference to the underlying Redis `Client`.
39    pub fn client(&self) -> &redis::Client {
40        &self.client
41    }
42
43    fn notify_queue_local(&self, queue: &str) {
44        let notifiers = self.notifiers.read().unwrap();
45        if let Some(tx) = notifiers.get(queue) {
46            let _ = tx.send(());
47        }
48    }
49
50    fn notify_stream_local(&self, stream: &str) {
51        let notifiers = self.stream_notifiers.read().unwrap();
52        if let Some(tx) = notifiers.get(stream) {
53            let _ = tx.send(());
54        }
55    }
56}
57
58#[async_trait]
59impl StorageBackend for RedisBackend {
60    fn as_stream(&self) -> Option<&dyn StreamBackend> {
61        Some(self)
62    }
63
64    async fn run_migrations(&self) -> anyhow::Result<()> {
65        let mut conn = self.conn_mgr.clone();
66        let _: String = redis::cmd("PING").query_async(&mut conn).await?;
67        Ok(())
68    }
69
70    async fn health_check(&self) -> anyhow::Result<()> {
71        let mut conn = self.conn_mgr.clone();
72        let res: String = redis::cmd("PING").query_async(&mut conn).await?;
73        if res == "PONG" || !res.is_empty() {
74            Ok(())
75        } else {
76            anyhow::bail!("Redis health check failed")
77        }
78    }
79
80    async fn enqueue(&self, job: NewJob) -> anyhow::Result<Uuid> {
81        let job_id = Uuid::new_v4();
82        let now = Utc::now();
83
84        let job_entity = Job {
85            dataset_id: "default".to_string(),
86            replay_of_job_id: None,
87            id: job_id,
88            queue: job.queue.clone(),
89            job_type: job.job_type,
90            payload: job.payload_json,
91            run_at: job.run_at,
92            status: JobStatus::Queued.as_str().to_string(),
93            priority: job.priority,
94            max_attempts: job.max_attempts,
95            locked_at: None,
96            locked_by: None,
97            lock_expires_at: None,
98            dlq_reason_code: None,
99            dlq_at: None,
100            created_at: now,
101            updated_at: now,
102        };
103
104        let json_str = serde_json::to_string(&job_entity)?;
105        let mut conn = self.conn_mgr.clone();
106
107        let _: () = conn
108            .hset("azums:jobs", job_id.to_string(), json_str)
109            .await?;
110        let queue_key = format!("azums:queue:{}", job.queue);
111        let _: () = conn.rpush(queue_key, job_id.to_string()).await?;
112
113        let notify_channel = format!("azums:notify:{}", job.queue);
114        let _: () = conn.publish(notify_channel, "1").await?;
115
116        self.notify_queue_local(&job.queue);
117
118        Ok(job_id)
119    }
120
121    async fn subscribe(&self, queue: &str) -> anyhow::Result<NotificationStream> {
122        use tokio_stream::wrappers::BroadcastStream;
123        use tokio_stream::StreamExt;
124
125        let channel = format!("azums:notify:{queue}");
126        let client_clone = self.client.clone();
127        let tx_clone = {
128            let mut notifiers = self.notifiers.write().unwrap();
129            notifiers
130                .entry(queue.to_string())
131                .or_insert_with(|| tokio::sync::broadcast::channel(128).0)
132                .clone()
133        };
134
135        let tx_spawn = tx_clone.clone();
136        // Spawn a dedicated, unpooled PubSub socket listener
137        tokio::spawn(async move {
138            if let Ok(mut pubsub) = client_clone.get_async_pubsub().await {
139                if pubsub.subscribe(&channel).await.is_ok() {
140                    let mut stream = pubsub.into_on_message();
141                    while stream.next().await.is_some() {
142                        let _ = tx_spawn.send(());
143                    }
144                }
145            }
146        });
147
148        let rx = tx_clone.subscribe();
149        let bcast_stream = BroadcastStream::new(rx).filter_map(|res| res.ok());
150        let interval_stream = tokio_stream::wrappers::IntervalStream::new(tokio::time::interval(
151            std::time::Duration::from_millis(100),
152        ))
153        .map(|_| ());
154
155        let merged = bcast_stream.merge(interval_stream);
156        Ok(Box::pin(merged))
157    }
158
159    async fn lease_jobs_batch(
160        &self,
161        queue: &str,
162        worker_id: &str,
163        lease_seconds: i64,
164        batch_size: i64,
165    ) -> anyhow::Result<Vec<Job>> {
166        let mut conn = self.conn_mgr.clone();
167        let queue_key = format!("azums:queue:{}", queue);
168        let processing_key = format!("azums:processing:{}:{}", queue, worker_id);
169        let now = Utc::now();
170        let lock_expires_at = now + chrono::Duration::seconds(lease_seconds);
171
172        let mut leased = Vec::new();
173        let batch_size = batch_size.clamp(1, 100) as usize;
174
175        for _ in 0..batch_size {
176            let job_id_str: Option<String> = redis::cmd("LMOVE")
177                .arg(&queue_key)
178                .arg(&processing_key)
179                .arg("LEFT")
180                .arg("RIGHT")
181                .query_async(&mut conn)
182                .await
183                .ok();
184
185            let job_id_str = match job_id_str {
186                Some(id) if !id.is_empty() => id,
187                _ => break,
188            };
189
190            let json_str: Option<String> = conn.hget("azums:jobs", &job_id_str).await?;
191            if let Some(json) = json_str {
192                if let Ok(mut job) = serde_json::from_str::<Job>(&json) {
193                    if job.run_at > now {
194                        // Put back at head if run_at is in the future
195                        let _: () = conn.lpush(&queue_key, &job_id_str).await?;
196                        let _: () = conn.lrem(&processing_key, 1, &job_id_str).await?;
197                        continue;
198                    }
199
200                    job.status = JobStatus::Running.as_str().to_string();
201                    job.locked_at = Some(now);
202                    job.locked_by = Some(worker_id.to_string());
203                    job.lock_expires_at = Some(lock_expires_at);
204                    job.updated_at = now;
205
206                    let updated_json = serde_json::to_string(&job)?;
207                    let _: () = conn.hset("azums:jobs", &job_id_str, updated_json).await?;
208                    leased.push(job);
209                }
210            }
211        }
212
213        Ok(leased)
214    }
215
216    async fn lease_jobs_batch_with_ordering(
217        &self,
218        queue: &str,
219        worker_id: &str,
220        lease_seconds: i64,
221        batch_size: i64,
222        ordering: azums_core::QueueOrdering,
223    ) -> anyhow::Result<Vec<Job>> {
224        let _ = ordering; // Redis RPUSH (enqueue) and LMOVE LEFT RIGHT (dequeue) natively preserve strict FIFO insertion order
225        self.lease_jobs_batch(queue, worker_id, lease_seconds, batch_size)
226            .await
227    }
228
229    async fn reap_expired_locks(&self) -> anyhow::Result<u64> {
230        let mut conn = self.conn_mgr.clone();
231        let keys: Vec<String> = conn.keys("azums:processing:*").await.unwrap_or_default();
232        let now = Utc::now();
233        let mut reaped = 0u64;
234
235        for proc_key in keys {
236            let parts: Vec<&str> = proc_key.split(':').collect();
237            if parts.len() < 4 {
238                continue;
239            }
240            let queue = parts[2];
241            let queue_key = format!("azums:queue:{}", queue);
242
243            let job_ids: Vec<String> = conn.lrange(&proc_key, 0, -1).await.unwrap_or_default();
244            for jid in job_ids {
245                if let Ok(Some(json)) = conn.hget::<_, _, Option<String>>("azums:jobs", &jid).await
246                {
247                    if let Ok(mut job) = serde_json::from_str::<Job>(&json) {
248                        if let Some(exp) = job.lock_expires_at {
249                            if exp <= now {
250                                job.status = JobStatus::Queued.as_str().to_string();
251                                job.locked_at = None;
252                                job.locked_by = None;
253                                job.lock_expires_at = None;
254                                job.updated_at = now;
255
256                                if let Ok(updated_json) = serde_json::to_string(&job) {
257                                    let _: () = conn
258                                        .hset("azums:jobs", &jid, updated_json)
259                                        .await
260                                        .unwrap_or(());
261                                    let _: () = conn.lrem(&proc_key, 1, &jid).await.unwrap_or(());
262                                    let _: () = conn.rpush(&queue_key, &jid).await.unwrap_or(());
263                                    reaped += 1;
264                                }
265                            }
266                        }
267                    }
268                }
269            }
270        }
271
272        Ok(reaped)
273    }
274
275    async fn start_attempts_batch(
276        &self,
277        _dataset_ids: &[String],
278        job_ids: &[Uuid],
279        _worker_id: &str,
280    ) -> anyhow::Result<Vec<(Uuid, Uuid, i32)>> {
281        let mut conn = self.conn_mgr.clone();
282        let mut results = Vec::with_capacity(job_ids.len());
283
284        for &job_id in job_ids {
285            let attempt_id = Uuid::new_v4();
286            let attempts_key = format!("azums:attempts:{}", job_id);
287            let attempt_no: i32 = conn.incr(&attempts_key, 1).await?;
288            results.push((job_id, attempt_id, attempt_no));
289        }
290
291        Ok(results)
292    }
293
294    async fn mark_succeeded(
295        &self,
296        job_id: Uuid,
297        _attempt_id: Uuid,
298        worker_id: &str,
299        _latency_ms: i32,
300    ) -> anyhow::Result<()> {
301        let mut conn = self.conn_mgr.clone();
302        let job_id_str = job_id.to_string();
303
304        if let Ok(Some(json)) = conn
305            .hget::<_, _, Option<String>>("azums:jobs", &job_id_str)
306            .await
307        {
308            if let Ok(mut job) = serde_json::from_str::<Job>(&json) {
309                job.status = JobStatus::Succeeded.as_str().to_string();
310                job.updated_at = Utc::now();
311                let updated_json = serde_json::to_string(&job)?;
312                let _: () = conn.hset("azums:jobs", &job_id_str, updated_json).await?;
313
314                let proc_key = format!("azums:processing:{}:{}", job.queue, worker_id);
315                let _: () = conn.lrem(proc_key, 1, &job_id_str).await?;
316            }
317        }
318
319        Ok(())
320    }
321
322    async fn mark_succeeded_batch(
323        &self,
324        _dataset_id: &str,
325        updates: &[(Uuid, Uuid, i32)],
326        worker_id: &str,
327    ) -> anyhow::Result<()> {
328        for &(job_id, attempt_id, latency_ms) in updates {
329            self.mark_succeeded(job_id, attempt_id, worker_id, latency_ms)
330                .await?;
331        }
332        Ok(())
333    }
334
335    async fn reschedule_for_retry(
336        &self,
337        job_id: Uuid,
338        _attempt_id: Uuid,
339        worker_id: &str,
340        _latency_ms: i32,
341        next_run_at: DateTime<Utc>,
342        _error_code: &str,
343        _error_message: &str,
344        _attempt_no: i32,
345    ) -> anyhow::Result<()> {
346        let mut conn = self.conn_mgr.clone();
347        let job_id_str = job_id.to_string();
348
349        if let Ok(Some(json)) = conn
350            .hget::<_, _, Option<String>>("azums:jobs", &job_id_str)
351            .await
352        {
353            if let Ok(mut job) = serde_json::from_str::<Job>(&json) {
354                job.status = JobStatus::Queued.as_str().to_string();
355                job.run_at = next_run_at;
356                job.locked_at = None;
357                job.locked_by = None;
358                job.lock_expires_at = None;
359                job.updated_at = Utc::now();
360
361                let updated_json = serde_json::to_string(&job)?;
362                let _: () = conn.hset("azums:jobs", &job_id_str, updated_json).await?;
363
364                let proc_key = format!("azums:processing:{}:{}", job.queue, worker_id);
365                let _: () = conn.lrem(proc_key, 1, &job_id_str).await?;
366
367                let queue_key = format!("azums:queue:{}", job.queue);
368                let _: () = conn.rpush(queue_key, &job_id_str).await?;
369            }
370        }
371
372        Ok(())
373    }
374
375    async fn mark_dlq(
376        &self,
377        job_id: Uuid,
378        _attempt_id: Uuid,
379        worker_id: &str,
380        _latency_ms: i32,
381        reason_code: &str,
382        _error_code: &str,
383        _error_message: &str,
384        _attempt_no: i32,
385    ) -> anyhow::Result<()> {
386        let mut conn = self.conn_mgr.clone();
387        let job_id_str = job_id.to_string();
388
389        if let Ok(Some(json)) = conn
390            .hget::<_, _, Option<String>>("azums:jobs", &job_id_str)
391            .await
392        {
393            if let Ok(mut job) = serde_json::from_str::<Job>(&json) {
394                job.status = JobStatus::Dlq.as_str().to_string();
395                job.dlq_reason_code = Some(reason_code.to_string());
396                job.dlq_at = Some(Utc::now());
397                job.updated_at = Utc::now();
398
399                let updated_json = serde_json::to_string(&job)?;
400                let _: () = conn.hset("azums:jobs", &job_id_str, updated_json).await?;
401
402                let proc_key = format!("azums:processing:{}:{}", job.queue, worker_id);
403                let _: () = conn.lrem(proc_key, 1, &job_id_str).await?;
404            }
405        }
406
407        Ok(())
408    }
409
410    async fn archive_succeeded_older_than(
411        &self,
412        _cutoff: DateTime<Utc>,
413        _limit: i64,
414    ) -> anyhow::Result<u64> {
415        Ok(0)
416    }
417
418    async fn delete_history_for_succeeded_older_than(
419        &self,
420        _cutoff: DateTime<Utc>,
421        _limit: i64,
422    ) -> anyhow::Result<(u64, u64)> {
423        Ok((0, 0))
424    }
425
426    async fn perform_maintenance(&self) -> anyhow::Result<()> {
427        let _ = self.reap_expired_locks().await;
428        Ok(())
429    }
430
431    async fn extend_lease(
432        &self,
433        job_id: Uuid,
434        worker_id: &str,
435        lease_seconds: i64,
436    ) -> anyhow::Result<bool> {
437        let mut conn = self.conn_mgr.clone();
438        let job_key = job_id.to_string();
439        let json_str: Option<String> = conn.hget("azums:jobs", &job_key).await?;
440
441        if let Some(json) = json_str {
442            if let Ok(mut job) = serde_json::from_str::<Job>(&json) {
443                if job.status == "running" && job.locked_by.as_deref() == Some(worker_id) {
444                    let now = Utc::now();
445                    job.lock_expires_at = Some(now + chrono::Duration::seconds(lease_seconds));
446                    job.updated_at = now;
447
448                    let updated_json = serde_json::to_string(&job)?;
449                    let _: () = conn.hset("azums:jobs", &job_key, updated_json).await?;
450                    return Ok(true);
451                }
452            }
453        }
454
455        Ok(false)
456    }
457
458    async fn get_job(&self, job_id: Uuid) -> anyhow::Result<Option<Job>> {
459        let mut conn = self.conn_mgr.clone();
460        let json_str: Option<String> = conn.hget("azums:jobs", job_id.to_string()).await?;
461        match json_str {
462            Some(json) => Ok(serde_json::from_str(&json).ok()),
463            None => Ok(None),
464        }
465    }
466
467    async fn list_jobs(
468        &self,
469        queue: Option<&str>,
470        status: Option<&str>,
471        limit: i64,
472        _cursor_created_at: Option<DateTime<Utc>>,
473        _cursor_id: Option<Uuid>,
474    ) -> anyhow::Result<Vec<JobListItem>> {
475        let mut conn = self.conn_mgr.clone();
476        let map: HashMap<String, String> = conn.hgetall("azums:jobs").await.unwrap_or_default();
477
478        let mut items = Vec::new();
479        for json in map.values() {
480            if let Ok(job) = serde_json::from_str::<Job>(json) {
481                if let Some(q) = queue {
482                    if job.queue != q {
483                        continue;
484                    }
485                }
486                if let Some(st) = status {
487                    if job.status != st {
488                        continue;
489                    }
490                }
491
492                items.push(JobListItem {
493                    id: job.id,
494                    queue: job.queue,
495                    job_type: job.job_type,
496                    status: job.status,
497                    run_at: job.run_at,
498                    priority: job.priority,
499                    max_attempts: job.max_attempts,
500                    last_error_code: None,
501                    last_error_message: None,
502                    dlq_reason_code: job.dlq_reason_code,
503                    created_at: job.created_at,
504                    updated_at: job.updated_at,
505                });
506            }
507        }
508
509        items.sort_by_key(|a| std::cmp::Reverse(a.created_at));
510        items.truncate(limit.clamp(1, 500) as usize);
511        Ok(items)
512    }
513
514    async fn replay_job(
515        &self,
516        job_id: Uuid,
517        override_queue: Option<&str>,
518        override_run_at: Option<DateTime<Utc>>,
519    ) -> anyhow::Result<Uuid> {
520        let mut conn = self.conn_mgr.clone();
521        let json_str: Option<String> = conn.hget("azums:jobs", job_id.to_string()).await?;
522
523        let src = match json_str {
524            Some(json) => serde_json::from_str::<Job>(&json)?,
525            None => anyhow::bail!("Job {} not found", job_id),
526        };
527
528        let new_id = Uuid::new_v4();
529        let target_queue = override_queue.unwrap_or(&src.queue).to_string();
530        let target_run_at = override_run_at.unwrap_or_else(Utc::now);
531        let now = Utc::now();
532
533        let new_job = Job {
534            dataset_id: "default".to_string(),
535            replay_of_job_id: Some(job_id),
536            id: new_id,
537            queue: target_queue.clone(),
538            job_type: src.job_type,
539            payload: src.payload,
540            run_at: target_run_at,
541            status: JobStatus::Queued.as_str().to_string(),
542            priority: src.priority,
543            max_attempts: src.max_attempts,
544            locked_at: None,
545            locked_by: None,
546            lock_expires_at: None,
547            dlq_reason_code: None,
548            dlq_at: None,
549            created_at: now,
550            updated_at: now,
551        };
552
553        let updated_json = serde_json::to_string(&new_job)?;
554        let _: () = conn
555            .hset("azums:jobs", new_id.to_string(), updated_json)
556            .await?;
557
558        let queue_key = format!("azums:queue:{}", target_queue);
559        let _: () = conn.rpush(queue_key, new_id.to_string()).await?;
560
561        self.notify_queue_local(&target_queue);
562        Ok(new_id)
563    }
564}
565
566#[async_trait]
567impl StreamBackend for RedisBackend {
568    async fn publish(&self, stream: &str, event: NewEvent) -> anyhow::Result<i64> {
569        let mut conn = self.conn_mgr.clone();
570        let seq_key = format!("azums:stream_seq:{}", stream);
571        let sequence_no: i64 = conn.incr(&seq_key, 1).await?;
572        let now = Utc::now();
573
574        let event_entity = Event {
575            sequence_no,
576            stream_name: stream.to_string(),
577            event_type: event.event_type,
578            payload_json: event.payload_json,
579            created_at: now,
580        };
581
582        let json_str = serde_json::to_string(&event_entity)?;
583        let stream_key = format!("azums:stream_events:{}", stream);
584        let _: () = conn.rpush(stream_key, json_str).await?;
585
586        let notify_channel = format!("azums:stream_notify:{}", stream);
587        let _: () = conn.publish(notify_channel, "1").await?;
588
589        self.notify_stream_local(stream);
590        Ok(sequence_no)
591    }
592
593    async fn subscribe_stream(
594        &self,
595        stream: &str,
596        _consumer_group: &str,
597        _last_seq: Option<i64>,
598    ) -> anyhow::Result<NotificationStream> {
599        use tokio_stream::wrappers::BroadcastStream;
600        use tokio_stream::StreamExt;
601
602        let rx = {
603            let mut notifiers = self.stream_notifiers.write().unwrap();
604            let tx = notifiers
605                .entry(stream.to_string())
606                .or_insert_with(|| tokio::sync::broadcast::channel(128).0);
607            tx.subscribe()
608        };
609
610        let bcast_stream = BroadcastStream::new(rx).filter_map(|res| res.ok());
611        let interval_stream = tokio_stream::wrappers::IntervalStream::new(tokio::time::interval(
612            std::time::Duration::from_millis(100),
613        ))
614        .map(|_| ());
615
616        let merged = bcast_stream.merge(interval_stream);
617        Ok(Box::pin(merged))
618    }
619
620    async fn ack(&self, stream: &str, consumer_group: &str, seq: i64) -> anyhow::Result<()> {
621        let mut conn = self.conn_mgr.clone();
622        let key = format!("azums:stream_offsets:{}", stream);
623        let now = Utc::now();
624
625        let current_offset: Option<i64> = conn.hget(&key, consumer_group).await.ok();
626        let new_seq = match current_offset {
627            Some(existing) => existing.max(seq),
628            None => seq,
629        };
630
631        let _: () = conn.hset(&key, consumer_group, new_seq).await?;
632        let timestamp_key = format!("azums:stream_offsets_time:{}", stream);
633        let _: () = conn
634            .hset(timestamp_key, consumer_group, now.to_rfc3339())
635            .await?;
636
637        Ok(())
638    }
639
640    async fn read_events(
641        &self,
642        stream: &str,
643        after_seq: i64,
644        limit: i64,
645    ) -> anyhow::Result<Vec<Event>> {
646        let mut conn = self.conn_mgr.clone();
647        let stream_key = format!("azums:stream_events:{}", stream);
648        let raw_events: Vec<String> = conn.lrange(&stream_key, 0, -1).await.unwrap_or_default();
649
650        let limit = limit.clamp(1, 1000) as usize;
651        let mut result = Vec::new();
652
653        for raw in raw_events {
654            if let Ok(event) = serde_json::from_str::<Event>(&raw) {
655                if event.sequence_no > after_seq {
656                    result.push(event);
657                    if result.len() >= limit {
658                        break;
659                    }
660                }
661            }
662        }
663
664        Ok(result)
665    }
666
667    async fn consumer_group_info(&self, stream: &str) -> anyhow::Result<Vec<ConsumerGroupStatus>> {
668        let mut conn = self.conn_mgr.clone();
669        let key = format!("azums:stream_offsets:{}", stream);
670        let map: HashMap<String, i64> = conn.hgetall(&key).await.unwrap_or_default();
671
672        let time_key = format!("azums:stream_offsets_time:{}", stream);
673        let time_map: HashMap<String, String> = conn.hgetall(time_key).await.unwrap_or_default();
674
675        let mut result = Vec::new();
676        for (group, last_acked_seq) in map {
677            let updated_at = time_map
678                .get(&group)
679                .and_then(|ts| DateTime::parse_from_rfc3339(ts).ok())
680                .map(|dt| dt.with_timezone(&Utc))
681                .unwrap_or_else(Utc::now);
682
683            result.push(ConsumerGroupStatus {
684                consumer_group: group,
685                stream_name: stream.to_string(),
686                last_acked_seq,
687                updated_at,
688            });
689        }
690
691        Ok(result)
692    }
693}