Skip to main content

awa_worker/
client.rs

1use crate::completion::CompletionBatcher;
2use crate::dispatcher::{ConcurrencyMode, Dispatcher, OverflowPool, QueueConfig};
3use crate::events::{BoxedUntypedEventHandler, JobEvent, UntypedJobEvent};
4use crate::executor::{BoxedWorker, JobError, JobExecutor, JobResult, Worker};
5use crate::heartbeat::HeartbeatService;
6use crate::maintenance::{MaintenanceService, RetentionPolicy};
7use crate::runtime::{InFlightMap, InFlightRegistry};
8use awa_model::admin::{
9    self, QueueRuntimeConfigSnapshot, QueueRuntimeMode, QueueRuntimeSnapshot, RateLimitSnapshot,
10    RuntimeSnapshotInput,
11};
12use awa_model::{JobArgs, PeriodicJob};
13use chrono::{DateTime, Utc};
14use serde::de::DeserializeOwned;
15use sqlx::PgPool;
16use std::any::{Any, TypeId};
17use std::collections::HashMap;
18use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
19use std::sync::Arc;
20use std::time::Duration;
21use tokio::sync::{Mutex, RwLock};
22use tokio::task::JoinSet;
23use tokio_util::sync::CancellationToken;
24use tracing::{info, warn};
25use uuid::Uuid;
26
27/// Errors returned when building a worker client.
28#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
29pub enum BuildError {
30    #[error("at least one queue must be configured")]
31    NoQueuesConfigured,
32    #[error("sum of min_workers ({total_min}) exceeds global_max_workers ({global_max})")]
33    MinWorkersExceedGlobal { total_min: u32, global_max: u32 },
34    #[error("rate_limit max_rate must be > 0.0")]
35    InvalidRateLimit,
36    #[error("queue weight must be > 0")]
37    InvalidWeight,
38    #[error("cleanup_batch_size must be > 0")]
39    InvalidBatchSize,
40}
41
42/// Health check result.
43#[derive(Debug, Clone)]
44pub struct HealthCheck {
45    pub healthy: bool,
46    pub postgres_connected: bool,
47    pub poll_loop_alive: bool,
48    pub heartbeat_alive: bool,
49    pub maintenance_alive: bool,
50    pub shutting_down: bool,
51    pub leader: bool,
52    pub queues: HashMap<String, QueueHealth>,
53}
54
55/// Per-queue health.
56#[derive(Debug, Clone)]
57pub struct QueueHealth {
58    pub in_flight: u32,
59    pub available: u64,
60    /// Capacity interpretation depends on mode.
61    pub capacity: QueueCapacity,
62}
63
64/// Capacity information for a queue, mode-dependent.
65#[derive(Debug, Clone)]
66pub enum QueueCapacity {
67    /// Hard-reserved: fixed max.
68    HardReserved { max_workers: u32 },
69    /// Weighted: min guaranteed + current overflow.
70    Weighted {
71        min_workers: u32,
72        weight: u32,
73        overflow_held: u32,
74    },
75}
76
77/// Builder for the Awa worker client.
78pub struct ClientBuilder {
79    pool: PgPool,
80    queues: Vec<(String, QueueConfig)>,
81    workers: HashMap<String, BoxedWorker>,
82    lifecycle_handlers: HashMap<String, Vec<BoxedUntypedEventHandler>>,
83    state: HashMap<TypeId, Box<dyn Any + Send + Sync>>,
84    heartbeat_interval: Duration,
85    promote_interval: Duration,
86    heartbeat_rescue_interval: Option<Duration>,
87    deadline_rescue_interval: Option<Duration>,
88    callback_rescue_interval: Option<Duration>,
89    periodic_jobs: Vec<PeriodicJob>,
90    global_max_workers: Option<u32>,
91    leader_election_interval: Option<Duration>,
92    leader_check_interval: Option<Duration>,
93    completed_retention: Option<Duration>,
94    failed_retention: Option<Duration>,
95    cleanup_batch_size: Option<i64>,
96    cleanup_interval: Option<Duration>,
97    queue_retention_overrides: HashMap<String, RetentionPolicy>,
98    runtime_snapshot_interval: Duration,
99}
100
101impl ClientBuilder {
102    pub fn new(pool: PgPool) -> Self {
103        Self {
104            pool,
105            queues: Vec::new(),
106            workers: HashMap::new(),
107            lifecycle_handlers: HashMap::new(),
108            state: HashMap::new(),
109            heartbeat_interval: Duration::from_secs(30),
110            promote_interval: Duration::from_millis(250),
111            heartbeat_rescue_interval: None,
112            deadline_rescue_interval: None,
113            callback_rescue_interval: None,
114            periodic_jobs: Vec::new(),
115            global_max_workers: None,
116            leader_election_interval: None,
117            leader_check_interval: None,
118            completed_retention: None,
119            failed_retention: None,
120            cleanup_batch_size: None,
121            cleanup_interval: None,
122            queue_retention_overrides: HashMap::new(),
123            runtime_snapshot_interval: Duration::from_secs(10),
124        }
125    }
126
127    /// Add a queue with its configuration.
128    pub fn queue(mut self, name: impl Into<String>, config: QueueConfig) -> Self {
129        self.queues.push((name.into(), config));
130        self
131    }
132
133    /// Register a typed worker.
134    ///
135    /// The worker handles jobs of type `T` where `T: JobArgs + DeserializeOwned`.
136    /// The handler function receives the deserialized args and job context.
137    pub fn register<T, F, Fut>(mut self, handler: F) -> Self
138    where
139        T: JobArgs + DeserializeOwned + Send + Sync + 'static,
140        F: Fn(T, &crate::context::JobContext) -> Fut + Send + Sync + 'static,
141        Fut: std::future::Future<Output = Result<JobResult, JobError>> + Send + Sync + 'static,
142    {
143        let kind = T::kind().to_string();
144        let worker = TypedWorker {
145            kind: T::kind(),
146            handler: Arc::new(handler),
147            _phantom: std::marker::PhantomData,
148        };
149        self.workers.insert(kind, Box::new(worker));
150        self
151    }
152
153    /// Register a typed lifecycle event handler for a job kind.
154    ///
155    /// Handlers run only after the corresponding DB state transition commits.
156    /// They are best-effort in-process hooks, not a durable workflow mechanism.
157    /// Capture any shared dependencies you need in the closure environment.
158    pub fn on_event<T, F, Fut>(mut self, handler: F) -> Self
159    where
160        T: JobArgs + DeserializeOwned + Send + Sync + 'static,
161        F: Fn(JobEvent<T>) -> Fut + Send + Sync + 'static,
162        Fut: std::future::Future<Output = ()> + Send + 'static,
163    {
164        let kind = T::kind().to_string();
165        let handler = Arc::new(handler);
166        let erased: BoxedUntypedEventHandler = Arc::new(move |event: UntypedJobEvent| {
167            let handler = handler.clone();
168            Box::pin(async move {
169                let args: T = match serde_json::from_value(event.job().args.clone()) {
170                    Ok(args) => args,
171                    Err(err) => {
172                        warn!(
173                            job_id = event.job().id,
174                            kind = %event.job().kind,
175                            error = %err,
176                            "Failed to deserialize args for lifecycle event handler"
177                        );
178                        return;
179                    }
180                };
181
182                (handler)(event.into_typed(args)).await;
183            })
184        });
185        self.lifecycle_handlers
186            .entry(kind)
187            .or_default()
188            .push(erased);
189        self
190    }
191
192    /// Register an untyped lifecycle event handler for a specific job kind.
193    ///
194    /// Use this with `register_worker(...)` or for cross-cutting logic that
195    /// doesn't need typed args.
196    pub fn on_event_kind<F, Fut>(mut self, kind: impl Into<String>, handler: F) -> Self
197    where
198        F: Fn(UntypedJobEvent) -> Fut + Send + Sync + 'static,
199        Fut: std::future::Future<Output = ()> + Send + 'static,
200    {
201        let kind = kind.into();
202        let handler = Arc::new(handler);
203        let erased: BoxedUntypedEventHandler = Arc::new(move |event: UntypedJobEvent| {
204            let handler = handler.clone();
205            Box::pin(async move {
206                (handler)(event).await;
207            })
208        });
209        self.lifecycle_handlers
210            .entry(kind)
211            .or_default()
212            .push(erased);
213        self
214    }
215
216    /// Register a raw worker implementation.
217    pub fn register_worker(mut self, worker: impl Worker + 'static) -> Self {
218        let kind = worker.kind().to_string();
219        self.workers.insert(kind, Box::new(worker));
220        self
221    }
222
223    /// Add shared state accessible via `ctx.extract::<T>()`.
224    pub fn state<T: Any + Send + Sync + Clone>(mut self, value: T) -> Self {
225        self.state.insert(TypeId::of::<T>(), Box::new(value));
226        self
227    }
228
229    /// Set the heartbeat interval (default: 30s).
230    pub fn heartbeat_interval(mut self, interval: Duration) -> Self {
231        self.heartbeat_interval = interval;
232        self
233    }
234
235    /// Set the scheduled/retryable promotion interval (default: 250ms).
236    pub fn promote_interval(mut self, interval: Duration) -> Self {
237        self.promote_interval = interval;
238        self
239    }
240
241    /// Set the stale-heartbeat rescue interval (default: 30s).
242    pub fn heartbeat_rescue_interval(mut self, interval: Duration) -> Self {
243        self.heartbeat_rescue_interval = Some(interval);
244        self
245    }
246
247    /// Set the deadline rescue interval (default: 30s).
248    pub fn deadline_rescue_interval(mut self, interval: Duration) -> Self {
249        self.deadline_rescue_interval = Some(interval);
250        self
251    }
252
253    /// Set the callback-timeout rescue interval (default: 30s).
254    pub fn callback_rescue_interval(mut self, interval: Duration) -> Self {
255        self.callback_rescue_interval = Some(interval);
256        self
257    }
258
259    /// Set the leader election retry interval (default: 10s).
260    ///
261    /// Controls how often a non-leader instance retries acquiring the maintenance
262    /// advisory lock. Lower values are useful in tests.
263    pub fn leader_election_interval(mut self, interval: Duration) -> Self {
264        self.leader_election_interval = Some(interval);
265        self
266    }
267
268    /// Set the leader connection health-check interval (default: 30s).
269    pub fn leader_check_interval(mut self, interval: Duration) -> Self {
270        self.leader_check_interval = Some(interval);
271        self
272    }
273
274    /// Set a global maximum worker count across all queues (enables weighted mode).
275    ///
276    /// When set, each queue gets `min_workers` guaranteed permits plus a share
277    /// of the remaining overflow capacity based on `weight`.
278    pub fn global_max_workers(mut self, max: u32) -> Self {
279        self.global_max_workers = Some(max);
280        self
281    }
282
283    /// Set retention for completed jobs (default: 24h).
284    pub fn completed_retention(mut self, retention: Duration) -> Self {
285        self.completed_retention = Some(retention);
286        self
287    }
288
289    /// Set retention for failed/cancelled jobs (default: 72h).
290    pub fn failed_retention(mut self, retention: Duration) -> Self {
291        self.failed_retention = Some(retention);
292        self
293    }
294
295    /// Set the maximum number of jobs to delete per cleanup pass (default: 1000).
296    pub fn cleanup_batch_size(mut self, batch_size: i64) -> Self {
297        self.cleanup_batch_size = Some(batch_size);
298        self
299    }
300
301    /// Set the cleanup interval (default: 60s).
302    pub fn cleanup_interval(mut self, interval: Duration) -> Self {
303        self.cleanup_interval = Some(interval);
304        self
305    }
306
307    /// Set a per-queue retention override.
308    pub fn queue_retention(mut self, queue: impl Into<String>, policy: RetentionPolicy) -> Self {
309        self.queue_retention_overrides.insert(queue.into(), policy);
310        self
311    }
312
313    /// Set how often runtime observability snapshots are published (default: 10s).
314    pub fn runtime_snapshot_interval(mut self, interval: Duration) -> Self {
315        self.runtime_snapshot_interval = interval;
316        self
317    }
318
319    /// Register a periodic (cron) job schedule.
320    ///
321    /// The schedule is synced to the database by the leader and evaluated
322    /// every second. When a fire is due, a job is atomically enqueued.
323    pub fn periodic(mut self, job: PeriodicJob) -> Self {
324        self.periodic_jobs.push(job);
325        self
326    }
327
328    /// Build the client.
329    pub fn build(self) -> Result<Client, BuildError> {
330        if self.queues.is_empty() {
331            return Err(BuildError::NoQueuesConfigured);
332        }
333
334        // Validate rate limits and weights
335        for (_, config) in &self.queues {
336            if let Some(rl) = &config.rate_limit {
337                if rl.max_rate <= 0.0 {
338                    return Err(BuildError::InvalidRateLimit);
339                }
340            }
341            if config.weight == 0 {
342                return Err(BuildError::InvalidWeight);
343            }
344        }
345
346        // Validate batch size
347        if let Some(bs) = self.cleanup_batch_size {
348            if bs <= 0 {
349                return Err(BuildError::InvalidBatchSize);
350            }
351        }
352
353        // Validate weighted mode constraints
354        let overflow_pool = if let Some(global_max) = self.global_max_workers {
355            let total_min: u32 = self.queues.iter().map(|(_, c)| c.min_workers).sum();
356            if total_min > global_max {
357                return Err(BuildError::MinWorkersExceedGlobal {
358                    total_min,
359                    global_max,
360                });
361            }
362            let overflow_capacity = global_max - total_min;
363            let weights: HashMap<String, u32> = self
364                .queues
365                .iter()
366                .map(|(name, c)| (name.clone(), c.weight.max(1)))
367                .collect();
368            Some(Arc::new(OverflowPool::new(overflow_capacity, weights)))
369        } else {
370            None
371        };
372
373        let metrics = crate::metrics::AwaMetrics::from_global();
374        let queue_in_flight = Arc::new(
375            self.queues
376                .iter()
377                .map(|(name, _)| (name.clone(), Arc::new(AtomicU32::new(0))))
378                .collect(),
379        );
380        let dispatcher_alive = Arc::new(
381            self.queues
382                .iter()
383                .map(|(name, _)| (name.clone(), Arc::new(AtomicBool::new(false))))
384                .collect(),
385        );
386
387        Ok(Client {
388            pool: self.pool,
389            queues: self.queues,
390            workers: Arc::new(self.workers),
391            lifecycle_handlers: Arc::new(self.lifecycle_handlers),
392            state: Arc::new(self.state),
393            heartbeat_interval: self.heartbeat_interval,
394            promote_interval: self.promote_interval,
395            heartbeat_rescue_interval: self.heartbeat_rescue_interval,
396            deadline_rescue_interval: self.deadline_rescue_interval,
397            callback_rescue_interval: self.callback_rescue_interval,
398            periodic_jobs: Arc::new(self.periodic_jobs),
399            dispatch_cancel: CancellationToken::new(),
400            service_cancel: CancellationToken::new(),
401            dispatcher_handles: RwLock::new(Vec::new()),
402            service_handles: RwLock::new(Vec::new()),
403            job_set: Arc::new(Mutex::new(JoinSet::new())),
404            in_flight: Arc::new(InFlightRegistry::default()),
405            queue_in_flight,
406            dispatcher_alive,
407            heartbeat_alive: Arc::new(AtomicBool::new(false)),
408            maintenance_alive: Arc::new(AtomicBool::new(false)),
409            leader: Arc::new(AtomicBool::new(false)),
410            overflow_pool,
411            metrics,
412            leader_election_interval: self.leader_election_interval,
413            leader_check_interval: self.leader_check_interval,
414            completed_retention: self.completed_retention,
415            failed_retention: self.failed_retention,
416            cleanup_batch_size: self.cleanup_batch_size,
417            cleanup_interval: self.cleanup_interval,
418            queue_retention_overrides: self.queue_retention_overrides,
419            global_max_workers: self.global_max_workers,
420            runtime_snapshot_interval: self.runtime_snapshot_interval,
421            runtime_instance_id: Uuid::new_v4(),
422            runtime_started_at: Utc::now(),
423            runtime_hostname: std::env::var("HOSTNAME").ok(),
424            runtime_pid: std::process::id() as i32,
425            runtime_version: env!("CARGO_PKG_VERSION"),
426        })
427    }
428}
429
430/// A typed worker that deserializes args and calls a handler function.
431struct TypedWorker<T, F, Fut>
432where
433    T: JobArgs + DeserializeOwned + Send + Sync + 'static,
434    F: Fn(T, &crate::context::JobContext) -> Fut + Send + Sync + 'static,
435    Fut: std::future::Future<Output = Result<JobResult, JobError>> + Send + Sync + 'static,
436{
437    kind: &'static str,
438    handler: Arc<F>,
439    _phantom: std::marker::PhantomData<fn() -> (T, Fut)>,
440}
441
442#[async_trait::async_trait]
443impl<T, F, Fut> Worker for TypedWorker<T, F, Fut>
444where
445    T: JobArgs + DeserializeOwned + Send + Sync + 'static,
446    F: Fn(T, &crate::context::JobContext) -> Fut + Send + Sync + 'static,
447    Fut: std::future::Future<Output = Result<JobResult, JobError>> + Send + Sync + 'static,
448{
449    fn kind(&self) -> &'static str {
450        self.kind
451    }
452
453    async fn perform(&self, ctx: &crate::context::JobContext) -> Result<JobResult, JobError> {
454        let args: T = serde_json::from_value(ctx.job.args.clone())
455            .map_err(|err| JobError::Terminal(format!("failed to deserialize args: {}", err)))?;
456
457        (self.handler)(args, ctx).await
458    }
459}
460
461/// The Awa worker client — manages dispatchers, heartbeat, and maintenance.
462pub struct Client {
463    pool: PgPool,
464    queues: Vec<(String, QueueConfig)>,
465    workers: Arc<HashMap<String, BoxedWorker>>,
466    lifecycle_handlers: Arc<HashMap<String, Vec<BoxedUntypedEventHandler>>>,
467    state: Arc<HashMap<TypeId, Box<dyn Any + Send + Sync>>>,
468    heartbeat_interval: Duration,
469    promote_interval: Duration,
470    heartbeat_rescue_interval: Option<Duration>,
471    deadline_rescue_interval: Option<Duration>,
472    callback_rescue_interval: Option<Duration>,
473    periodic_jobs: Arc<Vec<PeriodicJob>>,
474    /// Cancellation token for dispatchers only — stops claiming new jobs.
475    dispatch_cancel: CancellationToken,
476    /// Cancellation token for heartbeat + maintenance — kept alive during drain.
477    service_cancel: CancellationToken,
478    /// Handles for dispatcher tasks.
479    dispatcher_handles: RwLock<Vec<tokio::task::JoinHandle<()>>>,
480    /// Handles for service tasks (heartbeat + maintenance).
481    service_handles: RwLock<Vec<tokio::task::JoinHandle<()>>>,
482    /// JoinSet tracking in-flight job tasks for graceful drain.
483    job_set: Arc<Mutex<JoinSet<()>>>,
484    in_flight: InFlightMap,
485    queue_in_flight: Arc<HashMap<String, Arc<AtomicU32>>>,
486    dispatcher_alive: Arc<HashMap<String, Arc<AtomicBool>>>,
487    heartbeat_alive: Arc<AtomicBool>,
488    maintenance_alive: Arc<AtomicBool>,
489    leader: Arc<AtomicBool>,
490    /// Shared overflow pool for weighted mode (None in hard-reserved mode).
491    overflow_pool: Option<Arc<OverflowPool>>,
492    metrics: crate::metrics::AwaMetrics,
493    leader_election_interval: Option<Duration>,
494    leader_check_interval: Option<Duration>,
495    completed_retention: Option<Duration>,
496    failed_retention: Option<Duration>,
497    cleanup_batch_size: Option<i64>,
498    cleanup_interval: Option<Duration>,
499    queue_retention_overrides: HashMap<String, RetentionPolicy>,
500    global_max_workers: Option<u32>,
501    runtime_snapshot_interval: Duration,
502    runtime_instance_id: Uuid,
503    runtime_started_at: DateTime<Utc>,
504    runtime_hostname: Option<String>,
505    runtime_pid: i32,
506    runtime_version: &'static str,
507}
508
509#[derive(Clone)]
510struct RuntimeReporterState {
511    pool: PgPool,
512    queues: Vec<(String, QueueConfig)>,
513    queue_in_flight: Arc<HashMap<String, Arc<AtomicU32>>>,
514    dispatcher_alive: Arc<HashMap<String, Arc<AtomicBool>>>,
515    heartbeat_alive: Arc<AtomicBool>,
516    maintenance_alive: Arc<AtomicBool>,
517    leader: Arc<AtomicBool>,
518    dispatch_cancel: CancellationToken,
519    overflow_pool: Option<Arc<OverflowPool>>,
520    global_max_workers: Option<u32>,
521    instance_id: Uuid,
522    started_at: DateTime<Utc>,
523    hostname: Option<String>,
524    pid: i32,
525    version: &'static str,
526    snapshot_interval: Duration,
527}
528
529impl Client {
530    /// Create a new builder.
531    pub fn builder(pool: PgPool) -> ClientBuilder {
532        ClientBuilder::new(pool)
533    }
534
535    fn runtime_reporter_state(&self) -> RuntimeReporterState {
536        RuntimeReporterState {
537            pool: self.pool.clone(),
538            queues: self.queues.clone(),
539            queue_in_flight: self.queue_in_flight.clone(),
540            dispatcher_alive: self.dispatcher_alive.clone(),
541            heartbeat_alive: self.heartbeat_alive.clone(),
542            maintenance_alive: self.maintenance_alive.clone(),
543            leader: self.leader.clone(),
544            dispatch_cancel: self.dispatch_cancel.clone(),
545            overflow_pool: self.overflow_pool.clone(),
546            global_max_workers: self.global_max_workers,
547            instance_id: self.runtime_instance_id,
548            started_at: self.runtime_started_at,
549            hostname: self.runtime_hostname.clone(),
550            pid: self.runtime_pid,
551            version: self.runtime_version,
552            snapshot_interval: self.runtime_snapshot_interval,
553        }
554    }
555
556    async fn publish_runtime_snapshot(&self) {
557        let reporter = self.runtime_reporter_state();
558        reporter.publish_snapshot().await;
559    }
560
561    /// Start the worker runtime. Spawns dispatchers, heartbeat, and maintenance.
562    pub async fn start(&self) -> Result<(), awa_model::AwaError> {
563        info!(
564            queues = self.queues.len(),
565            workers = self.workers.len(),
566            "Starting Awa worker runtime"
567        );
568
569        // Completion batcher stays alive during drain so tasks can release
570        // only after their completion has been acknowledged.
571        let (completion_batcher, completion_handle) = CompletionBatcher::new(
572            self.pool.clone(),
573            self.service_cancel.clone(),
574            self.metrics.clone(),
575        );
576
577        // Create executor with metrics
578        let executor = Arc::new(JobExecutor::new(
579            self.pool.clone(),
580            self.workers.clone(),
581            self.lifecycle_handlers.clone(),
582            self.in_flight.clone(),
583            self.queue_in_flight.clone(),
584            self.state.clone(),
585            self.metrics.clone(),
586            completion_handle,
587        ));
588
589        let mut service_handles = self.service_handles.write().await;
590
591        service_handles.extend(completion_batcher.spawn());
592
593        // Start heartbeat service (uses service_cancel — stays alive during drain)
594        let heartbeat = HeartbeatService::new(
595            self.pool.clone(),
596            self.in_flight.clone(),
597            self.heartbeat_interval,
598            self.heartbeat_alive.clone(),
599            self.service_cancel.clone(),
600            self.metrics.clone(),
601        );
602        service_handles.push(tokio::spawn(async move {
603            heartbeat.run().await;
604        }));
605
606        // Start maintenance service (uses service_cancel — stays alive during drain)
607        let mut maintenance = MaintenanceService::new(
608            self.pool.clone(),
609            self.metrics.clone(),
610            self.leader.clone(),
611            self.maintenance_alive.clone(),
612            self.service_cancel.clone(),
613            self.periodic_jobs.clone(),
614            self.in_flight.clone(),
615        )
616        .promote_interval(self.promote_interval);
617        if let Some(interval) = self.heartbeat_rescue_interval {
618            maintenance = maintenance.heartbeat_rescue_interval(interval);
619        }
620        if let Some(interval) = self.deadline_rescue_interval {
621            maintenance = maintenance.deadline_rescue_interval(interval);
622        }
623        if let Some(interval) = self.callback_rescue_interval {
624            maintenance = maintenance.callback_rescue_interval(interval);
625        }
626        if let Some(interval) = self.leader_election_interval {
627            maintenance = maintenance.leader_election_interval(interval);
628        }
629        if let Some(interval) = self.leader_check_interval {
630            maintenance = maintenance.leader_check_interval(interval);
631        }
632        if let Some(retention) = self.completed_retention {
633            maintenance = maintenance.completed_retention(retention);
634        }
635        if let Some(retention) = self.failed_retention {
636            maintenance = maintenance.failed_retention(retention);
637        }
638        if let Some(batch_size) = self.cleanup_batch_size {
639            maintenance = maintenance.cleanup_batch_size(batch_size);
640        }
641        if let Some(interval) = self.cleanup_interval {
642            maintenance = maintenance.cleanup_interval(interval);
643        }
644        if !self.queue_retention_overrides.is_empty() {
645            maintenance =
646                maintenance.queue_retention_overrides(self.queue_retention_overrides.clone());
647        }
648        service_handles.push(tokio::spawn(async move {
649            maintenance.run().await;
650        }));
651
652        // Start a dispatcher per queue (uses dispatch_cancel — stops claiming first)
653        let mut dispatcher_handles = self.dispatcher_handles.write().await;
654        for (queue_name, config) in &self.queues {
655            let alive = self
656                .dispatcher_alive
657                .get(queue_name)
658                .cloned()
659                .unwrap_or_else(|| Arc::new(AtomicBool::new(false)));
660
661            let dispatcher = if let Some(overflow_pool) = &self.overflow_pool {
662                // Weighted mode
663                let concurrency = ConcurrencyMode::Weighted {
664                    local_semaphore: Arc::new(tokio::sync::Semaphore::new(
665                        config.min_workers as usize,
666                    )),
667                    overflow_pool: overflow_pool.clone(),
668                    queue_name: queue_name.clone(),
669                };
670                Dispatcher::with_concurrency(
671                    queue_name.clone(),
672                    config.clone(),
673                    self.pool.clone(),
674                    executor.clone(),
675                    self.metrics.clone(),
676                    self.in_flight.clone(),
677                    alive,
678                    self.dispatch_cancel.clone(),
679                    self.job_set.clone(),
680                    concurrency,
681                )
682            } else {
683                // Hard-reserved mode (default)
684                Dispatcher::new(
685                    queue_name.clone(),
686                    config.clone(),
687                    self.pool.clone(),
688                    executor.clone(),
689                    self.metrics.clone(),
690                    self.in_flight.clone(),
691                    alive,
692                    self.dispatch_cancel.clone(),
693                    self.job_set.clone(),
694                )
695            };
696            dispatcher_handles.push(tokio::spawn(async move {
697                dispatcher.run().await;
698            }));
699        }
700
701        self.publish_runtime_snapshot().await;
702
703        let reporter = self.runtime_reporter_state();
704        service_handles.push(tokio::spawn(async move {
705            reporter.run().await;
706        }));
707
708        info!("Awa worker runtime started");
709        Ok(())
710    }
711
712    /// Graceful shutdown with drain timeout.
713    ///
714    /// Phased lifecycle:
715    /// 1. Stop dispatchers (no new jobs claimed)
716    /// 2. Signal in-flight jobs to cancel
717    /// 3. Wait for dispatchers to exit
718    /// 4. Drain in-flight jobs (heartbeat + maintenance still alive!)
719    /// 5. Stop heartbeat + maintenance
720    pub async fn shutdown(&self, timeout: Duration) {
721        info!("Initiating graceful shutdown");
722
723        // Phase 1: Stop claiming new jobs
724        self.dispatch_cancel.cancel();
725
726        self.publish_runtime_snapshot().await;
727
728        // Phase 2: Signal in-flight cancellation flags
729        for flag in self.in_flight.flags() {
730            flag.store(true, Ordering::SeqCst);
731        }
732
733        // Phase 3: Wait for dispatchers to exit their poll loops
734        let dispatcher_handles: Vec<_> = {
735            let mut guard = self.dispatcher_handles.write().await;
736            std::mem::take(&mut *guard)
737        };
738        for handle in dispatcher_handles {
739            let _ = handle.await;
740        }
741
742        // Phase 4: Drain in-flight jobs (heartbeat + maintenance still alive)
743        let drain = async {
744            let mut set = self.job_set.lock().await;
745            while set.join_next().await.is_some() {}
746        };
747        if tokio::time::timeout(timeout, drain).await.is_err() {
748            warn!(
749                timeout_secs = timeout.as_secs(),
750                "Shutdown drain timeout exceeded, some jobs may not have completed"
751            );
752        }
753
754        // Phase 5: Stop background services (heartbeat + maintenance)
755        self.service_cancel.cancel();
756        let service_handles: Vec<_> = {
757            let mut guard = self.service_handles.write().await;
758            std::mem::take(&mut *guard)
759        };
760        for handle in service_handles {
761            let _ = handle.await;
762        }
763
764        info!("Awa worker runtime stopped");
765    }
766
767    /// Get the pool reference.
768    pub fn pool(&self) -> &PgPool {
769        &self.pool
770    }
771
772    /// Health check.
773    pub async fn health_check(&self) -> HealthCheck {
774        let postgres_connected = sqlx::query("SELECT 1").execute(&self.pool).await.is_ok();
775        let poll_loop_alive = self
776            .dispatcher_alive
777            .values()
778            .all(|alive| alive.load(Ordering::SeqCst));
779        let heartbeat_alive = self.heartbeat_alive.load(Ordering::SeqCst);
780        let maintenance_alive = self.maintenance_alive.load(Ordering::SeqCst);
781        let shutting_down = self.dispatch_cancel.is_cancelled();
782        let leader = self.leader.load(Ordering::SeqCst);
783        let available_rows = sqlx::query_as::<_, (String, i64)>(
784            r#"
785            SELECT queue, count(*)::bigint AS available
786            FROM awa.jobs_hot
787            WHERE state = 'available'
788            GROUP BY queue
789            "#,
790        )
791        .fetch_all(&self.pool)
792        .await
793        .unwrap_or_default();
794        let available_by_queue: HashMap<_, _> = available_rows.into_iter().collect();
795        let queues = self
796            .queues
797            .iter()
798            .map(|(queue, config)| {
799                let in_flight = self
800                    .queue_in_flight
801                    .get(queue)
802                    .map(|counter| counter.load(Ordering::SeqCst))
803                    .unwrap_or(0);
804                let available = available_by_queue.get(queue).copied().unwrap_or(0).max(0) as u64;
805                let capacity = if let Some(overflow_pool) = &self.overflow_pool {
806                    QueueCapacity::Weighted {
807                        min_workers: config.min_workers,
808                        weight: config.weight,
809                        overflow_held: overflow_pool.held(queue),
810                    }
811                } else {
812                    QueueCapacity::HardReserved {
813                        max_workers: config.max_workers,
814                    }
815                };
816                (
817                    queue.clone(),
818                    QueueHealth {
819                        in_flight,
820                        available,
821                        capacity,
822                    },
823                )
824            })
825            .collect();
826
827        HealthCheck {
828            healthy: postgres_connected
829                && poll_loop_alive
830                && heartbeat_alive
831                && maintenance_alive
832                && !shutting_down,
833            postgres_connected,
834            poll_loop_alive,
835            heartbeat_alive,
836            maintenance_alive,
837            shutting_down,
838            leader,
839            queues,
840        }
841    }
842}
843
844impl RuntimeReporterState {
845    fn queue_snapshot(&self, queue: &str, config: &QueueConfig) -> QueueRuntimeSnapshot {
846        let in_flight = self
847            .queue_in_flight
848            .get(queue)
849            .map(|counter| counter.load(Ordering::SeqCst))
850            .unwrap_or(0);
851
852        let (mode, max_workers, min_workers, weight, overflow_held) =
853            if let Some(overflow_pool) = &self.overflow_pool {
854                (
855                    QueueRuntimeMode::Weighted,
856                    None,
857                    Some(config.min_workers),
858                    Some(config.weight),
859                    Some(overflow_pool.held(queue)),
860                )
861            } else {
862                (
863                    QueueRuntimeMode::HardReserved,
864                    Some(config.max_workers),
865                    None,
866                    None,
867                    None,
868                )
869            };
870
871        QueueRuntimeSnapshot {
872            queue: queue.to_string(),
873            in_flight,
874            overflow_held,
875            config: QueueRuntimeConfigSnapshot {
876                mode,
877                max_workers,
878                min_workers,
879                weight,
880                global_max_workers: self.global_max_workers,
881                poll_interval_ms: config.poll_interval.as_millis() as u64,
882                deadline_duration_secs: config.deadline_duration.as_secs(),
883                priority_aging_interval_secs: config.priority_aging_interval.as_secs(),
884                rate_limit: config.rate_limit.as_ref().map(|rl| RateLimitSnapshot {
885                    max_rate: rl.max_rate,
886                    burst: rl.burst,
887                }),
888            },
889        }
890    }
891
892    async fn snapshot_input(&self) -> RuntimeSnapshotInput {
893        let postgres_connected = sqlx::query("SELECT 1").execute(&self.pool).await.is_ok();
894        let poll_loop_alive = self
895            .dispatcher_alive
896            .values()
897            .all(|alive| alive.load(Ordering::SeqCst));
898        let heartbeat_alive = self.heartbeat_alive.load(Ordering::SeqCst);
899        let maintenance_alive = self.maintenance_alive.load(Ordering::SeqCst);
900        let shutting_down = self.dispatch_cancel.is_cancelled();
901        let leader = self.leader.load(Ordering::SeqCst);
902        let healthy = postgres_connected
903            && poll_loop_alive
904            && heartbeat_alive
905            && maintenance_alive
906            && !shutting_down;
907        let queues = self
908            .queues
909            .iter()
910            .map(|(queue, config)| self.queue_snapshot(queue, config))
911            .collect();
912
913        RuntimeSnapshotInput {
914            instance_id: self.instance_id,
915            hostname: self.hostname.clone(),
916            pid: self.pid,
917            version: self.version.to_string(),
918            started_at: self.started_at,
919            snapshot_interval_ms: self.snapshot_interval.as_millis() as i64,
920            healthy,
921            postgres_connected,
922            poll_loop_alive,
923            heartbeat_alive,
924            maintenance_alive,
925            shutting_down,
926            leader,
927            global_max_workers: self.global_max_workers,
928            queues,
929        }
930    }
931
932    async fn publish_snapshot(&self) {
933        let snapshot = self.snapshot_input().await;
934        if let Err(err) = admin::upsert_runtime_snapshot(&self.pool, &snapshot).await {
935            warn!(error = %err, "Failed to publish runtime snapshot");
936        }
937    }
938
939    async fn run(self) {
940        let mut interval = tokio::time::interval(self.snapshot_interval);
941        interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
942        interval.tick().await;
943        loop {
944            tokio::select! {
945                _ = self.dispatch_cancel.cancelled() => {
946                    self.publish_snapshot().await;
947                    break;
948                }
949                _ = interval.tick() => {
950                    self.publish_snapshot().await;
951                }
952            }
953        }
954    }
955}