Skip to main content

bullmq/
queue_events.rs

1//! Cross-process queue event listener.
2//!
3//! [`QueueEvents`] consumes the Redis stream that BullMQ writes to (the
4//! `{prefix}:{name}:events` key) and exposes each entry as a typed
5//! [`QueueEvent`]. Unlike the in-process [`crate::worker::WorkerEvent`]
6//! channel, these events are observable from any process/instance connected to
7//! the same Redis server, mirroring the Node.js `QueueEvents` class.
8//!
9//! # Example
10//!
11//! ```rust,no_run
12//! use bullmq::{QueueEvents, QueueEventsOptions, QueueEvent};
13//!
14//! # async fn example() -> bullmq::Result<()> {
15//! let events = QueueEvents::new("my-queue", QueueEventsOptions::default()).await?;
16//!
17//! while let Some(entry) = events.next_event().await {
18//!     match entry.event {
19//!         QueueEvent::Completed { job_id, .. } => println!("{job_id} completed"),
20//!         QueueEvent::Failed { job_id, failed_reason, .. } => {
21//!             println!("{job_id} failed: {failed_reason}")
22//!         }
23//!         _ => {}
24//!     }
25//! }
26//! # Ok(())
27//! # }
28//! ```
29
30use std::collections::HashMap;
31use std::sync::atomic::{AtomicBool, Ordering};
32use std::sync::Arc;
33use std::time::Duration;
34
35use redis::streams::{StreamReadOptions, StreamReadReply};
36use redis::AsyncCommands;
37use tokio::sync::{mpsc, Mutex};
38use tokio::task::JoinHandle;
39use tracing::debug;
40
41use crate::error::Error;
42use crate::keys::{validate_queue_name, QueueKeys};
43use crate::options::{QueueOptions, RedisConnectionOptions};
44use crate::redis_connection::RedisConnection;
45
46/// Options for creating a [`QueueEvents`] listener.
47#[derive(Clone)]
48pub struct QueueEventsOptions {
49    /// Redis connection configuration.
50    pub connection: RedisConnectionOptions,
51    /// Key prefix for all queue keys.
52    pub prefix: String,
53    /// Maximum time in milliseconds to block on each `XREAD` before looping.
54    ///
55    /// Smaller values make [`QueueEvents::close`] more responsive at the cost of
56    /// extra round trips; defaults to `5000`.
57    pub blocking_timeout: u64,
58    /// The stream id to start reading from.
59    ///
60    /// When `None`, only events produced after the listener starts are
61    /// delivered (Redis `$`). Provide `Some("0")` to read the full history.
62    pub last_event_id: Option<String>,
63    /// Whether to start consuming events automatically on creation.
64    pub autorun: bool,
65}
66
67impl Default for QueueEventsOptions {
68    fn default() -> Self {
69        Self {
70            connection: RedisConnectionOptions::default(),
71            prefix: "bull".to_string(),
72            blocking_timeout: 5000,
73            last_event_id: None,
74            autorun: true,
75        }
76    }
77}
78
79/// A single entry read from the queue events stream.
80#[derive(Debug, Clone)]
81pub struct QueueEventEntry {
82    /// The Redis stream id of this entry (e.g. `"1518945496404-0"`).
83    ///
84    /// Can be passed as [`QueueEventsOptions::last_event_id`] to resume.
85    pub id: String,
86    /// The parsed event.
87    pub event: QueueEvent,
88}
89
90/// A typed queue event consumed from the events stream.
91///
92/// Variant names and payloads mirror the Node.js `QueueEventsListener`.
93#[derive(Debug, Clone, PartialEq)]
94pub enum QueueEvent {
95    /// A job entered the `active` state (started processing).
96    Active {
97        /// The job id.
98        job_id: String,
99        /// The previous state, if reported.
100        prev: Option<String>,
101    },
102    /// A job was created and added to the queue.
103    Added {
104        /// The job id.
105        job_id: String,
106        /// The job name.
107        name: String,
108    },
109    /// Jobs were cleaned from the queue.
110    Cleaned {
111        /// The number of cleaned jobs (as reported by Redis, a string).
112        count: String,
113    },
114    /// A job completed successfully.
115    Completed {
116        /// The job id.
117        job_id: String,
118        /// The raw (JSON-encoded) return value string.
119        return_value: String,
120        /// The previous state, if reported.
121        prev: Option<String>,
122    },
123    /// A job was debounced (deprecated alias of [`QueueEvent::Deduplicated`]).
124    Debounced {
125        /// The job id.
126        job_id: String,
127        /// The debounce id.
128        debounce_id: String,
129    },
130    /// A job was not added because a job with the same deduplication id exists.
131    Deduplicated {
132        /// The job id that was attempted.
133        job_id: String,
134        /// The deduplication id.
135        deduplication_id: String,
136        /// The id of the existing job that caused the deduplication, if any.
137        deduplicated_job_id: Option<String>,
138    },
139    /// A job was scheduled with a delay.
140    Delayed {
141        /// The job id.
142        job_id: String,
143        /// The delay timestamp in milliseconds.
144        delay: i64,
145    },
146    /// The waiting list became empty.
147    Drained,
148    /// A job with a duplicate id was rejected.
149    Duplicated {
150        /// The job id.
151        job_id: String,
152    },
153    /// An error event was written to the stream (or occurred while reading).
154    Error {
155        /// The error message.
156        message: String,
157    },
158    /// A job failed.
159    Failed {
160        /// The job id.
161        job_id: String,
162        /// The failure reason.
163        failed_reason: String,
164        /// The previous state, if reported.
165        prev: Option<String>,
166    },
167    /// The queue was paused.
168    Paused,
169    /// A job reported progress.
170    Progress {
171        /// The job id.
172        job_id: String,
173        /// The progress payload (parsed JSON, or a string fallback).
174        data: serde_json::Value,
175    },
176    /// A job was removed.
177    Removed {
178        /// The job id.
179        job_id: String,
180        /// The previous state, if reported.
181        prev: Option<String>,
182    },
183    /// The queue was resumed.
184    Resumed,
185    /// A job exhausted all retry attempts.
186    RetriesExhausted {
187        /// The job id.
188        job_id: String,
189        /// The number of attempts made (as reported by Redis, a string).
190        attempts_made: String,
191    },
192    /// A job was detected as stalled.
193    Stalled {
194        /// The job id.
195        job_id: String,
196    },
197    /// A job entered the `waiting` state.
198    Waiting {
199        /// The job id.
200        job_id: String,
201        /// The previous state, if reported.
202        prev: Option<String>,
203    },
204    /// A job entered the `waiting-children` state.
205    WaitingChildren {
206        /// The job id.
207        job_id: String,
208    },
209    /// An unrecognized event (forward compatibility).
210    Other {
211        /// The raw `event` field value.
212        event: String,
213        /// All parsed fields of the stream entry.
214        fields: HashMap<String, String>,
215    },
216}
217
218impl QueueEvent {
219    /// The job id associated with this event, if any.
220    pub fn job_id(&self) -> Option<&str> {
221        match self {
222            QueueEvent::Active { job_id, .. }
223            | QueueEvent::Added { job_id, .. }
224            | QueueEvent::Completed { job_id, .. }
225            | QueueEvent::Debounced { job_id, .. }
226            | QueueEvent::Deduplicated { job_id, .. }
227            | QueueEvent::Delayed { job_id, .. }
228            | QueueEvent::Duplicated { job_id }
229            | QueueEvent::Failed { job_id, .. }
230            | QueueEvent::Progress { job_id, .. }
231            | QueueEvent::Removed { job_id, .. }
232            | QueueEvent::RetriesExhausted { job_id, .. }
233            | QueueEvent::Stalled { job_id }
234            | QueueEvent::Waiting { job_id, .. }
235            | QueueEvent::WaitingChildren { job_id } => Some(job_id),
236            _ => None,
237        }
238    }
239
240    /// The event name as written to the stream (matches the Node.js event name).
241    pub fn name(&self) -> &str {
242        match self {
243            QueueEvent::Active { .. } => "active",
244            QueueEvent::Added { .. } => "added",
245            QueueEvent::Cleaned { .. } => "cleaned",
246            QueueEvent::Completed { .. } => "completed",
247            QueueEvent::Debounced { .. } => "debounced",
248            QueueEvent::Deduplicated { .. } => "deduplicated",
249            QueueEvent::Delayed { .. } => "delayed",
250            QueueEvent::Drained => "drained",
251            QueueEvent::Duplicated { .. } => "duplicated",
252            QueueEvent::Error { .. } => "error",
253            QueueEvent::Failed { .. } => "failed",
254            QueueEvent::Paused => "paused",
255            QueueEvent::Progress { .. } => "progress",
256            QueueEvent::Removed { .. } => "removed",
257            QueueEvent::Resumed => "resumed",
258            QueueEvent::RetriesExhausted { .. } => "retries-exhausted",
259            QueueEvent::Stalled { .. } => "stalled",
260            QueueEvent::Waiting { .. } => "waiting",
261            QueueEvent::WaitingChildren { .. } => "waiting-children",
262            QueueEvent::Other { event, .. } => event,
263        }
264    }
265}
266
267/// Parse a stream entry's fields into a typed [`QueueEvent`].
268fn parse_event(mut fields: HashMap<String, String>) -> QueueEvent {
269    let event = fields.remove("event").unwrap_or_default();
270    let job_id = |f: &mut HashMap<String, String>| f.remove("jobId").unwrap_or_default();
271
272    match event.as_str() {
273        "active" => QueueEvent::Active {
274            job_id: job_id(&mut fields),
275            prev: fields.remove("prev"),
276        },
277        "added" => QueueEvent::Added {
278            job_id: job_id(&mut fields),
279            name: fields.remove("name").unwrap_or_default(),
280        },
281        "cleaned" => QueueEvent::Cleaned {
282            count: fields.remove("count").unwrap_or_default(),
283        },
284        "completed" => QueueEvent::Completed {
285            job_id: job_id(&mut fields),
286            return_value: fields.remove("returnvalue").unwrap_or_default(),
287            prev: fields.remove("prev"),
288        },
289        "debounced" => QueueEvent::Debounced {
290            job_id: job_id(&mut fields),
291            debounce_id: fields.remove("debounceId").unwrap_or_default(),
292        },
293        "deduplicated" => QueueEvent::Deduplicated {
294            job_id: job_id(&mut fields),
295            deduplication_id: fields.remove("deduplicationId").unwrap_or_default(),
296            deduplicated_job_id: fields.remove("deduplicatedJobId"),
297        },
298        "delayed" => QueueEvent::Delayed {
299            job_id: job_id(&mut fields),
300            delay: fields
301                .remove("delay")
302                .and_then(|d| d.parse().ok())
303                .unwrap_or(0),
304        },
305        "drained" => QueueEvent::Drained,
306        "duplicated" => QueueEvent::Duplicated {
307            job_id: job_id(&mut fields),
308        },
309        "error" => QueueEvent::Error {
310            message: fields.remove("message").unwrap_or_default(),
311        },
312        "failed" => QueueEvent::Failed {
313            job_id: job_id(&mut fields),
314            failed_reason: fields.remove("failedReason").unwrap_or_default(),
315            prev: fields.remove("prev"),
316        },
317        "paused" => QueueEvent::Paused,
318        "progress" => {
319            let raw = fields.remove("data").unwrap_or_default();
320            let data = serde_json::from_str(&raw).unwrap_or(serde_json::Value::String(raw));
321            QueueEvent::Progress {
322                job_id: job_id(&mut fields),
323                data,
324            }
325        }
326        "removed" => QueueEvent::Removed {
327            job_id: job_id(&mut fields),
328            prev: fields.remove("prev"),
329        },
330        "resumed" => QueueEvent::Resumed,
331        "retries-exhausted" => QueueEvent::RetriesExhausted {
332            job_id: job_id(&mut fields),
333            attempts_made: fields.remove("attemptsMade").unwrap_or_default(),
334        },
335        "stalled" => QueueEvent::Stalled {
336            job_id: job_id(&mut fields),
337        },
338        "waiting" => QueueEvent::Waiting {
339            job_id: job_id(&mut fields),
340            prev: fields.remove("prev"),
341        },
342        "waiting-children" => QueueEvent::WaitingChildren {
343            job_id: job_id(&mut fields),
344        },
345        _ => QueueEvent::Other { event, fields },
346    }
347}
348
349/// Convert a stream entry's raw field map into a string map.
350fn map_to_strings(map: &HashMap<String, redis::Value>) -> HashMap<String, String> {
351    map.iter()
352        .filter_map(|(k, v)| {
353            redis::from_redis_value::<String>(v)
354                .ok()
355                .map(|s| (k.clone(), s))
356        })
357        .collect()
358}
359
360/// A cross-process, stream-based queue event listener.
361///
362/// See the [module documentation](crate::queue_events) for details.
363pub struct QueueEvents {
364    name: String,
365    keys: QueueKeys,
366    conn: RedisConnection,
367    opts: QueueEventsOptions,
368    closing: Arc<AtomicBool>,
369    running: Arc<AtomicBool>,
370    event_tx: Arc<Mutex<Option<mpsc::UnboundedSender<QueueEventEntry>>>>,
371    event_rx: Arc<Mutex<mpsc::UnboundedReceiver<QueueEventEntry>>>,
372    task: Arc<Mutex<Option<JoinHandle<()>>>>,
373}
374
375impl QueueEvents {
376    /// Create a new `QueueEvents` listener with its own Redis connection.
377    pub async fn new(name: &str, opts: QueueEventsOptions) -> Result<Self, Error> {
378        validate_queue_name(name)?;
379        let conn = RedisConnection::new(&opts.connection).await?;
380        Self::build(name, conn, opts).await
381    }
382
383    /// Create a `QueueEvents` listener that reuses an existing connection.
384    ///
385    /// Note: the listener still opens a *dedicated* connection internally for the
386    /// blocking `XREAD`; `conn` is only used for its client/configuration.
387    pub async fn with_connection(
388        name: &str,
389        conn: RedisConnection,
390        opts: QueueEventsOptions,
391    ) -> Result<Self, Error> {
392        validate_queue_name(name)?;
393        Self::build(name, conn, opts).await
394    }
395
396    /// Convenience constructor that derives options from [`QueueOptions`].
397    pub async fn from_queue_options(name: &str, opts: &QueueOptions) -> Result<Self, Error> {
398        Self::new(
399            name,
400            QueueEventsOptions {
401                connection: opts.connection.clone(),
402                prefix: opts.prefix.clone(),
403                ..Default::default()
404            },
405        )
406        .await
407    }
408
409    async fn build(
410        name: &str,
411        conn: RedisConnection,
412        opts: QueueEventsOptions,
413    ) -> Result<Self, Error> {
414        let keys = QueueKeys::new(name, Some(&opts.prefix));
415        let (event_tx, event_rx) = mpsc::unbounded_channel();
416
417        let events = Self {
418            name: name.to_string(),
419            keys,
420            conn,
421            opts,
422            closing: Arc::new(AtomicBool::new(false)),
423            running: Arc::new(AtomicBool::new(false)),
424            event_tx: Arc::new(Mutex::new(Some(event_tx))),
425            event_rx: Arc::new(Mutex::new(event_rx)),
426            task: Arc::new(Mutex::new(None)),
427        };
428
429        if events.opts.autorun {
430            events.run().await?;
431        }
432
433        Ok(events)
434    }
435
436    /// The queue name this listener observes.
437    pub fn name(&self) -> &str {
438        &self.name
439    }
440
441    /// The queue keys helper.
442    pub fn keys(&self) -> &QueueKeys {
443        &self.keys
444    }
445
446    /// Whether the consuming loop is currently running.
447    pub fn is_running(&self) -> bool {
448        self.running.load(Ordering::Relaxed)
449    }
450
451    /// Start consuming events.
452    ///
453    /// Called automatically when [`QueueEventsOptions::autorun`] is `true`.
454    /// Returns an error if the listener is already running.
455    pub async fn run(&self) -> Result<(), Error> {
456        if self.running.swap(true, Ordering::SeqCst) {
457            return Err(Error::InvalidConfig(
458                "QueueEvents is already running".to_string(),
459            ));
460        }
461
462        let conn = self.conn.clone();
463        let key = self.keys.events();
464        let opts = self.opts.clone();
465        let closing = self.closing.clone();
466        let tx = match self.event_tx.lock().await.as_ref().cloned() {
467            Some(tx) => tx,
468            None => {
469                self.running.store(false, Ordering::SeqCst);
470                return Err(Error::InvalidConfig(
471                    "Cannot run QueueEvents after close()".to_string(),
472                ));
473            }
474        };
475
476        let handle = tokio::spawn(async move {
477            Self::consume(conn, key, opts, closing, tx).await;
478        });
479
480        *self.task.lock().await = Some(handle);
481        Ok(())
482    }
483
484    async fn consume(
485        conn: RedisConnection,
486        key: String,
487        opts: QueueEventsOptions,
488        closing: Arc<AtomicBool>,
489        tx: mpsc::UnboundedSender<QueueEventEntry>,
490    ) {
491        let mut redis_conn = match conn.dedicated_connection().await {
492            Ok(c) => c,
493            Err(e) => {
494                let _ = tx.send(QueueEventEntry {
495                    id: String::new(),
496                    event: QueueEvent::Error {
497                        message: e.to_string(),
498                    },
499                });
500                return;
501            }
502        };
503
504        let mut id = opts
505            .last_event_id
506            .clone()
507            .unwrap_or_else(|| "$".to_string());
508        let read_opts = StreamReadOptions::default().block(opts.blocking_timeout as usize);
509
510        while !closing.load(Ordering::Relaxed) {
511            let reply: Result<Option<StreamReadReply>, _> =
512                redis_conn.xread_options(&[&key], &[&id], &read_opts).await;
513
514            match reply {
515                Ok(Some(reply)) => {
516                    for stream_key in reply.keys {
517                        for entry in stream_key.ids {
518                            id = entry.id.clone();
519                            let fields = map_to_strings(&entry.map);
520                            let event = parse_event(fields);
521                            if tx
522                                .send(QueueEventEntry {
523                                    id: entry.id,
524                                    event,
525                                })
526                                .is_err()
527                            {
528                                // Receiver dropped; stop consuming.
529                                return;
530                            }
531                        }
532                    }
533                }
534                Ok(None) => {
535                    // Block timeout elapsed with no new events; loop again.
536                }
537                Err(e) => {
538                    if closing.load(Ordering::Relaxed) {
539                        break;
540                    }
541                    let _ = tx.send(QueueEventEntry {
542                        id: String::new(),
543                        event: QueueEvent::Error {
544                            message: e.to_string(),
545                        },
546                    });
547                    tokio::time::sleep(Duration::from_millis(100)).await;
548                }
549            }
550        }
551
552        debug!(queue = %key, "queue events loop stopped");
553    }
554
555    /// Wait for and return the next event, or `None` once the listener is closed
556    /// and the buffer is drained.
557    pub async fn next_event(&self) -> Option<QueueEventEntry> {
558        let mut rx = self.event_rx.lock().await;
559        rx.recv().await
560    }
561
562    /// Try to receive a buffered event without waiting.
563    pub async fn try_next_event(&self) -> Option<QueueEventEntry> {
564        let mut rx = self.event_rx.lock().await;
565        rx.try_recv().ok()
566    }
567
568    /// Stop consuming events and release the dedicated connection.
569    pub async fn close(&self) {
570        self.closing.store(true, Ordering::SeqCst);
571        if let Some(handle) = self.task.lock().await.take() {
572            handle.abort();
573        }
574        self.event_tx.lock().await.take();
575        self.running.store(false, Ordering::SeqCst);
576    }
577}
578
579#[cfg(test)]
580mod tests {
581    use super::*;
582
583    fn fields(pairs: &[(&str, &str)]) -> HashMap<String, String> {
584        pairs
585            .iter()
586            .map(|(k, v)| (k.to_string(), v.to_string()))
587            .collect()
588    }
589
590    #[test]
591    fn parses_completed_event() {
592        let event = parse_event(fields(&[
593            ("event", "completed"),
594            ("jobId", "42"),
595            ("returnvalue", "{\"ok\":true}"),
596            ("prev", "active"),
597        ]));
598        assert_eq!(
599            event,
600            QueueEvent::Completed {
601                job_id: "42".to_string(),
602                return_value: "{\"ok\":true}".to_string(),
603                prev: Some("active".to_string()),
604            }
605        );
606        assert_eq!(event.job_id(), Some("42"));
607        assert_eq!(event.name(), "completed");
608    }
609
610    #[test]
611    fn parses_progress_json() {
612        let event = parse_event(fields(&[
613            ("event", "progress"),
614            ("jobId", "7"),
615            ("data", "{\"pct\":50}"),
616        ]));
617        match event {
618            QueueEvent::Progress { job_id, data } => {
619                assert_eq!(job_id, "7");
620                assert_eq!(data, serde_json::json!({ "pct": 50 }));
621            }
622            other => panic!("expected progress, got {other:?}"),
623        }
624    }
625
626    #[test]
627    fn parses_progress_string_fallback() {
628        let event = parse_event(fields(&[
629            ("event", "progress"),
630            ("jobId", "7"),
631            ("data", "not-json"),
632        ]));
633        match event {
634            QueueEvent::Progress { data, .. } => {
635                assert_eq!(data, serde_json::Value::String("not-json".to_string()));
636            }
637            other => panic!("expected progress, got {other:?}"),
638        }
639    }
640
641    #[test]
642    fn parses_delayed_number() {
643        let event = parse_event(fields(&[
644            ("event", "delayed"),
645            ("jobId", "1"),
646            ("delay", "1700000000000"),
647        ]));
648        assert_eq!(
649            event,
650            QueueEvent::Delayed {
651                job_id: "1".to_string(),
652                delay: 1_700_000_000_000,
653            }
654        );
655    }
656
657    #[test]
658    fn parses_drained() {
659        let event = parse_event(fields(&[("event", "drained")]));
660        assert_eq!(event, QueueEvent::Drained);
661        assert_eq!(event.job_id(), None);
662    }
663
664    #[test]
665    fn unknown_event_is_other() {
666        let event = parse_event(fields(&[("event", "future-event"), ("jobId", "9")]));
667        match event {
668            QueueEvent::Other { event, fields } => {
669                assert_eq!(event, "future-event");
670                assert_eq!(fields.get("jobId"), Some(&"9".to_string()));
671            }
672            other => panic!("expected other, got {other:?}"),
673        }
674    }
675
676    #[test]
677    fn parses_failed_event() {
678        let event = parse_event(fields(&[
679            ("event", "failed"),
680            ("jobId", "5"),
681            ("failedReason", "boom"),
682            ("prev", "active"),
683        ]));
684        assert_eq!(
685            event,
686            QueueEvent::Failed {
687                job_id: "5".to_string(),
688                failed_reason: "boom".to_string(),
689                prev: Some("active".to_string()),
690            }
691        );
692        assert_eq!(event.name(), "failed");
693    }
694
695    #[test]
696    fn parses_active_and_waiting_with_prev() {
697        let active = parse_event(fields(&[
698            ("event", "active"),
699            ("jobId", "1"),
700            ("prev", "waiting"),
701        ]));
702        assert_eq!(
703            active,
704            QueueEvent::Active {
705                job_id: "1".to_string(),
706                prev: Some("waiting".to_string()),
707            }
708        );
709
710        let waiting = parse_event(fields(&[("event", "waiting"), ("jobId", "2")]));
711        assert_eq!(
712            waiting,
713            QueueEvent::Waiting {
714                job_id: "2".to_string(),
715                prev: None,
716            }
717        );
718    }
719
720    #[test]
721    fn parses_added_cleaned_and_removed() {
722        assert_eq!(
723            parse_event(fields(&[
724                ("event", "added"),
725                ("jobId", "1"),
726                ("name", "job")
727            ])),
728            QueueEvent::Added {
729                job_id: "1".to_string(),
730                name: "job".to_string(),
731            }
732        );
733        assert_eq!(
734            parse_event(fields(&[("event", "cleaned"), ("count", "50")])),
735            QueueEvent::Cleaned {
736                count: "50".to_string(),
737            }
738        );
739        assert_eq!(
740            parse_event(fields(&[
741                ("event", "removed"),
742                ("jobId", "3"),
743                ("prev", "delayed")
744            ])),
745            QueueEvent::Removed {
746                job_id: "3".to_string(),
747                prev: Some("delayed".to_string()),
748            }
749        );
750    }
751
752    #[test]
753    fn parses_retries_exhausted_and_waiting_children() {
754        assert_eq!(
755            parse_event(fields(&[
756                ("event", "retries-exhausted"),
757                ("jobId", "7"),
758                ("attemptsMade", "3"),
759            ])),
760            QueueEvent::RetriesExhausted {
761                job_id: "7".to_string(),
762                attempts_made: "3".to_string(),
763            }
764        );
765        let wc = parse_event(fields(&[("event", "waiting-children"), ("jobId", "8")]));
766        assert_eq!(
767            wc,
768            QueueEvent::WaitingChildren {
769                job_id: "8".to_string(),
770            }
771        );
772        assert_eq!(wc.name(), "waiting-children");
773    }
774
775    #[test]
776    fn parses_deduplicated_and_duplicated() {
777        assert_eq!(
778            parse_event(fields(&[
779                ("event", "deduplicated"),
780                ("jobId", "1"),
781                ("deduplicationId", "d1"),
782                ("deduplicatedJobId", "2"),
783            ])),
784            QueueEvent::Deduplicated {
785                job_id: "1".to_string(),
786                deduplication_id: "d1".to_string(),
787                deduplicated_job_id: Some("2".to_string()),
788            }
789        );
790        assert_eq!(
791            parse_event(fields(&[("event", "duplicated"), ("jobId", "9")])),
792            QueueEvent::Duplicated {
793                job_id: "9".to_string(),
794            }
795        );
796    }
797
798    #[test]
799    fn parses_paused_resumed_and_error() {
800        assert_eq!(
801            parse_event(fields(&[("event", "paused")])),
802            QueueEvent::Paused
803        );
804        assert_eq!(
805            parse_event(fields(&[("event", "resumed")])),
806            QueueEvent::Resumed
807        );
808        assert_eq!(
809            parse_event(fields(&[("event", "error"), ("message", "bad")])),
810            QueueEvent::Error {
811                message: "bad".to_string(),
812            }
813        );
814    }
815
816    #[test]
817    fn job_id_and_name_helpers_are_consistent() {
818        // Events without a job id return None.
819        assert_eq!(QueueEvent::Drained.job_id(), None);
820        assert_eq!(QueueEvent::Paused.job_id(), None);
821        assert_eq!(QueueEvent::Resumed.job_id(), None);
822        assert_eq!(
823            QueueEvent::Cleaned {
824                count: "1".to_string()
825            }
826            .job_id(),
827            None
828        );
829        // name() round-trips through parse_event for every known variant.
830        for ev in [
831            "active",
832            "added",
833            "cleaned",
834            "completed",
835            "debounced",
836            "deduplicated",
837            "delayed",
838            "drained",
839            "duplicated",
840            "error",
841            "failed",
842            "paused",
843            "progress",
844            "removed",
845            "resumed",
846            "retries-exhausted",
847            "stalled",
848            "waiting",
849            "waiting-children",
850        ] {
851            assert_eq!(parse_event(fields(&[("event", ev)])).name(), ev);
852        }
853    }
854}