Skip to main content

awa_worker/
client.rs

1use crate::completion::CompletionBatcher;
2use crate::dispatcher::{ConcurrencyMode, Dispatcher, OverflowPool, QueueConfig};
3use crate::executor::{BoxedWorker, JobError, JobExecutor, JobResult, Worker};
4use crate::heartbeat::HeartbeatService;
5use crate::maintenance::{MaintenanceService, RetentionPolicy};
6use crate::runtime::{InFlightMap, InFlightRegistry};
7use awa_model::{JobArgs, JobRow, PeriodicJob};
8use serde::de::DeserializeOwned;
9use sqlx::PgPool;
10use std::any::{Any, TypeId};
11use std::collections::HashMap;
12use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
13use std::sync::Arc;
14use std::time::Duration;
15use tokio::sync::{Mutex, RwLock};
16use tokio::task::JoinSet;
17use tokio_util::sync::CancellationToken;
18use tracing::{info, warn};
19
20/// Errors returned when building a worker client.
21#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
22pub enum BuildError {
23    #[error("at least one queue must be configured")]
24    NoQueuesConfigured,
25    #[error("sum of min_workers ({total_min}) exceeds global_max_workers ({global_max})")]
26    MinWorkersExceedGlobal { total_min: u32, global_max: u32 },
27    #[error("rate_limit max_rate must be > 0.0")]
28    InvalidRateLimit,
29    #[error("queue weight must be > 0")]
30    InvalidWeight,
31    #[error("cleanup_batch_size must be > 0")]
32    InvalidBatchSize,
33}
34
35/// Health check result.
36#[derive(Debug, Clone)]
37pub struct HealthCheck {
38    pub healthy: bool,
39    pub postgres_connected: bool,
40    pub poll_loop_alive: bool,
41    pub heartbeat_alive: bool,
42    pub shutting_down: bool,
43    pub leader: bool,
44    pub queues: HashMap<String, QueueHealth>,
45}
46
47/// Per-queue health.
48#[derive(Debug, Clone)]
49pub struct QueueHealth {
50    pub in_flight: u32,
51    pub available: u64,
52    /// Capacity interpretation depends on mode.
53    pub capacity: QueueCapacity,
54}
55
56/// Capacity information for a queue, mode-dependent.
57#[derive(Debug, Clone)]
58pub enum QueueCapacity {
59    /// Hard-reserved: fixed max.
60    HardReserved { max_workers: u32 },
61    /// Weighted: min guaranteed + current overflow.
62    Weighted {
63        min_workers: u32,
64        weight: u32,
65        overflow_held: u32,
66    },
67}
68
69/// Builder for the Awa worker client.
70pub struct ClientBuilder {
71    pool: PgPool,
72    queues: Vec<(String, QueueConfig)>,
73    workers: HashMap<String, BoxedWorker>,
74    state: HashMap<TypeId, Box<dyn Any + Send + Sync>>,
75    heartbeat_interval: Duration,
76    promote_interval: Duration,
77    periodic_jobs: Vec<PeriodicJob>,
78    global_max_workers: Option<u32>,
79    leader_election_interval: Option<Duration>,
80    completed_retention: Option<Duration>,
81    failed_retention: Option<Duration>,
82    cleanup_batch_size: Option<i64>,
83    cleanup_interval: Option<Duration>,
84    queue_retention_overrides: HashMap<String, RetentionPolicy>,
85}
86
87impl ClientBuilder {
88    pub fn new(pool: PgPool) -> Self {
89        Self {
90            pool,
91            queues: Vec::new(),
92            workers: HashMap::new(),
93            state: HashMap::new(),
94            heartbeat_interval: Duration::from_secs(30),
95            promote_interval: Duration::from_millis(250),
96            periodic_jobs: Vec::new(),
97            global_max_workers: None,
98            leader_election_interval: None,
99            completed_retention: None,
100            failed_retention: None,
101            cleanup_batch_size: None,
102            cleanup_interval: None,
103            queue_retention_overrides: HashMap::new(),
104        }
105    }
106
107    /// Add a queue with its configuration.
108    pub fn queue(mut self, name: impl Into<String>, config: QueueConfig) -> Self {
109        self.queues.push((name.into(), config));
110        self
111    }
112
113    /// Register a typed worker.
114    ///
115    /// The worker handles jobs of type `T` where `T: JobArgs + DeserializeOwned`.
116    /// The handler function receives the deserialized args and job context.
117    pub fn register<T, F, Fut>(mut self, handler: F) -> Self
118    where
119        T: JobArgs + DeserializeOwned + Send + Sync + 'static,
120        F: Fn(T, &crate::context::JobContext) -> Fut + Send + Sync + 'static,
121        Fut: std::future::Future<Output = Result<JobResult, JobError>> + Send + Sync + 'static,
122    {
123        let kind = T::kind().to_string();
124        let worker = TypedWorker {
125            kind: T::kind(),
126            handler: Arc::new(handler),
127            _phantom: std::marker::PhantomData,
128        };
129        self.workers.insert(kind, Box::new(worker));
130        self
131    }
132
133    /// Register a raw worker implementation.
134    pub fn register_worker(mut self, worker: impl Worker + 'static) -> Self {
135        let kind = worker.kind().to_string();
136        self.workers.insert(kind, Box::new(worker));
137        self
138    }
139
140    /// Add shared state accessible via `ctx.extract::<T>()`.
141    pub fn state<T: Any + Send + Sync + Clone>(mut self, value: T) -> Self {
142        self.state.insert(TypeId::of::<T>(), Box::new(value));
143        self
144    }
145
146    /// Set the heartbeat interval (default: 30s).
147    pub fn heartbeat_interval(mut self, interval: Duration) -> Self {
148        self.heartbeat_interval = interval;
149        self
150    }
151
152    /// Set the scheduled/retryable promotion interval (default: 250ms).
153    pub fn promote_interval(mut self, interval: Duration) -> Self {
154        self.promote_interval = interval;
155        self
156    }
157
158    /// Set the leader election retry interval (default: 10s).
159    ///
160    /// Controls how often a non-leader instance retries acquiring the maintenance
161    /// advisory lock. Lower values are useful in tests.
162    pub fn leader_election_interval(mut self, interval: Duration) -> Self {
163        self.leader_election_interval = Some(interval);
164        self
165    }
166
167    /// Set a global maximum worker count across all queues (enables weighted mode).
168    ///
169    /// When set, each queue gets `min_workers` guaranteed permits plus a share
170    /// of the remaining overflow capacity based on `weight`.
171    pub fn global_max_workers(mut self, max: u32) -> Self {
172        self.global_max_workers = Some(max);
173        self
174    }
175
176    /// Set retention for completed jobs (default: 24h).
177    pub fn completed_retention(mut self, retention: Duration) -> Self {
178        self.completed_retention = Some(retention);
179        self
180    }
181
182    /// Set retention for failed/cancelled jobs (default: 72h).
183    pub fn failed_retention(mut self, retention: Duration) -> Self {
184        self.failed_retention = Some(retention);
185        self
186    }
187
188    /// Set the maximum number of jobs to delete per cleanup pass (default: 1000).
189    pub fn cleanup_batch_size(mut self, batch_size: i64) -> Self {
190        self.cleanup_batch_size = Some(batch_size);
191        self
192    }
193
194    /// Set the cleanup interval (default: 60s).
195    pub fn cleanup_interval(mut self, interval: Duration) -> Self {
196        self.cleanup_interval = Some(interval);
197        self
198    }
199
200    /// Set a per-queue retention override.
201    pub fn queue_retention(mut self, queue: impl Into<String>, policy: RetentionPolicy) -> Self {
202        self.queue_retention_overrides.insert(queue.into(), policy);
203        self
204    }
205
206    /// Register a periodic (cron) job schedule.
207    ///
208    /// The schedule is synced to the database by the leader and evaluated
209    /// every second. When a fire is due, a job is atomically enqueued.
210    pub fn periodic(mut self, job: PeriodicJob) -> Self {
211        self.periodic_jobs.push(job);
212        self
213    }
214
215    /// Build the client.
216    pub fn build(self) -> Result<Client, BuildError> {
217        if self.queues.is_empty() {
218            return Err(BuildError::NoQueuesConfigured);
219        }
220
221        // Validate rate limits and weights
222        for (_, config) in &self.queues {
223            if let Some(rl) = &config.rate_limit {
224                if rl.max_rate <= 0.0 {
225                    return Err(BuildError::InvalidRateLimit);
226                }
227            }
228            if config.weight == 0 {
229                return Err(BuildError::InvalidWeight);
230            }
231        }
232
233        // Validate batch size
234        if let Some(bs) = self.cleanup_batch_size {
235            if bs <= 0 {
236                return Err(BuildError::InvalidBatchSize);
237            }
238        }
239
240        // Validate weighted mode constraints
241        let overflow_pool = if let Some(global_max) = self.global_max_workers {
242            let total_min: u32 = self.queues.iter().map(|(_, c)| c.min_workers).sum();
243            if total_min > global_max {
244                return Err(BuildError::MinWorkersExceedGlobal {
245                    total_min,
246                    global_max,
247                });
248            }
249            let overflow_capacity = global_max - total_min;
250            let weights: HashMap<String, u32> = self
251                .queues
252                .iter()
253                .map(|(name, c)| (name.clone(), c.weight.max(1)))
254                .collect();
255            Some(Arc::new(OverflowPool::new(overflow_capacity, weights)))
256        } else {
257            None
258        };
259
260        let metrics = crate::metrics::AwaMetrics::from_global();
261        let queue_in_flight = Arc::new(
262            self.queues
263                .iter()
264                .map(|(name, _)| (name.clone(), Arc::new(AtomicU32::new(0))))
265                .collect(),
266        );
267        let dispatcher_alive = Arc::new(
268            self.queues
269                .iter()
270                .map(|(name, _)| (name.clone(), Arc::new(AtomicBool::new(false))))
271                .collect(),
272        );
273
274        Ok(Client {
275            pool: self.pool,
276            queues: self.queues,
277            workers: Arc::new(self.workers),
278            state: Arc::new(self.state),
279            heartbeat_interval: self.heartbeat_interval,
280            promote_interval: self.promote_interval,
281            periodic_jobs: Arc::new(self.periodic_jobs),
282            dispatch_cancel: CancellationToken::new(),
283            service_cancel: CancellationToken::new(),
284            dispatcher_handles: RwLock::new(Vec::new()),
285            service_handles: RwLock::new(Vec::new()),
286            job_set: Arc::new(Mutex::new(JoinSet::new())),
287            in_flight: Arc::new(InFlightRegistry::default()),
288            queue_in_flight,
289            dispatcher_alive,
290            heartbeat_alive: Arc::new(AtomicBool::new(false)),
291            leader: Arc::new(AtomicBool::new(false)),
292            overflow_pool,
293            metrics,
294            leader_election_interval: self.leader_election_interval,
295            completed_retention: self.completed_retention,
296            failed_retention: self.failed_retention,
297            cleanup_batch_size: self.cleanup_batch_size,
298            cleanup_interval: self.cleanup_interval,
299            queue_retention_overrides: self.queue_retention_overrides,
300        })
301    }
302}
303
304/// A typed worker that deserializes args and calls a handler function.
305struct TypedWorker<T, F, Fut>
306where
307    T: JobArgs + DeserializeOwned + Send + Sync + 'static,
308    F: Fn(T, &crate::context::JobContext) -> Fut + Send + Sync + 'static,
309    Fut: std::future::Future<Output = Result<JobResult, JobError>> + Send + Sync + 'static,
310{
311    kind: &'static str,
312    handler: Arc<F>,
313    _phantom: std::marker::PhantomData<fn() -> (T, Fut)>,
314}
315
316#[async_trait::async_trait]
317impl<T, F, Fut> Worker for TypedWorker<T, F, Fut>
318where
319    T: JobArgs + DeserializeOwned + Send + Sync + 'static,
320    F: Fn(T, &crate::context::JobContext) -> Fut + Send + Sync + 'static,
321    Fut: std::future::Future<Output = Result<JobResult, JobError>> + Send + Sync + 'static,
322{
323    fn kind(&self) -> &'static str {
324        self.kind
325    }
326
327    async fn perform(
328        &self,
329        job_row: &JobRow,
330        ctx: &crate::context::JobContext,
331    ) -> Result<JobResult, JobError> {
332        // Deserialize args
333        let args: T = serde_json::from_value(job_row.args.clone())
334            .map_err(|err| JobError::Terminal(format!("failed to deserialize args: {}", err)))?;
335
336        (self.handler)(args, ctx).await
337    }
338}
339
340/// The Awa worker client — manages dispatchers, heartbeat, and maintenance.
341pub struct Client {
342    pool: PgPool,
343    queues: Vec<(String, QueueConfig)>,
344    workers: Arc<HashMap<String, BoxedWorker>>,
345    state: Arc<HashMap<TypeId, Box<dyn Any + Send + Sync>>>,
346    heartbeat_interval: Duration,
347    promote_interval: Duration,
348    periodic_jobs: Arc<Vec<PeriodicJob>>,
349    /// Cancellation token for dispatchers only — stops claiming new jobs.
350    dispatch_cancel: CancellationToken,
351    /// Cancellation token for heartbeat + maintenance — kept alive during drain.
352    service_cancel: CancellationToken,
353    /// Handles for dispatcher tasks.
354    dispatcher_handles: RwLock<Vec<tokio::task::JoinHandle<()>>>,
355    /// Handles for service tasks (heartbeat + maintenance).
356    service_handles: RwLock<Vec<tokio::task::JoinHandle<()>>>,
357    /// JoinSet tracking in-flight job tasks for graceful drain.
358    job_set: Arc<Mutex<JoinSet<()>>>,
359    in_flight: InFlightMap,
360    queue_in_flight: Arc<HashMap<String, Arc<AtomicU32>>>,
361    dispatcher_alive: Arc<HashMap<String, Arc<AtomicBool>>>,
362    heartbeat_alive: Arc<AtomicBool>,
363    leader: Arc<AtomicBool>,
364    /// Shared overflow pool for weighted mode (None in hard-reserved mode).
365    overflow_pool: Option<Arc<OverflowPool>>,
366    metrics: crate::metrics::AwaMetrics,
367    leader_election_interval: Option<Duration>,
368    completed_retention: Option<Duration>,
369    failed_retention: Option<Duration>,
370    cleanup_batch_size: Option<i64>,
371    cleanup_interval: Option<Duration>,
372    queue_retention_overrides: HashMap<String, RetentionPolicy>,
373}
374
375impl Client {
376    /// Create a new builder.
377    pub fn builder(pool: PgPool) -> ClientBuilder {
378        ClientBuilder::new(pool)
379    }
380
381    /// Start the worker runtime. Spawns dispatchers, heartbeat, and maintenance.
382    pub async fn start(&self) -> Result<(), awa_model::AwaError> {
383        info!(
384            queues = self.queues.len(),
385            workers = self.workers.len(),
386            "Starting Awa worker runtime"
387        );
388
389        // Completion batcher stays alive during drain so tasks can release
390        // only after their completion has been acknowledged.
391        let (completion_batcher, completion_handle) = CompletionBatcher::new(
392            self.pool.clone(),
393            self.service_cancel.clone(),
394            self.metrics.clone(),
395        );
396
397        // Create executor with metrics
398        let executor = Arc::new(JobExecutor::new(
399            self.pool.clone(),
400            self.workers.clone(),
401            self.in_flight.clone(),
402            self.queue_in_flight.clone(),
403            self.state.clone(),
404            self.metrics.clone(),
405            completion_handle,
406        ));
407
408        let mut service_handles = self.service_handles.write().await;
409
410        service_handles.extend(completion_batcher.spawn());
411
412        // Start heartbeat service (uses service_cancel — stays alive during drain)
413        let heartbeat = HeartbeatService::new(
414            self.pool.clone(),
415            self.in_flight.clone(),
416            self.heartbeat_interval,
417            self.heartbeat_alive.clone(),
418            self.service_cancel.clone(),
419        );
420        service_handles.push(tokio::spawn(async move {
421            heartbeat.run().await;
422        }));
423
424        // Start maintenance service (uses service_cancel — stays alive during drain)
425        let mut maintenance = MaintenanceService::new(
426            self.pool.clone(),
427            self.metrics.clone(),
428            self.leader.clone(),
429            self.service_cancel.clone(),
430            self.periodic_jobs.clone(),
431            self.in_flight.clone(),
432        )
433        .promote_interval(self.promote_interval);
434        if let Some(interval) = self.leader_election_interval {
435            maintenance = maintenance.leader_election_interval(interval);
436        }
437        if let Some(retention) = self.completed_retention {
438            maintenance = maintenance.completed_retention(retention);
439        }
440        if let Some(retention) = self.failed_retention {
441            maintenance = maintenance.failed_retention(retention);
442        }
443        if let Some(batch_size) = self.cleanup_batch_size {
444            maintenance = maintenance.cleanup_batch_size(batch_size);
445        }
446        if let Some(interval) = self.cleanup_interval {
447            maintenance = maintenance.cleanup_interval(interval);
448        }
449        if !self.queue_retention_overrides.is_empty() {
450            maintenance =
451                maintenance.queue_retention_overrides(self.queue_retention_overrides.clone());
452        }
453        service_handles.push(tokio::spawn(async move {
454            maintenance.run().await;
455        }));
456
457        // Start a dispatcher per queue (uses dispatch_cancel — stops claiming first)
458        let mut dispatcher_handles = self.dispatcher_handles.write().await;
459        for (queue_name, config) in &self.queues {
460            let alive = self
461                .dispatcher_alive
462                .get(queue_name)
463                .cloned()
464                .unwrap_or_else(|| Arc::new(AtomicBool::new(false)));
465
466            let dispatcher = if let Some(overflow_pool) = &self.overflow_pool {
467                // Weighted mode
468                let concurrency = ConcurrencyMode::Weighted {
469                    local_semaphore: Arc::new(tokio::sync::Semaphore::new(
470                        config.min_workers as usize,
471                    )),
472                    overflow_pool: overflow_pool.clone(),
473                    queue_name: queue_name.clone(),
474                };
475                Dispatcher::with_concurrency(
476                    queue_name.clone(),
477                    config.clone(),
478                    self.pool.clone(),
479                    executor.clone(),
480                    self.metrics.clone(),
481                    self.in_flight.clone(),
482                    alive,
483                    self.dispatch_cancel.clone(),
484                    self.job_set.clone(),
485                    concurrency,
486                )
487            } else {
488                // Hard-reserved mode (default)
489                Dispatcher::new(
490                    queue_name.clone(),
491                    config.clone(),
492                    self.pool.clone(),
493                    executor.clone(),
494                    self.metrics.clone(),
495                    self.in_flight.clone(),
496                    alive,
497                    self.dispatch_cancel.clone(),
498                    self.job_set.clone(),
499                )
500            };
501            dispatcher_handles.push(tokio::spawn(async move {
502                dispatcher.run().await;
503            }));
504        }
505
506        info!("Awa worker runtime started");
507        Ok(())
508    }
509
510    /// Graceful shutdown with drain timeout.
511    ///
512    /// Phased lifecycle:
513    /// 1. Stop dispatchers (no new jobs claimed)
514    /// 2. Signal in-flight jobs to cancel
515    /// 3. Wait for dispatchers to exit
516    /// 4. Drain in-flight jobs (heartbeat + maintenance still alive!)
517    /// 5. Stop heartbeat + maintenance
518    pub async fn shutdown(&self, timeout: Duration) {
519        info!("Initiating graceful shutdown");
520
521        // Phase 1: Stop claiming new jobs
522        self.dispatch_cancel.cancel();
523
524        // Phase 2: Signal in-flight cancellation flags
525        for flag in self.in_flight.flags() {
526            flag.store(true, Ordering::SeqCst);
527        }
528
529        // Phase 3: Wait for dispatchers to exit their poll loops
530        let dispatcher_handles: Vec<_> = {
531            let mut guard = self.dispatcher_handles.write().await;
532            std::mem::take(&mut *guard)
533        };
534        for handle in dispatcher_handles {
535            let _ = handle.await;
536        }
537
538        // Phase 4: Drain in-flight jobs (heartbeat + maintenance still alive)
539        let drain = async {
540            let mut set = self.job_set.lock().await;
541            while set.join_next().await.is_some() {}
542        };
543        if tokio::time::timeout(timeout, drain).await.is_err() {
544            warn!(
545                timeout_secs = timeout.as_secs(),
546                "Shutdown drain timeout exceeded, some jobs may not have completed"
547            );
548        }
549
550        // Phase 5: Stop background services (heartbeat + maintenance)
551        self.service_cancel.cancel();
552        let service_handles: Vec<_> = {
553            let mut guard = self.service_handles.write().await;
554            std::mem::take(&mut *guard)
555        };
556        for handle in service_handles {
557            let _ = handle.await;
558        }
559
560        info!("Awa worker runtime stopped");
561    }
562
563    /// Get the pool reference.
564    pub fn pool(&self) -> &PgPool {
565        &self.pool
566    }
567
568    /// Health check.
569    pub async fn health_check(&self) -> HealthCheck {
570        let postgres_connected = sqlx::query("SELECT 1").execute(&self.pool).await.is_ok();
571        let poll_loop_alive = self
572            .dispatcher_alive
573            .values()
574            .all(|alive| alive.load(Ordering::SeqCst));
575        let heartbeat_alive = self.heartbeat_alive.load(Ordering::SeqCst);
576        let shutting_down = self.dispatch_cancel.is_cancelled();
577        let leader = self.leader.load(Ordering::SeqCst);
578        let available_rows = sqlx::query_as::<_, (String, i64)>(
579            r#"
580            SELECT queue, count(*)::bigint AS available
581            FROM awa.jobs_hot
582            WHERE state = 'available'
583            GROUP BY queue
584            "#,
585        )
586        .fetch_all(&self.pool)
587        .await
588        .unwrap_or_default();
589        let available_by_queue: HashMap<_, _> = available_rows.into_iter().collect();
590        let queues = self
591            .queues
592            .iter()
593            .map(|(queue, config)| {
594                let in_flight = self
595                    .queue_in_flight
596                    .get(queue)
597                    .map(|counter| counter.load(Ordering::SeqCst))
598                    .unwrap_or(0);
599                let available = available_by_queue.get(queue).copied().unwrap_or(0).max(0) as u64;
600                let capacity = if let Some(overflow_pool) = &self.overflow_pool {
601                    QueueCapacity::Weighted {
602                        min_workers: config.min_workers,
603                        weight: config.weight,
604                        overflow_held: overflow_pool.held(queue),
605                    }
606                } else {
607                    QueueCapacity::HardReserved {
608                        max_workers: config.max_workers,
609                    }
610                };
611                (
612                    queue.clone(),
613                    QueueHealth {
614                        in_flight,
615                        available,
616                        capacity,
617                    },
618                )
619            })
620            .collect();
621
622        HealthCheck {
623            healthy: postgres_connected && poll_loop_alive && heartbeat_alive && !shutting_down,
624            postgres_connected,
625            poll_loop_alive,
626            heartbeat_alive,
627            shutting_down,
628            leader,
629            queues,
630        }
631    }
632}