1use crate::completion::CompletionBatcher;
2use crate::dispatcher::{ConcurrencyMode, Dispatcher, OverflowPool, QueueConfig};
3use crate::executor::{BoxedWorker, JobError, JobExecutor, JobResult, Worker};
4use crate::heartbeat::HeartbeatService;
5use crate::maintenance::{MaintenanceService, RetentionPolicy};
6use crate::runtime::{InFlightMap, InFlightRegistry};
7use awa_model::{JobArgs, JobRow, PeriodicJob};
8use serde::de::DeserializeOwned;
9use sqlx::PgPool;
10use std::any::{Any, TypeId};
11use std::collections::HashMap;
12use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
13use std::sync::Arc;
14use std::time::Duration;
15use tokio::sync::{Mutex, RwLock};
16use tokio::task::JoinSet;
17use tokio_util::sync::CancellationToken;
18use tracing::{info, warn};
19
20#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
22pub enum BuildError {
23 #[error("at least one queue must be configured")]
24 NoQueuesConfigured,
25 #[error("sum of min_workers ({total_min}) exceeds global_max_workers ({global_max})")]
26 MinWorkersExceedGlobal { total_min: u32, global_max: u32 },
27 #[error("rate_limit max_rate must be > 0.0")]
28 InvalidRateLimit,
29 #[error("queue weight must be > 0")]
30 InvalidWeight,
31 #[error("cleanup_batch_size must be > 0")]
32 InvalidBatchSize,
33}
34
35#[derive(Debug, Clone)]
37pub struct HealthCheck {
38 pub healthy: bool,
39 pub postgres_connected: bool,
40 pub poll_loop_alive: bool,
41 pub heartbeat_alive: bool,
42 pub shutting_down: bool,
43 pub leader: bool,
44 pub queues: HashMap<String, QueueHealth>,
45}
46
47#[derive(Debug, Clone)]
49pub struct QueueHealth {
50 pub in_flight: u32,
51 pub available: u64,
52 pub capacity: QueueCapacity,
54}
55
56#[derive(Debug, Clone)]
58pub enum QueueCapacity {
59 HardReserved { max_workers: u32 },
61 Weighted {
63 min_workers: u32,
64 weight: u32,
65 overflow_held: u32,
66 },
67}
68
69pub struct ClientBuilder {
71 pool: PgPool,
72 queues: Vec<(String, QueueConfig)>,
73 workers: HashMap<String, BoxedWorker>,
74 state: HashMap<TypeId, Box<dyn Any + Send + Sync>>,
75 heartbeat_interval: Duration,
76 promote_interval: Duration,
77 periodic_jobs: Vec<PeriodicJob>,
78 global_max_workers: Option<u32>,
79 leader_election_interval: Option<Duration>,
80 completed_retention: Option<Duration>,
81 failed_retention: Option<Duration>,
82 cleanup_batch_size: Option<i64>,
83 cleanup_interval: Option<Duration>,
84 queue_retention_overrides: HashMap<String, RetentionPolicy>,
85}
86
87impl ClientBuilder {
88 pub fn new(pool: PgPool) -> Self {
89 Self {
90 pool,
91 queues: Vec::new(),
92 workers: HashMap::new(),
93 state: HashMap::new(),
94 heartbeat_interval: Duration::from_secs(30),
95 promote_interval: Duration::from_millis(250),
96 periodic_jobs: Vec::new(),
97 global_max_workers: None,
98 leader_election_interval: None,
99 completed_retention: None,
100 failed_retention: None,
101 cleanup_batch_size: None,
102 cleanup_interval: None,
103 queue_retention_overrides: HashMap::new(),
104 }
105 }
106
107 pub fn queue(mut self, name: impl Into<String>, config: QueueConfig) -> Self {
109 self.queues.push((name.into(), config));
110 self
111 }
112
113 pub fn register<T, F, Fut>(mut self, handler: F) -> Self
118 where
119 T: JobArgs + DeserializeOwned + Send + Sync + 'static,
120 F: Fn(T, &crate::context::JobContext) -> Fut + Send + Sync + 'static,
121 Fut: std::future::Future<Output = Result<JobResult, JobError>> + Send + Sync + 'static,
122 {
123 let kind = T::kind().to_string();
124 let worker = TypedWorker {
125 kind: T::kind(),
126 handler: Arc::new(handler),
127 _phantom: std::marker::PhantomData,
128 };
129 self.workers.insert(kind, Box::new(worker));
130 self
131 }
132
133 pub fn register_worker(mut self, worker: impl Worker + 'static) -> Self {
135 let kind = worker.kind().to_string();
136 self.workers.insert(kind, Box::new(worker));
137 self
138 }
139
140 pub fn state<T: Any + Send + Sync + Clone>(mut self, value: T) -> Self {
142 self.state.insert(TypeId::of::<T>(), Box::new(value));
143 self
144 }
145
146 pub fn heartbeat_interval(mut self, interval: Duration) -> Self {
148 self.heartbeat_interval = interval;
149 self
150 }
151
152 pub fn promote_interval(mut self, interval: Duration) -> Self {
154 self.promote_interval = interval;
155 self
156 }
157
158 pub fn leader_election_interval(mut self, interval: Duration) -> Self {
163 self.leader_election_interval = Some(interval);
164 self
165 }
166
167 pub fn global_max_workers(mut self, max: u32) -> Self {
172 self.global_max_workers = Some(max);
173 self
174 }
175
176 pub fn completed_retention(mut self, retention: Duration) -> Self {
178 self.completed_retention = Some(retention);
179 self
180 }
181
182 pub fn failed_retention(mut self, retention: Duration) -> Self {
184 self.failed_retention = Some(retention);
185 self
186 }
187
188 pub fn cleanup_batch_size(mut self, batch_size: i64) -> Self {
190 self.cleanup_batch_size = Some(batch_size);
191 self
192 }
193
194 pub fn cleanup_interval(mut self, interval: Duration) -> Self {
196 self.cleanup_interval = Some(interval);
197 self
198 }
199
200 pub fn queue_retention(mut self, queue: impl Into<String>, policy: RetentionPolicy) -> Self {
202 self.queue_retention_overrides.insert(queue.into(), policy);
203 self
204 }
205
206 pub fn periodic(mut self, job: PeriodicJob) -> Self {
211 self.periodic_jobs.push(job);
212 self
213 }
214
215 pub fn build(self) -> Result<Client, BuildError> {
217 if self.queues.is_empty() {
218 return Err(BuildError::NoQueuesConfigured);
219 }
220
221 for (_, config) in &self.queues {
223 if let Some(rl) = &config.rate_limit {
224 if rl.max_rate <= 0.0 {
225 return Err(BuildError::InvalidRateLimit);
226 }
227 }
228 if config.weight == 0 {
229 return Err(BuildError::InvalidWeight);
230 }
231 }
232
233 if let Some(bs) = self.cleanup_batch_size {
235 if bs <= 0 {
236 return Err(BuildError::InvalidBatchSize);
237 }
238 }
239
240 let overflow_pool = if let Some(global_max) = self.global_max_workers {
242 let total_min: u32 = self.queues.iter().map(|(_, c)| c.min_workers).sum();
243 if total_min > global_max {
244 return Err(BuildError::MinWorkersExceedGlobal {
245 total_min,
246 global_max,
247 });
248 }
249 let overflow_capacity = global_max - total_min;
250 let weights: HashMap<String, u32> = self
251 .queues
252 .iter()
253 .map(|(name, c)| (name.clone(), c.weight.max(1)))
254 .collect();
255 Some(Arc::new(OverflowPool::new(overflow_capacity, weights)))
256 } else {
257 None
258 };
259
260 let metrics = crate::metrics::AwaMetrics::from_global();
261 let queue_in_flight = Arc::new(
262 self.queues
263 .iter()
264 .map(|(name, _)| (name.clone(), Arc::new(AtomicU32::new(0))))
265 .collect(),
266 );
267 let dispatcher_alive = Arc::new(
268 self.queues
269 .iter()
270 .map(|(name, _)| (name.clone(), Arc::new(AtomicBool::new(false))))
271 .collect(),
272 );
273
274 Ok(Client {
275 pool: self.pool,
276 queues: self.queues,
277 workers: Arc::new(self.workers),
278 state: Arc::new(self.state),
279 heartbeat_interval: self.heartbeat_interval,
280 promote_interval: self.promote_interval,
281 periodic_jobs: Arc::new(self.periodic_jobs),
282 dispatch_cancel: CancellationToken::new(),
283 service_cancel: CancellationToken::new(),
284 dispatcher_handles: RwLock::new(Vec::new()),
285 service_handles: RwLock::new(Vec::new()),
286 job_set: Arc::new(Mutex::new(JoinSet::new())),
287 in_flight: Arc::new(InFlightRegistry::default()),
288 queue_in_flight,
289 dispatcher_alive,
290 heartbeat_alive: Arc::new(AtomicBool::new(false)),
291 leader: Arc::new(AtomicBool::new(false)),
292 overflow_pool,
293 metrics,
294 leader_election_interval: self.leader_election_interval,
295 completed_retention: self.completed_retention,
296 failed_retention: self.failed_retention,
297 cleanup_batch_size: self.cleanup_batch_size,
298 cleanup_interval: self.cleanup_interval,
299 queue_retention_overrides: self.queue_retention_overrides,
300 })
301 }
302}
303
304struct TypedWorker<T, F, Fut>
306where
307 T: JobArgs + DeserializeOwned + Send + Sync + 'static,
308 F: Fn(T, &crate::context::JobContext) -> Fut + Send + Sync + 'static,
309 Fut: std::future::Future<Output = Result<JobResult, JobError>> + Send + Sync + 'static,
310{
311 kind: &'static str,
312 handler: Arc<F>,
313 _phantom: std::marker::PhantomData<fn() -> (T, Fut)>,
314}
315
316#[async_trait::async_trait]
317impl<T, F, Fut> Worker for TypedWorker<T, F, Fut>
318where
319 T: JobArgs + DeserializeOwned + Send + Sync + 'static,
320 F: Fn(T, &crate::context::JobContext) -> Fut + Send + Sync + 'static,
321 Fut: std::future::Future<Output = Result<JobResult, JobError>> + Send + Sync + 'static,
322{
323 fn kind(&self) -> &'static str {
324 self.kind
325 }
326
327 async fn perform(
328 &self,
329 job_row: &JobRow,
330 ctx: &crate::context::JobContext,
331 ) -> Result<JobResult, JobError> {
332 let args: T = serde_json::from_value(job_row.args.clone())
334 .map_err(|err| JobError::Terminal(format!("failed to deserialize args: {}", err)))?;
335
336 (self.handler)(args, ctx).await
337 }
338}
339
340pub struct Client {
342 pool: PgPool,
343 queues: Vec<(String, QueueConfig)>,
344 workers: Arc<HashMap<String, BoxedWorker>>,
345 state: Arc<HashMap<TypeId, Box<dyn Any + Send + Sync>>>,
346 heartbeat_interval: Duration,
347 promote_interval: Duration,
348 periodic_jobs: Arc<Vec<PeriodicJob>>,
349 dispatch_cancel: CancellationToken,
351 service_cancel: CancellationToken,
353 dispatcher_handles: RwLock<Vec<tokio::task::JoinHandle<()>>>,
355 service_handles: RwLock<Vec<tokio::task::JoinHandle<()>>>,
357 job_set: Arc<Mutex<JoinSet<()>>>,
359 in_flight: InFlightMap,
360 queue_in_flight: Arc<HashMap<String, Arc<AtomicU32>>>,
361 dispatcher_alive: Arc<HashMap<String, Arc<AtomicBool>>>,
362 heartbeat_alive: Arc<AtomicBool>,
363 leader: Arc<AtomicBool>,
364 overflow_pool: Option<Arc<OverflowPool>>,
366 metrics: crate::metrics::AwaMetrics,
367 leader_election_interval: Option<Duration>,
368 completed_retention: Option<Duration>,
369 failed_retention: Option<Duration>,
370 cleanup_batch_size: Option<i64>,
371 cleanup_interval: Option<Duration>,
372 queue_retention_overrides: HashMap<String, RetentionPolicy>,
373}
374
375impl Client {
376 pub fn builder(pool: PgPool) -> ClientBuilder {
378 ClientBuilder::new(pool)
379 }
380
381 pub async fn start(&self) -> Result<(), awa_model::AwaError> {
383 info!(
384 queues = self.queues.len(),
385 workers = self.workers.len(),
386 "Starting Awa worker runtime"
387 );
388
389 let (completion_batcher, completion_handle) = CompletionBatcher::new(
392 self.pool.clone(),
393 self.service_cancel.clone(),
394 self.metrics.clone(),
395 );
396
397 let executor = Arc::new(JobExecutor::new(
399 self.pool.clone(),
400 self.workers.clone(),
401 self.in_flight.clone(),
402 self.queue_in_flight.clone(),
403 self.state.clone(),
404 self.metrics.clone(),
405 completion_handle,
406 ));
407
408 let mut service_handles = self.service_handles.write().await;
409
410 service_handles.extend(completion_batcher.spawn());
411
412 let heartbeat = HeartbeatService::new(
414 self.pool.clone(),
415 self.in_flight.clone(),
416 self.heartbeat_interval,
417 self.heartbeat_alive.clone(),
418 self.service_cancel.clone(),
419 );
420 service_handles.push(tokio::spawn(async move {
421 heartbeat.run().await;
422 }));
423
424 let mut maintenance = MaintenanceService::new(
426 self.pool.clone(),
427 self.metrics.clone(),
428 self.leader.clone(),
429 self.service_cancel.clone(),
430 self.periodic_jobs.clone(),
431 self.in_flight.clone(),
432 )
433 .promote_interval(self.promote_interval);
434 if let Some(interval) = self.leader_election_interval {
435 maintenance = maintenance.leader_election_interval(interval);
436 }
437 if let Some(retention) = self.completed_retention {
438 maintenance = maintenance.completed_retention(retention);
439 }
440 if let Some(retention) = self.failed_retention {
441 maintenance = maintenance.failed_retention(retention);
442 }
443 if let Some(batch_size) = self.cleanup_batch_size {
444 maintenance = maintenance.cleanup_batch_size(batch_size);
445 }
446 if let Some(interval) = self.cleanup_interval {
447 maintenance = maintenance.cleanup_interval(interval);
448 }
449 if !self.queue_retention_overrides.is_empty() {
450 maintenance =
451 maintenance.queue_retention_overrides(self.queue_retention_overrides.clone());
452 }
453 service_handles.push(tokio::spawn(async move {
454 maintenance.run().await;
455 }));
456
457 let mut dispatcher_handles = self.dispatcher_handles.write().await;
459 for (queue_name, config) in &self.queues {
460 let alive = self
461 .dispatcher_alive
462 .get(queue_name)
463 .cloned()
464 .unwrap_or_else(|| Arc::new(AtomicBool::new(false)));
465
466 let dispatcher = if let Some(overflow_pool) = &self.overflow_pool {
467 let concurrency = ConcurrencyMode::Weighted {
469 local_semaphore: Arc::new(tokio::sync::Semaphore::new(
470 config.min_workers as usize,
471 )),
472 overflow_pool: overflow_pool.clone(),
473 queue_name: queue_name.clone(),
474 };
475 Dispatcher::with_concurrency(
476 queue_name.clone(),
477 config.clone(),
478 self.pool.clone(),
479 executor.clone(),
480 self.metrics.clone(),
481 self.in_flight.clone(),
482 alive,
483 self.dispatch_cancel.clone(),
484 self.job_set.clone(),
485 concurrency,
486 )
487 } else {
488 Dispatcher::new(
490 queue_name.clone(),
491 config.clone(),
492 self.pool.clone(),
493 executor.clone(),
494 self.metrics.clone(),
495 self.in_flight.clone(),
496 alive,
497 self.dispatch_cancel.clone(),
498 self.job_set.clone(),
499 )
500 };
501 dispatcher_handles.push(tokio::spawn(async move {
502 dispatcher.run().await;
503 }));
504 }
505
506 info!("Awa worker runtime started");
507 Ok(())
508 }
509
510 pub async fn shutdown(&self, timeout: Duration) {
519 info!("Initiating graceful shutdown");
520
521 self.dispatch_cancel.cancel();
523
524 for flag in self.in_flight.flags() {
526 flag.store(true, Ordering::SeqCst);
527 }
528
529 let dispatcher_handles: Vec<_> = {
531 let mut guard = self.dispatcher_handles.write().await;
532 std::mem::take(&mut *guard)
533 };
534 for handle in dispatcher_handles {
535 let _ = handle.await;
536 }
537
538 let drain = async {
540 let mut set = self.job_set.lock().await;
541 while set.join_next().await.is_some() {}
542 };
543 if tokio::time::timeout(timeout, drain).await.is_err() {
544 warn!(
545 timeout_secs = timeout.as_secs(),
546 "Shutdown drain timeout exceeded, some jobs may not have completed"
547 );
548 }
549
550 self.service_cancel.cancel();
552 let service_handles: Vec<_> = {
553 let mut guard = self.service_handles.write().await;
554 std::mem::take(&mut *guard)
555 };
556 for handle in service_handles {
557 let _ = handle.await;
558 }
559
560 info!("Awa worker runtime stopped");
561 }
562
563 pub fn pool(&self) -> &PgPool {
565 &self.pool
566 }
567
568 pub async fn health_check(&self) -> HealthCheck {
570 let postgres_connected = sqlx::query("SELECT 1").execute(&self.pool).await.is_ok();
571 let poll_loop_alive = self
572 .dispatcher_alive
573 .values()
574 .all(|alive| alive.load(Ordering::SeqCst));
575 let heartbeat_alive = self.heartbeat_alive.load(Ordering::SeqCst);
576 let shutting_down = self.dispatch_cancel.is_cancelled();
577 let leader = self.leader.load(Ordering::SeqCst);
578 let available_rows = sqlx::query_as::<_, (String, i64)>(
579 r#"
580 SELECT queue, count(*)::bigint AS available
581 FROM awa.jobs_hot
582 WHERE state = 'available'
583 GROUP BY queue
584 "#,
585 )
586 .fetch_all(&self.pool)
587 .await
588 .unwrap_or_default();
589 let available_by_queue: HashMap<_, _> = available_rows.into_iter().collect();
590 let queues = self
591 .queues
592 .iter()
593 .map(|(queue, config)| {
594 let in_flight = self
595 .queue_in_flight
596 .get(queue)
597 .map(|counter| counter.load(Ordering::SeqCst))
598 .unwrap_or(0);
599 let available = available_by_queue.get(queue).copied().unwrap_or(0).max(0) as u64;
600 let capacity = if let Some(overflow_pool) = &self.overflow_pool {
601 QueueCapacity::Weighted {
602 min_workers: config.min_workers,
603 weight: config.weight,
604 overflow_held: overflow_pool.held(queue),
605 }
606 } else {
607 QueueCapacity::HardReserved {
608 max_workers: config.max_workers,
609 }
610 };
611 (
612 queue.clone(),
613 QueueHealth {
614 in_flight,
615 available,
616 capacity,
617 },
618 )
619 })
620 .collect();
621
622 HealthCheck {
623 healthy: postgres_connected && poll_loop_alive && heartbeat_alive && !shutting_down,
624 postgres_connected,
625 poll_loop_alive,
626 heartbeat_alive,
627 shutting_down,
628 leader,
629 queues,
630 }
631 }
632}