Skip to main content

bullmq/
queue.rs

1use std::collections::HashMap;
2use tracing::{debug, instrument};
3
4use crate::error::Error;
5use crate::job::{Job, ScriptContext};
6use crate::keys::{resolve_parent_queue_key, validate_queue_name, QueueKeys};
7use crate::options::{JobOptions, QueueOptions};
8use crate::redis_connection::RedisConnection;
9use crate::types::{DependenciesCount, JobCounts, JobState, QueueMeta};
10
11/// The version string stored in queue metadata for compatibility tracking.
12const BULLMQ_VERSION: &str = "bullmq-official:0.1.0";
13
14/// A callback invoked once per `(state, count)` pair by
15/// [`Queue::record_job_counts_metric`]. This is the integration point for
16/// forwarding job-count gauges to a metrics/telemetry backend.
17pub type JobCountRecorder<'a> = &'a dyn Fn(&str, u64);
18
19/// A Queue is the main entry point for adding jobs to be processed.
20///
21/// It provides methods for adding single and bulk jobs, and managing
22/// queue state (pause, resume, drain, obliterate).
23#[derive(Clone)]
24pub struct Queue {
25    name: String,
26    keys: QueueKeys,
27    conn: RedisConnection,
28    default_job_options: JobOptions,
29}
30
31impl Queue {
32    fn validate_job_size(job: &Job, argv2: &str) -> Result<(), Error> {
33        if let Some(size_limit) = job.opts().size_limit {
34            if argv2.len() > size_limit {
35                return Err(Error::InvalidConfig(format!(
36                    "The size of job {} exceeds the limit {} bytes",
37                    job.name(),
38                    size_limit
39                )));
40            }
41        }
42
43        Ok(())
44    }
45
46    /// Create a new Queue connected to Redis.
47    pub async fn new(name: &str, opts: QueueOptions) -> Result<Self, Error> {
48        validate_queue_name(name)?;
49        let conn = RedisConnection::new(&opts.connection).await?;
50        let keys = QueueKeys::new(name, Some(&opts.prefix));
51
52        let queue = Self {
53            name: name.to_string(),
54            keys,
55            conn,
56            default_job_options: opts.default_job_options,
57        };
58
59        queue.update_meta().await?;
60
61        Ok(queue)
62    }
63
64    /// Create a Queue with an existing Redis connection.
65    pub async fn with_connection(
66        name: &str,
67        conn: RedisConnection,
68        opts: QueueOptions,
69    ) -> Result<Self, Error> {
70        validate_queue_name(name)?;
71        let keys = QueueKeys::new(name, Some(&opts.prefix));
72
73        let queue = Self {
74            name: name.to_string(),
75            keys,
76            conn,
77            default_job_options: opts.default_job_options,
78        };
79
80        queue.update_meta().await?;
81
82        Ok(queue)
83    }
84
85    /// The queue name.
86    pub fn name(&self) -> &str {
87        &self.name
88    }
89
90    /// The queue keys helper.
91    pub fn keys(&self) -> &QueueKeys {
92        &self.keys
93    }
94
95    /// The underlying Redis connection.
96    pub fn connection(&self) -> &RedisConnection {
97        &self.conn
98    }
99
100    /// Add a job to the queue.
101    #[instrument(skip(self, data, opts), fields(queue = %self.name))]
102    pub async fn add(
103        &self,
104        name: &str,
105        data: serde_json::Value,
106        opts: Option<JobOptions>,
107    ) -> Result<Job, Error> {
108        let merged_opts = self.merge_job_options(opts);
109        let mut job = Job::new(name, data, merged_opts);
110        self.add_job(&mut job).await?;
111        job.set_context(self.make_script_context());
112        Ok(job)
113    }
114
115    /// Add multiple jobs to the queue in a single operation.
116    ///
117    /// Jobs are added concurrently over the multiplexed connection for maximum throughput.
118    #[instrument(skip(self, jobs), fields(queue = %self.name, count = jobs.len()))]
119    pub async fn add_bulk(
120        &self,
121        jobs: Vec<(String, serde_json::Value, Option<JobOptions>)>,
122    ) -> Result<Vec<Job>, Error> {
123        if jobs.is_empty() {
124            return Ok(Vec::new());
125        }
126
127        let mut job_objects: Vec<Job> = jobs
128            .into_iter()
129            .map(|(name, data, opts)| {
130                let merged = self.merge_job_options(opts);
131                Job::new(&name, data, merged)
132            })
133            .collect();
134
135        let serialized_job_data: Vec<String> = job_objects
136            .iter()
137            .map(|job| {
138                let argv2 = serde_json::to_string(job.data())?;
139                Self::validate_job_size(job, &argv2)?;
140                Ok(argv2)
141            })
142            .collect::<Result<_, Error>>()?;
143
144        // Run all add_job calls concurrently since the connection is multiplexed
145        let futures: Vec<_> = job_objects
146            .iter()
147            .zip(serialized_job_data)
148            .map(|(job, argv2)| {
149                let delay = job.delay();
150                let priority = job.priority();
151                let timestamp = job.timestamp();
152                let custom_job_id = job.opts().job_id.as_deref().unwrap_or("").to_string();
153
154                let script_name = if delay > 0 {
155                    "addDelayedJob"
156                } else if priority > 0 {
157                    "addPrioritizedJob"
158                } else {
159                    "addStandardJob"
160                };
161
162                let script = self
163                    .conn
164                    .scripts()
165                    .get(script_name)
166                    .ok_or_else(|| {
167                        Error::InvalidConfig(format!("script '{}' not found", script_name))
168                    })
169                    .cloned();
170                let keys = self.add_job_keys(script_name);
171                let packed_args = self.pack_add_args(job, &custom_job_id, timestamp);
172                let argv3 = self.pack_job_opts(job);
173                let mut conn = self.conn.conn();
174
175                async move {
176                    let script = script?;
177                    let argv1 = packed_args?;
178                    let argv2_bytes = argv2.into_bytes();
179                    let args: Vec<&[u8]> = vec![&argv1, &argv2_bytes, &argv3];
180                    let result = script.execute(&mut conn, &keys, &args).await?;
181                    Self::parse_added_job_id(result)
182                }
183            })
184            .collect();
185
186        let results = futures::future::join_all(futures).await;
187
188        let ctx = self.make_script_context();
189        for (job, result) in job_objects.iter_mut().zip(results.into_iter()) {
190            let id = result?;
191            job.set_id(id);
192            job.set_context(ctx.clone());
193        }
194
195        Ok(job_objects)
196    }
197
198    /// Internal: add a single job via the appropriate Lua script.
199    async fn add_job(&self, job: &mut Job) -> Result<(), Error> {
200        let delay = job.delay();
201        let priority = job.priority();
202        let timestamp = job.timestamp();
203
204        let custom_job_id = job.opts().job_id.as_deref().unwrap_or("");
205
206        let script_name = if delay > 0 {
207            "addDelayedJob"
208        } else if priority > 0 {
209            "addPrioritizedJob"
210        } else {
211            "addStandardJob"
212        };
213
214        let script = self
215            .conn
216            .scripts()
217            .get(script_name)
218            .ok_or_else(|| Error::InvalidConfig(format!("script '{}' not found", script_name)))?
219            .clone();
220
221        // Build KEYS
222        let keys = self.add_job_keys(script_name);
223
224        // Build ARGV[1]: msgpack array of metadata
225        let argv1 = self.pack_add_args(job, custom_job_id, timestamp)?;
226
227        // Build ARGV[2]: JSON stringified job data
228        let argv2 = serde_json::to_string(job.data())?;
229
230        // Enforce sizeLimit client-side (matches Node.js `validateOptions`):
231        // reject jobs whose serialized data exceeds the configured byte limit.
232        Self::validate_job_size(job, &argv2)?;
233
234        // Build ARGV[3]: msgpack map of options
235        let argv3 = self.pack_job_opts(job);
236
237        let argv2_bytes = argv2.into_bytes();
238        let args: Vec<&[u8]> = vec![&argv1, &argv2_bytes, &argv3];
239
240        let mut conn = self.conn.conn();
241        let result = script.execute(&mut conn, &keys, &args).await?;
242
243        let returned_job_id = Self::parse_added_job_id(result)?;
244        job.set_id(returned_job_id);
245
246        debug!(job_id = %job.id(), name = %job.name(), "job added");
247        Ok(())
248    }
249
250    /// Merge per-job options with queue default job options.
251    /// Per-job options take precedence over defaults.
252    /// Create a ScriptContext for jobs returned by queue methods.
253    fn make_script_context(&self) -> ScriptContext {
254        let (progress_tx, _) = tokio::sync::broadcast::channel(1);
255        ScriptContext {
256            conn: self.conn.clone(),
257            keys: self.keys.clone(),
258            progress_tx,
259            token: String::new(),
260            lock_duration: 0,
261        }
262    }
263
264    fn merge_job_options(&self, opts: Option<JobOptions>) -> Option<JobOptions> {
265        let defaults = &self.default_job_options;
266        let is_default = defaults.attempts.is_none()
267            && defaults.backoff.is_none()
268            && defaults.remove_on_complete.is_none()
269            && defaults.remove_on_fail.is_none()
270            && defaults.delay.is_none()
271            && defaults.priority.is_none()
272            && defaults.lifo.is_none();
273
274        if is_default {
275            return opts;
276        }
277
278        let job_opts = opts.unwrap_or_default();
279        Some(JobOptions {
280            attempts: job_opts.attempts.or(defaults.attempts),
281            backoff: job_opts
282                .backoff
283                .clone()
284                .or_else(|| defaults.backoff.clone()),
285            remove_on_complete: job_opts
286                .remove_on_complete
287                .clone()
288                .or_else(|| defaults.remove_on_complete.clone()),
289            remove_on_fail: job_opts
290                .remove_on_fail
291                .clone()
292                .or_else(|| defaults.remove_on_fail.clone()),
293            delay: job_opts.delay.or(defaults.delay),
294            priority: job_opts.priority.or(defaults.priority),
295            lifo: job_opts.lifo.or(defaults.lifo),
296            job_id: job_opts.job_id,
297            ..job_opts
298        })
299    }
300
301    /// Build KEYS array for addStandardJob/addDelayedJob/addPrioritizedJob.
302    fn add_job_keys(&self, script_name: &str) -> Vec<String> {
303        match script_name {
304            "addStandardJob" => vec![
305                self.keys.wait(),
306                self.keys.paused(),
307                self.keys.meta(),
308                self.keys.id(),
309                self.keys.completed(),
310                self.keys.delayed(),
311                self.keys.active(),
312                self.keys.events(),
313                self.keys.marker(),
314            ],
315            "addDelayedJob" => vec![
316                self.keys.marker(),
317                self.keys.meta(),
318                self.keys.id(),
319                self.keys.delayed(),
320                self.keys.completed(),
321                self.keys.events(),
322            ],
323            "addPrioritizedJob" => vec![
324                self.keys.marker(),
325                self.keys.meta(),
326                self.keys.id(),
327                self.keys.prioritized(),
328                self.keys.delayed(),
329                self.keys.completed(),
330                self.keys.active(),
331                self.keys.events(),
332                self.keys.pc(),
333            ],
334            _ => vec![],
335        }
336    }
337
338    /// Pack ARGV[1]: msgpack array matching the Lua script contract.
339    ///
340    /// Positions: [key_prefix, job_id, name, timestamp, parentKey, parentDepsKey, parent, repeatJobKey, deduplicationKey]
341    fn pack_add_args(&self, job: &Job, job_id: &str, timestamp: u64) -> Result<Vec<u8>, Error> {
342        use rmp::encode::*;
343
344        let mut buf = Vec::with_capacity(128);
345        write_array_len(&mut buf, 9).unwrap();
346
347        // [1] key prefix (with trailing colon)
348        write_str(&mut buf, &self.keys.key_prefix()).unwrap();
349        // [2] job id
350        write_str(&mut buf, job_id).unwrap();
351        // [3] name
352        write_str(&mut buf, job.name()).unwrap();
353        // [4] timestamp
354        write_uint(&mut buf, timestamp).unwrap();
355
356        // [5] parentKey, [6] parent deps key, [7] parent
357        if let Some(ref parent) = job.opts().parent {
358            let parent_queue_key = resolve_parent_queue_key(self.keys.prefix(), &parent.queue)?;
359            let parent_key = format!("{}:{}", parent_queue_key, parent.id);
360            let parent_deps_key = format!("{}:dependencies", parent_key);
361            let opts = job.opts();
362            let mut parent_map_len = 2u32;
363            if opts.fail_parent_on_failure == Some(true) {
364                parent_map_len += 1;
365            }
366            if opts.ignore_dependency_on_failure == Some(true) {
367                parent_map_len += 1;
368            }
369            if opts.remove_dependency_on_failure == Some(true) {
370                parent_map_len += 1;
371            }
372            if opts.continue_parent_on_failure == Some(true) {
373                parent_map_len += 1;
374            }
375
376            write_str(&mut buf, &parent_key).unwrap();
377            write_str(&mut buf, &parent_deps_key).unwrap();
378            write_map_len(&mut buf, parent_map_len).unwrap();
379            write_str(&mut buf, "id").unwrap();
380            write_str(&mut buf, &parent.id).unwrap();
381            write_str(&mut buf, "queueKey").unwrap();
382            write_str(&mut buf, &parent_queue_key).unwrap();
383            if opts.fail_parent_on_failure == Some(true) {
384                write_str(&mut buf, "fpof").unwrap();
385                write_bool(&mut buf, true).unwrap();
386            }
387            if opts.ignore_dependency_on_failure == Some(true) {
388                write_str(&mut buf, "idof").unwrap();
389                write_bool(&mut buf, true).unwrap();
390            }
391            if opts.remove_dependency_on_failure == Some(true) {
392                write_str(&mut buf, "rdof").unwrap();
393                write_bool(&mut buf, true).unwrap();
394            }
395            if opts.continue_parent_on_failure == Some(true) {
396                write_str(&mut buf, "cpof").unwrap();
397                write_bool(&mut buf, true).unwrap();
398            }
399        } else {
400            write_nil(&mut buf).unwrap();
401            write_nil(&mut buf).unwrap();
402            write_nil(&mut buf).unwrap();
403        }
404
405        // [8] repeat job key - nil
406        write_nil(&mut buf).unwrap();
407        // [9] deduplication key
408        if let Some(ref dedup) = job.opts().deduplication {
409            let key = format!("{}:de:{}", self.keys.base(), dedup.id);
410            write_str(&mut buf, &key).unwrap();
411        } else {
412            write_nil(&mut buf).unwrap();
413        }
414
415        Ok(buf)
416    }
417
418    /// Pack ARGV[3]: msgpack map of job options using the raw script keys.
419    fn pack_job_opts(&self, job: &Job) -> Vec<u8> {
420        use rmp::encode::*;
421
422        let opts = job.opts();
423
424        // Collect entries
425        let mut entries: Vec<(&str, Vec<u8>)> = Vec::new();
426
427        if let Some(delay) = opts.delay {
428            if delay > 0 {
429                let mut b = Vec::new();
430                write_uint(&mut b, delay).unwrap();
431                entries.push(("delay", b));
432            }
433        }
434        if let Some(priority) = opts.priority {
435            if priority > 0 {
436                let mut b = Vec::new();
437                write_uint(&mut b, priority as u64).unwrap();
438                entries.push(("priority", b));
439            }
440        }
441        if let Some(attempts) = opts.attempts {
442            let mut b = Vec::new();
443            write_uint(&mut b, attempts as u64).unwrap();
444            entries.push(("attempts", b));
445        }
446        if let Some(true) = opts.lifo {
447            let mut b = Vec::new();
448            write_bool(&mut b, true).unwrap();
449            entries.push(("lifo", b));
450        }
451
452        if let Some(ref roc) = opts.remove_on_complete {
453            let b = Self::encode_remove_on_finish(roc);
454            entries.push(("removeOnComplete", b));
455        }
456
457        if let Some(ref rof) = opts.remove_on_fail {
458            let b = Self::encode_remove_on_finish(rof);
459            entries.push(("removeOnFail", b));
460        }
461
462        if let Some(ref backoff) = opts.backoff {
463            let b = Self::encode_backoff(backoff);
464            entries.push(("backoff", b));
465        }
466
467        if let Some(ref dedup) = opts.deduplication {
468            let b = Self::encode_deduplication(dedup);
469            entries.push(("de", b));
470        }
471
472        // Encode as msgpack map
473        let mut buf = Vec::with_capacity(64);
474        write_map_len(&mut buf, entries.len() as u32).unwrap();
475        for (key, val) in &entries {
476            write_str(&mut buf, key).unwrap();
477            buf.extend_from_slice(val);
478        }
479
480        buf
481    }
482
483    pub(crate) fn encode_remove_on_finish(rof: &crate::types::RemoveOnFinish) -> Vec<u8> {
484        use rmp::encode::*;
485        let mut b = Vec::new();
486        match rof {
487            crate::types::RemoveOnFinish::Bool(val) => {
488                write_bool(&mut b, *val).unwrap();
489            }
490            crate::types::RemoveOnFinish::Count(n) => {
491                write_uint(&mut b, *n as u64).unwrap();
492            }
493            crate::types::RemoveOnFinish::Options(keep) => {
494                let mut count = 0u32;
495                if keep.age.is_some() {
496                    count += 1;
497                }
498                if keep.count.is_some() {
499                    count += 1;
500                }
501                if keep.limit.is_some() {
502                    count += 1;
503                }
504                write_map_len(&mut b, count).unwrap();
505                if let Some(age) = keep.age {
506                    write_str(&mut b, "age").unwrap();
507                    write_uint(&mut b, age).unwrap();
508                }
509                if let Some(cnt) = keep.count {
510                    write_str(&mut b, "count").unwrap();
511                    write_uint(&mut b, cnt as u64).unwrap();
512                }
513                if let Some(limit) = keep.limit {
514                    write_str(&mut b, "limit").unwrap();
515                    write_uint(&mut b, limit as u64).unwrap();
516                }
517            }
518        }
519        b
520    }
521
522    pub(crate) fn encode_backoff(backoff: &crate::types::BackoffStrategy) -> Vec<u8> {
523        use rmp::encode::*;
524        let mut b = Vec::new();
525        match backoff {
526            crate::types::BackoffStrategy::Fixed(delay) => {
527                write_map_len(&mut b, 2).unwrap();
528                write_str(&mut b, "type").unwrap();
529                write_str(&mut b, "fixed").unwrap();
530                write_str(&mut b, "delay").unwrap();
531                write_uint(&mut b, *delay).unwrap();
532            }
533            crate::types::BackoffStrategy::Exponential(delay) => {
534                write_map_len(&mut b, 2).unwrap();
535                write_str(&mut b, "type").unwrap();
536                write_str(&mut b, "exponential").unwrap();
537                write_str(&mut b, "delay").unwrap();
538                write_uint(&mut b, *delay).unwrap();
539            }
540            crate::types::BackoffStrategy::Custom(name) => {
541                write_map_len(&mut b, 2).unwrap();
542                write_str(&mut b, "type").unwrap();
543                write_str(&mut b, name).unwrap();
544                write_str(&mut b, "delay").unwrap();
545                write_uint(&mut b, 0).unwrap();
546            }
547        }
548        b
549    }
550
551    pub(crate) fn encode_deduplication(dedup: &crate::options::DeduplicationOptions) -> Vec<u8> {
552        use rmp::encode::*;
553        let mut b = Vec::new();
554
555        let mut count = 1u32; // 'id' is always present
556        if dedup.ttl.is_some() {
557            count += 1;
558        }
559        if dedup.extend.is_some() {
560            count += 1;
561        }
562        if dedup.replace.is_some() {
563            count += 1;
564        }
565        if dedup.keep_last_if_active.is_some() {
566            count += 1;
567        }
568
569        write_map_len(&mut b, count).unwrap();
570        write_str(&mut b, "id").unwrap();
571        write_str(&mut b, &dedup.id).unwrap();
572
573        if let Some(ttl) = dedup.ttl {
574            write_str(&mut b, "ttl").unwrap();
575            write_uint(&mut b, ttl).unwrap();
576        }
577        if let Some(extend) = dedup.extend {
578            write_str(&mut b, "extend").unwrap();
579            write_bool(&mut b, extend).unwrap();
580        }
581        if let Some(replace) = dedup.replace {
582            write_str(&mut b, "replace").unwrap();
583            write_bool(&mut b, replace).unwrap();
584        }
585        if let Some(keep_last) = dedup.keep_last_if_active {
586            write_str(&mut b, "keepLastIfActive").unwrap();
587            write_bool(&mut b, keep_last).unwrap();
588        }
589
590        b
591    }
592
593    fn parse_added_job_id(result: redis::Value) -> Result<String, Error> {
594        match result {
595            redis::Value::BulkString(bytes) => Ok(String::from_utf8_lossy(&bytes).to_string()),
596            redis::Value::SimpleString(value) => Ok(value),
597            redis::Value::Int(code) if code < 0 => Err(Error::from_script_code(code)),
598            redis::Value::Int(job_id) => Ok(job_id.to_string()),
599            value => Err(Error::InvalidConfig(format!(
600                "unexpected add job script result: {:?}",
601                value
602            ))),
603        }
604    }
605
606    /// Update queue metadata (version).
607    async fn update_meta(&self) -> Result<(), Error> {
608        let mut conn = self.conn.conn();
609        let meta_key = self.keys.meta();
610
611        redis::cmd("HSET")
612            .arg(&meta_key)
613            .arg("library")
614            .arg(BULLMQ_VERSION)
615            .query_async::<()>(&mut conn)
616            .await?;
617
618        Ok(())
619    }
620
621    /// Pause the queue.
622    #[instrument(skip(self), fields(queue = %self.name))]
623    pub async fn pause(&self) -> Result<(), Error> {
624        let script = self
625            .conn
626            .scripts()
627            .get("pause")
628            .ok_or_else(|| Error::InvalidConfig("pause script not found".to_string()))?
629            .clone();
630
631        let keys = vec![
632            self.keys.wait(),
633            self.keys.paused(),
634            self.keys.meta(),
635            self.keys.prioritized(),
636            self.keys.events(),
637            self.keys.delayed(),
638            self.keys.marker(),
639        ];
640
641        let args: Vec<&[u8]> = vec![b"paused"];
642
643        let mut conn = self.conn.conn();
644        script.execute(&mut conn, &keys, &args).await?;
645
646        debug!("queue paused");
647        Ok(())
648    }
649
650    /// Resume the queue.
651    #[instrument(skip(self), fields(queue = %self.name))]
652    pub async fn resume(&self) -> Result<(), Error> {
653        let script = self
654            .conn
655            .scripts()
656            .get("pause")
657            .ok_or_else(|| Error::InvalidConfig("pause script not found".to_string()))?
658            .clone();
659
660        let keys = vec![
661            self.keys.paused(),
662            self.keys.wait(),
663            self.keys.meta(),
664            self.keys.prioritized(),
665            self.keys.events(),
666            self.keys.delayed(),
667            self.keys.marker(),
668        ];
669
670        let args: Vec<&[u8]> = vec![b"resumed"];
671
672        let mut conn = self.conn.conn();
673        script.execute(&mut conn, &keys, &args).await?;
674
675        debug!("queue resumed");
676        Ok(())
677    }
678
679    /// Check if the queue is paused.
680    pub async fn is_paused(&self) -> Result<bool, Error> {
681        let mut conn = self.conn.conn();
682        let paused: Option<String> = redis::cmd("HGET")
683            .arg(self.keys.meta())
684            .arg("paused")
685            .query_async(&mut conn)
686            .await?;
687        Ok(paused.as_deref() == Some("1"))
688    }
689
690    /// Get a job by its ID.
691    pub async fn get_job(&self, job_id: &str) -> Result<Option<Job>, Error> {
692        let job = Job::from_id(&self.conn, &self.keys, job_id).await?;
693        Ok(job.map(|mut j| {
694            j.set_context(self.make_script_context());
695            j
696        }))
697    }
698
699    /// Get the counts of jobs in each state.
700    pub async fn get_job_counts(&self) -> Result<JobCounts, Error> {
701        let mut conn = self.conn.conn();
702        let mut pipe = redis::pipe();
703
704        pipe.cmd("LLEN").arg(self.keys.wait());
705        pipe.cmd("LLEN").arg(self.keys.active());
706        pipe.cmd("ZCARD").arg(self.keys.delayed());
707        pipe.cmd("ZCARD").arg(self.keys.prioritized());
708        pipe.cmd("ZCARD").arg(self.keys.completed());
709        pipe.cmd("ZCARD").arg(self.keys.failed());
710        pipe.cmd("ZCARD").arg(self.keys.waiting_children());
711        pipe.cmd("LLEN").arg(self.keys.paused());
712
713        let result: Vec<u64> = pipe.query_async(&mut conn).await?;
714
715        Ok(JobCounts {
716            waiting: result.first().copied().unwrap_or(0),
717            active: result.get(1).copied().unwrap_or(0),
718            delayed: result.get(2).copied().unwrap_or(0),
719            prioritized: result.get(3).copied().unwrap_or(0),
720            completed: result.get(4).copied().unwrap_or(0),
721            failed: result.get(5).copied().unwrap_or(0),
722            waiting_children: result.get(6).copied().unwrap_or(0),
723            paused: result.get(7).copied().unwrap_or(0),
724        })
725    }
726
727    /// Sanitize a list of job types: dedupe (preserving order) and add `paused`
728    /// whenever `waiting` is requested.
729    ///
730    /// Note: `paused` is **not** a per-job state — there is no way to pause an
731    /// individual job. It is the list where otherwise-`waiting` jobs are parked
732    /// while the *queue* is paused (via [`Queue::pause`]). Because a waiting job
733    /// may sit in either the `wait` or the `paused` list depending on whether the
734    /// queue is paused, querying `waiting` must also include `paused` to return
735    /// all waiting jobs. (This mirrors Node.js `sanitizeJobTypes`.)
736    ///
737    /// An empty input expands to all queryable states.
738    fn sanitize_job_types(types: &[&str]) -> Vec<String> {
739        if types.is_empty() {
740            return [
741                "active",
742                "completed",
743                "delayed",
744                "failed",
745                "paused",
746                "prioritized",
747                "waiting",
748                "waiting-children",
749            ]
750            .iter()
751            .map(|s| s.to_string())
752            .collect();
753        }
754
755        let mut out: Vec<String> = Vec::new();
756        let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
757        for t in types {
758            if seen.insert((*t).to_string()) {
759                out.push((*t).to_string());
760            }
761        }
762        if types.contains(&"waiting") && seen.insert("paused".to_string()) {
763            out.push("paused".to_string());
764        }
765        out
766    }
767
768    /// Return job IDs for the given states with pagination.
769    ///
770    /// `types` are state names (e.g. `"waiting"`, `"active"`, `"completed"`).
771    /// `start`/`end` are zero-based inclusive indices (`-1` = last). When `asc`
772    /// is true, jobs are returned in ascending (oldest-first) order.
773    ///
774    /// The requested types are sanitized via [`Queue::sanitize_job_types`]: an
775    /// empty slice expands to all states, `waiting` also queries `paused`, and
776    /// duplicate types are removed. Results from all requested states are
777    /// concatenated and de-duplicated while preserving order.
778    pub async fn get_ranges(
779        &self,
780        types: &[&str],
781        start: i64,
782        end: i64,
783        asc: bool,
784    ) -> Result<Vec<String>, Error> {
785        let script = self
786            .conn
787            .scripts()
788            .get("getRanges")
789            .ok_or_else(|| Error::InvalidConfig("getRanges script not found".to_string()))?
790            .clone();
791
792        // The Lua script uses the queue key prefix (with trailing colon) as KEYS[1].
793        let keys = vec![self.keys.key_prefix()];
794
795        let start_s = start.to_string();
796        let end_s = end.to_string();
797        let asc_s = if asc { "1" } else { "0" };
798        // Apply the same sanitization as `get_jobs`: de-duplicate the requested
799        // types and, when `waiting` is requested, also include `paused` (where
800        // otherwise-waiting jobs are parked while the queue is paused). Then
801        // alias "waiting" -> "wait" for the Lua script.
802        let sanitized = Self::sanitize_job_types(types);
803        let transformed: Vec<String> = sanitized
804            .iter()
805            .map(|t| {
806                if t == "waiting" {
807                    "wait".to_string()
808                } else {
809                    t.clone()
810                }
811            })
812            .collect();
813
814        let mut args: Vec<&[u8]> = vec![start_s.as_bytes(), end_s.as_bytes(), asc_s.as_bytes()];
815        for t in &transformed {
816            args.push(t.as_bytes());
817        }
818
819        let mut conn = self.conn.conn();
820        let result = script.execute(&mut conn, &keys, &args).await?;
821
822        // The script returns an array (one entry per state) of arrays of IDs.
823        // For list-based states (wait/paused/active) read in ascending order we
824        // must reverse the group — this mirrors the Node.js getter, which
825        // reverses `lrange` results when `asc` is set.
826        let extract_id = |v: redis::Value| -> Option<String> {
827            match v {
828                redis::Value::BulkString(bytes) => {
829                    Some(String::from_utf8_lossy(&bytes).to_string())
830                }
831                redis::Value::SimpleString(s) => Some(s),
832                _ => None,
833            }
834        };
835
836        let mut out: Vec<String> = Vec::new();
837        let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
838
839        if let redis::Value::Array(groups) = result {
840            for (i, group) in groups.into_iter().enumerate() {
841                let is_list = matches!(
842                    transformed.get(i).map(|s| s.as_str()),
843                    Some("wait") | Some("paused") | Some("active")
844                );
845                let mut ids: Vec<String> = match group {
846                    redis::Value::Array(items) => {
847                        items.into_iter().filter_map(extract_id).collect()
848                    }
849                    other => extract_id(other).into_iter().collect(),
850                };
851                if asc && is_list {
852                    ids.reverse();
853                }
854                for id in ids {
855                    if seen.insert(id.clone()) {
856                        out.push(id);
857                    }
858                }
859            }
860        }
861
862        Ok(out)
863    }
864
865    /// Return the jobs that are in the given states with pagination.
866    ///
867    /// `types` are state names. An empty slice returns jobs from all states.
868    pub async fn get_jobs(
869        &self,
870        types: &[&str],
871        start: i64,
872        end: i64,
873        asc: bool,
874    ) -> Result<Vec<Job>, Error> {
875        // `get_ranges` sanitizes the requested types (expanding `waiting` to
876        // also include `paused` and de-duplicating), so pass them through.
877        let ids = self.get_ranges(types, start, end, asc).await?;
878
879        // Fetch jobs concurrently over the multiplexed Redis connection.
880        let fetches = ids.iter().map(|id| self.get_job(id));
881        let results = futures::future::join_all(fetches).await;
882
883        let mut jobs = Vec::with_capacity(ids.len());
884        for result in results {
885            if let Some(job) = result? {
886                jobs.push(job);
887            }
888        }
889        Ok(jobs)
890    }
891
892    /// Return jobs in the `waiting` (and `paused`) state.
893    pub async fn get_waiting(&self, start: i64, end: i64) -> Result<Vec<Job>, Error> {
894        self.get_jobs(&["waiting"], start, end, true).await
895    }
896
897    /// Return jobs in the `waiting-children` state (parents with pending children).
898    pub async fn get_waiting_children(&self, start: i64, end: i64) -> Result<Vec<Job>, Error> {
899        self.get_jobs(&["waiting-children"], start, end, true).await
900    }
901
902    /// Return jobs in the `active` state.
903    pub async fn get_active(&self, start: i64, end: i64) -> Result<Vec<Job>, Error> {
904        self.get_jobs(&["active"], start, end, true).await
905    }
906
907    /// Return jobs in the `delayed` state.
908    pub async fn get_delayed(&self, start: i64, end: i64) -> Result<Vec<Job>, Error> {
909        self.get_jobs(&["delayed"], start, end, true).await
910    }
911
912    /// Return jobs in the `prioritized` state.
913    pub async fn get_prioritized(&self, start: i64, end: i64) -> Result<Vec<Job>, Error> {
914        self.get_jobs(&["prioritized"], start, end, true).await
915    }
916
917    /// Return jobs in the `completed` state.
918    pub async fn get_completed(&self, start: i64, end: i64) -> Result<Vec<Job>, Error> {
919        self.get_jobs(&["completed"], start, end, false).await
920    }
921
922    /// Return jobs in the `failed` state.
923    pub async fn get_failed(&self, start: i64, end: i64) -> Result<Vec<Job>, Error> {
924        self.get_jobs(&["failed"], start, end, false).await
925    }
926
927    /// Raw `getCounts` script call. Transforms `waiting` -> `wait` and returns
928    /// one count per provided type, in order.
929    async fn get_counts_raw(&self, types: &[String]) -> Result<Vec<u64>, Error> {
930        let script = self
931            .conn
932            .scripts()
933            .get("getCounts")
934            .ok_or_else(|| Error::InvalidConfig("getCounts script not found".to_string()))?
935            .clone();
936
937        let keys = vec![self.keys.key_prefix()];
938        let transformed: Vec<String> = types
939            .iter()
940            .map(|t| {
941                if t == "waiting" {
942                    "wait".to_string()
943                } else {
944                    t.clone()
945                }
946            })
947            .collect();
948        let args: Vec<&[u8]> = transformed.iter().map(|s| s.as_bytes()).collect();
949
950        let mut conn = self.conn.conn();
951        let result = script.execute(&mut conn, &keys, &args).await?;
952
953        let counts = match result {
954            redis::Value::Array(arr) => arr
955                .into_iter()
956                .map(|v| match v {
957                    redis::Value::Int(n) => n.max(0) as u64,
958                    _ => 0,
959                })
960                .collect(),
961            _ => Vec::new(),
962        };
963        Ok(counts)
964    }
965
966    /// Return the job counts for each provided type, keyed by type name.
967    ///
968    /// An empty `types` slice returns counts for every queryable state.
969    pub async fn get_job_counts_by_types(
970        &self,
971        types: &[&str],
972    ) -> Result<HashMap<String, u64>, Error> {
973        let sanitized = Self::sanitize_job_types(types);
974        let counts = self.get_counts_raw(&sanitized).await?;
975
976        let mut map = HashMap::new();
977        for (i, t) in sanitized.iter().enumerate() {
978            map.insert(t.clone(), counts.get(i).copied().unwrap_or(0));
979        }
980        Ok(map)
981    }
982
983    /// Return the total number of jobs across the provided types.
984    pub async fn get_job_count_by_types(&self, types: &[&str]) -> Result<u64, Error> {
985        let map = self.get_job_counts_by_types(types).await?;
986        Ok(map.values().sum())
987    }
988
989    /// Return job counts by state, invoking `recorder` once per `(state, count)`.
990    ///
991    /// Mirrors Node.js `Queue.recordJobCountsMetric`, which records gauge
992    /// metrics when telemetry is configured. The Rust port has no built-in
993    /// telemetry subsystem, so the optional `recorder` closure is the
994    /// integration point: pass `None` to simply retrieve the counts, or supply
995    /// a closure to forward each state's count to your metrics backend.
996    pub async fn record_job_counts_metric(
997        &self,
998        types: &[&str],
999        recorder: Option<JobCountRecorder<'_>>,
1000    ) -> Result<HashMap<String, u64>, Error> {
1001        let counts = self.get_job_counts_by_types(types).await?;
1002        if let Some(record) = recorder {
1003            for (state, count) in &counts {
1004                record(state, *count);
1005            }
1006        }
1007        Ok(counts)
1008    }
1009
1010    /// Return the number of jobs waiting to be processed: this sums `waiting`,
1011    /// `paused`, `delayed`, `prioritized` and `waiting-children`.
1012    pub async fn count(&self) -> Result<u64, Error> {
1013        self.get_job_count_by_types(&[
1014            "waiting",
1015            "paused",
1016            "delayed",
1017            "prioritized",
1018            "waiting-children",
1019        ])
1020        .await
1021    }
1022
1023    /// Return the number of jobs per priority for the provided priorities.
1024    ///
1025    /// Priorities are de-duplicated. The returned map is keyed by priority value.
1026    pub async fn get_counts_per_priority(
1027        &self,
1028        priorities: &[u64],
1029    ) -> Result<HashMap<u64, u64>, Error> {
1030        let script = self
1031            .conn
1032            .scripts()
1033            .get("getCountsPerPriority")
1034            .ok_or_else(|| {
1035                Error::InvalidConfig("getCountsPerPriority script not found".to_string())
1036            })?
1037            .clone();
1038
1039        // De-duplicate while preserving order.
1040        let mut unique: Vec<u64> = Vec::new();
1041        let mut seen: std::collections::HashSet<u64> = std::collections::HashSet::new();
1042        for p in priorities {
1043            if seen.insert(*p) {
1044                unique.push(*p);
1045            }
1046        }
1047
1048        let keys = vec![
1049            self.keys.wait(),
1050            self.keys.paused(),
1051            self.keys.meta(),
1052            self.keys.prioritized(),
1053        ];
1054        let prio_strs: Vec<String> = unique.iter().map(|p| p.to_string()).collect();
1055        let args: Vec<&[u8]> = prio_strs.iter().map(|s| s.as_bytes()).collect();
1056
1057        let mut conn = self.conn.conn();
1058        let result = script.execute(&mut conn, &keys, &args).await?;
1059
1060        let counts: Vec<u64> = match result {
1061            redis::Value::Array(arr) => arr
1062                .into_iter()
1063                .map(|v| match v {
1064                    redis::Value::Int(n) => n.max(0) as u64,
1065                    _ => 0,
1066                })
1067                .collect(),
1068            _ => Vec::new(),
1069        };
1070
1071        let mut map = HashMap::new();
1072        for (i, p) in unique.iter().enumerate() {
1073            map.insert(*p, counts.get(i).copied().unwrap_or(0));
1074        }
1075        Ok(map)
1076    }
1077
1078    /// Return the number of jobs in the `completed` state.
1079    pub async fn get_completed_count(&self) -> Result<u64, Error> {
1080        self.get_job_count_by_types(&["completed"]).await
1081    }
1082
1083    /// Return the number of jobs in the `failed` state.
1084    pub async fn get_failed_count(&self) -> Result<u64, Error> {
1085        self.get_job_count_by_types(&["failed"]).await
1086    }
1087
1088    /// Return the number of jobs in the `delayed` state.
1089    pub async fn get_delayed_count(&self) -> Result<u64, Error> {
1090        self.get_job_count_by_types(&["delayed"]).await
1091    }
1092
1093    /// Return the number of jobs in the `active` state.
1094    pub async fn get_active_count(&self) -> Result<u64, Error> {
1095        self.get_job_count_by_types(&["active"]).await
1096    }
1097
1098    /// Return the number of jobs in the `prioritized` state.
1099    pub async fn get_prioritized_count(&self) -> Result<u64, Error> {
1100        self.get_job_count_by_types(&["prioritized"]).await
1101    }
1102
1103    /// Return the number of jobs in the `waiting` (and `paused`) state.
1104    pub async fn get_waiting_count(&self) -> Result<u64, Error> {
1105        self.get_job_count_by_types(&["waiting"]).await
1106    }
1107
1108    /// Return the number of jobs in the `waiting-children` state.
1109    pub async fn get_waiting_children_count(&self) -> Result<u64, Error> {
1110        self.get_job_count_by_types(&["waiting-children"]).await
1111    }
1112
1113    /// Return the list of workers currently connected to this queue.
1114    ///
1115    /// Workers register themselves via `CLIENT SETNAME` on their blocking
1116    /// connection; this method runs `CLIENT LIST` and returns the parsed info
1117    /// maps for clients whose name matches this queue. Each map's `name` field
1118    /// is set to the queue name and the original client name is preserved in
1119    /// `rawname` (mirroring Node.js `Queue.getWorkers`).
1120    ///
1121    /// Note: some managed Redis providers (e.g. GCP Memorystore) do not support
1122    /// `CLIENT SETNAME`/`CLIENT LIST`, in which case this returns an empty list.
1123    pub async fn get_workers(&self) -> Result<Vec<HashMap<String, String>>, Error> {
1124        let mut cmd = redis::cmd("CLIENT");
1125        cmd.arg("LIST");
1126        let list: String = match self.conn.cmd(&mut cmd).await {
1127            Ok(list) => list,
1128            Err(err) => {
1129                debug!(error = %err, "CLIENT LIST unavailable; returning empty worker list");
1130                return Ok(Vec::new());
1131            }
1132        };
1133
1134        let unnamed = self.keys.client_name("");
1135        let named_prefix = self.keys.client_name(":w:");
1136        Ok(parse_client_list(
1137            &list,
1138            &self.name,
1139            &unnamed,
1140            &named_prefix,
1141        ))
1142    }
1143
1144    /// Return the number of workers currently connected to this queue.
1145    pub async fn get_workers_count(&self) -> Result<usize, Error> {
1146        Ok(self.get_workers().await?.len())
1147    }
1148
1149    /// Return the queue's public metadata (read from the `meta` hash).
1150    ///
1151    /// Well-known numeric/boolean fields (`concurrency`, `max`, `duration`,
1152    /// `opts.maxLenEvents`, `paused`) are parsed into typed fields; any other
1153    /// entries are preserved in [`QueueMeta::other`]. Mirrors Node.js
1154    /// `Queue.getMeta`.
1155    pub async fn get_meta(&self) -> Result<QueueMeta, Error> {
1156        let mut conn = self.conn.conn();
1157        let config: HashMap<String, String> = redis::cmd("HGETALL")
1158            .arg(self.keys.meta())
1159            .query_async(&mut conn)
1160            .await?;
1161
1162        let mut meta = QueueMeta::default();
1163        for (key, value) in config {
1164            match key.as_str() {
1165                "concurrency" => meta.concurrency = value.parse().ok(),
1166                "max" => meta.max = value.parse().ok(),
1167                "duration" => meta.duration = value.parse().ok(),
1168                "opts.maxLenEvents" => meta.max_len_events = value.parse().ok(),
1169                "paused" => meta.paused = value == "1",
1170                _ => {
1171                    meta.other.insert(key, value);
1172                }
1173            }
1174        }
1175
1176        Ok(meta)
1177    }
1178
1179    /// Return the library version string stored in the `meta` hash.
1180    ///
1181    /// The Rust port records `bullmq-official:<version>` under the `library` field
1182    /// when the queue is created. Returns `None` if the field is unset.
1183    pub async fn get_version(&self) -> Result<Option<String>, Error> {
1184        let mut conn = self.conn.conn();
1185        let value: Option<String> = redis::cmd("HGET")
1186            .arg(self.keys.meta())
1187            .arg("library")
1188            .query_async(&mut conn)
1189            .await?;
1190        Ok(value)
1191    }
1192
1193    /// Return `true` when the number of active jobs has reached the queue's
1194    /// global concurrency limit. Returns `false` when no global concurrency is
1195    /// configured. Mirrors Node.js `Queue.isMaxed`.
1196    pub async fn is_maxed(&self) -> Result<bool, Error> {
1197        let script = self
1198            .conn
1199            .scripts()
1200            .get("isMaxed")
1201            .ok_or_else(|| Error::InvalidConfig("isMaxed script not found".to_string()))?
1202            .clone();
1203
1204        let keys = vec![self.keys.meta(), self.keys.active()];
1205        let args: Vec<&[u8]> = vec![];
1206
1207        let mut conn = self.conn.conn();
1208        let result = script.execute(&mut conn, &keys, &args).await?;
1209        Ok(matches!(result, redis::Value::Int(1)) || matches!(result, redis::Value::Boolean(true)))
1210    }
1211
1212    /// Export the queue's job counts and totals in the Prometheus text
1213    /// exposition format.
1214    ///
1215    /// Emits a `bullmq_job_count` gauge per job state plus
1216    /// `bullmq_job_completed_total` / `bullmq_job_failed_total` counters sourced
1217    /// from the time-series metrics. `global_labels` are appended (in order) as
1218    /// extra labels on every series; pass `&[]` for none. Mirrors Node.js
1219    /// `Queue.exportPrometheusMetrics`.
1220    pub async fn export_prometheus_metrics(
1221        &self,
1222        global_labels: &[(&str, &str)],
1223    ) -> Result<String, Error> {
1224        let counts = self.get_job_counts().await?;
1225        let mut metrics: Vec<String> = Vec::new();
1226
1227        metrics.push("# HELP bullmq_job_count Number of jobs in the queue by state".to_string());
1228        metrics.push("# TYPE bullmq_job_count gauge".to_string());
1229
1230        let escaped_queue = escape_prometheus_label_value(&self.name);
1231        let variables: String = global_labels
1232            .iter()
1233            .map(|(k, v)| format!(", {}=\"{}\"", k, escape_prometheus_label_value(v)))
1234            .collect();
1235
1236        let states: [(&str, u64); 8] = [
1237            ("active", counts.active),
1238            ("completed", counts.completed),
1239            ("delayed", counts.delayed),
1240            ("failed", counts.failed),
1241            ("paused", counts.paused),
1242            ("prioritized", counts.prioritized),
1243            ("waiting", counts.waiting),
1244            ("waiting-children", counts.waiting_children),
1245        ];
1246        for (state, count) in states {
1247            metrics.push(format!(
1248                "bullmq_job_count{{queue=\"{escaped_queue}\", state=\"{state}\"{variables}}} {count}"
1249            ));
1250        }
1251
1252        let completed_metrics = self.get_metrics("completed", 0, -1).await?;
1253        let failed_metrics = self.get_metrics("failed", 0, -1).await?;
1254
1255        metrics
1256            .push("# HELP bullmq_job_completed_total Total number of completed jobs".to_string());
1257        metrics.push("# TYPE bullmq_job_completed_total counter".to_string());
1258        metrics.push(format!(
1259            "bullmq_job_completed_total{{queue=\"{escaped_queue}\"{variables}}} {}",
1260            completed_metrics.meta.count
1261        ));
1262
1263        metrics.push("# HELP bullmq_job_failed_total Total number of failed jobs".to_string());
1264        metrics.push("# TYPE bullmq_job_failed_total counter".to_string());
1265        metrics.push(format!(
1266            "bullmq_job_failed_total{{queue=\"{escaped_queue}\"{variables}}} {}",
1267            failed_metrics.meta.count
1268        ));
1269
1270        Ok(metrics.join("\n"))
1271    }
1272
1273    /// Return the time-series metrics for the queue.
1274    ///
1275    /// `metric_type` must be `"completed"` or `"failed"`. Metrics are recorded
1276    /// per minute by workers configured with the `metrics` option. `start`/`end`
1277    /// are zero-based indices into the data points where `0` is the newest.
1278    pub async fn get_metrics(
1279        &self,
1280        metric_type: &str,
1281        start: i64,
1282        end: i64,
1283    ) -> Result<crate::types::Metrics, Error> {
1284        // Node.js restricts the metric type to `completed`/`failed` at the type
1285        // level; in Rust we validate at runtime so an invalid name returns an
1286        // error instead of silently reading non-existent keys and returning
1287        // empty metrics.
1288        if metric_type != "completed" && metric_type != "failed" {
1289            return Err(Error::InvalidConfig(format!(
1290                "metric type must be \"completed\" or \"failed\", got \"{}\"",
1291                metric_type
1292            )));
1293        }
1294
1295        let script = self
1296            .conn
1297            .scripts()
1298            .get("getMetrics")
1299            .ok_or_else(|| Error::InvalidConfig("getMetrics script not found".to_string()))?
1300            .clone();
1301
1302        let metrics_key = self.keys.get(&format!("metrics:{}", metric_type));
1303        let data_key = self.keys.get(&format!("metrics:{}:data", metric_type));
1304        let keys = vec![metrics_key, data_key];
1305
1306        let start_s = start.to_string();
1307        let end_s = end.to_string();
1308        let args: Vec<&[u8]> = vec![start_s.as_bytes(), end_s.as_bytes()];
1309
1310        let mut conn = self.conn.conn();
1311        let result = script.execute(&mut conn, &keys, &args).await?;
1312
1313        // The script returns [meta(array of 3), data(array), count(int)].
1314        let parse_u64 = |v: &redis::Value| -> u64 {
1315            match v {
1316                redis::Value::BulkString(b) => String::from_utf8_lossy(b).parse().unwrap_or(0),
1317                redis::Value::SimpleString(s) => s.parse().unwrap_or(0),
1318                redis::Value::Int(n) => (*n).max(0) as u64,
1319                _ => 0,
1320            }
1321        };
1322
1323        let mut metrics = crate::types::Metrics::default();
1324        if let redis::Value::Array(parts) = result {
1325            if let Some(redis::Value::Array(meta)) = parts.first() {
1326                metrics.meta.count = meta.first().map(parse_u64).unwrap_or(0);
1327                metrics.meta.prev_ts = meta.get(1).map(parse_u64).unwrap_or(0);
1328                metrics.meta.prev_count = meta.get(2).map(parse_u64).unwrap_or(0);
1329            }
1330            if let Some(redis::Value::Array(data)) = parts.get(1) {
1331                metrics.data = data.iter().map(parse_u64).collect();
1332            }
1333            if let Some(count) = parts.get(2) {
1334                metrics.count = parse_u64(count);
1335            }
1336        }
1337
1338        Ok(metrics)
1339    }
1340
1341    /// Get return values of all completed children of a parent job.
1342    pub async fn get_children_values(
1343        &self,
1344        job_id: &str,
1345    ) -> Result<HashMap<String, serde_json::Value>, Error> {
1346        let processed_key = format!("{}:processed", self.keys.job_key(job_id));
1347        let mut conn = self.conn.conn();
1348
1349        let result: HashMap<String, String> = redis::cmd("HGETALL")
1350            .arg(&processed_key)
1351            .query_async(&mut conn)
1352            .await?;
1353
1354        let mut parsed = HashMap::new();
1355        for (key, value) in result {
1356            let parsed_value: serde_json::Value =
1357                serde_json::from_str(&value).unwrap_or(serde_json::Value::String(value));
1358            parsed.insert(key, parsed_value);
1359        }
1360        Ok(parsed)
1361    }
1362
1363    /// Get failure values of children that failed with ignoreDependencyOnFailure.
1364    pub async fn get_failed_children_values(
1365        &self,
1366        job_id: &str,
1367    ) -> Result<HashMap<String, String>, Error> {
1368        let failed_key = format!("{}:failed", self.keys.job_key(job_id));
1369        let mut conn = self.conn.conn();
1370
1371        let result: HashMap<String, String> = redis::cmd("HGETALL")
1372            .arg(&failed_key)
1373            .query_async(&mut conn)
1374            .await?;
1375
1376        Ok(result)
1377    }
1378
1379    /// Get counts of dependencies for a parent job.
1380    pub async fn get_dependencies_count(&self, job_id: &str) -> Result<DependenciesCount, Error> {
1381        let job_key = self.keys.job_key(job_id);
1382        let processed_key = format!("{}:processed", job_key);
1383        let deps_key = format!("{}:dependencies", job_key);
1384        let failed_key = format!("{}:failed", job_key);
1385        let unsuccessful_key = format!("{}:unsuccessful", job_key);
1386
1387        let mut conn = self.conn.conn();
1388        let mut pipe = redis::pipe();
1389        pipe.cmd("HLEN").arg(&processed_key);
1390        pipe.cmd("SCARD").arg(&deps_key);
1391        pipe.cmd("HLEN").arg(&failed_key);
1392        pipe.cmd("ZCARD").arg(&unsuccessful_key);
1393
1394        let (processed, unprocessed, ignored, failed): (u64, u64, u64, u64) =
1395            pipe.query_async(&mut conn).await?;
1396
1397        Ok(DependenciesCount {
1398            processed,
1399            unprocessed,
1400            ignored,
1401            failed,
1402        })
1403    }
1404
1405    /// Get unprocessed dependencies (children still pending).
1406    pub async fn get_unprocessed_dependencies(&self, job_id: &str) -> Result<Vec<String>, Error> {
1407        let deps_key = format!("{}:dependencies", self.keys.job_key(job_id));
1408        let mut conn = self.conn.conn();
1409
1410        let result: Vec<String> = redis::cmd("SMEMBERS")
1411            .arg(&deps_key)
1412            .query_async(&mut conn)
1413            .await?;
1414
1415        Ok(result)
1416    }
1417
1418    /// Remove a child's dependency from its parent.
1419    ///
1420    /// `job_id` - the child job ID
1421    /// `parent_key` - the fully qualified parent key (prefix:queue:parentId)
1422    ///
1423    /// Returns `true` if the dependency was broken, `false` otherwise.
1424    pub async fn remove_child_dependency(
1425        &self,
1426        job_id: &str,
1427        parent_key: &str,
1428    ) -> Result<bool, Error> {
1429        let script = self
1430            .conn
1431            .scripts()
1432            .get("removeChildDependency")
1433            .ok_or_else(|| {
1434                Error::InvalidConfig("removeChildDependency script not found".to_string())
1435            })?
1436            .clone();
1437
1438        let prefix_key = self.keys.key_prefix().to_string();
1439        let job_key = self.keys.job_key(job_id);
1440
1441        let keys = vec![prefix_key];
1442        let args: Vec<&[u8]> = vec![job_key.as_bytes(), parent_key.as_bytes()];
1443
1444        let mut conn = self.conn.conn();
1445        let result = script.execute(&mut conn, &keys, &args).await?;
1446
1447        match result {
1448            redis::Value::Int(0) => Ok(true),
1449            redis::Value::Int(1) => Ok(false),
1450            redis::Value::Int(-1) => Err(Error::InvalidConfig(format!(
1451                "Missing key for job {}. removeChildDependency",
1452                job_id
1453            ))),
1454            redis::Value::Int(-5) => Err(Error::InvalidConfig(format!(
1455                "Missing key for parent job {}. removeChildDependency",
1456                parent_key
1457            ))),
1458            _ => Ok(false),
1459        }
1460    }
1461
1462    /// Get the state of a specific job.
1463    pub async fn get_job_state(&self, job_id: &str) -> Result<JobState, Error> {
1464        let script = self
1465            .conn
1466            .scripts()
1467            .get("getState")
1468            .ok_or_else(|| Error::InvalidConfig("getState script not found".to_string()))?
1469            .clone();
1470
1471        let keys = vec![
1472            self.keys.completed(),
1473            self.keys.failed(),
1474            self.keys.delayed(),
1475            self.keys.active(),
1476            self.keys.wait(),
1477            self.keys.paused(),
1478            self.keys.waiting_children(),
1479            self.keys.prioritized(),
1480        ];
1481
1482        let job_id_bytes = job_id.as_bytes().to_vec();
1483        let args: Vec<&[u8]> = vec![&job_id_bytes];
1484
1485        let mut conn = self.conn.conn();
1486        let result = script.execute(&mut conn, &keys, &args).await?;
1487
1488        match result {
1489            redis::Value::BulkString(bytes) => {
1490                let state_str = String::from_utf8_lossy(&bytes);
1491                Ok(JobState::from_redis_str(&state_str))
1492            }
1493            redis::Value::SimpleString(s) => Ok(JobState::from_redis_str(&s)),
1494            _ => Ok(JobState::Unknown),
1495        }
1496    }
1497
1498    /// Remove a job by its ID.
1499    pub async fn remove(&self, job_id: &str) -> Result<bool, Error> {
1500        self.remove_job(job_id, true).await
1501    }
1502
1503    /// Remove a job without removing its children.
1504    ///
1505    /// Children remain in their queues and lose their parent reference.
1506    pub async fn remove_without_children(&self, job_id: &str) -> Result<bool, Error> {
1507        self.remove_job(job_id, false).await
1508    }
1509
1510    async fn remove_job(&self, job_id: &str, remove_children: bool) -> Result<bool, Error> {
1511        let script = self
1512            .conn
1513            .scripts()
1514            .get("removeJob")
1515            .ok_or_else(|| Error::InvalidConfig("removeJob script not found".to_string()))?
1516            .clone();
1517
1518        let keys = vec![self.keys.job_key(job_id), self.keys.repeat()];
1519        let prefix = self.keys.key_prefix();
1520        let remove_children_flag = if remove_children { b"1" as &[u8] } else { b"0" };
1521        let args: Vec<&[u8]> = vec![job_id.as_bytes(), remove_children_flag, prefix.as_bytes()];
1522
1523        let mut conn = self.conn.conn();
1524        let result = script.execute(&mut conn, &keys, &args).await?;
1525
1526        match result {
1527            // 1 = removed, 0 = job (or a dependency) is locked. Mirroring
1528            // Node.js, a locked job is a normal "not removed" outcome rather
1529            // than an error.
1530            redis::Value::Int(1) => Ok(true),
1531            redis::Value::Int(0) => Ok(false),
1532            redis::Value::Int(code) if code < 0 => {
1533                if code == crate::error::error_code::JOB_BELONGS_TO_JOB_SCHEDULER {
1534                    Err(Error::Script {
1535                        code,
1536                        message: format!(
1537                            "Job {} belongs to a job scheduler and cannot be removed directly. removeJob",
1538                            job_id
1539                        ),
1540                    })
1541                } else {
1542                    Err(Error::from_script_code(code))
1543                }
1544            }
1545            _ => Ok(false),
1546        }
1547    }
1548
1549    /// Remove all unprocessed children of a job.
1550    ///
1551    /// This removes children that are still in the dependencies set (not yet completed/failed).
1552    /// Active children are skipped.
1553    pub async fn remove_unprocessed_children(&self, job_id: &str) -> Result<(), Error> {
1554        let script = self
1555            .conn
1556            .scripts()
1557            .get("removeUnprocessedChildren")
1558            .ok_or_else(|| {
1559                Error::InvalidConfig("removeUnprocessedChildren script not found".to_string())
1560            })?
1561            .clone();
1562
1563        let keys = vec![self.keys.job_key(job_id), self.keys.meta()];
1564        let prefix = self.keys.key_prefix();
1565        let args: Vec<&[u8]> = vec![prefix.as_bytes(), job_id.as_bytes()];
1566
1567        let mut conn = self.conn.conn();
1568        script.execute(&mut conn, &keys, &args).await?;
1569        Ok(())
1570    }
1571
1572    /// Clean jobs from a specific set (completed, failed, etc.).
1573    ///
1574    /// `grace` - Only remove jobs older than this many milliseconds.
1575    /// `limit` - Maximum number of jobs to remove (0 = unlimited).
1576    /// `state` - Which state set to clean ("completed", "failed", "wait", "active", "delayed", "prioritized", "paused").
1577    ///
1578    /// Returns the IDs of removed jobs.
1579    pub async fn clean(&self, grace: u64, limit: u32, state: &str) -> Result<Vec<String>, Error> {
1580        let script = self
1581            .conn
1582            .scripts()
1583            .get("cleanJobsInSet")
1584            .ok_or_else(|| Error::InvalidConfig("cleanJobsInSet script not found".to_string()))?
1585            .clone();
1586
1587        let now = std::time::SystemTime::now()
1588            .duration_since(std::time::UNIX_EPOCH)
1589            .unwrap()
1590            .as_millis() as u64;
1591        let timestamp = now.saturating_sub(grace);
1592
1593        // Normalize "waiting" to "wait"
1594        let normalized = if state == "waiting" { "wait" } else { state };
1595
1596        let set_key = match normalized {
1597            "completed" => self.keys.completed(),
1598            "failed" => self.keys.failed(),
1599            "wait" => self.keys.wait(),
1600            "active" => self.keys.active(),
1601            "delayed" => self.keys.delayed(),
1602            "paused" => self.keys.paused(),
1603            "prioritized" => self.keys.prioritized(),
1604            _ => return Err(Error::InvalidConfig(format!("invalid state: {}", state))),
1605        };
1606
1607        let max_per_call = if limit == 0 {
1608            10000u32
1609        } else {
1610            limit.min(10000)
1611        };
1612        let max_total = if limit == 0 { u32::MAX } else { limit };
1613        let mut all_deleted: Vec<String> = Vec::new();
1614
1615        loop {
1616            let keys = vec![set_key.clone(), self.keys.events(), self.keys.repeat()];
1617
1618            let prefix = self.keys.key_prefix();
1619            let ts_str = timestamp.to_string();
1620            let limit_str = max_per_call.to_string();
1621
1622            let args: Vec<&[u8]> = vec![
1623                prefix.as_bytes(),
1624                ts_str.as_bytes(),
1625                limit_str.as_bytes(),
1626                normalized.as_bytes(),
1627            ];
1628
1629            let mut conn = self.conn.conn();
1630            let result = script.execute(&mut conn, &keys, &args).await?;
1631
1632            let batch: Vec<String> = match result {
1633                redis::Value::Array(arr) => arr
1634                    .into_iter()
1635                    .filter_map(|v| match v {
1636                        redis::Value::BulkString(bytes) => {
1637                            Some(String::from_utf8_lossy(&bytes).to_string())
1638                        }
1639                        redis::Value::SimpleString(s) => Some(s),
1640                        _ => None,
1641                    })
1642                    .collect(),
1643                _ => Vec::new(),
1644            };
1645
1646            let batch_len = batch.len() as u32;
1647            all_deleted.extend(batch);
1648
1649            if batch_len < max_per_call || all_deleted.len() as u32 >= max_total {
1650                break;
1651            }
1652        }
1653
1654        Ok(all_deleted)
1655    }
1656
1657    /// Drain the queue (remove all waiting and delayed jobs).
1658    pub async fn drain(&self, delayed: bool) -> Result<(), Error> {
1659        let script = self
1660            .conn
1661            .scripts()
1662            .get("drain")
1663            .ok_or_else(|| Error::InvalidConfig("drain script not found".to_string()))?
1664            .clone();
1665
1666        let keys = vec![
1667            self.keys.wait(),
1668            self.keys.paused(),
1669            self.keys.delayed(),
1670            self.keys.prioritized(),
1671            self.keys.repeat(),
1672        ];
1673
1674        let delayed_str = if delayed { "1" } else { "0" };
1675        let prefix = self.keys.key_prefix();
1676        let args: Vec<&[u8]> = vec![prefix.as_bytes(), delayed_str.as_bytes()];
1677
1678        let mut conn = self.conn.conn();
1679        script.execute(&mut conn, &keys, &args).await?;
1680
1681        debug!(delayed, "queue drained");
1682        Ok(())
1683    }
1684
1685    /// Retry all failed (or completed) jobs, moving them back to wait.
1686    ///
1687    /// - `state`: "failed" or "completed" (default: "failed")
1688    /// - `count`: max jobs to move per batch (default: 1000)
1689    /// - `timestamp`: only retry jobs finished before this timestamp in ms (default: now)
1690    pub async fn retry_jobs(
1691        &self,
1692        state: &str,
1693        count: u32,
1694        timestamp: Option<u64>,
1695    ) -> Result<(), Error> {
1696        let script = self
1697            .conn
1698            .scripts()
1699            .get("moveJobsToWait")
1700            .ok_or_else(|| Error::InvalidConfig("moveJobsToWait script not found".to_string()))?
1701            .clone();
1702
1703        let ts = timestamp.unwrap_or_else(|| {
1704            std::time::SystemTime::now()
1705                .duration_since(std::time::UNIX_EPOCH)
1706                .unwrap()
1707                .as_millis() as u64
1708        });
1709
1710        let keys = vec![
1711            self.keys.key_prefix(),
1712            self.keys.events(),
1713            self.keys.get(state),
1714            self.keys.wait(),
1715            self.keys.paused(),
1716            self.keys.meta(),
1717            self.keys.active(),
1718            self.keys.marker(),
1719        ];
1720
1721        let count_str = count.to_string();
1722        let ts_str = ts.to_string();
1723
1724        let mut conn = self.conn.conn();
1725        loop {
1726            let args: Vec<&[u8]> = vec![count_str.as_bytes(), ts_str.as_bytes(), state.as_bytes()];
1727            let result = script.execute(&mut conn, &keys, &args).await?;
1728
1729            match result {
1730                redis::Value::Int(1) => continue,
1731                _ => break,
1732            }
1733        }
1734
1735        debug!(state, "retry_jobs completed");
1736        Ok(())
1737    }
1738
1739    /// Promote all delayed jobs to waiting.
1740    ///
1741    /// - `count`: max jobs to promote per batch (default: 1000)
1742    pub async fn promote_jobs(&self, count: u32) -> Result<(), Error> {
1743        let script = self
1744            .conn
1745            .scripts()
1746            .get("moveJobsToWait")
1747            .ok_or_else(|| Error::InvalidConfig("moveJobsToWait script not found".to_string()))?
1748            .clone();
1749
1750        let keys = vec![
1751            self.keys.key_prefix(),
1752            self.keys.events(),
1753            self.keys.delayed(),
1754            self.keys.wait(),
1755            self.keys.paused(),
1756            self.keys.meta(),
1757            self.keys.active(),
1758            self.keys.marker(),
1759        ];
1760
1761        let count_str = count.to_string();
1762        // Use MAX_VALUE equivalent for timestamp so all delayed jobs match
1763        let ts_str = "9007199254740991".to_string(); // Number.MAX_SAFE_INTEGER
1764
1765        let mut conn = self.conn.conn();
1766        loop {
1767            let args: Vec<&[u8]> = vec![count_str.as_bytes(), ts_str.as_bytes(), b"delayed"];
1768            let result = script.execute(&mut conn, &keys, &args).await?;
1769
1770            match result {
1771                redis::Value::Int(1) => continue,
1772                _ => break,
1773            }
1774        }
1775
1776        debug!("promote_jobs completed");
1777        Ok(())
1778    }
1779
1780    /// Override the rate limit to be active for the next jobs.
1781    ///
1782    /// Sets the rate limiter key to MAX value with the given TTL,
1783    /// preventing any new jobs from being processed until it expires.
1784    pub async fn rate_limit(&self, expire_time_ms: u64) -> Result<(), Error> {
1785        let limiter_key = self.keys.limiter();
1786        let mut conn = self.conn.conn();
1787
1788        redis::cmd("SET")
1789            .arg(&limiter_key)
1790            .arg("9007199254740991") // Number.MAX_SAFE_INTEGER
1791            .arg("PX")
1792            .arg(expire_time_ms)
1793            .query_async::<()>(&mut conn)
1794            .await?;
1795
1796        Ok(())
1797    }
1798
1799    /// Remove the rate limit key, allowing processing to resume immediately.
1800    pub async fn remove_rate_limit_key(&self) -> Result<bool, Error> {
1801        let limiter_key = self.keys.limiter();
1802        let mut conn = self.conn.conn();
1803
1804        let result: u32 = redis::cmd("DEL")
1805            .arg(&limiter_key)
1806            .query_async(&mut conn)
1807            .await?;
1808
1809        Ok(result > 0)
1810    }
1811
1812    /// Set global concurrency limit (stored in queue meta hash).
1813    /// Limits the total number of active jobs across all workers for this queue.
1814    pub async fn set_global_concurrency(&self, concurrency: u64) -> Result<(), Error> {
1815        let meta_key = self.keys.meta();
1816        let mut conn = self.conn.conn();
1817
1818        redis::cmd("HSET")
1819            .arg(&meta_key)
1820            .arg("concurrency")
1821            .arg(concurrency)
1822            .query_async::<()>(&mut conn)
1823            .await?;
1824
1825        Ok(())
1826    }
1827
1828    /// Remove global concurrency limit from queue meta.
1829    pub async fn remove_global_concurrency(&self) -> Result<(), Error> {
1830        let meta_key = self.keys.meta();
1831        let mut conn = self.conn.conn();
1832
1833        redis::cmd("HDEL")
1834            .arg(&meta_key)
1835            .arg("concurrency")
1836            .query_async::<()>(&mut conn)
1837            .await?;
1838
1839        Ok(())
1840    }
1841
1842    /// Set global rate limit (stored in queue meta hash).
1843    pub async fn set_global_rate_limit(&self, max: u64, duration: u64) -> Result<(), Error> {
1844        let meta_key = self.keys.meta();
1845        let mut conn = self.conn.conn();
1846
1847        redis::cmd("HSET")
1848            .arg(&meta_key)
1849            .arg("max")
1850            .arg(max)
1851            .arg("duration")
1852            .arg(duration)
1853            .query_async::<()>(&mut conn)
1854            .await?;
1855
1856        Ok(())
1857    }
1858
1859    /// Remove global rate limit values from queue meta.
1860    pub async fn remove_global_rate_limit(&self) -> Result<(), Error> {
1861        let meta_key = self.keys.meta();
1862        let mut conn = self.conn.conn();
1863
1864        redis::cmd("HDEL")
1865            .arg(&meta_key)
1866            .arg("max")
1867            .arg("duration")
1868            .query_async::<()>(&mut conn)
1869            .await?;
1870
1871        Ok(())
1872    }
1873
1874    /// Return the time-to-live (in ms) for the rate-limited key.
1875    ///
1876    /// `max_jobs` is the maximum number of jobs considered in the rate-limit
1877    /// state. When `None`, the remaining TTL is returned without checking
1878    /// whether the max is exceeded. Returns `0` when not rate limited and
1879    /// `-2`/`-1` mirror Redis `PTTL` semantics (no key / no expiry).
1880    pub async fn get_rate_limit_ttl(&self, max_jobs: Option<u64>) -> Result<i64, Error> {
1881        let script = self
1882            .conn
1883            .scripts()
1884            .get("getRateLimitTtl")
1885            .ok_or_else(|| Error::InvalidConfig("getRateLimitTtl script not found".to_string()))?
1886            .clone();
1887
1888        let keys = vec![self.keys.limiter(), self.keys.meta()];
1889        let max_jobs_str = max_jobs
1890            .map(|m| m.to_string())
1891            .unwrap_or_else(|| "0".to_string());
1892        let args: Vec<&[u8]> = vec![max_jobs_str.as_bytes()];
1893
1894        let mut conn = self.conn.conn();
1895        let result = script.execute(&mut conn, &keys, &args).await?;
1896
1897        match result {
1898            redis::Value::Int(n) => Ok(n),
1899            _ => Ok(0),
1900        }
1901    }
1902
1903    /// Return the global concurrency value, or `None` when not set.
1904    pub async fn get_global_concurrency(&self) -> Result<Option<u64>, Error> {
1905        let meta_key = self.keys.meta();
1906        let mut conn = self.conn.conn();
1907
1908        let value: Option<String> = redis::cmd("HGET")
1909            .arg(&meta_key)
1910            .arg("concurrency")
1911            .query_async(&mut conn)
1912            .await?;
1913
1914        Ok(value.and_then(|v| v.parse::<u64>().ok()))
1915    }
1916
1917    /// Return the global rate limit as `(max, duration)`, or `None` when not set.
1918    pub async fn get_global_rate_limit(&self) -> Result<Option<(u64, u64)>, Error> {
1919        let meta_key = self.keys.meta();
1920        let mut conn = self.conn.conn();
1921
1922        let values: Vec<Option<String>> = redis::cmd("HMGET")
1923            .arg(&meta_key)
1924            .arg("max")
1925            .arg("duration")
1926            .query_async(&mut conn)
1927            .await?;
1928
1929        let max = values
1930            .first()
1931            .and_then(|v| v.as_ref())
1932            .and_then(|v| v.parse::<u64>().ok());
1933        let duration = values
1934            .get(1)
1935            .and_then(|v| v.as_ref())
1936            .and_then(|v| v.parse::<u64>().ok());
1937
1938        match (max, duration) {
1939            (Some(m), Some(d)) => Ok(Some((m, d))),
1940            _ => Ok(None),
1941        }
1942    }
1943
1944    /// Remove a deduplication key if the stored job ID matches the given one.
1945    ///
1946    /// Uses the `removeDeduplicationKey` Lua script for atomic check-and-delete.
1947    pub async fn remove_deduplication_key(
1948        &self,
1949        deduplication_id: &str,
1950        job_id: &str,
1951    ) -> Result<bool, Error> {
1952        let script = self
1953            .conn
1954            .scripts()
1955            .get("removeDeduplicationKey")
1956            .ok_or_else(|| {
1957                Error::InvalidConfig("removeDeduplicationKey script not found".to_string())
1958            })?
1959            .clone();
1960
1961        let dedup_key = format!("{}:de:{}", self.keys.base(), deduplication_id);
1962        let keys = vec![dedup_key];
1963        let args: Vec<&[u8]> = vec![job_id.as_bytes()];
1964
1965        let mut conn = self.conn.conn();
1966        let result = script.execute(&mut conn, &keys, &args).await?;
1967
1968        match result {
1969            redis::Value::Int(1) => Ok(true),
1970            _ => Ok(false),
1971        }
1972    }
1973
1974    /// Get the job ID stored for a given deduplication ID.
1975    ///
1976    /// Returns `None` if no deduplication key exists.
1977    pub async fn get_deduplication_job_id(
1978        &self,
1979        deduplication_id: &str,
1980    ) -> Result<Option<String>, Error> {
1981        let dedup_key = format!("{}:de:{}", self.keys.base(), deduplication_id);
1982        let mut conn = self.conn.conn();
1983        let result: redis::Value = redis::cmd("GET")
1984            .arg(&dedup_key)
1985            .query_async(&mut conn)
1986            .await
1987            .map_err(Error::Redis)?;
1988
1989        match result {
1990            redis::Value::BulkString(bytes) => {
1991                Ok(Some(String::from_utf8_lossy(&bytes).to_string()))
1992            }
1993            redis::Value::SimpleString(s) => Ok(Some(s)),
1994            redis::Value::Nil => Ok(None),
1995            _ => Ok(None),
1996        }
1997    }
1998
1999    /// Get the job ID that started a debounced state.
2000    ///
2001    /// **Deprecated:** use [`Queue::get_deduplication_job_id`] instead. Provided
2002    /// for parity with the legacy Node.js `Queue.getDebounceJobId`.
2003    pub async fn get_debounce_job_id(&self, id: &str) -> Result<Option<String>, Error> {
2004        self.get_deduplication_job_id(id).await
2005    }
2006
2007    /// Remove a debounce key unconditionally, returning the number of keys
2008    /// deleted (`0` or `1`).
2009    ///
2010    /// **Deprecated:** use [`Queue::remove_deduplication_key`] instead. Provided
2011    /// for parity with the legacy Node.js `Queue.removeDebounceKey`. Unlike the
2012    /// deduplication variant, this performs a plain `DEL` without checking the
2013    /// stored job ID.
2014    pub async fn remove_debounce_key(&self, id: &str) -> Result<u64, Error> {
2015        let dedup_key = format!("{}:de:{}", self.keys.base(), id);
2016        let mut conn = self.conn.conn();
2017        let deleted: u64 = redis::cmd("DEL")
2018            .arg(&dedup_key)
2019            .query_async(&mut conn)
2020            .await?;
2021        Ok(deleted)
2022    }
2023
2024    /// Get logs for a specific job.
2025    ///
2026    /// Returns the log entries and total count.
2027    pub async fn get_job_logs(
2028        &self,
2029        job_id: &str,
2030        start: isize,
2031        end: isize,
2032        asc: bool,
2033    ) -> Result<(Vec<String>, usize), Error> {
2034        let logs_key = format!("{}{}:logs", self.keys.key_prefix(), job_id);
2035        let mut conn = self.conn.conn();
2036
2037        let (logs, count): (Vec<String>, usize) = if asc {
2038            redis::pipe()
2039                .cmd("LRANGE")
2040                .arg(&logs_key)
2041                .arg(start)
2042                .arg(end)
2043                .cmd("LLEN")
2044                .arg(&logs_key)
2045                .query_async(&mut conn)
2046                .await?
2047        } else {
2048            let actual_start = -(end + 1);
2049            let actual_end = -(start + 1);
2050            let (mut logs, count): (Vec<String>, usize) = redis::pipe()
2051                .cmd("LRANGE")
2052                .arg(&logs_key)
2053                .arg(actual_start)
2054                .arg(actual_end)
2055                .cmd("LLEN")
2056                .arg(&logs_key)
2057                .query_async(&mut conn)
2058                .await?;
2059            logs.reverse();
2060            (logs, count)
2061        };
2062
2063        Ok((logs, count))
2064    }
2065
2066    /// Trim the event stream to approximately `max_length` entries.
2067    pub async fn trim_events(&self, max_length: usize) -> Result<usize, Error> {
2068        let mut conn = self.conn.conn();
2069        let trimmed: usize = redis::cmd("XTRIM")
2070            .arg(self.keys.events())
2071            .arg("MAXLEN")
2072            .arg("~")
2073            .arg(max_length)
2074            .query_async(&mut conn)
2075            .await?;
2076        Ok(trimmed)
2077    }
2078
2079    /// Update a job's progress by id, without loading the job first.
2080    ///
2081    /// Mirrors Node.js `Queue.updateJobProgress`: runs the `updateProgress`
2082    /// script which sets the job's `progress` field and emits a `progress`
2083    /// event on the queue's event stream.
2084    pub async fn update_job_progress(
2085        &self,
2086        job_id: &str,
2087        progress: crate::types::JobProgress,
2088    ) -> Result<(), Error> {
2089        let script = self
2090            .conn
2091            .scripts()
2092            .get("updateProgress")
2093            .ok_or_else(|| Error::InvalidConfig("updateProgress script not found".to_string()))?
2094            .clone();
2095
2096        let job_key = self.keys.job_key(job_id);
2097        let events_key = self.keys.events();
2098        let meta_key = self.keys.meta();
2099        let progress_json = serialize_progress_for_script(&progress)?;
2100
2101        let keys = vec![job_key, events_key, meta_key];
2102        let args: Vec<&[u8]> = vec![job_id.as_bytes(), progress_json.as_bytes()];
2103
2104        let mut conn = self.conn.conn();
2105        let result: redis::Value = script.execute(&mut conn, &keys, &args).await?;
2106
2107        match result {
2108            redis::Value::Int(code) if code < 0 => Err(Error::from_script_code(code)),
2109            _ => Ok(()),
2110        }
2111    }
2112
2113    /// Obliterate the queue (remove all keys).
2114    ///
2115    /// When `force` is true, automatically pauses the queue first.
2116    pub async fn obliterate(&self, force: bool, count: usize) -> Result<(), Error> {
2117        // The script requires the queue to be paused
2118        if force {
2119            let _ = self.pause().await;
2120        }
2121
2122        let script = self
2123            .conn
2124            .scripts()
2125            .get("obliterate")
2126            .ok_or_else(|| Error::InvalidConfig("obliterate script not found".to_string()))?
2127            .clone();
2128
2129        let keys = vec![self.keys.meta(), self.keys.key_prefix()];
2130        let count_str = count.to_string();
2131        let force_str = if force { "1" } else { "0" };
2132
2133        let mut conn = self.conn.conn();
2134        loop {
2135            let args: Vec<&[u8]> = vec![count_str.as_bytes(), force_str.as_bytes()];
2136            let result = script.execute(&mut conn, &keys, &args).await?;
2137
2138            match result {
2139                redis::Value::Nil => break,
2140                redis::Value::Int(0) => break,
2141                redis::Value::Int(1) => continue, // more to delete
2142                redis::Value::Int(code) if code < 0 => {
2143                    return Err(Error::from_script_code(code));
2144                }
2145                _ => break,
2146            }
2147        }
2148
2149        debug!(force, "queue obliterated");
2150        Ok(())
2151    }
2152
2153    // ── Job Scheduler Methods ────────────────────────────────────────────
2154
2155    /// Create or update a job scheduler.
2156    ///
2157    /// Creates a scheduled repeating job that will run on a cron pattern or at
2158    /// fixed intervals. The scheduler is persisted in Redis and will create
2159    /// the next delayed job automatically after each execution.
2160    ///
2161    /// # Arguments
2162    /// - `job_scheduler_id` — Unique ID for this scheduler.
2163    /// - `repeat_opts` — Schedule configuration (cron pattern or every-ms).
2164    /// - `job_name` — Name for the created jobs (defaults to scheduler ID).
2165    /// - `job_data` — JSON data for the created jobs.
2166    /// - `job_opts` — Options for the created jobs (attempts, backoff, etc.).
2167    pub async fn upsert_job_scheduler(
2168        &self,
2169        job_scheduler_id: &str,
2170        repeat_opts: crate::job_scheduler::RepeatOptions,
2171        job_name: Option<&str>,
2172        job_data: Option<serde_json::Value>,
2173        job_opts: Option<JobOptions>,
2174    ) -> Result<Option<Job>, Error> {
2175        use crate::job_scheduler::{next_cron_millis, pack_delayed_job_opts, pack_scheduler_opts};
2176
2177        // Validation
2178        if repeat_opts.pattern.is_some() && repeat_opts.every.is_some() {
2179            return Err(Error::InvalidConfig(
2180                "Both .pattern and .every options are defined; only one may be used".to_string(),
2181            ));
2182        }
2183        if repeat_opts.pattern.is_none() && repeat_opts.every.is_none() {
2184            return Err(Error::InvalidConfig(
2185                "Either .pattern or .every option must be defined".to_string(),
2186            ));
2187        }
2188        if repeat_opts.immediately == Some(true) && repeat_opts.start_date.is_some() {
2189            return Err(Error::InvalidConfig(
2190                "Both .immediately and .startDate options are defined; only one may be used"
2191                    .to_string(),
2192            ));
2193        }
2194
2195        let now = std::time::SystemTime::now()
2196            .duration_since(std::time::UNIX_EPOCH)
2197            .unwrap()
2198            .as_millis() as u64;
2199
2200        // Validate end date
2201        if let Some(end_date) = repeat_opts.end_date {
2202            if end_date < now {
2203                return Err(Error::InvalidConfig(
2204                    "End date must be greater than current timestamp".to_string(),
2205                ));
2206            }
2207        }
2208
2209        // Compute iteration count
2210        let iteration_count = repeat_opts.count.unwrap_or(0) + 1;
2211        if let Some(limit) = repeat_opts.limit {
2212            if iteration_count > limit {
2213                return Ok(None);
2214            }
2215        }
2216
2217        // Compute nextMillis for cron patterns
2218        let next_millis: Option<u64> = if let Some(ref pattern) = repeat_opts.pattern {
2219            if repeat_opts.immediately == Some(true) {
2220                Some(now)
2221            } else {
2222                next_cron_millis(
2223                    pattern,
2224                    now,
2225                    repeat_opts.tz.as_deref(),
2226                    repeat_opts.start_date,
2227                )?
2228            }
2229        } else {
2230            // For `every`, nextMillis is not computed here — the Lua script handles it
2231            None
2232        };
2233
2234        // We need either nextMillis or every to proceed
2235        if next_millis.is_none() && repeat_opts.every.is_none() {
2236            return Ok(None);
2237        }
2238
2239        let effective_name = job_name.unwrap_or(job_scheduler_id);
2240        let effective_data = job_data.unwrap_or(serde_json::json!({}));
2241        let effective_opts = job_opts.unwrap_or_default();
2242
2243        let offset = if repeat_opts.every.is_some() {
2244            repeat_opts.offset
2245        } else {
2246            None
2247        };
2248
2249        // Pack arguments for Lua script
2250        let scheduler_opts_packed = pack_scheduler_opts(effective_name, &repeat_opts);
2251        let template_data_json =
2252            serde_json::to_string(&effective_data).unwrap_or_else(|_| "{}".to_string());
2253        let template_opts_packed = self.pack_job_opts_from_options(&effective_opts);
2254        let delayed_job_opts_packed = pack_delayed_job_opts(
2255            &effective_opts,
2256            job_scheduler_id,
2257            next_millis.unwrap_or(0),
2258            iteration_count,
2259            offset,
2260            &repeat_opts,
2261        );
2262
2263        let script = self
2264            .conn
2265            .scripts()
2266            .get("addJobScheduler")
2267            .ok_or_else(|| Error::InvalidConfig("addJobScheduler script not found".to_string()))?
2268            .clone();
2269
2270        // KEYS[1-11]
2271        let keys = vec![
2272            self.keys.repeat(),      // KEYS[1]
2273            self.keys.delayed(),     // KEYS[2]
2274            self.keys.wait(),        // KEYS[3]
2275            self.keys.paused(),      // KEYS[4]
2276            self.keys.meta(),        // KEYS[5]
2277            self.keys.prioritized(), // KEYS[6]
2278            self.keys.marker(),      // KEYS[7]
2279            self.keys.id(),          // KEYS[8]
2280            self.keys.events(),      // KEYS[9]
2281            self.keys.pc(),          // KEYS[10]
2282            self.keys.active(),      // KEYS[11]
2283        ];
2284
2285        // ARGV
2286        let next_millis_str = next_millis.unwrap_or(0).to_string();
2287        let timestamp_str = now.to_string();
2288        let prefix = self.keys.key_prefix();
2289        let producer_key = Vec::new(); // empty for non-flow jobs
2290
2291        let args: Vec<&[u8]> = vec![
2292            next_millis_str.as_bytes(),    // ARGV[1]
2293            &scheduler_opts_packed,        // ARGV[2]
2294            job_scheduler_id.as_bytes(),   // ARGV[3]
2295            template_data_json.as_bytes(), // ARGV[4]
2296            &template_opts_packed,         // ARGV[5]
2297            &delayed_job_opts_packed,      // ARGV[6]
2298            timestamp_str.as_bytes(),      // ARGV[7]
2299            prefix.as_bytes(),             // ARGV[8]
2300            &producer_key,                 // ARGV[9]
2301        ];
2302
2303        let mut conn = self.conn.conn();
2304        let result = script.execute(&mut conn, &keys, &args).await?;
2305
2306        // Parse result: the script returns [jobId, delay] on success
2307        match result {
2308            redis::Value::Array(ref arr) if arr.len() >= 2 => {
2309                let job_id = match &arr[0] {
2310                    redis::Value::BulkString(bytes) => String::from_utf8_lossy(bytes).to_string(),
2311                    redis::Value::SimpleString(s) => s.clone(),
2312                    redis::Value::Int(n) => n.to_string(),
2313                    _ => return Ok(None),
2314                };
2315
2316                let mut job = Job::new(effective_name, effective_data, Some(effective_opts));
2317                job.set_id(job_id);
2318                job.set_context(self.make_script_context());
2319                Ok(Some(job))
2320            }
2321            redis::Value::Int(code) if code < 0 => Err(Error::from_script_code(code)),
2322            _ => Ok(None),
2323        }
2324    }
2325
2326    /// Get a job scheduler by its ID.
2327    pub async fn get_job_scheduler(
2328        &self,
2329        job_scheduler_id: &str,
2330    ) -> Result<Option<crate::job_scheduler::JobSchedulerJson>, Error> {
2331        let script = self
2332            .conn
2333            .scripts()
2334            .get("getJobScheduler")
2335            .ok_or_else(|| Error::InvalidConfig("getJobScheduler script not found".to_string()))?
2336            .clone();
2337
2338        let keys = vec![self.keys.repeat()];
2339        let args: Vec<&[u8]> = vec![job_scheduler_id.as_bytes()];
2340
2341        let mut conn = self.conn.conn();
2342        let result = script.execute(&mut conn, &keys, &args).await?;
2343
2344        // Result is [hash_fields_array, score_string]
2345        match result {
2346            redis::Value::Array(ref arr) if arr.len() >= 2 => {
2347                let fields = Self::parse_hash_array(&arr[0]);
2348                if fields.is_empty() {
2349                    return Ok(None);
2350                }
2351                let next_millis = match &arr[1] {
2352                    redis::Value::BulkString(bytes) => {
2353                        String::from_utf8_lossy(bytes).parse::<u64>().ok()
2354                    }
2355                    redis::Value::SimpleString(s) => s.parse::<u64>().ok(),
2356                    redis::Value::Int(n) => Some(*n as u64),
2357                    _ => None,
2358                };
2359                Ok(Some(crate::job_scheduler::parse_scheduler_hash(
2360                    job_scheduler_id,
2361                    &fields,
2362                    next_millis,
2363                )))
2364            }
2365            redis::Value::Nil => Ok(None),
2366            redis::Value::Array(ref arr) if arr.is_empty() => Ok(None),
2367            _ => Ok(None),
2368        }
2369    }
2370
2371    /// Get a paginated list of job schedulers.
2372    ///
2373    /// Returns schedulers ordered by next execution time.
2374    pub async fn get_job_schedulers(
2375        &self,
2376        start: isize,
2377        end: isize,
2378        asc: bool,
2379    ) -> Result<Vec<crate::job_scheduler::JobSchedulerJson>, Error> {
2380        let mut conn = self.conn.conn();
2381        let repeat_key = self.keys.repeat();
2382
2383        // Get members with scores
2384        let results: Vec<(String, f64)> = if asc {
2385            redis::cmd("ZRANGE")
2386                .arg(&repeat_key)
2387                .arg(start)
2388                .arg(end)
2389                .arg("WITHSCORES")
2390                .query_async(&mut conn)
2391                .await?
2392        } else {
2393            redis::cmd("ZREVRANGE")
2394                .arg(&repeat_key)
2395                .arg(start)
2396                .arg(end)
2397                .arg("WITHSCORES")
2398                .query_async(&mut conn)
2399                .await?
2400        };
2401
2402        let mut schedulers = Vec::with_capacity(results.len());
2403        for (scheduler_id, score) in &results {
2404            let scheduler_hash_key = format!("{}repeat:{}", self.keys.key_prefix(), scheduler_id);
2405            let fields: std::collections::HashMap<String, String> = redis::cmd("HGETALL")
2406                .arg(&scheduler_hash_key)
2407                .query_async(&mut conn)
2408                .await?;
2409
2410            if !fields.is_empty() {
2411                schedulers.push(crate::job_scheduler::parse_scheduler_hash(
2412                    scheduler_id,
2413                    &fields,
2414                    Some(*score as u64),
2415                ));
2416            }
2417        }
2418
2419        Ok(schedulers)
2420    }
2421
2422    /// Get the total number of job schedulers.
2423    pub async fn get_job_schedulers_count(&self) -> Result<u64, Error> {
2424        let mut conn = self.conn.conn();
2425        let count: u64 = redis::cmd("ZCARD")
2426            .arg(self.keys.repeat())
2427            .query_async(&mut conn)
2428            .await?;
2429        Ok(count)
2430    }
2431
2432    /// Remove a job scheduler by its ID.
2433    ///
2434    /// Also removes the next scheduled delayed job if one exists.
2435    /// Returns `true` if the scheduler was found and removed.
2436    pub async fn remove_job_scheduler(&self, job_scheduler_id: &str) -> Result<bool, Error> {
2437        let script = self
2438            .conn
2439            .scripts()
2440            .get("removeJobScheduler")
2441            .ok_or_else(|| Error::InvalidConfig("removeJobScheduler script not found".to_string()))?
2442            .clone();
2443
2444        let keys = vec![self.keys.repeat(), self.keys.delayed(), self.keys.events()];
2445
2446        let prefix = self.keys.key_prefix();
2447        let args: Vec<&[u8]> = vec![job_scheduler_id.as_bytes(), prefix.as_bytes()];
2448
2449        let mut conn = self.conn.conn();
2450        let result = script.execute(&mut conn, &keys, &args).await?;
2451
2452        match result {
2453            redis::Value::Int(0) => Ok(true),  // 0 = success (removed)
2454            redis::Value::Int(1) => Ok(false), // 1 = not found
2455            _ => Ok(false),
2456        }
2457    }
2458
2459    /// Parse a Redis hash array (alternating key/value) into a HashMap.
2460    fn parse_hash_array(value: &redis::Value) -> std::collections::HashMap<String, String> {
2461        let mut map = std::collections::HashMap::new();
2462        if let redis::Value::Array(arr) = value {
2463            let mut iter = arr.iter();
2464            while let (Some(key_val), Some(val_val)) = (iter.next(), iter.next()) {
2465                let key = match key_val {
2466                    redis::Value::BulkString(b) => String::from_utf8_lossy(b).to_string(),
2467                    redis::Value::SimpleString(s) => s.clone(),
2468                    _ => continue,
2469                };
2470                let val = match val_val {
2471                    redis::Value::BulkString(b) => String::from_utf8_lossy(b).to_string(),
2472                    redis::Value::SimpleString(s) => s.clone(),
2473                    redis::Value::Int(n) => n.to_string(),
2474                    _ => String::new(),
2475                };
2476                map.insert(key, val);
2477            }
2478        }
2479        map
2480    }
2481
2482    /// Pack job options from a JobOptions struct into msgpack (for template opts).
2483    fn pack_job_opts_from_options(&self, opts: &JobOptions) -> Vec<u8> {
2484        use rmp::encode::*;
2485
2486        let mut entries: Vec<(&str, Vec<u8>)> = Vec::new();
2487
2488        if let Some(attempts) = opts.attempts {
2489            let mut b = Vec::new();
2490            write_uint(&mut b, attempts as u64).unwrap();
2491            entries.push(("attempts", b));
2492        }
2493        if let Some(ref backoff) = opts.backoff {
2494            let b = Self::encode_backoff(backoff);
2495            entries.push(("backoff", b));
2496        }
2497        if let Some(ref roc) = opts.remove_on_complete {
2498            let b = Self::encode_remove_on_finish(roc);
2499            entries.push(("removeOnComplete", b));
2500        }
2501        if let Some(ref rof) = opts.remove_on_fail {
2502            let b = Self::encode_remove_on_finish(rof);
2503            entries.push(("removeOnFail", b));
2504        }
2505        if let Some(priority) = opts.priority {
2506            if priority > 0 {
2507                let mut b = Vec::new();
2508                write_uint(&mut b, priority as u64).unwrap();
2509                entries.push(("priority", b));
2510            }
2511        }
2512
2513        let mut buf = Vec::with_capacity(64);
2514        write_map_len(&mut buf, entries.len() as u32).unwrap();
2515        for (key, val) in &entries {
2516            write_str(&mut buf, key).unwrap();
2517            buf.extend_from_slice(val);
2518        }
2519        buf
2520    }
2521
2522    /// Close the queue connection.
2523    pub async fn close(&self) {
2524        self.conn.close().await;
2525    }
2526}
2527
2528/// Escape a Prometheus label value (`\`, `"`, and newlines).
2529///
2530/// Mirrors Node.js `escapePrometheusLabelValue`.
2531fn escape_prometheus_label_value(value: &str) -> String {
2532    value
2533        .replace('\\', "\\\\")
2534        .replace('"', "\\\"")
2535        .replace('\n', "\\n")
2536}
2537
2538/// Parse the output of Redis `CLIENT LIST` and return the info maps for clients
2539/// belonging to this queue.
2540/// Mirrors Node.js `QueueGetters.parseClientList`: each line is a space-
2541/// separated set of `key=value` pairs. A client matches when its `name` equals
2542/// `unnamed` (an unnamed worker) or starts with `named_prefix` (a named worker,
2543/// `{clientName}:w:`). For matches, `name` is replaced with the queue name and
2544/// the raw client name is kept under `rawname`.
2545fn parse_client_list(
2546    list: &str,
2547    queue_name: &str,
2548    unnamed: &str,
2549    named_prefix: &str,
2550) -> Vec<HashMap<String, String>> {
2551    let mut clients = Vec::new();
2552
2553    for line in list.split(['\r', '\n']).filter(|l| !l.is_empty()) {
2554        let mut client: HashMap<String, String> = HashMap::new();
2555        for key_value in line.split(' ') {
2556            if let Some(idx) = key_value.find('=') {
2557                let key = &key_value[..idx];
2558                let value = &key_value[idx + 1..];
2559                client.insert(key.to_string(), value.to_string());
2560            }
2561        }
2562
2563        let name = client.get("name").cloned().unwrap_or_default();
2564        if !name.is_empty() && (name == unnamed || name.starts_with(named_prefix)) {
2565            client.insert("name".to_string(), queue_name.to_string());
2566            client.insert("rawname".to_string(), name);
2567            clients.push(client);
2568        }
2569    }
2570
2571    clients
2572}
2573
2574fn serialize_progress_for_script(progress: &crate::types::JobProgress) -> Result<String, Error> {
2575    match progress {
2576        // Match Node.js behavior: JSON.stringify(NaN/±Infinity) -> "null".
2577        crate::types::JobProgress::Number(n) if !n.is_finite() => Ok("null".to_string()),
2578        _ => Ok(serde_json::to_string(progress)?),
2579    }
2580}
2581
2582#[cfg(test)]
2583mod client_list_tests {
2584    use super::parse_client_list;
2585
2586    #[test]
2587    fn matches_unnamed_and_named_workers() {
2588        let unnamed = "bull:dGVzdA==";
2589        let named_prefix = "bull:dGVzdA==:w:";
2590        let list = format!(
2591            "id=1 addr=127.0.0.1:1 name={unnamed} age=5\n\
2592             id=2 addr=127.0.0.1:2 name={named_prefix}alpha age=3\n\
2593             id=3 addr=127.0.0.1:3 name=bull:b3RoZXI= age=1\n\
2594             id=4 addr=127.0.0.1:4 name= age=1\n"
2595        );
2596
2597        let clients = parse_client_list(&list, "test", unnamed, named_prefix);
2598        assert_eq!(clients.len(), 2);
2599        assert_eq!(clients[0].get("name"), Some(&"test".to_string()));
2600        assert_eq!(clients[0].get("rawname"), Some(&unnamed.to_string()));
2601        assert_eq!(
2602            clients[1].get("rawname"),
2603            Some(&format!("{named_prefix}alpha"))
2604        );
2605        assert_eq!(clients[1].get("name"), Some(&"test".to_string()));
2606    }
2607
2608    #[test]
2609    fn returns_empty_when_no_match() {
2610        let list = "id=1 addr=127.0.0.1:1 name=other age=5\n";
2611        let clients = parse_client_list(list, "test", "bull:dGVzdA==", "bull:dGVzdA==:w:");
2612        assert!(clients.is_empty());
2613    }
2614}
2615
2616#[cfg(test)]
2617mod prometheus_tests {
2618    use super::escape_prometheus_label_value;
2619
2620    #[test]
2621    fn escapes_special_characters() {
2622        assert_eq!(escape_prometheus_label_value("plain"), "plain");
2623        assert_eq!(escape_prometheus_label_value("a\\b"), "a\\\\b");
2624        assert_eq!(escape_prometheus_label_value("a\"b"), "a\\\"b");
2625        assert_eq!(escape_prometheus_label_value("a\nb"), "a\\nb");
2626    }
2627}
2628
2629#[cfg(test)]
2630mod progress_serialization_tests {
2631    use super::serialize_progress_for_script;
2632    use crate::types::JobProgress;
2633
2634    #[test]
2635    fn serializes_non_finite_numbers_as_null() {
2636        for value in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
2637            let serialized = serialize_progress_for_script(&JobProgress::Number(value)).unwrap();
2638            assert_eq!(serialized, "null");
2639        }
2640    }
2641
2642    #[test]
2643    fn serializes_regular_values_as_json() {
2644        let serialized = serialize_progress_for_script(&JobProgress::Number(42.0)).unwrap();
2645        assert_eq!(serialized, "42.0");
2646    }
2647}