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 capabilities(&self) -> azums_core::BackendCapabilities {
61        azums_core::BackendCapabilities::redis()
62    }
63
64    fn as_stream(&self) -> Option<&dyn StreamBackend> {
65        Some(self)
66    }
67
68    async fn run_migrations(&self) -> anyhow::Result<()> {
69        let mut conn = self.conn_mgr.clone();
70        let _: String = redis::cmd("PING").query_async(&mut conn).await?;
71        Ok(())
72    }
73
74    async fn health_check(&self) -> anyhow::Result<()> {
75        let mut conn = self.conn_mgr.clone();
76        let res: String = redis::cmd("PING").query_async(&mut conn).await?;
77        if res == "PONG" || !res.is_empty() {
78            Ok(())
79        } else {
80            anyhow::bail!("Redis health check failed")
81        }
82    }
83
84    async fn enqueue(&self, job: NewJob) -> anyhow::Result<Uuid> {
85        let mut conn = self.conn_mgr.clone();
86        let job_id = Uuid::new_v4();
87        let now = Utc::now();
88        let idempotency_key = job.idempotency_key.clone();
89
90        if let Some(key) = &idempotency_key {
91            let claimed: bool = conn
92                .hset_nx("azums:idempotency", key, job_id.to_string())
93                .await?;
94            if !claimed {
95                let existing: String = conn.hget("azums:idempotency", key).await?;
96                return Ok(Uuid::parse_str(&existing)?);
97            }
98        }
99
100        let job_entity = Job {
101            dataset_id: "default".to_string(),
102            replay_of_job_id: None,
103            idempotency_key,
104            id: job_id,
105            queue: job.queue.clone(),
106            job_type: job.job_type,
107            payload: job.payload_json,
108            run_at: job.run_at,
109            deadline_at: job.deadline_at,
110            timeout_seconds: job.timeout_seconds,
111            recurring_interval_seconds: job.recurring_interval_seconds,
112            status: JobStatus::Queued.as_str().to_string(),
113            priority: job.priority,
114            max_attempts: job.max_attempts,
115            locked_at: None,
116            locked_by: None,
117            lock_expires_at: None,
118            dlq_reason_code: None,
119            dlq_at: None,
120            created_at: now,
121            updated_at: now,
122        };
123
124        let json_str = serde_json::to_string(&job_entity)?;
125
126        let _: () = conn
127            .hset("azums:jobs", job_id.to_string(), json_str)
128            .await?;
129        let queue_key = format!("azums:queue:{}", job.queue);
130        let _: () = conn.rpush(queue_key, job_id.to_string()).await?;
131
132        let notify_channel = format!("azums:notify:{}", job.queue);
133        let _: () = conn.publish(notify_channel, "1").await?;
134
135        self.notify_queue_local(&job.queue);
136
137        Ok(job_id)
138    }
139
140    async fn subscribe(&self, queue: &str) -> anyhow::Result<NotificationStream> {
141        use tokio_stream::wrappers::BroadcastStream;
142        use tokio_stream::StreamExt;
143
144        let channel = format!("azums:notify:{queue}");
145        let client_clone = self.client.clone();
146        let tx_clone = {
147            let mut notifiers = self.notifiers.write().unwrap();
148            notifiers
149                .entry(queue.to_string())
150                .or_insert_with(|| tokio::sync::broadcast::channel(128).0)
151                .clone()
152        };
153
154        let tx_spawn = tx_clone.clone();
155        // Spawn a dedicated, unpooled PubSub socket listener
156        tokio::spawn(async move {
157            if let Ok(mut pubsub) = client_clone.get_async_pubsub().await {
158                if pubsub.subscribe(&channel).await.is_ok() {
159                    let mut stream = pubsub.into_on_message();
160                    while stream.next().await.is_some() {
161                        let _ = tx_spawn.send(());
162                    }
163                }
164            }
165        });
166
167        let rx = tx_clone.subscribe();
168        let bcast_stream = BroadcastStream::new(rx).filter_map(|res| res.ok());
169        let interval_stream = tokio_stream::wrappers::IntervalStream::new(tokio::time::interval(
170            std::time::Duration::from_millis(100),
171        ))
172        .map(|_| ());
173
174        let merged = bcast_stream.merge(interval_stream);
175        Ok(Box::pin(merged))
176    }
177
178    async fn lease_jobs_batch(
179        &self,
180        queue: &str,
181        worker_id: &str,
182        lease_seconds: i64,
183        batch_size: i64,
184    ) -> anyhow::Result<Vec<Job>> {
185        let mut conn = self.conn_mgr.clone();
186        let queue_key = format!("azums:queue:{}", queue);
187        let processing_key = format!("azums:processing:{}:{}", queue, worker_id);
188        let now = Utc::now();
189        let lock_expires_at = now + chrono::Duration::seconds(lease_seconds);
190
191        let mut leased = Vec::new();
192        let batch_size = batch_size.clamp(1, 100) as usize;
193
194        for _ in 0..batch_size {
195            let job_id_str: Option<String> = redis::cmd("LMOVE")
196                .arg(&queue_key)
197                .arg(&processing_key)
198                .arg("LEFT")
199                .arg("RIGHT")
200                .query_async(&mut conn)
201                .await
202                .ok();
203
204            let job_id_str = match job_id_str {
205                Some(id) if !id.is_empty() => id,
206                _ => break,
207            };
208
209            let json_str: Option<String> = conn.hget("azums:jobs", &job_id_str).await?;
210            if let Some(json) = json_str {
211                if let Ok(mut job) = serde_json::from_str::<Job>(&json) {
212                    if job.run_at > now {
213                        // Put back at head if run_at is in the future
214                        let _: () = conn.lpush(&queue_key, &job_id_str).await?;
215                        let _: () = conn.lrem(&processing_key, 1, &job_id_str).await?;
216                        continue;
217                    }
218                    if job.deadline_at.is_some_and(|deadline| deadline < now) {
219                        job.status = JobStatus::Dlq.as_str().to_string();
220                        job.dlq_reason_code = Some("DEADLINE_EXCEEDED".to_string());
221                        job.dlq_at = Some(now);
222                        job.updated_at = now;
223
224                        let updated_json = serde_json::to_string(&job)?;
225                        let _: () = conn.hset("azums:jobs", &job_id_str, updated_json).await?;
226                        let _: () = conn.lrem(&processing_key, 1, &job_id_str).await?;
227                        continue;
228                    }
229
230                    job.status = JobStatus::Running.as_str().to_string();
231                    job.locked_at = Some(now);
232                    job.locked_by = Some(worker_id.to_string());
233                    job.lock_expires_at = Some(lock_expires_at);
234                    job.updated_at = now;
235
236                    let updated_json = serde_json::to_string(&job)?;
237                    let _: () = conn.hset("azums:jobs", &job_id_str, updated_json).await?;
238                    leased.push(job);
239                }
240            }
241        }
242
243        Ok(leased)
244    }
245
246    async fn lease_jobs_batch_with_ordering(
247        &self,
248        queue: &str,
249        worker_id: &str,
250        lease_seconds: i64,
251        batch_size: i64,
252        ordering: azums_core::QueueOrdering,
253    ) -> anyhow::Result<Vec<Job>> {
254        let _ = ordering; // Redis RPUSH (enqueue) and LMOVE LEFT RIGHT (dequeue) natively preserve strict FIFO insertion order
255        self.lease_jobs_batch(queue, worker_id, lease_seconds, batch_size)
256            .await
257    }
258
259    async fn reap_expired_locks(&self) -> anyhow::Result<u64> {
260        let mut conn = self.conn_mgr.clone();
261        let keys: Vec<String> = conn.keys("azums:processing:*").await.unwrap_or_default();
262        let now = Utc::now();
263        let mut reaped = 0u64;
264
265        for proc_key in keys {
266            let parts: Vec<&str> = proc_key.split(':').collect();
267            if parts.len() < 4 {
268                continue;
269            }
270            let queue = parts[2];
271            let queue_key = format!("azums:queue:{}", queue);
272
273            let job_ids: Vec<String> = conn.lrange(&proc_key, 0, -1).await.unwrap_or_default();
274            for jid in job_ids {
275                if let Ok(Some(json)) = conn.hget::<_, _, Option<String>>("azums:jobs", &jid).await
276                {
277                    if let Ok(mut job) = serde_json::from_str::<Job>(&json) {
278                        if let Some(exp) = job.lock_expires_at {
279                            if exp <= now {
280                                job.status = JobStatus::Queued.as_str().to_string();
281                                job.locked_at = None;
282                                job.locked_by = None;
283                                job.lock_expires_at = None;
284                                job.updated_at = now;
285
286                                if let Ok(updated_json) = serde_json::to_string(&job) {
287                                    let _: () = conn
288                                        .hset("azums:jobs", &jid, updated_json)
289                                        .await
290                                        .unwrap_or(());
291                                    let _: () = conn.lrem(&proc_key, 1, &jid).await.unwrap_or(());
292                                    let _: () = conn.rpush(&queue_key, &jid).await.unwrap_or(());
293                                    reaped += 1;
294                                }
295                            }
296                        }
297                    }
298                }
299            }
300        }
301
302        Ok(reaped)
303    }
304
305    async fn start_attempts_batch(
306        &self,
307        _dataset_ids: &[String],
308        job_ids: &[Uuid],
309        _worker_id: &str,
310    ) -> anyhow::Result<Vec<(Uuid, Uuid, i32)>> {
311        let mut conn = self.conn_mgr.clone();
312        let mut results = Vec::with_capacity(job_ids.len());
313
314        for &job_id in job_ids {
315            let job_id_str = job_id.to_string();
316            let json_str: Option<String> = conn.hget("azums:jobs", &job_id_str).await?;
317            let job = json_str
318                .as_deref()
319                .and_then(|json| serde_json::from_str::<Job>(json).ok())
320                .ok_or_else(|| anyhow::anyhow!("job {job_id} not found"))?;
321            if job.status != "running" || job.locked_by.as_deref() != Some(_worker_id) {
322                anyhow::bail!(
323                    "cannot start attempt for job {job_id}: expected running lease held by {_worker_id}"
324                );
325            }
326
327            let attempt_id = Uuid::new_v4();
328            let attempts_key = format!("azums:attempts:{}", job_id);
329            let attempt_no: i32 = conn.incr(&attempts_key, 1).await?;
330            results.push((job_id, attempt_id, attempt_no));
331        }
332
333        Ok(results)
334    }
335
336    async fn mark_succeeded(
337        &self,
338        job_id: Uuid,
339        _attempt_id: Uuid,
340        worker_id: &str,
341        _latency_ms: i32,
342    ) -> anyhow::Result<()> {
343        let mut conn = self.conn_mgr.clone();
344        let job_id_str = job_id.to_string();
345
346        if let Ok(Some(json)) = conn
347            .hget::<_, _, Option<String>>("azums:jobs", &job_id_str)
348            .await
349        {
350            if let Ok(mut job) = serde_json::from_str::<Job>(&json) {
351                if job.status != "running" || job.locked_by.as_deref() != Some(worker_id) {
352                    anyhow::bail!(
353                        "illegal job state transition to completed for job {job_id}: expected running lease held by {worker_id}"
354                    );
355                }
356
357                job.status = JobStatus::Succeeded.as_str().to_string();
358                job.locked_at = None;
359                job.locked_by = None;
360                job.lock_expires_at = None;
361                let now = Utc::now();
362                job.updated_at = now;
363                let next_job = job.recurring_interval_seconds.map(|interval| {
364                    let mut next = job.clone();
365                    let interval = interval.max(1);
366                    next.id = Uuid::new_v4();
367                    next.replay_of_job_id = Some(job.id);
368                    next.idempotency_key = None;
369                    next.run_at = job.run_at + chrono::Duration::seconds(interval);
370                    next.deadline_at = job
371                        .deadline_at
372                        .map(|deadline| deadline + chrono::Duration::seconds(interval));
373                    next.status = JobStatus::Queued.as_str().to_string();
374                    next.locked_at = None;
375                    next.locked_by = None;
376                    next.lock_expires_at = None;
377                    next.dlq_reason_code = None;
378                    next.dlq_at = None;
379                    next.created_at = now;
380                    next.updated_at = now;
381                    next
382                });
383                let updated_json = serde_json::to_string(&job)?;
384                let _: () = conn.hset("azums:jobs", &job_id_str, updated_json).await?;
385
386                let proc_key = format!("azums:processing:{}:{}", job.queue, worker_id);
387                let _: () = conn.lrem(proc_key, 1, &job_id_str).await?;
388
389                if let Some(next) = next_job {
390                    let next_id = next.id.to_string();
391                    let queue_key = format!("azums:queue:{}", next.queue);
392                    let next_json = serde_json::to_string(&next)?;
393                    let _: () = conn.hset("azums:jobs", &next_id, next_json).await?;
394                    let _: () = conn.rpush(queue_key, next_id).await?;
395                    self.notify_queue_local(&next.queue);
396                }
397            }
398        }
399
400        Ok(())
401    }
402
403    async fn mark_succeeded_batch(
404        &self,
405        _dataset_id: &str,
406        updates: &[(Uuid, Uuid, i32)],
407        worker_id: &str,
408    ) -> anyhow::Result<()> {
409        for &(job_id, attempt_id, latency_ms) in updates {
410            self.mark_succeeded(job_id, attempt_id, worker_id, latency_ms)
411                .await?;
412        }
413        Ok(())
414    }
415
416    async fn reschedule_for_retry(
417        &self,
418        job_id: Uuid,
419        _attempt_id: Uuid,
420        worker_id: &str,
421        _latency_ms: i32,
422        next_run_at: DateTime<Utc>,
423        _error_code: &str,
424        _error_message: &str,
425        _attempt_no: i32,
426    ) -> anyhow::Result<()> {
427        let mut conn = self.conn_mgr.clone();
428        let job_id_str = job_id.to_string();
429
430        if let Ok(Some(json)) = conn
431            .hget::<_, _, Option<String>>("azums:jobs", &job_id_str)
432            .await
433        {
434            if let Ok(mut job) = serde_json::from_str::<Job>(&json) {
435                if job.status != "running" || job.locked_by.as_deref() != Some(worker_id) {
436                    anyhow::bail!(
437                        "illegal job state transition to retry_wait for job {job_id}: expected running lease held by {worker_id}"
438                    );
439                }
440
441                job.status = JobStatus::Queued.as_str().to_string();
442                job.run_at = next_run_at;
443                job.locked_at = None;
444                job.locked_by = None;
445                job.lock_expires_at = None;
446                job.updated_at = Utc::now();
447
448                let updated_json = serde_json::to_string(&job)?;
449                let _: () = conn.hset("azums:jobs", &job_id_str, updated_json).await?;
450
451                let proc_key = format!("azums:processing:{}:{}", job.queue, worker_id);
452                let _: () = conn.lrem(proc_key, 1, &job_id_str).await?;
453
454                let queue_key = format!("azums:queue:{}", job.queue);
455                let _: () = conn.rpush(queue_key, &job_id_str).await?;
456            }
457        }
458
459        Ok(())
460    }
461
462    async fn mark_dlq(
463        &self,
464        job_id: Uuid,
465        _attempt_id: Uuid,
466        worker_id: &str,
467        _latency_ms: i32,
468        reason_code: &str,
469        _error_code: &str,
470        _error_message: &str,
471        _attempt_no: i32,
472    ) -> anyhow::Result<()> {
473        let mut conn = self.conn_mgr.clone();
474        let job_id_str = job_id.to_string();
475
476        if let Ok(Some(json)) = conn
477            .hget::<_, _, Option<String>>("azums:jobs", &job_id_str)
478            .await
479        {
480            if let Ok(mut job) = serde_json::from_str::<Job>(&json) {
481                if job.status != "running" || job.locked_by.as_deref() != Some(worker_id) {
482                    anyhow::bail!(
483                        "illegal job state transition to dlq for job {job_id}: expected running lease held by {worker_id}"
484                    );
485                }
486
487                job.status = JobStatus::Dlq.as_str().to_string();
488                job.dlq_reason_code = Some(reason_code.to_string());
489                job.dlq_at = Some(Utc::now());
490                job.updated_at = Utc::now();
491
492                let updated_json = serde_json::to_string(&job)?;
493                let _: () = conn.hset("azums:jobs", &job_id_str, updated_json).await?;
494
495                let proc_key = format!("azums:processing:{}:{}", job.queue, worker_id);
496                let _: () = conn.lrem(proc_key, 1, &job_id_str).await?;
497            }
498        }
499
500        Ok(())
501    }
502
503    async fn archive_succeeded_older_than(
504        &self,
505        _cutoff: DateTime<Utc>,
506        _limit: i64,
507    ) -> anyhow::Result<u64> {
508        Ok(0)
509    }
510
511    async fn delete_history_for_succeeded_older_than(
512        &self,
513        _cutoff: DateTime<Utc>,
514        _limit: i64,
515    ) -> anyhow::Result<(u64, u64)> {
516        Ok((0, 0))
517    }
518
519    async fn perform_maintenance(&self) -> anyhow::Result<()> {
520        let _ = self.reap_expired_locks().await;
521        Ok(())
522    }
523
524    async fn extend_lease(
525        &self,
526        job_id: Uuid,
527        worker_id: &str,
528        lease_seconds: i64,
529    ) -> anyhow::Result<bool> {
530        let mut conn = self.conn_mgr.clone();
531        let job_key = job_id.to_string();
532        let json_str: Option<String> = conn.hget("azums:jobs", &job_key).await?;
533
534        if let Some(json) = json_str {
535            if let Ok(mut job) = serde_json::from_str::<Job>(&json) {
536                if job.status == "running" && job.locked_by.as_deref() == Some(worker_id) {
537                    let now = Utc::now();
538                    job.lock_expires_at = Some(now + chrono::Duration::seconds(lease_seconds));
539                    job.updated_at = now;
540
541                    let updated_json = serde_json::to_string(&job)?;
542                    let _: () = conn.hset("azums:jobs", &job_key, updated_json).await?;
543                    return Ok(true);
544                }
545            }
546        }
547
548        Ok(false)
549    }
550
551    async fn cancel_job(&self, job_id: Uuid, worker_id: Option<&str>) -> anyhow::Result<()> {
552        let mut conn = self.conn_mgr.clone();
553        let job_id_str = job_id.to_string();
554        let json_str: Option<String> = conn.hget("azums:jobs", &job_id_str).await?;
555
556        let mut job = match json_str {
557            Some(json) => serde_json::from_str::<Job>(&json)?,
558            None => anyhow::bail!("job {job_id} not found"),
559        };
560
561        match job.status.as_str() {
562            "queued" => {}
563            "running" => {
564                let Some(worker_id) = worker_id else {
565                    anyhow::bail!(
566                        "cannot cancel running job {job_id}: worker identity is required"
567                    );
568                };
569                if job.locked_by.as_deref() != Some(worker_id) {
570                    anyhow::bail!(
571                        "illegal job state transition to cancelled for job {job_id}: expected running lease held by {worker_id}"
572                    );
573                }
574
575                let proc_key = format!("azums:processing:{}:{}", job.queue, worker_id);
576                let _: () = conn.lrem(proc_key, 1, &job_id_str).await?;
577            }
578            "succeeded" | "dlq" | "canceled" => {
579                anyhow::bail!("cannot cancel terminal job {job_id}: status={}", job.status);
580            }
581            other => anyhow::bail!("cannot cancel job {job_id}: invalid status={other}"),
582        }
583
584        job.status = JobStatus::Cancelled.as_str().to_string();
585        job.locked_at = None;
586        job.locked_by = None;
587        job.lock_expires_at = None;
588        job.updated_at = Utc::now();
589
590        let updated_json = serde_json::to_string(&job)?;
591        let _: () = conn.hset("azums:jobs", &job_id_str, updated_json).await?;
592
593        Ok(())
594    }
595
596    async fn get_job(&self, job_id: Uuid) -> anyhow::Result<Option<Job>> {
597        let mut conn = self.conn_mgr.clone();
598        let json_str: Option<String> = conn.hget("azums:jobs", job_id.to_string()).await?;
599        match json_str {
600            Some(json) => Ok(serde_json::from_str(&json).ok()),
601            None => Ok(None),
602        }
603    }
604
605    async fn list_jobs(
606        &self,
607        queue: Option<&str>,
608        status: Option<&str>,
609        limit: i64,
610        _cursor_created_at: Option<DateTime<Utc>>,
611        _cursor_id: Option<Uuid>,
612    ) -> anyhow::Result<Vec<JobListItem>> {
613        let mut conn = self.conn_mgr.clone();
614        let map: HashMap<String, String> = conn.hgetall("azums:jobs").await.unwrap_or_default();
615
616        let mut items = Vec::new();
617        for json in map.values() {
618            if let Ok(job) = serde_json::from_str::<Job>(json) {
619                if let Some(q) = queue {
620                    if job.queue != q {
621                        continue;
622                    }
623                }
624                if let Some(st) = status {
625                    if job.status != st {
626                        continue;
627                    }
628                }
629
630                items.push(JobListItem {
631                    id: job.id,
632                    idempotency_key: job.idempotency_key,
633                    queue: job.queue,
634                    job_type: job.job_type,
635                    status: job.status,
636                    run_at: job.run_at,
637                    deadline_at: job.deadline_at,
638                    timeout_seconds: job.timeout_seconds,
639                    recurring_interval_seconds: job.recurring_interval_seconds,
640                    priority: job.priority,
641                    max_attempts: job.max_attempts,
642                    last_error_code: None,
643                    last_error_message: None,
644                    dlq_reason_code: job.dlq_reason_code,
645                    created_at: job.created_at,
646                    updated_at: job.updated_at,
647                });
648            }
649        }
650
651        items.sort_by_key(|a| std::cmp::Reverse(a.created_at));
652        items.truncate(limit.clamp(1, 500) as usize);
653        Ok(items)
654    }
655
656    async fn replay_job(
657        &self,
658        job_id: Uuid,
659        override_queue: Option<&str>,
660        override_run_at: Option<DateTime<Utc>>,
661    ) -> anyhow::Result<Uuid> {
662        let mut conn = self.conn_mgr.clone();
663        let json_str: Option<String> = conn.hget("azums:jobs", job_id.to_string()).await?;
664
665        let src = match json_str {
666            Some(json) => serde_json::from_str::<Job>(&json)?,
667            None => anyhow::bail!("Job {} not found", job_id),
668        };
669
670        let new_id = Uuid::new_v4();
671        let target_queue = override_queue.unwrap_or(&src.queue).to_string();
672        let target_run_at = override_run_at.unwrap_or_else(Utc::now);
673        let now = Utc::now();
674
675        let new_job = Job {
676            dataset_id: "default".to_string(),
677            replay_of_job_id: Some(job_id),
678            idempotency_key: None,
679            id: new_id,
680            queue: target_queue.clone(),
681            job_type: src.job_type,
682            payload: src.payload,
683            run_at: target_run_at,
684            deadline_at: src.deadline_at,
685            timeout_seconds: src.timeout_seconds,
686            recurring_interval_seconds: src.recurring_interval_seconds,
687            status: JobStatus::Queued.as_str().to_string(),
688            priority: src.priority,
689            max_attempts: src.max_attempts,
690            locked_at: None,
691            locked_by: None,
692            lock_expires_at: None,
693            dlq_reason_code: None,
694            dlq_at: None,
695            created_at: now,
696            updated_at: now,
697        };
698
699        let updated_json = serde_json::to_string(&new_job)?;
700        let _: () = conn
701            .hset("azums:jobs", new_id.to_string(), updated_json)
702            .await?;
703
704        let queue_key = format!("azums:queue:{}", target_queue);
705        let _: () = conn.rpush(queue_key, new_id.to_string()).await?;
706
707        self.notify_queue_local(&target_queue);
708        Ok(new_id)
709    }
710}
711
712#[async_trait]
713impl StreamBackend for RedisBackend {
714    async fn publish(&self, stream: &str, event: NewEvent) -> anyhow::Result<i64> {
715        let mut conn = self.conn_mgr.clone();
716        let seq_key = format!("azums:stream_seq:{}", stream);
717        let sequence_no: i64 = conn.incr(&seq_key, 1).await?;
718        let now = Utc::now();
719
720        let event_entity = Event {
721            sequence_no,
722            stream_name: stream.to_string(),
723            event_type: event.event_type,
724            payload_json: event.payload_json,
725            created_at: now,
726        };
727
728        let json_str = serde_json::to_string(&event_entity)?;
729        let stream_key = format!("azums:stream_events:{}", stream);
730        let _: () = conn.rpush(stream_key, json_str).await?;
731
732        let notify_channel = format!("azums:stream_notify:{}", stream);
733        let _: () = conn.publish(notify_channel, "1").await?;
734
735        self.notify_stream_local(stream);
736        Ok(sequence_no)
737    }
738
739    async fn subscribe_stream(
740        &self,
741        stream: &str,
742        _consumer_group: &str,
743        _last_seq: Option<i64>,
744    ) -> anyhow::Result<NotificationStream> {
745        use tokio_stream::wrappers::BroadcastStream;
746        use tokio_stream::StreamExt;
747
748        let rx = {
749            let mut notifiers = self.stream_notifiers.write().unwrap();
750            let tx = notifiers
751                .entry(stream.to_string())
752                .or_insert_with(|| tokio::sync::broadcast::channel(128).0);
753            tx.subscribe()
754        };
755
756        let bcast_stream = BroadcastStream::new(rx).filter_map(|res| res.ok());
757        let interval_stream = tokio_stream::wrappers::IntervalStream::new(tokio::time::interval(
758            std::time::Duration::from_millis(100),
759        ))
760        .map(|_| ());
761
762        let merged = bcast_stream.merge(interval_stream);
763        Ok(Box::pin(merged))
764    }
765
766    async fn ack(&self, stream: &str, consumer_group: &str, seq: i64) -> anyhow::Result<()> {
767        let mut conn = self.conn_mgr.clone();
768        let key = format!("azums:stream_offsets:{}", stream);
769        let now = Utc::now();
770
771        let current_offset: Option<i64> = conn.hget(&key, consumer_group).await.ok();
772        let new_seq = match current_offset {
773            Some(existing) => existing.max(seq),
774            None => seq,
775        };
776
777        let _: () = conn.hset(&key, consumer_group, new_seq).await?;
778        let timestamp_key = format!("azums:stream_offsets_time:{}", stream);
779        let _: () = conn
780            .hset(timestamp_key, consumer_group, now.to_rfc3339())
781            .await?;
782
783        Ok(())
784    }
785
786    async fn read_events(
787        &self,
788        stream: &str,
789        after_seq: i64,
790        limit: i64,
791    ) -> anyhow::Result<Vec<Event>> {
792        let mut conn = self.conn_mgr.clone();
793        let stream_key = format!("azums:stream_events:{}", stream);
794        let raw_events: Vec<String> = conn.lrange(&stream_key, 0, -1).await.unwrap_or_default();
795
796        let limit = limit.clamp(1, 1000) as usize;
797        let mut result = Vec::new();
798
799        for raw in raw_events {
800            if let Ok(event) = serde_json::from_str::<Event>(&raw) {
801                if event.sequence_no > after_seq {
802                    result.push(event);
803                    if result.len() >= limit {
804                        break;
805                    }
806                }
807            }
808        }
809
810        Ok(result)
811    }
812
813    async fn prune_events(&self, stream: &str, through_seq: i64) -> anyhow::Result<u64> {
814        let mut conn = self.conn_mgr.clone();
815        let offsets_key = format!("azums:stream_offsets:{}", stream);
816        let offsets: HashMap<String, i64> = conn.hgetall(&offsets_key).await.unwrap_or_default();
817        let min_offset = offsets.values().copied().min().unwrap_or(through_seq);
818        let cutoff = through_seq.min(min_offset);
819
820        let stream_key = format!("azums:stream_events:{}", stream);
821        let raw_events: Vec<String> = conn.lrange(&stream_key, 0, -1).await.unwrap_or_default();
822        let before = raw_events.len();
823        let retained: Vec<String> = raw_events
824            .into_iter()
825            .filter(|raw| {
826                serde_json::from_str::<Event>(raw)
827                    .map(|event| event.sequence_no > cutoff)
828                    .unwrap_or(true)
829            })
830            .collect();
831
832        let _: () = conn.del(&stream_key).await?;
833        if !retained.is_empty() {
834            let _: () = conn.rpush(&stream_key, retained).await?;
835        }
836
837        let removed = before.saturating_sub(conn.llen::<_, usize>(&stream_key).await?);
838        Ok(removed as u64)
839    }
840
841    async fn consumer_group_info(&self, stream: &str) -> anyhow::Result<Vec<ConsumerGroupStatus>> {
842        let mut conn = self.conn_mgr.clone();
843        let key = format!("azums:stream_offsets:{}", stream);
844        let map: HashMap<String, i64> = conn.hgetall(&key).await.unwrap_or_default();
845
846        let time_key = format!("azums:stream_offsets_time:{}", stream);
847        let time_map: HashMap<String, String> = conn.hgetall(time_key).await.unwrap_or_default();
848
849        let mut result = Vec::new();
850        for (group, last_acked_seq) in map {
851            let updated_at = time_map
852                .get(&group)
853                .and_then(|ts| DateTime::parse_from_rfc3339(ts).ok())
854                .map(|dt| dt.with_timezone(&Utc))
855                .unwrap_or_else(Utc::now);
856
857            result.push(ConsumerGroupStatus {
858                consumer_group: group,
859                stream_name: stream.to_string(),
860                last_acked_seq,
861                updated_at,
862            });
863        }
864
865        Ok(result)
866    }
867}