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#[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#[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#[derive(Debug, Clone)]
60pub struct QueueHealth {
61 pub in_flight: u32,
62 pub available: u64,
63 pub capacity: QueueCapacity,
65}
66
67#[derive(Debug, Clone)]
69pub enum QueueCapacity {
70 HardReserved { max_workers: u32 },
72 Weighted {
74 min_workers: u32,
75 weight: u32,
76 overflow_held: u32,
77 },
78}
79
80pub 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 pub fn queue(mut self, name: impl Into<String>, config: QueueConfig) -> Self {
142 self.queues.push((name.into(), config));
143 self
144 }
145
146 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 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 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 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 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 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 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 #[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 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 pub fn heartbeat_interval(mut self, interval: Duration) -> Self {
296 self.heartbeat_interval = interval;
297 self
298 }
299
300 pub fn promote_interval(mut self, interval: Duration) -> Self {
302 self.promote_interval = interval;
303 self
304 }
305
306 pub fn heartbeat_rescue_interval(mut self, interval: Duration) -> Self {
308 self.heartbeat_rescue_interval = Some(interval);
309 self
310 }
311
312 pub fn heartbeat_staleness(mut self, staleness: Duration) -> Self {
316 self.heartbeat_staleness = Some(staleness);
317 self
318 }
319
320 pub fn deadline_rescue_interval(mut self, interval: Duration) -> Self {
322 self.deadline_rescue_interval = Some(interval);
323 self
324 }
325
326 pub fn callback_rescue_interval(mut self, interval: Duration) -> Self {
328 self.callback_rescue_interval = Some(interval);
329 self
330 }
331
332 pub fn leader_election_interval(mut self, interval: Duration) -> Self {
337 self.leader_election_interval = Some(interval);
338 self
339 }
340
341 pub fn leader_check_interval(mut self, interval: Duration) -> Self {
343 self.leader_check_interval = Some(interval);
344 self
345 }
346
347 pub fn global_max_workers(mut self, max: u32) -> Self {
352 self.global_max_workers = Some(max);
353 self
354 }
355
356 pub fn completed_retention(mut self, retention: Duration) -> Self {
358 self.completed_retention = Some(retention);
359 self
360 }
361
362 pub fn failed_retention(mut self, retention: Duration) -> Self {
364 self.failed_retention = Some(retention);
365 self
366 }
367
368 pub fn descriptor_retention(mut self, retention: Duration) -> Self {
373 self.descriptor_retention = Some(retention);
374 self
375 }
376
377 pub fn cleanup_batch_size(mut self, batch_size: i64) -> Self {
379 self.cleanup_batch_size = Some(batch_size);
380 self
381 }
382
383 pub fn cleanup_interval(mut self, interval: Duration) -> Self {
385 self.cleanup_interval = Some(interval);
386 self
387 }
388
389 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 pub fn runtime_snapshot_interval(mut self, interval: Duration) -> Self {
397 self.runtime_snapshot_interval = interval;
398 self
399 }
400
401 pub fn queue_stats_interval(mut self, interval: Duration) -> Self {
403 self.queue_stats_interval = Some(interval);
404 self
405 }
406
407 pub fn periodic(mut self, job: PeriodicJob) -> Self {
412 self.periodic_jobs.push(job);
413 self
414 }
415
416 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 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 if let Some(bs) = self.cleanup_batch_size {
444 if bs <= 0 {
445 return Err(BuildError::InvalidBatchSize);
446 }
447 }
448
449 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 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
547struct 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
578pub 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 dispatch_cancel: CancellationToken,
596 service_cancel: CancellationToken,
598 dispatcher_handles: RwLock<Vec<tokio::task::JoinHandle<()>>>,
600 service_handles: RwLock<Vec<tokio::task::JoinHandle<()>>>,
602 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 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 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 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 let (completion_batcher, completion_handle) = CompletionBatcher::new(
751 self.pool.clone(),
752 self.service_cancel.clone(),
753 self.metrics.clone(),
754 );
755
756 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 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 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 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 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 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 pub async fn shutdown(&self, timeout: Duration) {
909 info!("Initiating graceful shutdown");
910
911 self.dispatch_cancel.cancel();
913
914 self.publish_runtime_snapshot().await;
915
916 for flag in self.in_flight.flags() {
918 flag.store(true, Ordering::SeqCst);
919 }
920
921 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 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 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 pub fn pool(&self) -> &PgPool {
957 &self.pool
958 }
959
960 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 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}