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