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#[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 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 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 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 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; 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}