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}