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