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#[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#[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 pub fn new() -> Self {
93 Self::default()
94 }
95
96 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}