Skip to main content

armature_queue/
worker.rs

1//! Worker implementation for processing jobs.
2
3use crate::error::{QueueError, QueueResult};
4use crate::job::{Job, JobId};
5use crate::queue::Queue;
6use armature_log::{debug, error, info, warn};
7use std::collections::HashMap;
8use std::future::Future;
9use std::pin::Pin;
10use std::sync::Arc;
11use std::time::Duration;
12use tokio::sync::RwLock;
13use tokio::task::JoinSet;
14
15/// Job handler function type.
16pub type JobHandler =
17    Arc<dyn Fn(Job) -> Pin<Box<dyn Future<Output = QueueResult<()>> + Send>> + Send + Sync>;
18
19/// Worker configuration.
20#[derive(Debug, Clone)]
21pub struct WorkerConfig {
22    /// Number of concurrent jobs to process
23    pub concurrency: usize,
24
25    /// Poll interval for checking new jobs
26    pub poll_interval: Duration,
27
28    /// Timeout for job execution
29    pub job_timeout: Duration,
30
31    /// How long an in-flight claim may go unresolved before the reaper assumes
32    /// the worker holding it died and returns the job to its pending queue.
33    ///
34    /// `dequeue` records a claim in the queue's `processing` set; nothing else
35    /// resolves it if the worker crashes, is SIGKILLed, or its handler task
36    /// panics, so without a reaper such a job is stranded in no queue at all
37    /// and is never retried. [`Worker::start`] runs
38    /// [`Queue::reclaim_stale`](crate::Queue::reclaim_stale) with this timeout
39    /// in the background.
40    ///
41    /// Must comfortably exceed `job_timeout` (the default is twice it), or a
42    /// merely slow job is re-filed while still running and executes twice.
43    /// `None` disables reaping entirely — only appropriate when some other
44    /// process reaps the same queue.
45    pub visibility_timeout: Option<Duration>,
46
47    /// Whether to log job execution
48    pub log_execution: bool,
49}
50
51impl Default for WorkerConfig {
52    fn default() -> Self {
53        let job_timeout = Duration::from_secs(300); // 5 minutes
54        Self {
55            concurrency: 10,
56            poll_interval: Duration::from_secs(1),
57            job_timeout,
58            visibility_timeout: Some(job_timeout * 2),
59            log_execution: true,
60        }
61    }
62}
63
64/// Outcome of a graceful (or partially force-aborted) worker shutdown, as
65/// returned by [`Worker::stop_with_timeout`].
66///
67/// The three counts always sum to `concurrency` — the job-processing tasks
68/// spawned by [`Worker::start`]. The stale-claim reaper is cancelled up front
69/// and deliberately excluded, so these numbers describe in-flight *jobs* and
70/// nothing else.
71#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
72pub struct StopOutcome {
73    /// Number of worker tasks that returned normally on their own within the
74    /// grace period.
75    pub gracefully_completed: usize,
76    /// Number of worker tasks that panicked while a job handler was running,
77    /// observed as a `JoinError` while draining during the grace period. Each
78    /// occurrence is also logged at `error` level unconditionally (regardless
79    /// of `WorkerConfig::log_execution`), since the job that task was
80    /// processing is left orphaned "Processing" with no other signal.
81    pub panicked: usize,
82    /// Number of worker tasks still running when the grace period elapsed
83    /// and were therefore forcibly aborted as a last resort.
84    pub force_aborted: usize,
85}
86
87/// Worker for processing jobs from a queue.
88pub struct Worker {
89    queue: Queue,
90    handlers: Arc<RwLock<HashMap<String, JobHandler>>>,
91    config: WorkerConfig,
92    running: Arc<RwLock<bool>>,
93    // A `JoinSet` (rather than `Vec<JoinHandle<_>>`) is required for graceful
94    // shutdown: `stop()` needs to `abort_all()` the *actual* spawned tasks as
95    // a last resort after a grace period, not just stop awaiting them. Wrapping
96    // each `JoinHandle` in a second spawned task and aborting that wrapper
97    // would leave the original worker task running detached.
98    handles: JoinSet<()>,
99    /// The stale-claim reaper, held apart from `handles`.
100    ///
101    /// It is infrastructure, not a job-processing task, so counting it in
102    /// [`StopOutcome`] would tell an operator that one more job drained than
103    /// actually ran — and those counts exist precisely to reason about
104    /// in-flight jobs.
105    reaper: Option<tokio::task::JoinHandle<()>>,
106}
107
108impl Worker {
109    /// Create a new worker.
110    pub fn new(queue: Queue) -> Self {
111        Self::with_config(queue, WorkerConfig::default())
112    }
113
114    /// Create a worker with custom configuration.
115    pub fn with_config(queue: Queue, config: WorkerConfig) -> Self {
116        info!("Creating worker with concurrency: {}", config.concurrency);
117        debug!(
118            "Worker config - poll_interval: {:?}, job_timeout: {:?}",
119            config.poll_interval, config.job_timeout
120        );
121        Self {
122            queue,
123            handlers: Arc::new(RwLock::new(HashMap::new())),
124            config,
125            running: Arc::new(RwLock::new(false)),
126            handles: JoinSet::new(),
127            reaper: None,
128        }
129    }
130
131    /// Register a job handler.
132    ///
133    /// The handler is inserted synchronously before this call returns, so a
134    /// `register_handler(...).await` immediately followed by `start()` never
135    /// races a worker that dequeues a job before the handler is present.
136    ///
137    /// # Examples
138    ///
139    /// ```no_run
140    /// use armature_queue::*;
141    ///
142    /// # async fn example() -> QueueResult<()> {
143    /// let queue = Queue::new("redis://localhost:6379", "default").await?;
144    /// let mut worker = Worker::new(queue);
145    ///
146    /// worker
147    ///     .register_handler("send_email", |job| async move {
148    ///         // Send email logic
149    ///         println!("Sending email: {:?}", job.data);
150    ///         Ok(())
151    ///     })
152    ///     .await;
153    /// # Ok(())
154    /// # }
155    /// ```
156    pub async fn register_handler<F, Fut>(&mut self, job_type: impl Into<String>, handler: F)
157    where
158        F: Fn(Job) -> Fut + Send + Sync + 'static,
159        Fut: Future<Output = QueueResult<()>> + Send + 'static,
160    {
161        let wrapped_handler = Arc::new(
162            move |job: Job| -> Pin<Box<dyn Future<Output = QueueResult<()>> + Send>> {
163                Box::pin(handler(job))
164            },
165        );
166
167        // Insert synchronously: the handler must be present the moment this
168        // call returns, so a subsequent `start()` cannot dequeue a job whose
169        // handler has not been registered yet.
170        self.handlers
171            .write()
172            .await
173            .insert(job_type.into(), wrapped_handler);
174    }
175
176    /// Start the worker.
177    pub async fn start(&mut self) -> QueueResult<()> {
178        let mut running = self.running.write().await;
179        if *running {
180            warn!("Worker already running");
181            return Err(QueueError::WorkerAlreadyRunning);
182        }
183        *running = true;
184        drop(running);
185
186        info!(
187            "Starting worker with {} concurrent processors",
188            self.config.concurrency
189        );
190
191        // Reaper task. `dequeue` records a claim timestamp in the queue's
192        // `processing` set, but a worker that is SIGKILLed (or whose handler
193        // task panics) never resolves its claim: the job sits in no pending
194        // queue, no retry path can see it, and its body eventually TTL-expires.
195        // Nothing else in the system reads that timestamp back, so without this
196        // task the claim is written and never looked at again.
197        //
198        // It is held apart from the worker `JoinSet` so it does not inflate
199        // `StopOutcome`, and it wakes on `poll_interval` (rather than on the
200        // far longer visibility timeout) so the stop flag is observed promptly.
201        if let Some(visibility_timeout) = self.config.visibility_timeout {
202            let queue = self.queue.clone();
203            let running = self.running.clone();
204            let poll_interval = self.config.poll_interval;
205
206            self.reaper = Some(tokio::spawn(async move {
207                while *running.read().await {
208                    // A reap failure is transient (a Redis blip): log and keep
209                    // the loop alive, exactly as the worker tasks do, rather
210                    // than silently leaving the queue without a reaper for the
211                    // rest of the process's life.
212                    if let Err(e) = queue.reclaim_stale(visibility_timeout).await {
213                        warn!("[REAPER] Failed to reclaim stale in-flight jobs: {}", e);
214                    }
215                    tokio::time::sleep(poll_interval).await;
216                }
217            }));
218        }
219
220        // Start worker tasks
221        for i in 0..self.config.concurrency {
222            let queue = self.queue.clone();
223            let handlers = self.handlers.clone();
224            let running = self.running.clone();
225            let poll_interval = self.config.poll_interval;
226            let job_timeout = self.config.job_timeout;
227            let log = self.config.log_execution;
228
229            self.handles.spawn(async move {
230                while *running.read().await {
231                    match queue.dequeue().await {
232                        Ok(Some(job)) => {
233                            let job_id = job.id;
234                            let job_type = job.job_type.clone();
235
236                            if log {
237                                println!(
238                                    "[WORKER-{}] Processing job: {} (type: {})",
239                                    i, job_id, job_type
240                                );
241                            }
242
243                            // Get handler
244                            let handler = {
245                                let handlers = handlers.read().await;
246                                handlers.get(&job_type).cloned()
247                            };
248
249                            if let Some(handler) = handler {
250                                // Execute job with timeout
251                                let result =
252                                    tokio::time::timeout(job_timeout, handler(job.clone())).await;
253
254                                match result {
255                                    Ok(Ok(())) => {
256                                        // Job succeeded
257                                        if let Err(e) = queue.complete(job_id).await {
258                                            eprintln!(
259                                                "[WORKER-{}] Failed to mark job as complete: {}",
260                                                i, e
261                                            );
262                                        } else if log {
263                                            println!(
264                                                "[WORKER-{}] Job {} completed successfully",
265                                                i, job_id
266                                            );
267                                        }
268                                    }
269                                    Ok(Err(e)) => {
270                                        // Job failed
271                                        eprintln!("[WORKER-{}] Job {} failed: {}", i, job_id, e);
272                                        if let Err(err) = queue.fail(job_id, e.to_string()).await {
273                                            eprintln!(
274                                                "[WORKER-{}] Failed to mark job as failed: {}",
275                                                i, err
276                                            );
277                                        }
278                                    }
279                                    Err(_) => {
280                                        // Timeout
281                                        eprintln!("[WORKER-{}] Job {} timed out", i, job_id);
282                                        if let Err(e) =
283                                            queue.fail(job_id, "Job timeout".to_string()).await
284                                        {
285                                            eprintln!(
286                                                "[WORKER-{}] Failed to mark job as failed: {}",
287                                                i, e
288                                            );
289                                        }
290                                    }
291                                }
292                            } else {
293                                eprintln!("[WORKER-{}] No handler for job type: {}", i, job_type);
294                                if let Err(e) = queue
295                                    .fail(job_id, format!("No handler for job type: {}", job_type))
296                                    .await
297                                {
298                                    eprintln!("[WORKER-{}] Failed to mark job as failed: {}", i, e);
299                                }
300                            }
301                        }
302                        Ok(None) => {
303                            // No jobs available, wait before polling again
304                            tokio::time::sleep(poll_interval).await;
305                        }
306                        Err(e) => {
307                            eprintln!("[WORKER-{}] Error dequeuing job: {}", i, e);
308                            tokio::time::sleep(poll_interval).await;
309                        }
310                    }
311                }
312
313                if log {
314                    println!("[WORKER-{}] Stopped", i);
315                }
316            });
317        }
318
319        Ok(())
320    }
321
322    /// Process multiple jobs of the same type in parallel
323    ///
324    /// This method dequeues and processes multiple jobs of the same type
325    /// concurrently, providing significant throughput improvements.
326    ///
327    /// # Performance
328    ///
329    /// - **Sequential:** O(n * job_time)
330    /// - **Parallel:** O(max(job_times))
331    /// - **Speedup:** 3-5x higher throughput
332    ///
333    /// # Examples
334    ///
335    /// ```no_run
336    /// # use armature_queue::*;
337    /// # async fn example(worker: &Worker) -> QueueResult<()> {
338    /// // Process up to 10 image processing jobs in parallel
339    /// let processed = worker.process_batch("process_image", 10).await?;
340    /// println!("Processed {} jobs", processed.len());
341    /// # Ok(())
342    /// # }
343    /// ```
344    pub async fn process_batch(
345        &self,
346        job_type: &str,
347        max_batch_size: usize,
348    ) -> QueueResult<Vec<JobId>> {
349        use tokio::task::JoinSet;
350
351        // Dequeue multiple jobs of the same type
352        let mut jobs = Vec::new();
353        for _ in 0..max_batch_size {
354            match self.queue.dequeue().await? {
355                Some(job) => {
356                    if job.job_type == job_type {
357                        jobs.push(job);
358                    } else {
359                        // Different job type: `dequeue()` already popped this
360                        // job, started_processing it, and put it in the
361                        // `processing` set. Batching stops here, so we must
362                        // re-enqueue it — otherwise it is orphaned in
363                        // `processing` forever (data loss).
364                        self.queue.requeue(&job).await?;
365                        break;
366                    }
367                }
368                None => break,
369            }
370        }
371
372        if jobs.is_empty() {
373            return Ok(Vec::new());
374        }
375
376        // Capture the true batch size before `jobs` is consumed below, so the
377        // completion log can report real succeeded/total counts.
378        let total = jobs.len();
379
380        if self.config.log_execution {
381            println!("[BATCH] Processing {} jobs of type '{}'", total, job_type);
382        }
383
384        // Get handler
385        let handler = {
386            let handlers = self.handlers.read().await;
387            handlers.get(job_type).cloned()
388        };
389
390        let Some(handler) = handler else {
391            return Err(QueueError::NoHandler(job_type.to_string()));
392        };
393
394        // Process all jobs in parallel
395        let mut set = JoinSet::new();
396        for job in jobs {
397            let handler = handler.clone();
398            let queue = self.queue.clone();
399            let job_id = job.id;
400            let log = self.config.log_execution;
401            let timeout = self.config.job_timeout;
402
403            set.spawn(async move {
404                let result = tokio::time::timeout(timeout, handler(job.clone())).await;
405
406                match result {
407                    Ok(Ok(())) => {
408                        // Job succeeded
409                        if let Err(e) = queue.complete(job_id).await {
410                            eprintln!("[BATCH] Failed to mark job {} as complete: {}", job_id, e);
411                        } else if log {
412                            println!("[BATCH] Job {} completed successfully", job_id);
413                        }
414                        Ok(job_id)
415                    }
416                    Ok(Err(e)) => {
417                        // Job failed
418                        eprintln!("[BATCH] Job {} failed: {}", job_id, e);
419                        if let Err(err) = queue.fail(job_id, e.to_string()).await {
420                            eprintln!("[BATCH] Failed to mark job {} as failed: {}", job_id, err);
421                        }
422                        Err(e)
423                    }
424                    Err(_) => {
425                        // Timeout
426                        eprintln!("[BATCH] Job {} timed out", job_id);
427                        if let Err(e) = queue
428                            .fail(job_id, "Job execution timed out".to_string())
429                            .await
430                        {
431                            eprintln!("[BATCH] Failed to mark job {} as failed: {}", job_id, e);
432                        }
433                        Err(QueueError::ExecutionFailed("Timeout".to_string()))
434                    }
435                }
436            });
437        }
438
439        // Collect results
440        let mut processed = Vec::new();
441        while let Some(result) = set.join_next().await {
442            match result {
443                Ok(Ok(job_id)) => processed.push(job_id),
444                Ok(Err(_)) => {} // Error already logged
445                Err(e) => eprintln!("[BATCH] Task join error: {}", e),
446            }
447        }
448
449        if self.config.log_execution {
450            println!(
451                "[BATCH] Batch complete: {}/{} jobs succeeded",
452                processed.len(),
453                total
454            );
455        }
456
457        Ok(processed)
458    }
459
460    /// Register a CPU-intensive handler that runs in blocking thread pool
461    ///
462    /// For CPU-bound operations (image processing, encryption, etc.), use this
463    /// method to avoid blocking the async runtime.
464    ///
465    /// # Examples
466    ///
467    /// ```no_run
468    /// # use armature_queue::*;
469    /// # async fn example(worker: &mut Worker) {
470    /// worker
471    ///     .register_cpu_intensive_handler("resize_image", |job| {
472    ///         // CPU-intensive work here
473    ///         let image_path = job.data["path"].as_str().unwrap();
474    ///         // ... resize image ...
475    ///         Ok(())
476    ///     })
477    ///     .await;
478    /// # }
479    /// ```
480    pub async fn register_cpu_intensive_handler<F>(
481        &mut self,
482        job_type: impl Into<String>,
483        handler: F,
484    ) where
485        F: Fn(Job) -> QueueResult<()> + Send + Sync + 'static,
486    {
487        let handler = Arc::new(handler);
488
489        let wrapped = Arc::new(move |job: Job| {
490            let handler = handler.clone();
491            Box::pin(async move {
492                // Run in blocking thread pool to avoid blocking async runtime
493                tokio::task::spawn_blocking(move || handler(job))
494                    .await
495                    .map_err(|e| QueueError::ExecutionFailed(e.to_string()))?
496            }) as Pin<Box<dyn Future<Output = QueueResult<()>> + Send>>
497        });
498
499        // Insert via `.await`, never `block_on`: this method is documented as
500        // being called from an async context, where `Handle::block_on` panics.
501        self.handlers.write().await.insert(job_type.into(), wrapped);
502    }
503
504    /// Stop the worker gracefully.
505    ///
506    /// This is [`stop_with_timeout`] using `self.config.job_timeout` as the
507    /// grace period. That bound is not arbitrary: each worker task wraps its
508    /// current job's handler invocation in `tokio::time::timeout(job_timeout,
509    /// ...)` (see the loop spawned by [`start`]), so `job_timeout` is already
510    /// the worst-case time a task can be stuck inside a handler before it
511    /// self-times-out the job and loops back to observe the stop flag. Waiting
512    /// that long guarantees every task gets the chance to either finish its
513    /// current job or hit its own internal timeout -- and therefore call
514    /// `queue.complete()`/`queue.fail()` -- before shutdown falls back to
515    /// aborting anything still outstanding.
516    ///
517    /// This wrapper discards the [`StopOutcome`] that [`stop_with_timeout`]
518    /// returns, for backward-compatible callers that only care whether
519    /// shutdown was initiated successfully. Call [`stop_with_timeout`]
520    /// directly if you need to know whether any tasks panicked or had to be
521    /// force-aborted.
522    ///
523    /// [`stop_with_timeout`]: Self::stop_with_timeout
524    /// [`start`]: Self::start
525    pub async fn stop(&mut self) -> QueueResult<()> {
526        self.stop_with_timeout(self.config.job_timeout).await?;
527        Ok(())
528    }
529
530    /// Stop the worker, waiting up to `timeout` for in-flight jobs to finish
531    /// gracefully before forcibly aborting any worker tasks still running.
532    ///
533    /// Setting `running` to `false` only *signals* the spawned tasks to stop --
534    /// each task only observes the flag at the top of its `while
535    /// *running.read().await` loop (see [`start`]), so a task currently inside
536    /// `handler(job.clone()).await` keeps running until that call returns (or
537    /// times out) and it loops back around. This method waits for that to
538    /// happen naturally, up to `timeout`, so the handler gets a chance to reach
539    /// `queue.complete()`/`queue.fail()` instead of being cancelled mid-flight
540    /// and leaving the job orphaned "Processing" in Redis forever. Only once
541    /// `timeout` elapses are any still-running tasks forcibly aborted as a
542    /// last resort -- at that point whatever job they were mid-handler on is
543    /// still orphaned, exactly as the previous unconditional-abort behavior
544    /// left it.
545    ///
546    /// Returns a [`StopOutcome`] reporting how many worker tasks finished
547    /// gracefully, how many panicked mid-handler during the drain (logged at
548    /// `error` level unconditionally when it happens), and how many had to be
549    /// force-aborted after the grace period elapsed (logged at `warn` level
550    /// unconditionally when it happens). A panicked worker task is *not*
551    /// treated the same as a normal completion: a `JoinError` from
552    /// `join_next()` means the task's handler crashed, not that it returned
553    /// `Ok(())`, and the in-flight job it was processing is left orphaned
554    /// "Processing" with no other signal unless this is observed.
555    ///
556    /// [`start`]: Self::start
557    ///
558    /// # Examples
559    ///
560    /// ```no_run
561    /// use armature_queue::*;
562    /// use std::time::Duration;
563    ///
564    /// # async fn example(mut worker: Worker) -> QueueResult<()> {
565    /// // Give in-flight jobs up to 10 seconds to finish before force-killing them.
566    /// let outcome = worker.stop_with_timeout(Duration::from_secs(10)).await?;
567    /// if outcome.panicked > 0 || outcome.force_aborted > 0 {
568    ///     eprintln!("shutdown was not fully graceful: {:?}", outcome);
569    /// }
570    /// # Ok(())
571    /// # }
572    /// ```
573    pub async fn stop_with_timeout(&mut self, timeout: Duration) -> QueueResult<StopOutcome> {
574        let mut running = self.running.write().await;
575        if !*running {
576            return Err(QueueError::WorkerNotRunning);
577        }
578        *running = false;
579        drop(running);
580
581        if self.config.log_execution {
582            println!("[WORKER] Stopping (grace period: {:?})...", timeout);
583        }
584
585        // The reaper does no work worth finishing at shutdown, and its Lua
586        // reclaim is atomic server-side, so aborting cannot leave a partial
587        // reclaim behind. Cancelling it up front also keeps it from consuming
588        // the grace period the in-flight jobs need.
589        if let Some(reaper) = self.reaper.take() {
590            reaper.abort();
591        }
592
593        // Wait for tasks to return on their own, up to `timeout` total (not
594        // per-task): each completed task is drained from the `JoinSet` as it
595        // finishes, and we keep waiting -- against a single shared deadline --
596        // for the rest.
597        let mut gracefully_completed = 0usize;
598        let mut panicked = 0usize;
599        let deadline = tokio::time::Instant::now() + timeout;
600        loop {
601            let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
602            if remaining.is_zero() {
603                break;
604            }
605            match tokio::time::timeout(remaining, self.handles.join_next()).await {
606                // A task finished normally on its own; keep waiting for the rest.
607                Ok(Some(Ok(()))) => {
608                    gracefully_completed += 1;
609                    continue;
610                }
611                // A task PANICKED mid-handler during the drain. This must not
612                // be treated the same as a normal completion: the job it was
613                // processing is left orphaned "Processing" in Redis with no
614                // other signal, so this is always logged -- unconditionally,
615                // not gated by `log_execution` -- a panicked handler is not a
616                // routine event worth suppressing.
617                Ok(Some(Err(join_err))) => {
618                    panicked += 1;
619                    error!(
620                        "[WORKER] worker task panicked during graceful shutdown drain: {}",
621                        join_err
622                    );
623                    continue;
624                }
625                // All tasks finished on their own within the grace period.
626                Ok(None) => break,
627                // Grace period elapsed with tasks still outstanding.
628                Err(_) => break,
629            }
630        }
631
632        // Last resort: force-abort anything still running once the grace
633        // period has elapsed. `abort_all` + draining is a no-op for tasks that
634        // already finished above.
635        let force_aborted = self.handles.len();
636        if force_aborted > 0 {
637            warn!(
638                "[WORKER] force-aborting {} in-flight worker task(s) after {:?} grace period elapsed",
639                force_aborted, timeout
640            );
641        }
642        self.handles.abort_all();
643        while self.handles.join_next().await.is_some() {}
644
645        if self.config.log_execution {
646            if force_aborted > 0 {
647                println!(
648                    "[WORKER] Stopped ({}s grace period elapsed; {} task(s) force-aborted)",
649                    timeout.as_secs_f64(),
650                    force_aborted
651                );
652            } else {
653                println!("[WORKER] Stopped (all tasks exited gracefully)");
654            }
655        }
656
657        Ok(StopOutcome {
658            gracefully_completed,
659            panicked,
660            force_aborted,
661        })
662    }
663
664    /// Check if the worker is running.
665    pub async fn is_running(&self) -> bool {
666        *self.running.read().await
667    }
668}
669
670#[cfg(test)]
671mod tests {
672    use super::*;
673
674    #[test]
675    fn test_worker_config() {
676        let config = WorkerConfig::default();
677        assert_eq!(config.concurrency, 10);
678        assert!(config.log_execution);
679    }
680
681    /// Reaping must be on by default -- a worker that crashes without one
682    /// strands its in-flight job in `processing` forever -- and the timeout
683    /// must exceed `job_timeout`, or a merely slow job is re-filed while still
684    /// running and executes twice.
685    #[test]
686    fn test_default_visibility_timeout_exceeds_job_timeout() {
687        let config = WorkerConfig::default();
688        let visibility = config
689            .visibility_timeout
690            .expect("stale-claim reaping must be enabled by default");
691        assert!(
692            visibility > config.job_timeout,
693            "visibility_timeout {visibility:?} must exceed job_timeout {:?}",
694            config.job_timeout
695        );
696    }
697
698    #[tokio::test]
699    async fn test_worker_creation() {
700        // This test requires a real Redis connection, so we just test creation
701        // In a real environment, you'd use a test Redis instance
702        let config = WorkerConfig {
703            concurrency: 5,
704            poll_interval: Duration::from_millis(500),
705            job_timeout: Duration::from_secs(60),
706            log_execution: false,
707            ..Default::default()
708        };
709
710        assert_eq!(config.concurrency, 5);
711    }
712}