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, JobKindDescriptor, NamedJobKindDescriptor, NamedQueueDescriptor, QueueDescriptor,
10    QueueRuntimeConfigSnapshot, QueueRuntimeMode, QueueRuntimeSnapshot, RateLimitSnapshot,
11    RuntimeSnapshotInput, StorageCapability,
12};
13use awa_model::{JobArgs, PeriodicJob};
14use chrono::{DateTime, Utc};
15use serde::de::DeserializeOwned;
16use sqlx::PgPool;
17use std::any::{Any, TypeId};
18use std::collections::HashMap;
19use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
20use std::sync::Arc;
21use std::time::Duration;
22use tokio::sync::{Mutex, RwLock};
23use tokio::task::JoinSet;
24use tokio_util::sync::CancellationToken;
25use tracing::{info, warn};
26use uuid::Uuid;
27
28/// Errors returned when building a worker client.
29#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
30pub enum BuildError {
31    #[error("at least one queue must be configured")]
32    NoQueuesConfigured,
33    #[error("queue descriptor declared for unknown queue '{queue}'")]
34    QueueDescriptorWithoutQueue { queue: String },
35    #[error("sum of min_workers ({total_min}) exceeds global_max_workers ({global_max})")]
36    MinWorkersExceedGlobal { total_min: u32, global_max: u32 },
37    #[error("rate_limit max_rate must be > 0.0")]
38    InvalidRateLimit,
39    #[error("queue weight must be > 0")]
40    InvalidWeight,
41    #[error("cleanup_batch_size must be > 0")]
42    InvalidBatchSize,
43}
44
45/// Health check result.
46#[derive(Debug, Clone)]
47pub struct HealthCheck {
48    pub healthy: bool,
49    pub postgres_connected: bool,
50    pub poll_loop_alive: bool,
51    pub heartbeat_alive: bool,
52    pub maintenance_alive: bool,
53    pub shutting_down: bool,
54    pub leader: bool,
55    pub queues: HashMap<String, QueueHealth>,
56}
57
58/// Per-queue health.
59#[derive(Debug, Clone)]
60pub struct QueueHealth {
61    pub in_flight: u32,
62    pub available: u64,
63    /// Capacity interpretation depends on mode.
64    pub capacity: QueueCapacity,
65}
66
67/// Capacity information for a queue, mode-dependent.
68#[derive(Debug, Clone)]
69pub enum QueueCapacity {
70    /// Hard-reserved: fixed max.
71    HardReserved { max_workers: u32 },
72    /// Weighted: min guaranteed + current overflow.
73    Weighted {
74        min_workers: u32,
75        weight: u32,
76        overflow_held: u32,
77    },
78}
79
80/// Builder for the Awa worker client.
81pub struct ClientBuilder {
82    pool: PgPool,
83    queues: Vec<(String, QueueConfig)>,
84    queue_descriptors: HashMap<String, QueueDescriptor>,
85    job_kind_descriptors: HashMap<String, JobKindDescriptor>,
86    workers: HashMap<String, BoxedWorker>,
87    lifecycle_handlers: HashMap<String, Vec<BoxedUntypedEventHandler>>,
88    state: HashMap<TypeId, Box<dyn Any + Send + Sync>>,
89    heartbeat_interval: Duration,
90    promote_interval: Duration,
91    heartbeat_rescue_interval: Option<Duration>,
92    heartbeat_staleness: Option<Duration>,
93    deadline_rescue_interval: Option<Duration>,
94    callback_rescue_interval: Option<Duration>,
95    periodic_jobs: Vec<PeriodicJob>,
96    global_max_workers: Option<u32>,
97    leader_election_interval: Option<Duration>,
98    leader_check_interval: Option<Duration>,
99    completed_retention: Option<Duration>,
100    failed_retention: Option<Duration>,
101    descriptor_retention: Option<Duration>,
102    cleanup_batch_size: Option<i64>,
103    cleanup_interval: Option<Duration>,
104    queue_retention_overrides: HashMap<String, RetentionPolicy>,
105    runtime_snapshot_interval: Duration,
106    queue_stats_interval: Option<Duration>,
107}
108
109impl ClientBuilder {
110    pub fn new(pool: PgPool) -> Self {
111        Self {
112            pool,
113            queues: Vec::new(),
114            queue_descriptors: HashMap::new(),
115            job_kind_descriptors: HashMap::new(),
116            workers: HashMap::new(),
117            lifecycle_handlers: HashMap::new(),
118            state: HashMap::new(),
119            heartbeat_interval: Duration::from_secs(30),
120            promote_interval: Duration::from_millis(250),
121            heartbeat_rescue_interval: None,
122            heartbeat_staleness: None,
123            deadline_rescue_interval: None,
124            callback_rescue_interval: None,
125            periodic_jobs: Vec::new(),
126            global_max_workers: None,
127            leader_election_interval: None,
128            leader_check_interval: None,
129            completed_retention: None,
130            failed_retention: None,
131            descriptor_retention: None,
132            cleanup_batch_size: None,
133            cleanup_interval: None,
134            queue_retention_overrides: HashMap::new(),
135            runtime_snapshot_interval: Duration::from_secs(10),
136            queue_stats_interval: None,
137        }
138    }
139
140    /// Add a queue with its configuration.
141    pub fn queue(mut self, name: impl Into<String>, config: QueueConfig) -> Self {
142        self.queues.push((name.into(), config));
143        self
144    }
145
146    /// Attach descriptive metadata (display name, description, owner,
147    /// docs URL, tags, extra JSON) to a queue so it appears labelled in
148    /// the admin API and UI. The queue must also be declared via
149    /// [`queue`]; otherwise [`build`] fails with
150    /// [`BuildError::QueueDescriptorWithoutQueue`].
151    ///
152    /// [`queue`]: ClientBuilder::queue
153    /// [`build`]: ClientBuilder::build
154    pub fn queue_descriptor(
155        mut self,
156        name: impl Into<String>,
157        descriptor: QueueDescriptor,
158    ) -> Self {
159        self.queue_descriptors.insert(name.into(), descriptor);
160        self
161    }
162
163    /// Attach descriptive metadata to a typed job kind. The kind string is
164    /// taken from [`JobArgs::kind`] on `T`.
165    pub fn job_kind_descriptor<T: JobArgs>(mut self, descriptor: JobKindDescriptor) -> Self {
166        self.job_kind_descriptors
167            .insert(T::kind().to_string(), descriptor);
168        self
169    }
170
171    /// Attach descriptive metadata to a job kind by string name. Useful
172    /// when the kind is known dynamically (e.g. from language bridges).
173    pub fn job_kind_descriptor_kind(
174        mut self,
175        kind: impl Into<String>,
176        descriptor: JobKindDescriptor,
177    ) -> Self {
178        self.job_kind_descriptors.insert(kind.into(), descriptor);
179        self
180    }
181
182    /// Register a typed worker.
183    ///
184    /// The worker handles jobs of type `T` where `T: JobArgs + DeserializeOwned`.
185    /// The handler function receives the deserialized args and job context.
186    pub fn register<T, F, Fut>(mut self, handler: F) -> Self
187    where
188        T: JobArgs + DeserializeOwned + Send + Sync + 'static,
189        F: Fn(T, &crate::context::JobContext) -> Fut + Send + Sync + 'static,
190        Fut: std::future::Future<Output = Result<JobResult, JobError>> + Send + Sync + 'static,
191    {
192        let kind = T::kind().to_string();
193        let worker = TypedWorker {
194            kind: T::kind(),
195            handler: Arc::new(handler),
196            _phantom: std::marker::PhantomData,
197        };
198        self.workers.insert(kind, Box::new(worker));
199        self
200    }
201
202    /// Register a typed lifecycle event handler for a job kind.
203    ///
204    /// Handlers run only after the corresponding DB state transition commits.
205    /// They are best-effort in-process hooks, not a durable workflow mechanism.
206    /// Capture any shared dependencies you need in the closure environment.
207    pub fn on_event<T, F, Fut>(mut self, handler: F) -> Self
208    where
209        T: JobArgs + DeserializeOwned + Send + Sync + 'static,
210        F: Fn(JobEvent<T>) -> Fut + Send + Sync + 'static,
211        Fut: std::future::Future<Output = ()> + Send + 'static,
212    {
213        let kind = T::kind().to_string();
214        let handler = Arc::new(handler);
215        let erased: BoxedUntypedEventHandler = Arc::new(move |event: UntypedJobEvent| {
216            let handler = handler.clone();
217            Box::pin(async move {
218                let args: T = match serde_json::from_value(event.job().args.clone()) {
219                    Ok(args) => args,
220                    Err(err) => {
221                        warn!(
222                            job_id = event.job().id,
223                            kind = %event.job().kind,
224                            error = %err,
225                            "Failed to deserialize args for lifecycle event handler"
226                        );
227                        return;
228                    }
229                };
230
231                (handler)(event.into_typed(args)).await;
232            })
233        });
234        self.lifecycle_handlers
235            .entry(kind)
236            .or_default()
237            .push(erased);
238        self
239    }
240
241    /// Register an untyped lifecycle event handler for a specific job kind.
242    ///
243    /// Use this with `register_worker(...)` or for cross-cutting logic that
244    /// doesn't need typed args.
245    pub fn on_event_kind<F, Fut>(mut self, kind: impl Into<String>, handler: F) -> Self
246    where
247        F: Fn(UntypedJobEvent) -> Fut + Send + Sync + 'static,
248        Fut: std::future::Future<Output = ()> + Send + 'static,
249    {
250        let kind = kind.into();
251        let handler = Arc::new(handler);
252        let erased: BoxedUntypedEventHandler = Arc::new(move |event: UntypedJobEvent| {
253            let handler = handler.clone();
254            Box::pin(async move {
255                (handler)(event).await;
256            })
257        });
258        self.lifecycle_handlers
259            .entry(kind)
260            .or_default()
261            .push(erased);
262        self
263    }
264
265    /// Register a raw worker implementation.
266    pub fn register_worker(mut self, worker: impl Worker + 'static) -> Self {
267        let kind = worker.kind().to_string();
268        self.workers.insert(kind, Box::new(worker));
269        self
270    }
271
272    /// Register an HTTP worker that dispatches jobs to a remote endpoint.
273    ///
274    /// In async mode the worker POSTs the job and parks in `waiting_external`.
275    /// In sync mode the worker awaits the HTTP response directly.
276    ///
277    /// Requires the `http-worker` feature.
278    #[cfg(feature = "http-worker")]
279    pub fn http_worker(
280        self,
281        kind: impl Into<String>,
282        config: crate::http_worker::HttpWorkerConfig,
283    ) -> Self {
284        let worker = crate::http_worker::HttpWorker::new(kind.into(), config);
285        self.register_worker(worker)
286    }
287
288    /// Add shared state accessible via `ctx.extract::<T>()`.
289    pub fn state<T: Any + Send + Sync + Clone>(mut self, value: T) -> Self {
290        self.state.insert(TypeId::of::<T>(), Box::new(value));
291        self
292    }
293
294    /// Set the heartbeat interval (default: 30s).
295    pub fn heartbeat_interval(mut self, interval: Duration) -> Self {
296        self.heartbeat_interval = interval;
297        self
298    }
299
300    /// Set the scheduled/retryable promotion interval (default: 250ms).
301    pub fn promote_interval(mut self, interval: Duration) -> Self {
302        self.promote_interval = interval;
303        self
304    }
305
306    /// Set the stale-heartbeat rescue interval (default: 30s).
307    pub fn heartbeat_rescue_interval(mut self, interval: Duration) -> Self {
308        self.heartbeat_rescue_interval = Some(interval);
309        self
310    }
311
312    /// Set how long a heartbeat must be stale before the job is rescued (default: 90s).
313    ///
314    /// Should be at least 3× the heartbeat interval to avoid false rescues.
315    pub fn heartbeat_staleness(mut self, staleness: Duration) -> Self {
316        self.heartbeat_staleness = Some(staleness);
317        self
318    }
319
320    /// Set the deadline rescue interval (default: 30s).
321    pub fn deadline_rescue_interval(mut self, interval: Duration) -> Self {
322        self.deadline_rescue_interval = Some(interval);
323        self
324    }
325
326    /// Set the callback-timeout rescue interval (default: 30s).
327    pub fn callback_rescue_interval(mut self, interval: Duration) -> Self {
328        self.callback_rescue_interval = Some(interval);
329        self
330    }
331
332    /// Set the leader election retry interval (default: 10s).
333    ///
334    /// Controls how often a non-leader instance retries acquiring the maintenance
335    /// advisory lock. Lower values are useful in tests.
336    pub fn leader_election_interval(mut self, interval: Duration) -> Self {
337        self.leader_election_interval = Some(interval);
338        self
339    }
340
341    /// Set the leader connection health-check interval (default: 30s).
342    pub fn leader_check_interval(mut self, interval: Duration) -> Self {
343        self.leader_check_interval = Some(interval);
344        self
345    }
346
347    /// Set a global maximum worker count across all queues (enables weighted mode).
348    ///
349    /// When set, each queue gets `min_workers` guaranteed permits plus a share
350    /// of the remaining overflow capacity based on `weight`.
351    pub fn global_max_workers(mut self, max: u32) -> Self {
352        self.global_max_workers = Some(max);
353        self
354    }
355
356    /// Set retention for completed jobs (default: 24h).
357    pub fn completed_retention(mut self, retention: Duration) -> Self {
358        self.completed_retention = Some(retention);
359        self
360    }
361
362    /// Set retention for failed/cancelled jobs (default: 72h).
363    pub fn failed_retention(mut self, retention: Duration) -> Self {
364        self.failed_retention = Some(retention);
365        self
366    }
367
368    /// How long a descriptor catalog row can go un-refreshed before the
369    /// maintenance leader deletes it (default: 30 days). Pass
370    /// `Duration::ZERO` to disable — the catalog will then accumulate
371    /// rows indefinitely. See [`MaintenanceService::descriptor_retention`].
372    pub fn descriptor_retention(mut self, retention: Duration) -> Self {
373        self.descriptor_retention = Some(retention);
374        self
375    }
376
377    /// Set the maximum number of jobs to delete per cleanup pass (default: 1000).
378    pub fn cleanup_batch_size(mut self, batch_size: i64) -> Self {
379        self.cleanup_batch_size = Some(batch_size);
380        self
381    }
382
383    /// Set the cleanup interval (default: 60s).
384    pub fn cleanup_interval(mut self, interval: Duration) -> Self {
385        self.cleanup_interval = Some(interval);
386        self
387    }
388
389    /// Set a per-queue retention override.
390    pub fn queue_retention(mut self, queue: impl Into<String>, policy: RetentionPolicy) -> Self {
391        self.queue_retention_overrides.insert(queue.into(), policy);
392        self
393    }
394
395    /// Set how often runtime observability snapshots are published (default: 10s).
396    pub fn runtime_snapshot_interval(mut self, interval: Duration) -> Self {
397        self.runtime_snapshot_interval = interval;
398        self
399    }
400
401    /// Set how often queue depth/lag metrics are published (default: 30s).
402    pub fn queue_stats_interval(mut self, interval: Duration) -> Self {
403        self.queue_stats_interval = Some(interval);
404        self
405    }
406
407    /// Register a periodic (cron) job schedule.
408    ///
409    /// The schedule is synced to the database by the leader and evaluated
410    /// every second. When a fire is due, a job is atomically enqueued.
411    pub fn periodic(mut self, job: PeriodicJob) -> Self {
412        self.periodic_jobs.push(job);
413        self
414    }
415
416    /// Build the client.
417    pub fn build(self) -> Result<Client, BuildError> {
418        if self.queues.is_empty() {
419            return Err(BuildError::NoQueuesConfigured);
420        }
421
422        for queue in self.queue_descriptors.keys() {
423            if !self.queues.iter().any(|(name, _)| name == queue) {
424                return Err(BuildError::QueueDescriptorWithoutQueue {
425                    queue: queue.clone(),
426                });
427            }
428        }
429
430        // Validate rate limits and weights
431        for (_, config) in &self.queues {
432            if let Some(rl) = &config.rate_limit {
433                if rl.max_rate <= 0.0 {
434                    return Err(BuildError::InvalidRateLimit);
435                }
436            }
437            if config.weight == 0 {
438                return Err(BuildError::InvalidWeight);
439            }
440        }
441
442        // Validate batch size
443        if let Some(bs) = self.cleanup_batch_size {
444            if bs <= 0 {
445                return Err(BuildError::InvalidBatchSize);
446            }
447        }
448
449        // Validate weighted mode constraints
450        let overflow_pool = if let Some(global_max) = self.global_max_workers {
451            let total_min: u32 = self.queues.iter().map(|(_, c)| c.min_workers).sum();
452            if total_min > global_max {
453                return Err(BuildError::MinWorkersExceedGlobal {
454                    total_min,
455                    global_max,
456                });
457            }
458            let overflow_capacity = global_max - total_min;
459            let weights: HashMap<String, u32> = self
460                .queues
461                .iter()
462                .map(|(name, c)| (name.clone(), c.weight.max(1)))
463                .collect();
464            Some(Arc::new(OverflowPool::new(overflow_capacity, weights)))
465        } else {
466            None
467        };
468
469        // Warn if heartbeat_staleness is less than 3× heartbeat_interval
470        if let Some(staleness) = self.heartbeat_staleness {
471            let min_safe = self.heartbeat_interval * 3;
472            if staleness < min_safe {
473                tracing::warn!(
474                    heartbeat_staleness_ms = staleness.as_millis() as u64,
475                    heartbeat_interval_ms = self.heartbeat_interval.as_millis() as u64,
476                    recommended_min_ms = min_safe.as_millis() as u64,
477                    "heartbeat_staleness ({:?}) is less than 3× heartbeat_interval ({:?}); \
478                     this may cause false rescues of jobs that are still running",
479                    staleness,
480                    self.heartbeat_interval,
481                );
482            }
483        }
484
485        let metrics = crate::metrics::AwaMetrics::from_global();
486        let queue_in_flight = Arc::new(
487            self.queues
488                .iter()
489                .map(|(name, _)| (name.clone(), Arc::new(AtomicU32::new(0))))
490                .collect(),
491        );
492        let dispatcher_alive = Arc::new(
493            self.queues
494                .iter()
495                .map(|(name, _)| (name.clone(), Arc::new(AtomicBool::new(false))))
496                .collect(),
497        );
498
499        Ok(Client {
500            pool: self.pool,
501            queues: self.queues,
502            queue_descriptors: self.queue_descriptors,
503            job_kind_descriptors: self.job_kind_descriptors,
504            workers: Arc::new(self.workers),
505            lifecycle_handlers: Arc::new(self.lifecycle_handlers),
506            state: Arc::new(self.state),
507            heartbeat_interval: self.heartbeat_interval,
508            promote_interval: self.promote_interval,
509            heartbeat_rescue_interval: self.heartbeat_rescue_interval,
510            heartbeat_staleness: self.heartbeat_staleness,
511            deadline_rescue_interval: self.deadline_rescue_interval,
512            callback_rescue_interval: self.callback_rescue_interval,
513            periodic_jobs: Arc::new(self.periodic_jobs),
514            dispatch_cancel: CancellationToken::new(),
515            service_cancel: CancellationToken::new(),
516            dispatcher_handles: RwLock::new(Vec::new()),
517            service_handles: RwLock::new(Vec::new()),
518            job_set: Arc::new(Mutex::new(JoinSet::new())),
519            in_flight: Arc::new(InFlightRegistry::default()),
520            queue_in_flight,
521            dispatcher_alive,
522            heartbeat_alive: Arc::new(AtomicBool::new(false)),
523            maintenance_alive: Arc::new(AtomicBool::new(false)),
524            leader: Arc::new(AtomicBool::new(false)),
525            overflow_pool,
526            metrics,
527            leader_election_interval: self.leader_election_interval,
528            leader_check_interval: self.leader_check_interval,
529            completed_retention: self.completed_retention,
530            failed_retention: self.failed_retention,
531            descriptor_retention: self.descriptor_retention,
532            cleanup_batch_size: self.cleanup_batch_size,
533            cleanup_interval: self.cleanup_interval,
534            queue_retention_overrides: self.queue_retention_overrides,
535            queue_stats_interval: self.queue_stats_interval,
536            global_max_workers: self.global_max_workers,
537            runtime_snapshot_interval: self.runtime_snapshot_interval,
538            runtime_instance_id: Uuid::new_v4(),
539            runtime_started_at: Utc::now(),
540            runtime_hostname: std::env::var("HOSTNAME").ok(),
541            runtime_pid: std::process::id() as i32,
542            runtime_version: env!("CARGO_PKG_VERSION"),
543        })
544    }
545}
546
547/// A typed worker that deserializes args and calls a handler function.
548struct TypedWorker<T, F, Fut>
549where
550    T: JobArgs + DeserializeOwned + Send + Sync + 'static,
551    F: Fn(T, &crate::context::JobContext) -> Fut + Send + Sync + 'static,
552    Fut: std::future::Future<Output = Result<JobResult, JobError>> + Send + Sync + 'static,
553{
554    kind: &'static str,
555    handler: Arc<F>,
556    _phantom: std::marker::PhantomData<fn() -> (T, Fut)>,
557}
558
559#[async_trait::async_trait]
560impl<T, F, Fut> Worker for TypedWorker<T, F, Fut>
561where
562    T: JobArgs + DeserializeOwned + Send + Sync + 'static,
563    F: Fn(T, &crate::context::JobContext) -> Fut + Send + Sync + 'static,
564    Fut: std::future::Future<Output = Result<JobResult, JobError>> + Send + Sync + 'static,
565{
566    fn kind(&self) -> &'static str {
567        self.kind
568    }
569
570    async fn perform(&self, ctx: &crate::context::JobContext) -> Result<JobResult, JobError> {
571        let args: T = serde_json::from_value(ctx.job.args.clone())
572            .map_err(|err| JobError::Terminal(format!("failed to deserialize args: {}", err)))?;
573
574        (self.handler)(args, ctx).await
575    }
576}
577
578/// The Awa worker client — manages dispatchers, heartbeat, and maintenance.
579pub struct Client {
580    pool: PgPool,
581    queues: Vec<(String, QueueConfig)>,
582    queue_descriptors: HashMap<String, QueueDescriptor>,
583    job_kind_descriptors: HashMap<String, JobKindDescriptor>,
584    workers: Arc<HashMap<String, BoxedWorker>>,
585    lifecycle_handlers: Arc<HashMap<String, Vec<BoxedUntypedEventHandler>>>,
586    state: Arc<HashMap<TypeId, Box<dyn Any + Send + Sync>>>,
587    heartbeat_interval: Duration,
588    promote_interval: Duration,
589    heartbeat_rescue_interval: Option<Duration>,
590    heartbeat_staleness: Option<Duration>,
591    deadline_rescue_interval: Option<Duration>,
592    callback_rescue_interval: Option<Duration>,
593    periodic_jobs: Arc<Vec<PeriodicJob>>,
594    /// Cancellation token for dispatchers only — stops claiming new jobs.
595    dispatch_cancel: CancellationToken,
596    /// Cancellation token for heartbeat + maintenance — kept alive during drain.
597    service_cancel: CancellationToken,
598    /// Handles for dispatcher tasks.
599    dispatcher_handles: RwLock<Vec<tokio::task::JoinHandle<()>>>,
600    /// Handles for service tasks (heartbeat + maintenance).
601    service_handles: RwLock<Vec<tokio::task::JoinHandle<()>>>,
602    /// JoinSet tracking in-flight job tasks for graceful drain.
603    job_set: Arc<Mutex<JoinSet<()>>>,
604    in_flight: InFlightMap,
605    queue_in_flight: Arc<HashMap<String, Arc<AtomicU32>>>,
606    dispatcher_alive: Arc<HashMap<String, Arc<AtomicBool>>>,
607    heartbeat_alive: Arc<AtomicBool>,
608    maintenance_alive: Arc<AtomicBool>,
609    leader: Arc<AtomicBool>,
610    /// Shared overflow pool for weighted mode (None in hard-reserved mode).
611    overflow_pool: Option<Arc<OverflowPool>>,
612    metrics: crate::metrics::AwaMetrics,
613    leader_election_interval: Option<Duration>,
614    leader_check_interval: Option<Duration>,
615    completed_retention: Option<Duration>,
616    failed_retention: Option<Duration>,
617    descriptor_retention: Option<Duration>,
618    cleanup_batch_size: Option<i64>,
619    cleanup_interval: Option<Duration>,
620    queue_retention_overrides: HashMap<String, RetentionPolicy>,
621    queue_stats_interval: Option<Duration>,
622    global_max_workers: Option<u32>,
623    runtime_snapshot_interval: Duration,
624    runtime_instance_id: Uuid,
625    runtime_started_at: DateTime<Utc>,
626    runtime_hostname: Option<String>,
627    runtime_pid: i32,
628    runtime_version: &'static str,
629}
630
631#[derive(Clone)]
632struct RuntimeReporterState {
633    pool: PgPool,
634    queues: Vec<(String, QueueConfig)>,
635    queue_descriptors: HashMap<String, QueueDescriptor>,
636    job_kind_descriptors: HashMap<String, JobKindDescriptor>,
637    worker_kinds: Vec<String>,
638    queue_in_flight: Arc<HashMap<String, Arc<AtomicU32>>>,
639    dispatcher_alive: Arc<HashMap<String, Arc<AtomicBool>>>,
640    heartbeat_alive: Arc<AtomicBool>,
641    maintenance_alive: Arc<AtomicBool>,
642    leader: Arc<AtomicBool>,
643    dispatch_cancel: CancellationToken,
644    overflow_pool: Option<Arc<OverflowPool>>,
645    global_max_workers: Option<u32>,
646    instance_id: Uuid,
647    started_at: DateTime<Utc>,
648    hostname: Option<String>,
649    pid: i32,
650    version: &'static str,
651    snapshot_interval: Duration,
652    metrics: crate::metrics::AwaMetrics,
653}
654
655impl Client {
656    /// Create a new builder.
657    pub fn builder(pool: PgPool) -> ClientBuilder {
658        ClientBuilder::new(pool)
659    }
660
661    fn declared_queue_descriptors(&self) -> Vec<NamedQueueDescriptor> {
662        self.queues
663            .iter()
664            .map(|(queue, _)| NamedQueueDescriptor {
665                queue: queue.clone(),
666                descriptor: self
667                    .queue_descriptors
668                    .get(queue)
669                    .cloned()
670                    .unwrap_or_default(),
671            })
672            .collect()
673    }
674
675    fn declared_job_kind_descriptors(&self) -> Vec<NamedJobKindDescriptor> {
676        let mut kinds: Vec<String> = self.workers.keys().cloned().collect();
677        for kind in self.job_kind_descriptors.keys() {
678            if !kinds.iter().any(|existing| existing == kind) {
679                kinds.push(kind.clone());
680            }
681        }
682        kinds.sort();
683
684        kinds
685            .into_iter()
686            .map(|kind| NamedJobKindDescriptor {
687                descriptor: self
688                    .job_kind_descriptors
689                    .get(&kind)
690                    .cloned()
691                    .unwrap_or_default(),
692                kind,
693            })
694            .collect()
695    }
696
697    fn runtime_reporter_state(&self) -> RuntimeReporterState {
698        RuntimeReporterState {
699            pool: self.pool.clone(),
700            queues: self.queues.clone(),
701            queue_descriptors: self.queue_descriptors.clone(),
702            job_kind_descriptors: self.job_kind_descriptors.clone(),
703            worker_kinds: self.workers.keys().cloned().collect(),
704            queue_in_flight: self.queue_in_flight.clone(),
705            dispatcher_alive: self.dispatcher_alive.clone(),
706            heartbeat_alive: self.heartbeat_alive.clone(),
707            maintenance_alive: self.maintenance_alive.clone(),
708            leader: self.leader.clone(),
709            dispatch_cancel: self.dispatch_cancel.clone(),
710            overflow_pool: self.overflow_pool.clone(),
711            global_max_workers: self.global_max_workers,
712            instance_id: self.runtime_instance_id,
713            started_at: self.runtime_started_at,
714            hostname: self.runtime_hostname.clone(),
715            pid: self.runtime_pid,
716            version: self.runtime_version,
717            snapshot_interval: self.runtime_snapshot_interval,
718            metrics: self.metrics.clone(),
719        }
720    }
721
722    async fn publish_runtime_snapshot(&self) {
723        let reporter = self.runtime_reporter_state();
724        reporter.publish_snapshot().await;
725    }
726
727    /// Start the worker runtime. Spawns dispatchers, heartbeat, and maintenance.
728    pub async fn start(&self) -> Result<(), awa_model::AwaError> {
729        info!(
730            queues = self.queues.len(),
731            workers = self.workers.len(),
732            "Starting Awa worker runtime"
733        );
734
735        admin::sync_queue_descriptors(
736            &self.pool,
737            &self.declared_queue_descriptors(),
738            self.runtime_snapshot_interval,
739        )
740        .await?;
741        admin::sync_job_kind_descriptors(
742            &self.pool,
743            &self.declared_job_kind_descriptors(),
744            self.runtime_snapshot_interval,
745        )
746        .await?;
747
748        // Completion batcher stays alive during drain so tasks can release
749        // only after their completion has been acknowledged.
750        let (completion_batcher, completion_handle) = CompletionBatcher::new(
751            self.pool.clone(),
752            self.service_cancel.clone(),
753            self.metrics.clone(),
754        );
755
756        // Create executor with metrics
757        let executor = Arc::new(JobExecutor::new(
758            self.pool.clone(),
759            self.workers.clone(),
760            self.lifecycle_handlers.clone(),
761            self.in_flight.clone(),
762            self.queue_in_flight.clone(),
763            self.state.clone(),
764            self.metrics.clone(),
765            completion_handle,
766        ));
767
768        let mut service_handles = self.service_handles.write().await;
769
770        service_handles.extend(completion_batcher.spawn());
771
772        // Start heartbeat service (uses service_cancel — stays alive during drain)
773        let heartbeat = HeartbeatService::new(
774            self.pool.clone(),
775            self.in_flight.clone(),
776            self.heartbeat_interval,
777            self.heartbeat_alive.clone(),
778            self.service_cancel.clone(),
779            self.metrics.clone(),
780        );
781        service_handles.push(tokio::spawn(async move {
782            heartbeat.run().await;
783        }));
784
785        // Start maintenance service (uses service_cancel — stays alive during drain)
786        let mut maintenance = MaintenanceService::new(
787            self.pool.clone(),
788            self.metrics.clone(),
789            self.leader.clone(),
790            self.maintenance_alive.clone(),
791            self.service_cancel.clone(),
792            self.periodic_jobs.clone(),
793            self.in_flight.clone(),
794        )
795        .promote_interval(self.promote_interval);
796        if let Some(interval) = self.heartbeat_rescue_interval {
797            maintenance = maintenance.heartbeat_rescue_interval(interval);
798        }
799        if let Some(staleness) = self.heartbeat_staleness {
800            maintenance = maintenance.heartbeat_staleness(staleness);
801        }
802        if let Some(interval) = self.deadline_rescue_interval {
803            maintenance = maintenance.deadline_rescue_interval(interval);
804        }
805        if let Some(interval) = self.callback_rescue_interval {
806            maintenance = maintenance.callback_rescue_interval(interval);
807        }
808        if let Some(interval) = self.leader_election_interval {
809            maintenance = maintenance.leader_election_interval(interval);
810        }
811        if let Some(interval) = self.leader_check_interval {
812            maintenance = maintenance.leader_check_interval(interval);
813        }
814        if let Some(retention) = self.completed_retention {
815            maintenance = maintenance.completed_retention(retention);
816        }
817        if let Some(retention) = self.failed_retention {
818            maintenance = maintenance.failed_retention(retention);
819        }
820        if let Some(retention) = self.descriptor_retention {
821            maintenance = maintenance.descriptor_retention(retention);
822        }
823        if let Some(batch_size) = self.cleanup_batch_size {
824            maintenance = maintenance.cleanup_batch_size(batch_size);
825        }
826        if let Some(interval) = self.cleanup_interval {
827            maintenance = maintenance.cleanup_interval(interval);
828        }
829        if !self.queue_retention_overrides.is_empty() {
830            maintenance =
831                maintenance.queue_retention_overrides(self.queue_retention_overrides.clone());
832        }
833        if let Some(interval) = self.queue_stats_interval {
834            maintenance = maintenance.queue_stats_interval(interval);
835        }
836        service_handles.push(tokio::spawn(async move {
837            maintenance.run().await;
838        }));
839
840        // Start a dispatcher per queue (uses dispatch_cancel — stops claiming first)
841        let mut dispatcher_handles = self.dispatcher_handles.write().await;
842        for (queue_name, config) in &self.queues {
843            let alive = self
844                .dispatcher_alive
845                .get(queue_name)
846                .cloned()
847                .unwrap_or_else(|| Arc::new(AtomicBool::new(false)));
848
849            let dispatcher = if let Some(overflow_pool) = &self.overflow_pool {
850                // Weighted mode
851                let concurrency = ConcurrencyMode::Weighted {
852                    local_semaphore: Arc::new(tokio::sync::Semaphore::new(
853                        config.min_workers as usize,
854                    )),
855                    overflow_pool: overflow_pool.clone(),
856                    queue_name: queue_name.clone(),
857                };
858                Dispatcher::with_concurrency(
859                    queue_name.clone(),
860                    config.clone(),
861                    self.pool.clone(),
862                    executor.clone(),
863                    self.metrics.clone(),
864                    self.in_flight.clone(),
865                    alive,
866                    self.dispatch_cancel.clone(),
867                    self.job_set.clone(),
868                    concurrency,
869                )
870            } else {
871                // Hard-reserved mode (default)
872                Dispatcher::new(
873                    queue_name.clone(),
874                    config.clone(),
875                    self.pool.clone(),
876                    executor.clone(),
877                    self.metrics.clone(),
878                    self.in_flight.clone(),
879                    alive,
880                    self.dispatch_cancel.clone(),
881                    self.job_set.clone(),
882                )
883            };
884            dispatcher_handles.push(tokio::spawn(async move {
885                dispatcher.run().await;
886            }));
887        }
888
889        self.publish_runtime_snapshot().await;
890
891        let reporter = self.runtime_reporter_state();
892        service_handles.push(tokio::spawn(async move {
893            reporter.run().await;
894        }));
895
896        info!("Awa worker runtime started");
897        Ok(())
898    }
899
900    /// Graceful shutdown with drain timeout.
901    ///
902    /// Phased lifecycle:
903    /// 1. Stop dispatchers (no new jobs claimed)
904    /// 2. Signal in-flight jobs to cancel
905    /// 3. Wait for dispatchers to exit
906    /// 4. Drain in-flight jobs (heartbeat + maintenance still alive!)
907    /// 5. Stop heartbeat + maintenance
908    pub async fn shutdown(&self, timeout: Duration) {
909        info!("Initiating graceful shutdown");
910
911        // Phase 1: Stop claiming new jobs
912        self.dispatch_cancel.cancel();
913
914        self.publish_runtime_snapshot().await;
915
916        // Phase 2: Signal in-flight cancellation flags
917        for flag in self.in_flight.flags() {
918            flag.store(true, Ordering::SeqCst);
919        }
920
921        // Phase 3: Wait for dispatchers to exit their poll loops
922        let dispatcher_handles: Vec<_> = {
923            let mut guard = self.dispatcher_handles.write().await;
924            std::mem::take(&mut *guard)
925        };
926        for handle in dispatcher_handles {
927            let _ = handle.await;
928        }
929
930        // Phase 4: Drain in-flight jobs (heartbeat + maintenance still alive)
931        let drain = async {
932            let mut set = self.job_set.lock().await;
933            while set.join_next().await.is_some() {}
934        };
935        if tokio::time::timeout(timeout, drain).await.is_err() {
936            warn!(
937                timeout_secs = timeout.as_secs(),
938                "Shutdown drain timeout exceeded, some jobs may not have completed"
939            );
940        }
941
942        // Phase 5: Stop background services (heartbeat + maintenance)
943        self.service_cancel.cancel();
944        let service_handles: Vec<_> = {
945            let mut guard = self.service_handles.write().await;
946            std::mem::take(&mut *guard)
947        };
948        for handle in service_handles {
949            let _ = handle.await;
950        }
951
952        info!("Awa worker runtime stopped");
953    }
954
955    /// Get the pool reference.
956    pub fn pool(&self) -> &PgPool {
957        &self.pool
958    }
959
960    /// Health check.
961    pub async fn health_check(&self) -> HealthCheck {
962        let postgres_connected = sqlx::query("SELECT 1").execute(&self.pool).await.is_ok();
963        let poll_loop_alive = self
964            .dispatcher_alive
965            .values()
966            .all(|alive| alive.load(Ordering::SeqCst));
967        let heartbeat_alive = self.heartbeat_alive.load(Ordering::SeqCst);
968        let maintenance_alive = self.maintenance_alive.load(Ordering::SeqCst);
969        let shutting_down = self.dispatch_cancel.is_cancelled();
970        let leader = self.leader.load(Ordering::SeqCst);
971        let available_rows = sqlx::query_as::<_, (String, i64)>(
972            r#"
973            SELECT queue, count(*)::bigint AS available
974            FROM awa.jobs_hot
975            WHERE state = 'available'
976            GROUP BY queue
977            "#,
978        )
979        .fetch_all(&self.pool)
980        .await
981        .unwrap_or_default();
982        let available_by_queue: HashMap<_, _> = available_rows.into_iter().collect();
983        let queues = self
984            .queues
985            .iter()
986            .map(|(queue, config)| {
987                let in_flight = self
988                    .queue_in_flight
989                    .get(queue)
990                    .map(|counter| counter.load(Ordering::SeqCst))
991                    .unwrap_or(0);
992                let available = available_by_queue.get(queue).copied().unwrap_or(0).max(0) as u64;
993                let capacity = if let Some(overflow_pool) = &self.overflow_pool {
994                    QueueCapacity::Weighted {
995                        min_workers: config.min_workers,
996                        weight: config.weight,
997                        overflow_held: overflow_pool.held(queue),
998                    }
999                } else {
1000                    QueueCapacity::HardReserved {
1001                        max_workers: config.max_workers,
1002                    }
1003                };
1004                (
1005                    queue.clone(),
1006                    QueueHealth {
1007                        in_flight,
1008                        available,
1009                        capacity,
1010                    },
1011                )
1012            })
1013            .collect();
1014
1015        HealthCheck {
1016            healthy: postgres_connected
1017                && poll_loop_alive
1018                && heartbeat_alive
1019                && maintenance_alive
1020                && !shutting_down,
1021            postgres_connected,
1022            poll_loop_alive,
1023            heartbeat_alive,
1024            maintenance_alive,
1025            shutting_down,
1026            leader,
1027            queues,
1028        }
1029    }
1030}
1031
1032impl RuntimeReporterState {
1033    fn queue_descriptor_hashes(&self) -> HashMap<String, String> {
1034        self.declared_queue_descriptors()
1035            .into_iter()
1036            .map(|named| (named.queue, named.descriptor.descriptor_hash()))
1037            .collect()
1038    }
1039
1040    fn job_kind_descriptor_hashes(&self) -> HashMap<String, String> {
1041        self.declared_job_kind_descriptors()
1042            .into_iter()
1043            .map(|named| (named.kind, named.descriptor.descriptor_hash()))
1044            .collect()
1045    }
1046
1047    fn declared_queue_descriptors(&self) -> Vec<NamedQueueDescriptor> {
1048        self.queues
1049            .iter()
1050            .map(|(queue, _)| NamedQueueDescriptor {
1051                queue: queue.clone(),
1052                descriptor: self
1053                    .queue_descriptors
1054                    .get(queue)
1055                    .cloned()
1056                    .unwrap_or_default(),
1057            })
1058            .collect()
1059    }
1060
1061    fn declared_job_kind_descriptors(&self) -> Vec<NamedJobKindDescriptor> {
1062        let mut kinds = self.worker_kinds.clone();
1063        for kind in self.job_kind_descriptors.keys() {
1064            if !kinds.iter().any(|existing| existing == kind) {
1065                kinds.push(kind.clone());
1066            }
1067        }
1068        kinds.sort();
1069        kinds.dedup();
1070
1071        kinds
1072            .into_iter()
1073            .map(|kind| NamedJobKindDescriptor {
1074                descriptor: self
1075                    .job_kind_descriptors
1076                    .get(&kind)
1077                    .cloned()
1078                    .unwrap_or_default(),
1079                kind,
1080            })
1081            .collect()
1082    }
1083
1084    fn queue_snapshot(&self, queue: &str, config: &QueueConfig) -> QueueRuntimeSnapshot {
1085        let in_flight = self
1086            .queue_in_flight
1087            .get(queue)
1088            .map(|counter| counter.load(Ordering::SeqCst))
1089            .unwrap_or(0);
1090
1091        let (mode, max_workers, min_workers, weight, overflow_held) =
1092            if let Some(overflow_pool) = &self.overflow_pool {
1093                (
1094                    QueueRuntimeMode::Weighted,
1095                    None,
1096                    Some(config.min_workers),
1097                    Some(config.weight),
1098                    Some(overflow_pool.held(queue)),
1099                )
1100            } else {
1101                (
1102                    QueueRuntimeMode::HardReserved,
1103                    Some(config.max_workers),
1104                    None,
1105                    None,
1106                    None,
1107                )
1108            };
1109
1110        QueueRuntimeSnapshot {
1111            queue: queue.to_string(),
1112            in_flight,
1113            overflow_held,
1114            config: QueueRuntimeConfigSnapshot {
1115                mode,
1116                max_workers,
1117                min_workers,
1118                weight,
1119                global_max_workers: self.global_max_workers,
1120                poll_interval_ms: config.poll_interval.as_millis() as u64,
1121                deadline_duration_secs: config.deadline_duration.as_secs(),
1122                priority_aging_interval_secs: config.priority_aging_interval.as_secs(),
1123                rate_limit: config.rate_limit.as_ref().map(|rl| RateLimitSnapshot {
1124                    max_rate: rl.max_rate,
1125                    burst: rl.burst,
1126                }),
1127            },
1128        }
1129    }
1130
1131    async fn snapshot_input(&self) -> RuntimeSnapshotInput {
1132        let postgres_connected = sqlx::query("SELECT 1").execute(&self.pool).await.is_ok();
1133        let poll_loop_alive = self
1134            .dispatcher_alive
1135            .values()
1136            .all(|alive| alive.load(Ordering::SeqCst));
1137        let heartbeat_alive = self.heartbeat_alive.load(Ordering::SeqCst);
1138        let maintenance_alive = self.maintenance_alive.load(Ordering::SeqCst);
1139        let shutting_down = self.dispatch_cancel.is_cancelled();
1140        let leader = self.leader.load(Ordering::SeqCst);
1141        let healthy = postgres_connected
1142            && poll_loop_alive
1143            && heartbeat_alive
1144            && maintenance_alive
1145            && !shutting_down;
1146        let queues = self
1147            .queues
1148            .iter()
1149            .map(|(queue, config)| self.queue_snapshot(queue, config))
1150            .collect();
1151
1152        RuntimeSnapshotInput {
1153            instance_id: self.instance_id,
1154            hostname: self.hostname.clone(),
1155            pid: self.pid,
1156            version: self.version.to_string(),
1157            storage_capability: StorageCapability::Canonical,
1158            started_at: self.started_at,
1159            snapshot_interval_ms: self.snapshot_interval.as_millis() as i64,
1160            healthy,
1161            postgres_connected,
1162            poll_loop_alive,
1163            heartbeat_alive,
1164            maintenance_alive,
1165            shutting_down,
1166            leader,
1167            global_max_workers: self.global_max_workers,
1168            queues,
1169            queue_descriptor_hashes: self.queue_descriptor_hashes(),
1170            job_kind_descriptor_hashes: self.job_kind_descriptor_hashes(),
1171        }
1172    }
1173
1174    async fn publish_snapshot(&self) {
1175        let queue_descriptors = self.declared_queue_descriptors();
1176        let kind_descriptors = self.declared_job_kind_descriptors();
1177
1178        if let Err(err) =
1179            admin::sync_queue_descriptors(&self.pool, &queue_descriptors, self.snapshot_interval)
1180                .await
1181        {
1182            warn!(error = %err, "Failed to sync queue descriptors");
1183        }
1184        if let Err(err) =
1185            admin::sync_job_kind_descriptors(&self.pool, &kind_descriptors, self.snapshot_interval)
1186                .await
1187        {
1188            warn!(error = %err, "Failed to sync job kind descriptors");
1189        }
1190
1191        // Emit OTel info gauges for every declared descriptor. One series per
1192        // descriptor, value=1, with all descriptor fields as attributes. Panels
1193        // lift descriptor fields into existing metrics via a Prometheus label
1194        // join: `awa_job_completed_total * on(awa_job_queue) group_left(awa_queue_display_name) awa_queue_info`.
1195        for named in &queue_descriptors {
1196            self.metrics.record_queue_info(
1197                &named.queue,
1198                named.descriptor.display_name.as_deref(),
1199                named.descriptor.description.as_deref(),
1200                named.descriptor.owner.as_deref(),
1201                named.descriptor.docs_url.as_deref(),
1202                &named.descriptor.tags,
1203            );
1204        }
1205        for named in &kind_descriptors {
1206            self.metrics.record_job_kind_info(
1207                &named.kind,
1208                named.descriptor.display_name.as_deref(),
1209                named.descriptor.description.as_deref(),
1210                named.descriptor.owner.as_deref(),
1211                named.descriptor.docs_url.as_deref(),
1212                &named.descriptor.tags,
1213            );
1214        }
1215
1216        let snapshot = self.snapshot_input().await;
1217        if let Err(err) = admin::upsert_runtime_snapshot(&self.pool, &snapshot).await {
1218            warn!(error = %err, "Failed to publish runtime snapshot");
1219        }
1220    }
1221
1222    async fn run(self) {
1223        let mut interval = tokio::time::interval(self.snapshot_interval);
1224        interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
1225        interval.tick().await;
1226        loop {
1227            tokio::select! {
1228                _ = self.dispatch_cancel.cancelled() => {
1229                    self.publish_snapshot().await;
1230                    break;
1231                }
1232                _ = interval.tick() => {
1233                    self.publish_snapshot().await;
1234                }
1235            }
1236        }
1237    }
1238}
1239
1240#[cfg(test)]
1241mod tests {
1242    use super::*;
1243    use sqlx::postgres::PgPoolOptions;
1244
1245    fn lazy_pool() -> PgPool {
1246        PgPoolOptions::new()
1247            .connect_lazy("postgres://postgres:test@localhost/awa_test")
1248            .expect("lazy pool should build")
1249    }
1250
1251    #[tokio::test]
1252    async fn queue_descriptor_requires_declared_queue() {
1253        let result = Client::builder(lazy_pool())
1254            .queue("default", QueueConfig::default())
1255            .queue_descriptor("billing", QueueDescriptor::new().display_name("Billing"))
1256            .build();
1257
1258        assert!(matches!(
1259            result,
1260            Err(BuildError::QueueDescriptorWithoutQueue { queue }) if queue == "billing"
1261        ));
1262    }
1263
1264    #[tokio::test]
1265    async fn queue_descriptor_allows_declared_queue() {
1266        let result = Client::builder(lazy_pool())
1267            .queue("billing", QueueConfig::default())
1268            .queue_descriptor("billing", QueueDescriptor::new().display_name("Billing"))
1269            .build();
1270
1271        assert!(result.is_ok(), "descriptor for declared queue should build");
1272    }
1273
1274    #[tokio::test]
1275    async fn job_kind_descriptor_allows_registered_kind() {
1276        #[derive(serde::Serialize, serde::Deserialize, awa_macros::JobArgs)]
1277        struct TestJob;
1278
1279        let result = Client::builder(lazy_pool())
1280            .queue("default", QueueConfig::default())
1281            .register::<TestJob, _, _>(|_args, _ctx| async { Ok(JobResult::Completed) })
1282            .job_kind_descriptor::<TestJob>(JobKindDescriptor::new().display_name("Test job"))
1283            .build();
1284
1285        assert!(
1286            result.is_ok(),
1287            "descriptor for registered kind should build"
1288        );
1289    }
1290}