Skip to main content

armature_queue/
queue.rs

1//! Queue implementation with Redis backend.
2
3use crate::error::{QueueError, QueueResult};
4use crate::job::{Job, JobData, JobId, JobPriority, JobState, JobStatus};
5use armature_log::{debug, info, warn};
6use chrono::{DateTime, Utc};
7use redis::{AsyncCommands, Client, aio::ConnectionManager};
8use std::sync::LazyLock;
9use std::time::Duration;
10
11/// Number of keys hinted per `SCAN` iteration when clearing the queue.
12const SCAN_COUNT: usize = 500;
13
14/// Lua helper mapping a stored job's serialized `JobPriority` to its
15/// `pending:<name>` queue suffix and sort score.
16///
17/// Prepended to every script that has to re-file a job onto a priority queue
18/// (delayed promotion and stale-claim reclaim) so the two can never drift
19/// apart from each other or from the Rust-side `-(priority as i64)` scoring.
20/// `test_lua_priority_mapping_matches_rust` pins it to the Rust definition.
21const PRIORITY_LUA: &str = r#"
22local function priority_of(job_json)
23    local pname = 'normal'
24    local pscore = -1
25    local ok, job = pcall(cjson.decode, job_json)
26    if ok and type(job) == 'table' and job.priority then
27        local p = job.priority
28        if p == 'Low' then pname = 'low'; pscore = 0
29        elseif p == 'Normal' then pname = 'normal'; pscore = -1
30        elseif p == 'High' then pname = 'high'; pscore = -2
31        elseif p == 'Critical' then pname = 'critical'; pscore = -3
32        end
33    end
34    return pname, pscore
35end
36"#;
37
38/// Atomically promote all due delayed jobs to their priority queues.
39///
40/// `KEYS[1]` = delayed sorted-set key, `ARGV[1]` = queue key prefix,
41/// `ARGV[2]` = current unix timestamp. Returns `{promoted, dropped}`.
42///
43/// The whole script runs atomically on the server, so `ZRANGEBYSCORE` +
44/// `ZREM` + `ZADD` happen with zero per-job client round-trips and two
45/// concurrent workers can never promote the same job twice (the `ZREM == 1`
46/// guard is belt-and-suspenders on top of that atomicity). Priority is read
47/// from the stored job JSON server-side via `cjson`, matching the client-side
48/// `-(priority as i64)` scoring and `pending:<name>` key layout.
49///
50/// Due ids whose job body is gone are `ZREM`ed rather than skipped: leaving
51/// them in place would leak an entry that is rescanned by every subsequent
52/// promotion pass forever and permanently inflates `backlog_size()` (and so
53/// eventually trips `max_size`). They are counted separately so the caller can
54/// surface them.
55const MOVE_DELAYED_BODY: &str = r#"
56local delayed_key = KEYS[1]
57local prefix = ARGV[1]
58local now = ARGV[2]
59
60local job_ids = redis.call('ZRANGEBYSCORE', delayed_key, '-inf', now)
61local promoted = 0
62local dropped = 0
63
64for _, job_id in ipairs(job_ids) do
65    local job_json = redis.call('GET', prefix .. ':job:' .. job_id)
66    if job_json then
67        -- Claim the job atomically; skip if another pass already took it.
68        if redis.call('ZREM', delayed_key, job_id) == 1 then
69            local pname, pscore = priority_of(job_json)
70            redis.call('ZADD', prefix .. ':pending:' .. pname, pscore, job_id)
71            promoted = promoted + 1
72        end
73    elseif redis.call('ZREM', delayed_key, job_id) == 1 then
74        -- Body expired before the job came due: it can never run, so drop the
75        -- id instead of rescanning it on every future pass.
76        dropped = dropped + 1
77    end
78end
79
80return {promoted, dropped}
81"#;
82
83/// Return in-flight jobs whose claim has outlived the visibility timeout to
84/// their priority queues.
85///
86/// `KEYS[1]` = processing sorted-set key, `ARGV[1]` = queue key prefix,
87/// `ARGV[2]` = cutoff unix timestamp (claims scored at or before this are
88/// stale). Returns `{reclaimed, dropped}`.
89///
90/// Mirrors the promotion script: the `ZREM == 1` claim guard means two reapers
91/// running concurrently can never re-file the same job twice, and ids whose
92/// body has TTL-expired are dropped rather than left to accumulate.
93const RECLAIM_STALE_BODY: &str = r#"
94local processing_key = KEYS[1]
95local prefix = ARGV[1]
96local cutoff = ARGV[2]
97
98local job_ids = redis.call('ZRANGEBYSCORE', processing_key, '-inf', cutoff)
99local reclaimed = 0
100local dropped = 0
101
102for _, job_id in ipairs(job_ids) do
103    local job_json = redis.call('GET', prefix .. ':job:' .. job_id)
104    if job_json then
105        if redis.call('ZREM', processing_key, job_id) == 1 then
106            local pname, pscore = priority_of(job_json)
107            redis.call('ZADD', prefix .. ':pending:' .. pname, pscore, job_id)
108            reclaimed = reclaimed + 1
109        end
110    elseif redis.call('ZREM', processing_key, job_id) == 1 then
111        dropped = dropped + 1
112    end
113end
114
115return {reclaimed, dropped}
116"#;
117
118/// [`MOVE_DELAYED_BODY`] with the shared priority helper prepended.
119static MOVE_DELAYED_SCRIPT: LazyLock<String> =
120    LazyLock::new(|| format!("{PRIORITY_LUA}{MOVE_DELAYED_BODY}"));
121
122/// [`RECLAIM_STALE_BODY`] with the shared priority helper prepended.
123static RECLAIM_STALE_SCRIPT: LazyLock<String> =
124    LazyLock::new(|| format!("{PRIORITY_LUA}{RECLAIM_STALE_BODY}"));
125
126/// Pop the highest-priority available job id (and its job JSON) across the
127/// priority queues, claiming it in the `processing` set in the same step.
128///
129/// `KEYS` are the priority queue keys in descending priority order
130/// (critical, high, normal, low); `ARGV[1]` is the queue key prefix used to
131/// build the `prefix:job:<id>` lookup key and the `prefix:processing` claim
132/// key, `ARGV[2]` is the current unix timestamp recorded as the claim time.
133/// Returns `{id, job_json}`, or `nil` when every queue is empty. Running
134/// server-side makes the "check queues high to low, pop the first non-empty,
135/// fetch its job body, and if that body has expired keep popping" sequence a
136/// single atomic round-trip: it removes the concurrent double-pop window AND
137/// folds the client-side `GET` (plus the expired-job retry loop, which
138/// previously re-invoked the whole script) into one call.
139///
140/// The claim `ZADD` lives here rather than in a follow-up pipeline because a
141/// crash between "popped from pending" and "recorded in processing" would
142/// otherwise lose the job with no trace anywhere for the reaper to find.
143const DEQUEUE_POP_SCRIPT: &str = r#"
144local prefix = ARGV[1]
145local now = ARGV[2]
146for i = 1, #KEYS do
147    while true do
148        local popped = redis.call('ZPOPMIN', KEYS[i], 1)
149        if not popped or not popped[1] then
150            break
151        end
152        local job_id = popped[1]
153        local job_json = redis.call('GET', prefix .. ':job:' .. job_id)
154        if job_json then
155            redis.call('ZADD', prefix .. ':processing', now, job_id)
156            return {job_id, job_json}
157        end
158        -- Job body expired between enqueue and dequeue: the id is discarded
159        -- (already popped) and we keep draining this same queue.
160    end
161end
162return nil
163"#;
164
165/// Queue configuration.
166#[derive(Debug, Clone)]
167pub struct QueueConfig {
168    /// Redis connection URL
169    pub redis_url: String,
170
171    /// Queue name
172    pub queue_name: String,
173
174    /// Key prefix for Redis keys
175    pub key_prefix: String,
176
177    /// Maximum queue size (0 = unlimited)
178    pub max_size: usize,
179
180    /// Job retention time for completed jobs
181    pub retention_time: Duration,
182}
183
184impl QueueConfig {
185    /// Create a new queue configuration.
186    pub fn new(redis_url: impl Into<String>, queue_name: impl Into<String>) -> Self {
187        let queue_name = queue_name.into();
188        Self {
189            redis_url: redis_url.into(),
190            key_prefix: format!("armature:queue:{}", queue_name),
191            queue_name,
192            max_size: 0,
193            retention_time: Duration::from_secs(86400), // 24 hours
194        }
195    }
196
197    /// Set the key prefix.
198    pub fn with_key_prefix(mut self, prefix: impl Into<String>) -> Self {
199        self.key_prefix = prefix.into();
200        self
201    }
202
203    /// Set the maximum queue size.
204    pub fn with_max_size(mut self, max_size: usize) -> Self {
205        self.max_size = max_size;
206        self
207    }
208
209    /// Set the retention time for completed jobs.
210    pub fn with_retention_time(mut self, retention_time: Duration) -> Self {
211        self.retention_time = retention_time;
212        self
213    }
214
215    /// Build Redis key.
216    fn key(&self, suffix: &str) -> String {
217        format!("{}:{}", self.key_prefix, suffix)
218    }
219
220    /// TTL to store a job body under, given how long from now the job is
221    /// scheduled to run.
222    ///
223    /// A flat `retention_time` TTL is wrong for scheduled jobs: promotion only
224    /// happens if the body still exists, so any `enqueue_at`/`enqueue_in`
225    /// further out than the retention window (24h by default) would silently
226    /// expire before coming due and never run. Scheduled jobs therefore get the
227    /// wait itself *plus* the full retention window, so `retention_time` keeps
228    /// meaning "how long the terminal record survives after the job ran".
229    fn body_ttl_secs(&self, wait: Duration) -> u64 {
230        self.retention_time.as_secs().saturating_add(wait.as_secs())
231    }
232}
233
234/// How long from now until `scheduled_at`, clamped at zero for past times.
235fn wait_until(scheduled_at: Option<DateTime<Utc>>) -> Duration {
236    scheduled_at
237        .map(|at| Duration::from_secs((at - Utc::now()).num_seconds().max(0) as u64))
238        .unwrap_or(Duration::ZERO)
239}
240
241/// Job queue backed by Redis.
242#[derive(Clone)]
243pub struct Queue {
244    connection: ConnectionManager,
245    config: QueueConfig,
246}
247
248impl Queue {
249    /// Create a new queue.
250    pub async fn new(
251        redis_url: impl Into<String>,
252        queue_name: impl Into<String>,
253    ) -> QueueResult<Self> {
254        let config = QueueConfig::new(redis_url, queue_name);
255        Self::with_config(config).await
256    }
257
258    /// Create a queue with custom configuration.
259    pub async fn with_config(config: QueueConfig) -> QueueResult<Self> {
260        info!("Initializing job queue: {}", config.queue_name);
261        debug!(
262            "Queue config - prefix: {}, max_size: {}",
263            config.key_prefix, config.max_size
264        );
265
266        let client = Client::open(config.redis_url.as_str())
267            .map_err(|e| QueueError::Config(e.to_string()))?;
268
269        let connection = ConnectionManager::new(client).await?;
270
271        info!("Job queue '{}' ready", config.queue_name);
272        Ok(Self { connection, config })
273    }
274
275    /// Enqueue a job.
276    pub async fn enqueue(&self, job_type: impl Into<String>, data: JobData) -> QueueResult<JobId> {
277        let job_type = job_type.into();
278        debug!(
279            "Enqueueing job: {} on queue '{}'",
280            job_type, self.config.queue_name
281        );
282        let job = Job::new(&self.config.queue_name, &job_type, data);
283        self.enqueue_job(job).await
284    }
285
286    /// Enqueue a job to run after a delay.
287    ///
288    /// Convenience wrapper over [`Job::schedule_after`] + [`enqueue_job`]: the
289    /// job lands in the delayed set and is promoted once due.
290    ///
291    /// [`enqueue_job`]: Self::enqueue_job
292    pub async fn enqueue_in(
293        &self,
294        delay: chrono::Duration,
295        job_type: impl Into<String>,
296        data: JobData,
297    ) -> QueueResult<JobId> {
298        let job = Job::new(&self.config.queue_name, job_type, data).schedule_after(delay);
299        self.enqueue_job(job).await
300    }
301
302    /// Enqueue a job to run at a specific time.
303    ///
304    /// Convenience wrapper over [`Job::schedule_at`] + [`enqueue_job`]: the job
305    /// lands in the delayed set and is promoted once its scheduled time passes.
306    ///
307    /// [`enqueue_job`]: Self::enqueue_job
308    pub async fn enqueue_at(
309        &self,
310        when: DateTime<Utc>,
311        job_type: impl Into<String>,
312        data: JobData,
313    ) -> QueueResult<JobId> {
314        let job = Job::new(&self.config.queue_name, job_type, data).schedule_at(when);
315        self.enqueue_job(job).await
316    }
317
318    /// Enqueue a job with options.
319    pub async fn enqueue_job(&self, job: Job) -> QueueResult<JobId> {
320        // Check queue size limit. The cap counts every job occupying the
321        // queue -- pending (all priorities), delayed/scheduled, and in-flight
322        // `processing` -- not just the ready `pending:*` sets, so scheduled and
323        // in-flight jobs cannot silently push the queue past `max_size`.
324        if self.config.max_size > 0 {
325            let size = self.backlog_size().await?;
326            if size >= self.config.max_size {
327                return Err(QueueError::QueueFull);
328            }
329        }
330
331        let job_id = job.id;
332        let mut conn = self.connection.clone();
333
334        // Serialize job
335        let job_json =
336            serde_json::to_string(&job).map_err(|e| QueueError::Serialization(e.to_string()))?;
337
338        // Store job data
339        let job_key = self.config.key(&format!("job:{}", job_id));
340        let _: () = conn
341            .set_ex(
342                &job_key,
343                job_json,
344                self.config.body_ttl_secs(wait_until(job.scheduled_at)),
345            )
346            .await?;
347
348        // Add to appropriate queue based on priority and schedule
349        if job.is_ready() {
350            let queue_key = self.priority_queue_key(job.priority);
351            let score = -(job.priority as i64); // Negative for high priority first
352            let _: () = conn.zadd(&queue_key, job_id.to_string(), score).await?;
353        } else {
354            // Scheduled job
355            let delayed_key = self.config.key("delayed");
356            let score = job.scheduled_at.unwrap().timestamp();
357            let _: () = conn.zadd(&delayed_key, job_id.to_string(), score).await?;
358        }
359
360        Ok(job_id)
361    }
362
363    /// Dequeue the next job.
364    pub async fn dequeue(&self) -> QueueResult<Option<Job>> {
365        self.move_delayed_jobs().await?;
366
367        let mut conn = self.connection.clone();
368
369        // A single Lua pop replaces the previous four sequential ZPOPMIN
370        // round-trips PLUS the separate client-side GET: it scans the
371        // priority queues high-to-low server-side, atomically pops the first
372        // available job id, records the claim in `processing`, and fetches its
373        // job JSON in the same round-trip, internally re-draining a queue if a
374        // popped id's job body has expired (mirroring the old client-side retry
375        // loop with zero extra round-trips).
376        let script = redis::Script::new(DEQUEUE_POP_SCRIPT);
377        let popped: Option<(String, String)> = script
378            .key(self.priority_queue_key(JobPriority::Critical))
379            .key(self.priority_queue_key(JobPriority::High))
380            .key(self.priority_queue_key(JobPriority::Normal))
381            .key(self.priority_queue_key(JobPriority::Low))
382            .arg(&self.config.key_prefix)
383            .arg(Utc::now().timestamp())
384            .invoke_async(&mut conn)
385            .await?;
386
387        let Some((job_id_str, job_json)) = popped else {
388            return Ok(None);
389        };
390        // Ids are always UUIDs written by `enqueue_job`; a parse failure here
391        // would indicate corrupted queue data, not a normal empty-queue case.
392        let job_id = job_id_str
393            .parse::<JobId>()
394            .map_err(|e| QueueError::Deserialization(e.to_string()))?;
395        let mut job: Job = serde_json::from_str(&job_json)
396            .map_err(|e| QueueError::Deserialization(e.to_string()))?;
397
398        job.start_processing();
399        let job_key = self.config.key(&format!("job:{}", job_id));
400        let updated_json =
401            serde_json::to_string(&job).map_err(|e| QueueError::Serialization(e.to_string()))?;
402
403        // The `processing` claim was already recorded atomically by the pop
404        // script; only the mutated job body has to be written back here. If
405        // this write fails (or the process dies before it), the claim is
406        // already visible to `reclaim_stale`, so the job is recoverable.
407        let _: () = conn
408            .set_ex(&job_key, updated_json, self.config.retention_time.as_secs())
409            .await?;
410
411        Ok(Some(job))
412    }
413
414    /// Complete a job.
415    pub async fn complete(&self, job_id: JobId) -> QueueResult<()> {
416        // `remove_from_processing` runs unconditionally, even when the job
417        // body itself is gone (TTL-expired mid-flight between dequeue and
418        // complete): otherwise the id would be orphaned forever in
419        // `processing` while this fn still returned `Ok(())`.
420        if let Some(mut job) = self.get_job(job_id).await? {
421            job.complete();
422            let job_key = self.config.key(&format!("job:{}", job_id));
423            let processing_key = self.config.key("processing");
424            let job_json = serde_json::to_string(&job)
425                .map_err(|e| QueueError::Serialization(e.to_string()))?;
426
427            let mut conn = self.connection.clone();
428            let _: () = redis::pipe()
429                .set_ex(&job_key, job_json, self.config.retention_time.as_secs())
430                .ignore()
431                .zrem(&processing_key, job_id.to_string())
432                .ignore()
433                .query_async(&mut conn)
434                .await?;
435        } else {
436            self.remove_from_processing(job_id).await?;
437        }
438        Ok(())
439    }
440
441    /// Fail a job.
442    pub async fn fail(&self, job_id: JobId, error: String) -> QueueResult<()> {
443        // As in `complete`, `remove_from_processing` must run even when the
444        // job body has TTL-expired mid-flight, so the id is never left
445        // orphaned in `processing`.
446        if let Some(mut job) = self.get_job(job_id).await? {
447            job.fail(error);
448
449            let job_key = self.config.key(&format!("job:{}", job_id));
450            let processing_key = self.config.key("processing");
451            let job_json = serde_json::to_string(&job)
452                .map_err(|e| QueueError::Serialization(e.to_string()))?;
453            let mut conn = self.connection.clone();
454
455            if job.status.state == JobState::Failed && job.can_retry() {
456                // Retry with backoff
457                let retry_at = Utc::now() + job.backoff_delay();
458                job.scheduled_at = Some(retry_at);
459                let job_json = serde_json::to_string(&job)
460                    .map_err(|e| QueueError::Serialization(e.to_string()))?;
461
462                // Add to delayed queue. The body TTL has to cover the backoff
463                // wait as well as retention, or a long backoff would expire the
464                // job before it comes due and it would never be retried.
465                let delayed_key = self.config.key("delayed");
466                let _: () = redis::pipe()
467                    .set_ex(
468                        &job_key,
469                        job_json,
470                        self.config.body_ttl_secs(wait_until(Some(retry_at))),
471                    )
472                    .ignore()
473                    .zadd(&delayed_key, job_id.to_string(), retry_at.timestamp())
474                    .ignore()
475                    .zrem(&processing_key, job_id.to_string())
476                    .ignore()
477                    .query_async(&mut conn)
478                    .await?;
479            } else {
480                // Move to dead letter queue
481                let dead_key = self.config.key("dead");
482                let _: () = redis::pipe()
483                    .set_ex(&job_key, job_json, self.config.retention_time.as_secs())
484                    .ignore()
485                    .zadd(&dead_key, job_id.to_string(), Utc::now().timestamp())
486                    .ignore()
487                    .zrem(&processing_key, job_id.to_string())
488                    .ignore()
489                    .query_async(&mut conn)
490                    .await?;
491            }
492        } else {
493            self.remove_from_processing(job_id).await?;
494        }
495        Ok(())
496    }
497
498    /// Return a dequeued-but-unprocessed job to its pending priority queue.
499    ///
500    /// A job popped by [`dequeue`] has already been marked processing (attempt
501    /// incremented) and placed in the `processing` set. When the caller cannot
502    /// run it after all (e.g. a batch consumer that dequeued the wrong job
503    /// type), this puts it back on its priority queue and removes it from
504    /// `processing`, undoing the `start_processing` bookkeeping so the requeue
505    /// does not burn a retry attempt. Without this the job would be orphaned in
506    /// `processing` forever (data loss).
507    ///
508    /// [`dequeue`]: Self::dequeue
509    pub async fn requeue(&self, job: &Job) -> QueueResult<()> {
510        let mut job = job.clone();
511
512        // Undo the `start_processing` side effects so the job looks untouched.
513        job.status = JobStatus::pending();
514        job.started_at = None;
515        job.attempts = job.attempts.saturating_sub(1);
516        self.save_job(&job).await?;
517
518        let mut conn = self.connection.clone();
519        let queue_key = self.priority_queue_key(job.priority);
520        let score = -(job.priority as i64);
521        let _: () = conn.zadd(&queue_key, job.id.to_string(), score).await?;
522
523        self.remove_from_processing(job.id).await?;
524        Ok(())
525    }
526
527    /// Get a job by ID.
528    pub async fn get_job(&self, job_id: JobId) -> QueueResult<Option<Job>> {
529        let mut conn = self.connection.clone();
530        let job_key = self.config.key(&format!("job:{}", job_id));
531
532        let job_json: Option<String> = conn.get(&job_key).await?;
533
534        if let Some(json) = job_json {
535            let job: Job = serde_json::from_str(&json)
536                .map_err(|e| QueueError::Deserialization(e.to_string()))?;
537            Ok(Some(job))
538        } else {
539            Ok(None)
540        }
541    }
542
543    /// Save a job.
544    async fn save_job(&self, job: &Job) -> QueueResult<()> {
545        let mut conn = self.connection.clone();
546        let job_key = self.config.key(&format!("job:{}", job.id));
547        let job_json =
548            serde_json::to_string(job).map_err(|e| QueueError::Serialization(e.to_string()))?;
549
550        let _: () = conn
551            .set_ex(&job_key, job_json, self.config.retention_time.as_secs())
552            .await?;
553        Ok(())
554    }
555
556    /// Get queue size.
557    pub async fn size(&self) -> QueueResult<usize> {
558        let mut conn = self.connection.clone();
559
560        // Pipeline the four ZCARDs into one round-trip instead of issuing them
561        // sequentially.
562        let mut pipe = redis::pipe();
563        for priority in [
564            JobPriority::Critical,
565            JobPriority::High,
566            JobPriority::Normal,
567            JobPriority::Low,
568        ] {
569            pipe.zcard(self.priority_queue_key(priority));
570        }
571
572        let counts: Vec<usize> = pipe.query_async(&mut conn).await?;
573        Ok(counts.iter().sum())
574    }
575
576    /// Total number of jobs occupying the queue, for `max_size` enforcement.
577    ///
578    /// Unlike [`size`], which counts only ready `pending:*` jobs, this also
579    /// counts delayed/scheduled jobs and in-flight `processing` jobs -- every
580    /// job that holds a slot against the configured cap. Pipelined into one
581    /// round-trip.
582    ///
583    /// [`size`]: Self::size
584    pub async fn backlog_size(&self) -> QueueResult<usize> {
585        let mut conn = self.connection.clone();
586
587        let mut pipe = redis::pipe();
588        for priority in [
589            JobPriority::Critical,
590            JobPriority::High,
591            JobPriority::Normal,
592            JobPriority::Low,
593        ] {
594            pipe.zcard(self.priority_queue_key(priority));
595        }
596        pipe.zcard(self.config.key("delayed"));
597        pipe.zcard(self.config.key("processing"));
598
599        let counts: Vec<usize> = pipe.query_async(&mut conn).await?;
600        Ok(counts.iter().sum())
601    }
602
603    /// Number of jobs currently in the in-flight `processing` set.
604    pub async fn processing_len(&self) -> QueueResult<usize> {
605        let mut conn = self.connection.clone();
606        let processing_key = self.config.key("processing");
607        let count: usize = conn.zcard(&processing_key).await?;
608        Ok(count)
609    }
610
611    /// Move delayed jobs to ready queue.
612    ///
613    /// Runs entirely server-side via a single atomic Lua script: the previous
614    /// implementation was an N+1 (one ZRANGEBYSCORE plus a GET + ZREM + ZADD
615    /// per due job) and could double-promote a job when two workers dequeued
616    /// concurrently. The script promotes all due jobs in one round-trip with
617    /// zero per-job client traffic and no double-promotion race.
618    async fn move_delayed_jobs(&self) -> QueueResult<()> {
619        let mut conn = self.connection.clone();
620        let delayed_key = self.config.key("delayed");
621        let now = Utc::now().timestamp();
622
623        // Cheap O(log N) guard on the hot dequeue path: peek the earliest
624        // delayed job's score and skip the promotion round-trip entirely unless
625        // something is actually due. Previously the full ZRANGEBYSCORE script
626        // ran on every single `dequeue()` even when the delayed set was empty
627        // or entirely in the future.
628        let earliest: Vec<(String, i64)> = conn.zrange_withscores(&delayed_key, 0, 0).await?;
629        match earliest.first() {
630            Some((_, score)) if *score <= now => {}
631            _ => return Ok(()),
632        }
633
634        let script = redis::Script::new(&MOVE_DELAYED_SCRIPT);
635        let (_promoted, dropped): (i64, i64) = script
636            .key(&delayed_key)
637            .arg(&self.config.key_prefix)
638            .arg(now)
639            .invoke_async(&mut conn)
640            .await?;
641
642        if dropped > 0 {
643            warn!(
644                "Dropped {} delayed job(s) from queue '{}' whose body expired before their scheduled time",
645                dropped, self.config.queue_name
646            );
647        }
648
649        Ok(())
650    }
651
652    /// Return jobs whose in-flight claim is older than `visibility_timeout` to
653    /// their pending priority queues, and report how many were reclaimed.
654    ///
655    /// `dequeue` records a claim timestamp in the `processing` set, but nothing
656    /// else ever reads it back: if a worker crashes, is SIGKILLed, or its
657    /// handler task panics, its job is in no pending queue and no retry path
658    /// will ever pick it up. This is the reaper that closes that hole, and
659    /// [`Worker::start`] runs it periodically in the background.
660    ///
661    /// `visibility_timeout` must exceed the longest a job may legitimately stay
662    /// in flight (i.e. at least `WorkerConfig::job_timeout`), otherwise a job
663    /// that is merely slow will be re-filed and run twice. Handlers should be
664    /// idempotent regardless, since a crash after the handler's side effects
665    /// but before `complete()` is indistinguishable from a crash before them.
666    ///
667    /// Reclaimed jobs re-enter the queue with their `attempts` counter as the
668    /// crashed worker left it, so a job that reliably kills its worker still
669    /// exhausts `max_attempts` and lands in the dead-letter set rather than
670    /// looping forever.
671    ///
672    /// [`Worker::start`]: crate::Worker::start
673    pub async fn reclaim_stale(&self, visibility_timeout: Duration) -> QueueResult<usize> {
674        let mut conn = self.connection.clone();
675        let processing_key = self.config.key("processing");
676        let cutoff = Utc::now().timestamp() - visibility_timeout.as_secs() as i64;
677
678        let script = redis::Script::new(&RECLAIM_STALE_SCRIPT);
679        let (reclaimed, dropped): (i64, i64) = script
680            .key(&processing_key)
681            .arg(&self.config.key_prefix)
682            .arg(cutoff)
683            .invoke_async(&mut conn)
684            .await?;
685
686        if reclaimed > 0 {
687            warn!(
688                "Reclaimed {} stale in-flight job(s) on queue '{}' (claim older than {:?})",
689                reclaimed, self.config.queue_name, visibility_timeout
690            );
691        }
692        if dropped > 0 {
693            warn!(
694                "Dropped {} stale in-flight job(s) on queue '{}' whose body had already expired",
695                dropped, self.config.queue_name
696            );
697        }
698
699        Ok(reclaimed as usize)
700    }
701
702    /// Remove job from processing set.
703    async fn remove_from_processing(&self, job_id: JobId) -> QueueResult<()> {
704        let mut conn = self.connection.clone();
705        let processing_key = self.config.key("processing");
706        let _: () = conn.zrem(&processing_key, job_id.to_string()).await?;
707        Ok(())
708    }
709
710    /// Get the priority queue key.
711    fn priority_queue_key(&self, priority: JobPriority) -> String {
712        self.config
713            .key(&format!("pending:{:?}", priority).to_lowercase())
714    }
715
716    /// Clear all jobs from the queue.
717    pub async fn clear(&self) -> QueueResult<()> {
718        let mut conn = self.connection.clone();
719        let pattern = format!("{}:*", self.config.key_prefix);
720
721        // Cursored SCAN + UNLINK instead of the blocking KEYS + DEL, so a large
722        // queue does not stall the Redis event loop while being cleared.
723        let mut cursor: u64 = 0;
724        loop {
725            let (next, keys): (u64, Vec<String>) = redis::cmd("SCAN")
726                .arg(cursor)
727                .arg("MATCH")
728                .arg(&pattern)
729                .arg("COUNT")
730                .arg(SCAN_COUNT)
731                .query_async(&mut conn)
732                .await?;
733
734            if !keys.is_empty() {
735                let _: () = redis::cmd("UNLINK")
736                    .arg(&keys)
737                    .query_async(&mut conn)
738                    .await?;
739            }
740
741            cursor = next;
742            if cursor == 0 {
743                break;
744            }
745        }
746
747        Ok(())
748    }
749}
750
751#[cfg(test)]
752mod tests {
753    use super::*;
754
755    #[test]
756    fn test_queue_config() {
757        let config = QueueConfig::new("redis://localhost:6379", "test");
758        assert_eq!(config.queue_name, "test");
759        assert!(config.key_prefix.contains("test"));
760    }
761
762    /// The Lua scripts hard-code the priority -> (queue name, score) mapping so
763    /// they can re-file jobs without a client round-trip. If the Rust
764    /// `JobPriority` scoring or the `pending:<name>` key layout ever changes,
765    /// this test fails, flagging the now-stale script.
766    #[test]
767    fn test_lua_priority_mapping_matches_rust() {
768        for priority in [
769            JobPriority::Low,
770            JobPriority::Normal,
771            JobPriority::High,
772            JobPriority::Critical,
773        ] {
774            // Rust-side scoring used by enqueue/dequeue.
775            let rust_score = -(priority as i64);
776            // Serde/Debug variant name the job JSON carries (e.g. "Normal").
777            let variant = format!("{priority:?}");
778            // Lowercased suffix used by `priority_queue_key`.
779            let name = variant.to_lowercase();
780
781            let expected = format!("'{variant}' then pname = '{name}'; pscore = {rust_score}");
782            assert!(
783                PRIORITY_LUA.contains(&expected),
784                "script missing mapping for {priority:?}: expected `{expected}`"
785            );
786        }
787    }
788
789    /// Both re-filing scripts must actually pull in the shared helper, or the
790    /// mapping test above would pin a fragment nothing uses.
791    #[test]
792    fn test_refiling_scripts_share_priority_helper() {
793        for script in [&*MOVE_DELAYED_SCRIPT, &*RECLAIM_STALE_SCRIPT] {
794            assert!(script.contains("local function priority_of"));
795            assert!(script.contains("priority_of(job_json)"));
796        }
797    }
798
799    /// The delayed set must not accumulate ids whose body has expired: they can
800    /// never be promoted, are rescanned by every future pass, and inflate
801    /// `backlog_size()` against `max_size` forever.
802    #[test]
803    fn test_move_delayed_drops_bodyless_ids() {
804        assert!(MOVE_DELAYED_BODY.contains("elseif redis.call('ZREM', delayed_key, job_id) == 1"));
805    }
806
807    /// The reaper must re-file stale claims, not merely count them.
808    #[test]
809    fn test_reclaim_script_refiles_to_pending() {
810        assert!(RECLAIM_STALE_BODY.contains("ZRANGEBYSCORE"));
811        assert!(RECLAIM_STALE_BODY.contains("ZADD', prefix .. ':pending:' .. pname"));
812        assert!(RECLAIM_STALE_BODY.contains("ZREM', processing_key"));
813    }
814
815    /// Pop and claim have to happen in the same script: a crash in between
816    /// would leave the job in no queue and no processing set at all, with
817    /// nothing anywhere for the reaper to find.
818    #[test]
819    fn test_dequeue_script_claims_atomically() {
820        assert!(DEQUEUE_POP_SCRIPT.contains("ZADD', prefix .. ':processing', now, job_id"));
821    }
822
823    #[test]
824    fn test_priority_queue_key_layout() {
825        // Confirms the key layout the Lua script reconstructs (`prefix:pending:<name>`).
826        let config = QueueConfig::new("redis://localhost:6379", "jobs");
827        for (priority, name) in [
828            (JobPriority::Low, "low"),
829            (JobPriority::Normal, "normal"),
830            (JobPriority::High, "high"),
831            (JobPriority::Critical, "critical"),
832        ] {
833            let key = config.key(&format!("pending:{priority:?}").to_lowercase());
834            assert_eq!(key, format!("{}:pending:{}", config.key_prefix, name));
835        }
836    }
837
838    #[test]
839    fn test_dequeue_script_scans_all_keys() {
840        // The pop script must consider every priority queue passed as KEYS.
841        assert!(DEQUEUE_POP_SCRIPT.contains("for i = 1, #KEYS do"));
842        assert!(DEQUEUE_POP_SCRIPT.contains("ZPOPMIN"));
843    }
844
845    // Backend-dependent: requires a live Redis at redis://localhost:6379.
846    #[tokio::test]
847    #[ignore = "requires a running Redis instance"]
848    async fn test_move_delayed_promotes_due_jobs() {
849        use crate::job::Job;
850
851        let queue = Queue::new("redis://localhost:6379", "test_move_delayed")
852            .await
853            .unwrap();
854        queue.clear().await.unwrap();
855
856        // Enqueue a job scheduled in the past -> lands in the delayed set.
857        let past = Utc::now() - chrono::Duration::seconds(30);
858        let job = Job::new("test_move_delayed", "task", serde_json::json!({}))
859            .with_priority(JobPriority::High)
860            .schedule_at(past);
861        queue.enqueue_job(job).await.unwrap();
862
863        // Delayed jobs are not counted in size() until promoted.
864        assert_eq!(queue.size().await.unwrap(), 0);
865
866        // Dequeue triggers move_delayed_jobs; the due job should come back out.
867        let dequeued = queue.dequeue().await.unwrap();
868        assert!(dequeued.is_some());
869        assert_eq!(dequeued.unwrap().priority, JobPriority::High);
870
871        queue.clear().await.unwrap();
872    }
873
874    /// A job scheduled beyond `retention_time` must get a body TTL that
875    /// outlives its own schedule, otherwise it expires before it comes due and
876    /// silently never runs.
877    #[test]
878    fn test_body_ttl_covers_long_horizon_schedule() {
879        let config = QueueConfig::new("redis://localhost:6379", "test"); // 24h retention
880        let retention = config.retention_time.as_secs();
881
882        // Immediate job: plain retention.
883        assert_eq!(config.body_ttl_secs(Duration::ZERO), retention);
884
885        // Scheduled 40 days out: TTL must exceed the wait, not fall inside it.
886        let wait = Duration::from_secs(40 * 86_400);
887        let ttl = config.body_ttl_secs(wait);
888        assert!(
889            ttl > wait.as_secs(),
890            "body would expire {} s before its scheduled time",
891            wait.as_secs() - ttl
892        );
893        assert_eq!(ttl, wait.as_secs() + retention);
894    }
895
896    #[test]
897    fn test_wait_until_clamps_past_times() {
898        // Past-scheduled jobs are already due; they must not get a negative
899        // (i.e. wrapping) wait.
900        let past = Utc::now() - chrono::Duration::days(3);
901        assert_eq!(wait_until(Some(past)), Duration::ZERO);
902        assert_eq!(wait_until(None), Duration::ZERO);
903        assert!(wait_until(Some(Utc::now() + chrono::Duration::hours(2))) > Duration::from_secs(0));
904    }
905
906    // Backend-dependent: requires a live Redis at redis://localhost:6379.
907    #[tokio::test]
908    #[ignore = "requires a running Redis instance"]
909    async fn test_long_horizon_scheduled_job_body_survives() {
910        use crate::job::Job;
911
912        let queue = Queue::new("redis://localhost:6379", "test_long_horizon")
913            .await
914            .unwrap();
915        queue.clear().await.unwrap();
916
917        // Scheduled well beyond the 24h default retention time.
918        let far_future = Utc::now() + chrono::Duration::days(40);
919        let job =
920            Job::new("test_long_horizon", "task", serde_json::json!({})).schedule_at(far_future);
921        let job_id = queue.enqueue_job(job).await.unwrap();
922
923        // The body must still be readable and its TTL must outlast the wait,
924        // or the promotion script will never find anything to promote.
925        assert!(queue.get_job(job_id).await.unwrap().is_some());
926
927        let mut conn = queue.connection.clone();
928        let ttl: i64 = conn
929            .ttl(queue.config.key(&format!("job:{job_id}")))
930            .await
931            .unwrap();
932        assert!(
933            ttl > 40 * 86_400,
934            "body TTL {ttl}s expires before the job's scheduled time"
935        );
936
937        queue.clear().await.unwrap();
938    }
939
940    // Backend-dependent: requires a live Redis at redis://localhost:6379.
941    #[tokio::test]
942    #[ignore = "requires a running Redis instance"]
943    async fn test_reclaim_stale_returns_orphaned_job() {
944        use crate::job::Job;
945
946        let queue = Queue::new("redis://localhost:6379", "test_reclaim")
947            .await
948            .unwrap();
949        queue.clear().await.unwrap();
950
951        let job = Job::new("test_reclaim", "task", serde_json::json!({}))
952            .with_priority(JobPriority::High);
953        let job_id = queue.enqueue_job(job).await.unwrap();
954
955        // Simulate a worker that dequeued the job and then died: the claim is
956        // in `processing` and the job is in no pending queue.
957        let dequeued = queue.dequeue().await.unwrap().unwrap();
958        assert_eq!(dequeued.id, job_id);
959        assert_eq!(queue.size().await.unwrap(), 0);
960        assert_eq!(queue.processing_len().await.unwrap(), 1);
961
962        // A timeout longer than the claim's age reclaims nothing...
963        assert_eq!(
964            queue
965                .reclaim_stale(Duration::from_secs(3600))
966                .await
967                .unwrap(),
968            0
969        );
970        assert_eq!(queue.processing_len().await.unwrap(), 1);
971
972        // ...but once the claim is considered stale the job goes back to its
973        // own priority queue and becomes dequeueable again.
974        assert_eq!(queue.reclaim_stale(Duration::ZERO).await.unwrap(), 1);
975        assert_eq!(queue.processing_len().await.unwrap(), 0);
976        assert_eq!(queue.size().await.unwrap(), 1);
977
978        let again = queue.dequeue().await.unwrap().unwrap();
979        assert_eq!(again.id, job_id);
980        assert_eq!(again.priority, JobPriority::High);
981
982        queue.clear().await.unwrap();
983    }
984
985    #[test]
986    fn test_priority_queue_key() {
987        let config = QueueConfig::new("redis://localhost:6379", "test");
988        assert!(config.key("pending:high").contains("high"));
989    }
990
991    #[test]
992    fn test_queue_config_with_custom_prefix() {
993        let config = QueueConfig::new("redis://localhost:6379", "myqueue").with_key_prefix("app");
994        assert!(config.key_prefix.contains("app"));
995    }
996
997    #[test]
998    fn test_queue_config_default_retention() {
999        let config = QueueConfig::new("redis://localhost:6379", "test");
1000        assert_eq!(config.retention_time, Duration::from_secs(86400)); // 1 day
1001    }
1002
1003    #[test]
1004    fn test_queue_config_custom_retention() {
1005        let retention = Duration::from_secs(3600);
1006        let config =
1007            QueueConfig::new("redis://localhost:6379", "test").with_retention_time(retention);
1008        assert_eq!(config.retention_time, retention);
1009    }
1010
1011    #[test]
1012    fn test_queue_config_default_max_size() {
1013        let config = QueueConfig::new("redis://localhost:6379", "test");
1014        assert_eq!(config.max_size, 0); // 0 means unlimited
1015    }
1016
1017    #[test]
1018    fn test_queue_config_custom_max_size() {
1019        let config = QueueConfig::new("redis://localhost:6379", "test").with_max_size(1000);
1020        assert_eq!(config.max_size, 1000);
1021    }
1022
1023    #[test]
1024    fn test_queue_key_generation() {
1025        let config = QueueConfig::new("redis://localhost:6379", "jobs");
1026
1027        let pending_key = config.key("pending:normal");
1028        let processing_key = config.key("processing");
1029        let completed_key = config.key("completed");
1030
1031        assert!(pending_key.contains("jobs"));
1032        assert!(processing_key.contains("jobs"));
1033        assert!(completed_key.contains("jobs"));
1034    }
1035
1036    #[test]
1037    fn test_queue_config_clone() {
1038        let config1 = QueueConfig::new("redis://localhost:6379", "test");
1039        let config2 = config1.clone();
1040
1041        assert_eq!(config1.queue_name, config2.queue_name);
1042        assert_eq!(config1.redis_url, config2.redis_url);
1043    }
1044
1045    #[test]
1046    fn test_queue_config_different_queues() {
1047        let config1 = QueueConfig::new("redis://localhost:6379", "queue1");
1048        let config2 = QueueConfig::new("redis://localhost:6379", "queue2");
1049
1050        assert_ne!(config1.key_prefix, config2.key_prefix);
1051    }
1052
1053    #[test]
1054    fn test_queue_config_key_consistency() {
1055        let config = QueueConfig::new("redis://localhost:6379", "test");
1056
1057        let key1 = config.key("pending");
1058        let key2 = config.key("pending");
1059
1060        assert_eq!(key1, key2);
1061    }
1062
1063    #[test]
1064    fn test_queue_config_builder_pattern() {
1065        let config = QueueConfig::new("redis://localhost:6379", "test")
1066            .with_key_prefix("app")
1067            .with_retention_time(Duration::from_secs(7200))
1068            .with_max_size(500);
1069
1070        assert!(config.key_prefix.contains("app"));
1071        assert_eq!(config.retention_time, Duration::from_secs(7200));
1072        assert_eq!(config.max_size, 500);
1073    }
1074
1075    #[test]
1076    fn test_queue_config_redis_url() {
1077        let url = "redis://user:pass@host:6380/2";
1078        let config = QueueConfig::new(url, "test");
1079        assert_eq!(config.redis_url, url);
1080    }
1081
1082    #[test]
1083    fn test_queue_config_key_with_empty_suffix() {
1084        let config = QueueConfig::new("redis://localhost:6379", "test");
1085        let key = config.key("");
1086        assert!(key.contains("test"));
1087    }
1088
1089    #[test]
1090    fn test_queue_config_key_with_special_characters() {
1091        let config = QueueConfig::new("redis://localhost:6379", "test");
1092        let key = config.key("pending:high:priority");
1093        assert!(key.contains("pending:high:priority"));
1094    }
1095
1096    #[test]
1097    fn test_queue_config_multiple_prefixes() {
1098        let config1 =
1099            QueueConfig::new("redis://localhost:6379", "app1").with_key_prefix("production");
1100        let config2 =
1101            QueueConfig::new("redis://localhost:6379", "app2").with_key_prefix("development");
1102
1103        let key1 = config1.key("jobs");
1104        let key2 = config2.key("jobs");
1105
1106        assert_ne!(key1, key2);
1107    }
1108
1109    #[test]
1110    fn test_queue_config_unlimited_max_size() {
1111        let config = QueueConfig::new("redis://localhost:6379", "test").with_max_size(0);
1112        assert_eq!(config.max_size, 0);
1113    }
1114
1115    #[test]
1116    fn test_queue_config_large_retention() {
1117        let week = Duration::from_secs(7 * 24 * 3600);
1118        let config = QueueConfig::new("redis://localhost:6379", "test").with_retention_time(week);
1119        assert_eq!(config.retention_time, week);
1120    }
1121}