Skip to main content

azums_core/backend/
memory.rs

1use crate::{
2    backend::{
3        observability::{trace_id_from_job, JobExplanation, JobObservationEvent},
4        NotificationStream, ObservabilityBackend, QueueMetrics, StorageBackend, StreamBackend,
5    },
6    model::{ConsumerGroupStatus, Event, Job, JobListItem, JobStatus, NewEvent, NewJob},
7};
8use async_trait::async_trait;
9use chrono::{DateTime, Utc};
10use std::{
11    collections::{BTreeSet, HashMap, HashSet},
12    sync::{Arc, RwLock},
13};
14use tokio_stream::{wrappers::BroadcastStream, StreamExt};
15use uuid::Uuid;
16
17/// Attempt history record stored in [`MemoryBackend`].
18#[derive(Debug, Clone)]
19pub struct MemoryAttempt {
20    pub id: Uuid,
21    pub dataset_id: String,
22    pub job_id: Uuid,
23    pub attempt_no: i32,
24    pub status: String,
25    pub worker_id: String,
26    pub started_at: DateTime<Utc>,
27    pub finished_at: Option<DateTime<Utc>>,
28    pub latency_ms: Option<i32>,
29    pub error_code: Option<String>,
30    pub error_message: Option<String>,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq)]
34struct QueueEntry {
35    priority: i32,
36    run_at: DateTime<Utc>,
37    created_at: DateTime<Utc>,
38    id: Uuid,
39}
40
41impl QueueEntry {
42    fn from_job(job: &Job) -> Self {
43        Self {
44            priority: job.priority,
45            run_at: job.run_at,
46            created_at: job.created_at,
47            id: job.id,
48        }
49    }
50}
51
52impl Ord for QueueEntry {
53    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
54        other
55            .priority
56            .cmp(&self.priority)
57            .then_with(|| self.run_at.cmp(&other.run_at))
58            .then_with(|| self.created_at.cmp(&other.created_at))
59            .then_with(|| self.id.cmp(&other.id))
60    }
61}
62
63impl PartialOrd for QueueEntry {
64    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
65        Some(self.cmp(other))
66    }
67}
68
69#[derive(Debug, Default)]
70struct InnerState {
71    jobs: HashMap<Uuid, Job>,
72    queued_jobs: HashMap<String, BTreeSet<QueueEntry>>,
73    archive: HashMap<Uuid, Job>,
74    attempts: HashMap<Uuid, MemoryAttempt>,
75    attempt_counts: HashMap<Uuid, i32>,
76    streams: HashMap<String, Vec<Event>>,
77    stream_offsets: HashMap<(String, String), ConsumerGroupStatus>,
78}
79
80/// Thread-safe in-memory implementation of [`StorageBackend`].
81///
82/// Ideal for unit testing, ephemeral workloads, and local development without external database servers.
83#[derive(Debug, Clone, Default)]
84pub struct MemoryBackend {
85    state: Arc<RwLock<InnerState>>,
86    notifiers: Arc<RwLock<HashMap<String, tokio::sync::broadcast::Sender<()>>>>,
87    stream_notifiers: Arc<RwLock<HashMap<String, tokio::sync::broadcast::Sender<()>>>>,
88}
89
90impl MemoryBackend {
91    /// Creates a new, empty `MemoryBackend`.
92    pub fn new() -> Self {
93        Self::default()
94    }
95
96    /// Resets and clears all stored jobs and attempt history.
97    pub fn clear(&self) {
98        let mut state = self.state.write().unwrap();
99        state.jobs.clear();
100        state.queued_jobs.clear();
101        state.archive.clear();
102        state.attempts.clear();
103        state.attempt_counts.clear();
104        state.streams.clear();
105        state.stream_offsets.clear();
106    }
107
108    #[doc(hidden)]
109    pub fn attempts_snapshot(&self) -> Vec<MemoryAttempt> {
110        let state = self.state.read().unwrap();
111        state.attempts.values().cloned().collect()
112    }
113
114    fn notify_queue(&self, queue: &str) {
115        let notifiers = self.notifiers.read().unwrap();
116        if let Some(tx) = notifiers.get(queue) {
117            let _ = tx.send(());
118        }
119    }
120
121    fn notify_stream(&self, stream: &str) {
122        let notifiers = self.stream_notifiers.read().unwrap();
123        if let Some(tx) = notifiers.get(stream) {
124            let _ = tx.send(());
125        }
126    }
127}
128
129#[async_trait]
130impl StorageBackend for MemoryBackend {
131    fn capabilities(&self) -> crate::model::BackendCapabilities {
132        crate::model::BackendCapabilities::memory()
133    }
134
135    fn as_stream(&self) -> Option<&dyn StreamBackend> {
136        Some(self)
137    }
138
139    fn as_observability(&self) -> Option<&dyn ObservabilityBackend> {
140        Some(self)
141    }
142
143    async fn run_migrations(&self) -> anyhow::Result<()> {
144        Ok(())
145    }
146
147    async fn health_check(&self) -> anyhow::Result<()> {
148        Ok(())
149    }
150
151    async fn enqueue(&self, job: NewJob) -> anyhow::Result<Uuid> {
152        let job_id = Uuid::new_v4();
153        let now = Utc::now();
154        let queue_name = job.queue.clone();
155        let mut state = self.state.write().unwrap();
156
157        if let Some(key) = &job.idempotency_key {
158            if let Some(existing) = state
159                .jobs
160                .values()
161                .find(|existing| existing.idempotency_key.as_deref() == Some(key.as_str()))
162            {
163                return Ok(existing.id);
164            }
165        }
166
167        let job_entity = Job {
168            dataset_id: "default".to_string(),
169            replay_of_job_id: None,
170            idempotency_key: job.idempotency_key,
171            id: job_id,
172            queue: job.queue,
173            job_type: job.job_type,
174            payload: job.payload_json,
175            run_at: job.run_at,
176            deadline_at: job.deadline_at,
177            timeout_seconds: job.timeout_seconds,
178            recurring_interval_seconds: job.recurring_interval_seconds,
179            status: JobStatus::Queued.as_str().to_string(),
180            priority: job.priority,
181            max_attempts: job.max_attempts,
182            locked_at: None,
183            locked_by: None,
184            lock_expires_at: None,
185            dlq_reason_code: None,
186            dlq_at: None,
187            created_at: now,
188            updated_at: now,
189        };
190
191        state
192            .queued_jobs
193            .entry(queue_name.clone())
194            .or_default()
195            .insert(QueueEntry::from_job(&job_entity));
196        state.jobs.insert(job_id, job_entity);
197        drop(state);
198
199        self.notify_queue(&queue_name);
200        Ok(job_id)
201    }
202
203    async fn subscribe(&self, queue: &str) -> anyhow::Result<NotificationStream> {
204        let rx = {
205            let mut notifiers = self.notifiers.write().unwrap();
206            let tx = notifiers
207                .entry(queue.to_string())
208                .or_insert_with(|| tokio::sync::broadcast::channel(128).0);
209            tx.subscribe()
210        };
211
212        let stream = BroadcastStream::new(rx).filter_map(|res| res.ok());
213        Ok(Box::pin(stream))
214    }
215
216    async fn lease_jobs_batch(
217        &self,
218        queue: &str,
219        worker_id: &str,
220        lease_seconds: i64,
221        batch_size: i64,
222    ) -> anyhow::Result<Vec<Job>> {
223        self.lease_jobs_batch_with_ordering(
224            queue,
225            worker_id,
226            lease_seconds,
227            batch_size,
228            crate::model::QueueOrdering::Fifo,
229        )
230        .await
231    }
232
233    async fn lease_jobs_batch_with_ordering(
234        &self,
235        queue: &str,
236        worker_id: &str,
237        lease_seconds: i64,
238        batch_size: i64,
239        ordering: crate::model::QueueOrdering,
240    ) -> anyhow::Result<Vec<Job>> {
241        let mut state = self.state.write().unwrap();
242        let now = Utc::now();
243
244        let expired_deadlines: Vec<QueueEntry> = state
245            .queued_jobs
246            .get(queue)
247            .into_iter()
248            .flatten()
249            .filter(|entry| {
250                entry.run_at <= now
251                    && state
252                        .jobs
253                        .get(&entry.id)
254                        .and_then(|job| job.deadline_at)
255                        .is_some_and(|deadline| deadline < now)
256            })
257            .cloned()
258            .collect();
259
260        for entry in expired_deadlines {
261            if let Some(index) = state.queued_jobs.get_mut(queue) {
262                index.remove(&entry);
263            }
264            if let Some(job) = state.jobs.get_mut(&entry.id) {
265                job.status = JobStatus::Dlq.as_str().to_string();
266                job.dlq_reason_code = Some("DEADLINE_EXCEEDED".to_string());
267                job.dlq_at = Some(now);
268                job.updated_at = now;
269            }
270        }
271
272        let _ = ordering;
273        let candidates: Vec<QueueEntry> = state
274            .queued_jobs
275            .get(queue)
276            .into_iter()
277            .flatten()
278            .filter(|entry| entry.run_at <= now)
279            .take(batch_size.max(0) as usize)
280            .cloned()
281            .collect();
282        if candidates.is_empty() {
283            return Ok(Vec::new());
284        }
285
286        if let Some(index) = state.queued_jobs.get_mut(queue) {
287            for entry in &candidates {
288                index.remove(entry);
289            }
290        }
291
292        let lock_expires_at = now + chrono::Duration::seconds(lease_seconds);
293        let mut leased = Vec::with_capacity(candidates.len());
294
295        for candidate in candidates {
296            if let Some(j) = state.jobs.get_mut(&candidate.id) {
297                j.status = JobStatus::Running.as_str().to_string();
298                j.locked_at = Some(now);
299                j.locked_by = Some(worker_id.to_string());
300                j.lock_expires_at = Some(lock_expires_at);
301                j.updated_at = now;
302
303                leased.push(j.clone());
304            }
305        }
306
307        Ok(leased)
308    }
309
310    async fn reap_expired_locks(&self) -> anyhow::Result<u64> {
311        let mut state = self.state.write().unwrap();
312        let now = Utc::now();
313        let mut reaped = 0u64;
314
315        let expired_job_ids: Vec<Uuid> = state
316            .jobs
317            .values()
318            .filter(|job| {
319                job.status == "running"
320                    && job
321                        .lock_expires_at
322                        .is_some_and(|lock_expires_at| lock_expires_at <= now)
323            })
324            .map(|job| job.id)
325            .collect();
326
327        for attempt in state.attempts.values_mut() {
328            if expired_job_ids.contains(&attempt.job_id) && attempt.status == "running" {
329                attempt.status = "failed".to_string();
330                attempt.finished_at = Some(now);
331                attempt.latency_ms = Some(0);
332                attempt.error_code = Some("LEASE_EXPIRED".to_string());
333                attempt.error_message = Some("worker lease expired before ACK".to_string());
334            }
335        }
336
337        for job_id in expired_job_ids {
338            let requeued = if let Some(job) = state.jobs.get_mut(&job_id) {
339                job.status = JobStatus::Queued.as_str().to_string();
340                job.locked_at = None;
341                job.locked_by = None;
342                job.lock_expires_at = None;
343                job.updated_at = now;
344                reaped += 1;
345                Some((job.queue.clone(), QueueEntry::from_job(job)))
346            } else {
347                None
348            };
349            if let Some((queue, entry)) = requeued {
350                state.queued_jobs.entry(queue).or_default().insert(entry);
351            }
352        }
353
354        Ok(reaped)
355    }
356
357    async fn start_attempts_batch(
358        &self,
359        _dataset_ids: &[String],
360        job_ids: &[Uuid],
361        worker_id: &str,
362    ) -> anyhow::Result<Vec<(Uuid, Uuid, i32)>> {
363        if job_ids.is_empty() {
364            return Ok(Vec::new());
365        }
366
367        let mut state = self.state.write().unwrap();
368        let now = Utc::now();
369        let mut results = Vec::with_capacity(job_ids.len());
370
371        for &job_id in job_ids {
372            let job = state
373                .jobs
374                .get(&job_id)
375                .ok_or_else(|| anyhow::anyhow!("job {job_id} not found"))?;
376            if job.status != "running" || job.locked_by.as_deref() != Some(worker_id) {
377                anyhow::bail!(
378                    "cannot start attempt for job {job_id}: expected running lease held by {worker_id}"
379                );
380            }
381
382            let next_attempt_no = state.attempt_counts.get(&job_id).copied().unwrap_or(0) + 1;
383            let attempt_id = Uuid::new_v4();
384
385            let attempt = MemoryAttempt {
386                id: attempt_id,
387                dataset_id: "default".to_string(),
388                job_id,
389                attempt_no: next_attempt_no,
390                status: "running".to_string(),
391                worker_id: worker_id.to_string(),
392                started_at: now,
393                finished_at: None,
394                latency_ms: None,
395                error_code: None,
396                error_message: None,
397            };
398
399            state.attempts.insert(attempt_id, attempt);
400            state.attempt_counts.insert(job_id, next_attempt_no);
401            results.push((job_id, attempt_id, next_attempt_no));
402        }
403
404        Ok(results)
405    }
406
407    async fn mark_succeeded(
408        &self,
409        job_id: Uuid,
410        attempt_id: Uuid,
411        _worker_id: &str,
412        latency_ms: i32,
413    ) -> anyhow::Result<()> {
414        let mut state = self.state.write().unwrap();
415        let now = Utc::now();
416
417        let recurring_job = state.jobs.get(&job_id).and_then(|job| {
418            job.recurring_interval_seconds.map(|interval| {
419                let mut next = job.clone();
420                let next_run_at = job.run_at + chrono::Duration::seconds(interval);
421                next.id = Uuid::new_v4();
422                next.replay_of_job_id = Some(job.id);
423                next.idempotency_key = None;
424                next.run_at = next_run_at;
425                next.deadline_at = job
426                    .deadline_at
427                    .map(|deadline| deadline + chrono::Duration::seconds(interval));
428                next.status = JobStatus::Queued.as_str().to_string();
429                next.locked_at = None;
430                next.locked_by = None;
431                next.lock_expires_at = None;
432                next.dlq_reason_code = None;
433                next.dlq_at = None;
434                next.created_at = now;
435                next.updated_at = now;
436                next
437            })
438        });
439
440        let job = state
441            .jobs
442            .get_mut(&job_id)
443            .ok_or_else(|| anyhow::anyhow!("job {job_id} not found"))?;
444        if job.status != "running" || job.locked_by.as_deref() != Some(_worker_id) {
445            anyhow::bail!(
446                "illegal job state transition to completed for job {job_id}: expected running lease held by {_worker_id}"
447            );
448        }
449
450        let att = state
451            .attempts
452            .get_mut(&attempt_id)
453            .ok_or_else(|| anyhow::anyhow!("attempt {attempt_id} not found"))?;
454        if att.status != "running" || att.job_id != job_id {
455            anyhow::bail!(
456                "cannot complete attempt {attempt_id}: expected running attempt for job {job_id}"
457            );
458        }
459
460        att.status = "succeeded".to_string();
461        att.finished_at = Some(now);
462        att.latency_ms = Some(latency_ms);
463
464        let job = state.jobs.get_mut(&job_id).expect("job checked above");
465        job.status = JobStatus::Completed.as_str().to_string();
466        job.locked_at = None;
467        job.locked_by = None;
468        job.lock_expires_at = None;
469        job.updated_at = now;
470
471        if let Some(next) = recurring_job {
472            let queue_name = next.queue.clone();
473            state
474                .queued_jobs
475                .entry(queue_name.clone())
476                .or_default()
477                .insert(QueueEntry::from_job(&next));
478            state.jobs.insert(next.id, next);
479            drop(state);
480            self.notify_queue(&queue_name);
481            return Ok(());
482        }
483
484        Ok(())
485    }
486
487    async fn mark_succeeded_batch(
488        &self,
489        _dataset_id: &str,
490        updates: &[(Uuid, Uuid, i32)],
491        worker_id: &str,
492    ) -> anyhow::Result<()> {
493        for &(job_id, attempt_id, latency_ms) in updates {
494            self.mark_succeeded(job_id, attempt_id, worker_id, latency_ms)
495                .await?;
496        }
497        Ok(())
498    }
499
500    #[allow(clippy::too_many_arguments)]
501    async fn reschedule_for_retry(
502        &self,
503        job_id: Uuid,
504        attempt_id: Uuid,
505        _worker_id: &str,
506        latency_ms: i32,
507        next_run_at: DateTime<Utc>,
508        error_code: &str,
509        error_message: &str,
510        _attempt_no: i32,
511    ) -> anyhow::Result<()> {
512        let mut state = self.state.write().unwrap();
513        let now = Utc::now();
514
515        let job = state
516            .jobs
517            .get_mut(&job_id)
518            .ok_or_else(|| anyhow::anyhow!("job {job_id} not found"))?;
519        if job.status != "running" || job.locked_by.as_deref() != Some(_worker_id) {
520            anyhow::bail!(
521                "illegal job state transition to retry_wait for job {job_id}: expected running lease held by {_worker_id}"
522            );
523        }
524
525        let att = state
526            .attempts
527            .get_mut(&attempt_id)
528            .ok_or_else(|| anyhow::anyhow!("attempt {attempt_id} not found"))?;
529        if att.status != "running" || att.job_id != job_id {
530            anyhow::bail!(
531                "cannot fail attempt {attempt_id}: expected running attempt for job {job_id}"
532            );
533        }
534
535        att.status = "failed".to_string();
536        att.finished_at = Some(now);
537        att.latency_ms = Some(latency_ms);
538        att.error_code = Some(error_code.to_string());
539        att.error_message = Some(error_message.to_string());
540
541        let job = state.jobs.get_mut(&job_id).expect("job checked above");
542        job.status = JobStatus::Queued.as_str().to_string();
543        job.run_at = next_run_at;
544        job.locked_at = None;
545        job.locked_by = None;
546        job.lock_expires_at = None;
547        job.updated_at = now;
548        let queue = job.queue.clone();
549        let entry = QueueEntry::from_job(job);
550        state.queued_jobs.entry(queue).or_default().insert(entry);
551
552        Ok(())
553    }
554
555    #[allow(clippy::too_many_arguments)]
556    async fn mark_dlq(
557        &self,
558        job_id: Uuid,
559        attempt_id: Uuid,
560        _worker_id: &str,
561        latency_ms: i32,
562        reason_code: &str,
563        error_code: &str,
564        error_message: &str,
565        _attempt_no: i32,
566    ) -> anyhow::Result<()> {
567        let mut state = self.state.write().unwrap();
568        let now = Utc::now();
569
570        let job = state
571            .jobs
572            .get_mut(&job_id)
573            .ok_or_else(|| anyhow::anyhow!("job {job_id} not found"))?;
574        if job.status != "running" || job.locked_by.as_deref() != Some(_worker_id) {
575            anyhow::bail!(
576                "illegal job state transition to dlq for job {job_id}: expected running lease held by {_worker_id}"
577            );
578        }
579
580        let att = state
581            .attempts
582            .get_mut(&attempt_id)
583            .ok_or_else(|| anyhow::anyhow!("attempt {attempt_id} not found"))?;
584        if att.status != "running" || att.job_id != job_id {
585            anyhow::bail!(
586                "cannot fail attempt {attempt_id}: expected running attempt for job {job_id}"
587            );
588        }
589
590        att.status = "failed".to_string();
591        att.finished_at = Some(now);
592        att.latency_ms = Some(latency_ms);
593        att.error_code = Some(error_code.to_string());
594        att.error_message = Some(error_message.to_string());
595
596        let job = state.jobs.get_mut(&job_id).expect("job checked above");
597        job.status = JobStatus::Dlq.as_str().to_string();
598        job.dlq_reason_code = Some(reason_code.to_string());
599        job.dlq_at = Some(now);
600        job.locked_at = None;
601        job.locked_by = None;
602        job.lock_expires_at = None;
603        job.updated_at = now;
604
605        Ok(())
606    }
607
608    async fn archive_succeeded_older_than(
609        &self,
610        cutoff: DateTime<Utc>,
611        limit: i64,
612    ) -> anyhow::Result<u64> {
613        let mut state = self.state.write().unwrap();
614
615        let to_archive: Vec<Uuid> = state
616            .jobs
617            .values()
618            .filter(|j| j.status == "succeeded" && j.updated_at < cutoff)
619            .take(limit as usize)
620            .map(|j| j.id)
621            .collect();
622
623        let count = to_archive.len() as u64;
624        for id in to_archive {
625            if let Some(job) = state.jobs.remove(&id) {
626                state.archive.insert(id, job);
627            }
628        }
629
630        Ok(count)
631    }
632
633    async fn delete_history_for_succeeded_older_than(
634        &self,
635        cutoff: DateTime<Utc>,
636        limit: i64,
637    ) -> anyhow::Result<(u64, u64)> {
638        let mut state = self.state.write().unwrap();
639
640        let archived_ids: Vec<Uuid> = state
641            .archive
642            .values()
643            .filter(|j| j.updated_at < cutoff)
644            .map(|j| j.id)
645            .collect();
646
647        let to_remove: Vec<Uuid> = state
648            .attempts
649            .values()
650            .filter(|a| a.started_at < cutoff && archived_ids.contains(&a.job_id))
651            .take(limit as usize)
652            .map(|a| a.id)
653            .collect();
654
655        let count = to_remove.len() as u64;
656        for aid in to_remove {
657            state.attempts.remove(&aid);
658        }
659
660        Ok((count, 0))
661    }
662
663    async fn perform_maintenance(&self) -> anyhow::Result<()> {
664        Ok(())
665    }
666
667    async fn extend_lease(
668        &self,
669        job_id: Uuid,
670        worker_id: &str,
671        lease_seconds: i64,
672    ) -> anyhow::Result<bool> {
673        let mut state = self.state.write().unwrap();
674        if let Some(job) = state.jobs.get_mut(&job_id) {
675            if job.status == "running" && job.locked_by.as_deref() == Some(worker_id) {
676                let now = Utc::now();
677                job.lock_expires_at = Some(now + chrono::Duration::seconds(lease_seconds));
678                job.updated_at = now;
679                return Ok(true);
680            }
681        }
682        Ok(false)
683    }
684
685    async fn cancel_job(&self, job_id: Uuid, worker_id: Option<&str>) -> anyhow::Result<()> {
686        let mut state = self.state.write().unwrap();
687        let now = Utc::now();
688
689        let queued_entry = state
690            .jobs
691            .get(&job_id)
692            .filter(|job| job.status == "queued")
693            .map(|job| (job.queue.clone(), QueueEntry::from_job(job)));
694
695        let job = state
696            .jobs
697            .get_mut(&job_id)
698            .ok_or_else(|| anyhow::anyhow!("job {job_id} not found"))?;
699
700        match job.status.as_str() {
701            "queued" => {}
702            "running" => {
703                let Some(worker_id) = worker_id else {
704                    anyhow::bail!(
705                        "cannot cancel running job {job_id}: worker identity is required"
706                    );
707                };
708                if job.locked_by.as_deref() != Some(worker_id) {
709                    anyhow::bail!(
710                        "illegal job state transition to cancelled for job {job_id}: expected running lease held by {worker_id}"
711                    );
712                }
713            }
714            "succeeded" | "dlq" | "canceled" => {
715                anyhow::bail!("cannot cancel terminal job {job_id}: status={}", job.status);
716            }
717            other => anyhow::bail!("cannot cancel job {job_id}: invalid status={other}"),
718        }
719
720        if job.status == "running" {
721            if let Some(att) = state
722                .attempts
723                .values_mut()
724                .filter(|a| a.job_id == job_id && a.status == "running")
725                .max_by_key(|a| a.attempt_no)
726            {
727                att.status = "failed".to_string();
728                att.finished_at = Some(now);
729                att.latency_ms = Some(0);
730                att.error_code = Some("CANCELLED".to_string());
731                att.error_message = Some("job cancelled".to_string());
732            }
733        }
734
735        let job = state.jobs.get_mut(&job_id).expect("job checked above");
736        job.status = JobStatus::Cancelled.as_str().to_string();
737        job.locked_at = None;
738        job.locked_by = None;
739        job.lock_expires_at = None;
740        job.updated_at = now;
741
742        if let Some((queue, entry)) = queued_entry {
743            if let Some(index) = state.queued_jobs.get_mut(&queue) {
744                index.remove(&entry);
745            }
746        }
747
748        Ok(())
749    }
750
751    async fn get_job(&self, job_id: Uuid) -> anyhow::Result<Option<Job>> {
752        let state = self.state.read().unwrap();
753        let job = state
754            .jobs
755            .get(&job_id)
756            .or_else(|| state.archive.get(&job_id))
757            .cloned();
758        Ok(job)
759    }
760
761    async fn list_jobs(
762        &self,
763        queue: Option<&str>,
764        status: Option<&str>,
765        limit: i64,
766        cursor_created_at: Option<DateTime<Utc>>,
767        cursor_id: Option<Uuid>,
768    ) -> anyhow::Result<Vec<JobListItem>> {
769        let state = self.state.read().unwrap();
770        let limit = limit.clamp(1, 500) as usize;
771
772        let mut items: Vec<JobListItem> = state
773            .jobs
774            .values()
775            .filter(|j| {
776                if let Some(q) = queue {
777                    if j.queue != q {
778                        return false;
779                    }
780                }
781                if let Some(st) = status {
782                    if j.status != st {
783                        return false;
784                    }
785                }
786                true
787            })
788            .map(|j| JobListItem {
789                id: j.id,
790                idempotency_key: j.idempotency_key.clone(),
791                queue: j.queue.clone(),
792                job_type: j.job_type.clone(),
793                status: j.status.clone(),
794                run_at: j.run_at,
795                deadline_at: j.deadline_at,
796                timeout_seconds: j.timeout_seconds,
797                recurring_interval_seconds: j.recurring_interval_seconds,
798                priority: j.priority,
799                max_attempts: j.max_attempts,
800                last_error_code: None,
801                last_error_message: None,
802                dlq_reason_code: j.dlq_reason_code.clone(),
803                created_at: j.created_at,
804                updated_at: j.updated_at,
805            })
806            .filter(|item| match (cursor_created_at, cursor_id) {
807                (Some(cursor_created_at), Some(cursor_id)) => {
808                    item.created_at < cursor_created_at
809                        || (item.created_at == cursor_created_at && item.id < cursor_id)
810                }
811                _ => true,
812            })
813            .collect();
814
815        items.sort_by(|a, b| {
816            b.created_at
817                .cmp(&a.created_at)
818                .then_with(|| b.id.cmp(&a.id))
819        });
820
821        items.truncate(limit);
822        Ok(items)
823    }
824
825    async fn replay_job(
826        &self,
827        job_id: Uuid,
828        override_queue: Option<&str>,
829        override_run_at: Option<DateTime<Utc>>,
830    ) -> anyhow::Result<Uuid> {
831        let mut state = self.state.write().unwrap();
832
833        let src = state
834            .jobs
835            .get(&job_id)
836            .or_else(|| state.archive.get(&job_id))
837            .cloned()
838            .ok_or_else(|| anyhow::anyhow!("Job {job_id} not found"))?;
839
840        let new_id = Uuid::new_v4();
841        let now = Utc::now();
842        let target_queue = override_queue.unwrap_or(&src.queue).to_string();
843        let target_run_at = override_run_at.unwrap_or(now);
844
845        let target_queue_clone = target_queue.clone();
846        let new_job = Job {
847            dataset_id: "default".to_string(),
848            replay_of_job_id: Some(job_id),
849            idempotency_key: None,
850            id: new_id,
851            queue: target_queue,
852            job_type: src.job_type,
853            payload: src.payload,
854            run_at: target_run_at,
855            deadline_at: src.deadline_at,
856            timeout_seconds: src.timeout_seconds,
857            recurring_interval_seconds: src.recurring_interval_seconds,
858            status: JobStatus::Queued.as_str().to_string(),
859            priority: src.priority,
860            max_attempts: src.max_attempts,
861            locked_at: None,
862            locked_by: None,
863            lock_expires_at: None,
864            dlq_reason_code: None,
865            dlq_at: None,
866            created_at: now,
867            updated_at: now,
868        };
869
870        state
871            .queued_jobs
872            .entry(target_queue_clone.clone())
873            .or_default()
874            .insert(QueueEntry::from_job(&new_job));
875        state.jobs.insert(new_id, new_job);
876        drop(state);
877        self.notify_queue(&target_queue_clone);
878        Ok(new_id)
879    }
880}
881
882fn attempt_error(attempt: &MemoryAttempt) -> Option<String> {
883    match (&attempt.error_code, &attempt.error_message) {
884        (Some(code), Some(message)) => Some(format!("{code}: {message}")),
885        (Some(code), None) => Some(code.clone()),
886        (None, Some(message)) => Some(message.clone()),
887        (None, None) => None,
888    }
889}
890
891fn job_summary(job: &Job, attempts: &[MemoryAttempt]) -> String {
892    let attempt_count = attempts.len();
893    match job.status.as_str() {
894        "queued" if job.run_at > Utc::now() => {
895            format!("Job is waiting until {}.", job.run_at)
896        }
897        "queued" => "Job is queued and eligible when ordering and priority allow it.".to_string(),
898        "running" => match &job.locked_by {
899            Some(worker_id) => format!("Job is running on worker {worker_id}."),
900            None => "Job is running without a recorded worker identity.".to_string(),
901        },
902        "succeeded" | "completed" => {
903            format!("Job completed after {attempt_count} attempt(s).")
904        }
905        "dlq" => {
906            let reason = job
907                .dlq_reason_code
908                .clone()
909                .unwrap_or_else(|| "UNKNOWN".to_string());
910            format!("Job is in DLQ after {attempt_count} attempt(s): {reason}.")
911        }
912        "canceled" | "cancelled" => "Job was cancelled.".to_string(),
913        other => format!("Job is in backend-specific status '{other}'."),
914    }
915}
916
917fn avg_i64(values: impl Iterator<Item = i64>) -> f64 {
918    let mut count = 0_u64;
919    let mut sum = 0_i64;
920    for value in values {
921        count += 1;
922        sum += value;
923    }
924    if count == 0 {
925        0.0
926    } else {
927        sum as f64 / count as f64
928    }
929}
930
931#[async_trait]
932impl ObservabilityBackend for MemoryBackend {
933    async fn explain_job(&self, job_id: Uuid) -> anyhow::Result<Option<JobExplanation>> {
934        let state = self.state.read().unwrap();
935        let Some(job) = state
936            .jobs
937            .get(&job_id)
938            .or_else(|| state.archive.get(&job_id))
939            .cloned()
940        else {
941            return Ok(None);
942        };
943
944        let mut attempts: Vec<MemoryAttempt> = state
945            .attempts
946            .values()
947            .filter(|attempt| attempt.job_id == job_id)
948            .cloned()
949            .collect();
950        attempts.sort_by_key(|attempt| attempt.attempt_no);
951
952        let trace_id = trace_id_from_job(&job);
953        let retry_count = attempts
954            .iter()
955            .filter(|attempt| attempt.status == "failed")
956            .count() as i32;
957        let last_worker_id = attempts.last().map(|attempt| attempt.worker_id.clone());
958        let last_error = attempts.iter().rev().find_map(attempt_error);
959
960        let mut events = Vec::with_capacity(attempts.len() + 1);
961        events.push(JobObservationEvent {
962            at: job.created_at,
963            job_id,
964            attempt: None,
965            worker_id: None,
966            queue: job.queue.clone(),
967            duration_ms: None,
968            status: "queued".to_string(),
969            retry_count: 0,
970            error: None,
971            trace_id: trace_id.clone(),
972        });
973
974        for attempt in &attempts {
975            events.push(JobObservationEvent {
976                at: attempt.finished_at.unwrap_or(attempt.started_at),
977                job_id,
978                attempt: Some(attempt.attempt_no),
979                worker_id: Some(attempt.worker_id.clone()),
980                queue: job.queue.clone(),
981                duration_ms: attempt.latency_ms,
982                status: attempt.status.clone(),
983                retry_count: (attempt.attempt_no - 1).max(0),
984                error: attempt_error(attempt),
985                trace_id: trace_id.clone(),
986            });
987        }
988
989        Ok(Some(JobExplanation {
990            job_id,
991            job_type: job.job_type.clone(),
992            queue: job.queue.clone(),
993            status: job.status.clone(),
994            retry_count,
995            last_worker_id,
996            last_error,
997            trace_id,
998            events,
999            summary: job_summary(&job, &attempts),
1000        }))
1001    }
1002
1003    async fn queue_metrics(&self, queue: Option<&str>) -> anyhow::Result<Vec<QueueMetrics>> {
1004        let state = self.state.read().unwrap();
1005        let now = Utc::now();
1006        let mut queues: HashSet<String> = state
1007            .jobs
1008            .values()
1009            .filter(|job| queue.is_none_or(|target| job.queue == target))
1010            .map(|job| job.queue.clone())
1011            .collect();
1012
1013        if let Some(queue) = queue {
1014            queues.insert(queue.to_string());
1015        }
1016
1017        let mut rows = Vec::with_capacity(queues.len());
1018        for queue_name in queues {
1019            let jobs: Vec<&Job> = state
1020                .jobs
1021                .values()
1022                .filter(|job| job.queue == queue_name)
1023                .collect();
1024            let attempts: Vec<&MemoryAttempt> = state
1025                .attempts
1026                .values()
1027                .filter(|attempt| {
1028                    state
1029                        .jobs
1030                        .get(&attempt.job_id)
1031                        .or_else(|| state.archive.get(&attempt.job_id))
1032                        .is_some_and(|job| job.queue == queue_name)
1033                })
1034                .collect();
1035            let workers: HashSet<String> = jobs
1036                .iter()
1037                .filter(|job| job.status == "running")
1038                .filter_map(|job| job.locked_by.clone())
1039                .collect();
1040
1041            let retry_latency = attempts.iter().filter_map(|attempt| {
1042                let finished_at = attempt.finished_at?;
1043                let job = state
1044                    .jobs
1045                    .get(&attempt.job_id)
1046                    .or_else(|| state.archive.get(&attempt.job_id))?;
1047                let millis = (job.run_at - finished_at).num_milliseconds();
1048                (millis > 0).then_some(millis)
1049            });
1050
1051            rows.push(QueueMetrics {
1052                at: now,
1053                queue: queue_name,
1054                jobs_total: jobs.len() as u64,
1055                jobs_completed: jobs
1056                    .iter()
1057                    .filter(|job| matches!(job.status.as_str(), "succeeded" | "completed"))
1058                    .count() as u64,
1059                jobs_failed: attempts
1060                    .iter()
1061                    .filter(|attempt| attempt.status == "failed")
1062                    .count() as u64,
1063                jobs_retried: attempts
1064                    .iter()
1065                    .filter(|attempt| attempt.status == "failed")
1066                    .filter(|attempt| {
1067                        state
1068                            .jobs
1069                            .get(&attempt.job_id)
1070                            .is_some_and(|job| job.status == "queued")
1071                    })
1072                    .count() as u64,
1073                jobs_dlq: jobs.iter().filter(|job| job.status == "dlq").count() as u64,
1074                queue_depth: jobs
1075                    .iter()
1076                    .filter(|job| job.status == "queued" && job.run_at <= now)
1077                    .count() as u64,
1078                execution_latency_ms_avg: avg_i64(
1079                    attempts
1080                        .iter()
1081                        .filter_map(|attempt| attempt.latency_ms.map(i64::from)),
1082                ),
1083                claim_latency_ms_avg: avg_i64(jobs.iter().filter_map(|job| {
1084                    job.locked_at
1085                        .map(|locked_at| (locked_at - job.created_at).num_milliseconds())
1086                })),
1087                retry_latency_ms_avg: avg_i64(retry_latency),
1088                worker_count: workers.len() as u64,
1089            });
1090        }
1091
1092        rows.sort_by(|a, b| a.queue.cmp(&b.queue));
1093        Ok(rows)
1094    }
1095}
1096
1097#[async_trait]
1098impl StreamBackend for MemoryBackend {
1099    async fn publish(&self, stream: &str, event: NewEvent) -> anyhow::Result<i64> {
1100        let mut state = self.state.write().unwrap();
1101        let log = state.streams.entry(stream.to_string()).or_default();
1102        let sequence_no = (log.len() + 1) as i64;
1103        let now = Utc::now();
1104
1105        let event_entity = Event {
1106            sequence_no,
1107            stream_name: stream.to_string(),
1108            event_type: event.event_type,
1109            payload_json: event.payload_json,
1110            created_at: now,
1111        };
1112
1113        log.push(event_entity);
1114        drop(state);
1115
1116        self.notify_stream(stream);
1117        Ok(sequence_no)
1118    }
1119
1120    async fn subscribe_stream(
1121        &self,
1122        stream: &str,
1123        _consumer_group: &str,
1124        _last_seq: Option<i64>,
1125    ) -> anyhow::Result<NotificationStream> {
1126        let rx = {
1127            let mut notifiers = self.stream_notifiers.write().unwrap();
1128            let tx = notifiers
1129                .entry(stream.to_string())
1130                .or_insert_with(|| tokio::sync::broadcast::channel(128).0);
1131            tx.subscribe()
1132        };
1133
1134        let stream = BroadcastStream::new(rx).filter_map(|res| res.ok());
1135        Ok(Box::pin(stream))
1136    }
1137
1138    async fn ack(&self, stream: &str, consumer_group: &str, seq: i64) -> anyhow::Result<()> {
1139        let mut state = self.state.write().unwrap();
1140        let key = (stream.to_string(), consumer_group.to_string());
1141        let now = Utc::now();
1142
1143        let entry = state
1144            .stream_offsets
1145            .entry(key)
1146            .or_insert_with(|| ConsumerGroupStatus {
1147                consumer_group: consumer_group.to_string(),
1148                stream_name: stream.to_string(),
1149                last_acked_seq: 0,
1150                updated_at: now,
1151            });
1152
1153        if seq > entry.last_acked_seq {
1154            entry.last_acked_seq = seq;
1155            entry.updated_at = now;
1156        }
1157
1158        Ok(())
1159    }
1160
1161    async fn read_events(
1162        &self,
1163        stream: &str,
1164        after_seq: i64,
1165        limit: i64,
1166    ) -> anyhow::Result<Vec<Event>> {
1167        let state = self.state.read().unwrap();
1168        let limit = limit.clamp(1, 1000) as usize;
1169
1170        if let Some(log) = state.streams.get(stream) {
1171            let events: Vec<Event> = log
1172                .iter()
1173                .filter(|e| e.sequence_no > after_seq)
1174                .take(limit)
1175                .cloned()
1176                .collect();
1177            Ok(events)
1178        } else {
1179            Ok(Vec::new())
1180        }
1181    }
1182
1183    async fn prune_events(&self, stream: &str, through_seq: i64) -> anyhow::Result<u64> {
1184        let mut state = self.state.write().unwrap();
1185        let min_offset = state
1186            .stream_offsets
1187            .values()
1188            .filter(|status| status.stream_name == stream)
1189            .map(|status| status.last_acked_seq)
1190            .min()
1191            .unwrap_or(through_seq);
1192        let cutoff = through_seq.min(min_offset);
1193
1194        let Some(log) = state.streams.get_mut(stream) else {
1195            return Ok(0);
1196        };
1197
1198        let before = log.len();
1199        log.retain(|event| event.sequence_no > cutoff);
1200        Ok((before - log.len()) as u64)
1201    }
1202
1203    async fn consumer_group_info(&self, stream: &str) -> anyhow::Result<Vec<ConsumerGroupStatus>> {
1204        let state = self.state.read().unwrap();
1205        let info: Vec<ConsumerGroupStatus> = state
1206            .stream_offsets
1207            .values()
1208            .filter(|cg| cg.stream_name == stream)
1209            .cloned()
1210            .collect();
1211        Ok(info)
1212    }
1213}