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}