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