1use crate::executor::{DispatchedJob, JobExecutor};
2use crate::runtime::InFlightMap;
3use crate::storage::RuntimeStorage;
4use awa_model::JobRow;
5use sqlx::PgPool;
6use std::collections::HashMap;
7use std::sync::atomic::{AtomicBool, Ordering};
8use std::sync::{Arc, Mutex as StdMutex, OnceLock};
9use std::time::{Duration, Instant};
10use tokio::sync::{Mutex, Notify, Semaphore};
11use tokio::task::JoinSet;
12use tokio_util::sync::CancellationToken;
13use tracing::{debug, info, warn};
14use uuid::Uuid;
15
16const DEFAULT_CLAIM_BATCH_LIMIT: usize = 512;
17const MAX_CLAIMERS_PER_QUEUE: i16 = 4;
18const CLAIMER_LEASE_TTL: Duration = Duration::from_secs(3);
19const CLAIMER_IDLE_THRESHOLD: Duration = Duration::from_millis(500);
20
21fn max_claimers_per_queue() -> i16 {
22 static MAX_CLAIMERS: OnceLock<i16> = OnceLock::new();
23 *MAX_CLAIMERS.get_or_init(|| {
24 let Ok(raw) = std::env::var("AWA_MAX_CLAIMERS_PER_QUEUE") else {
25 return MAX_CLAIMERS_PER_QUEUE;
26 };
27
28 match raw.parse::<i16>() {
29 Ok(value) if value > 0 => value,
30 Ok(value) => {
31 warn!(
32 value,
33 default = MAX_CLAIMERS_PER_QUEUE,
34 "AWA_MAX_CLAIMERS_PER_QUEUE must be positive; using default"
35 );
36 MAX_CLAIMERS_PER_QUEUE
37 }
38 Err(error) => {
39 warn!(
40 raw = %raw,
41 %error,
42 default = MAX_CLAIMERS_PER_QUEUE,
43 "Failed to parse AWA_MAX_CLAIMERS_PER_QUEUE; using default"
44 );
45 MAX_CLAIMERS_PER_QUEUE
46 }
47 }
48 })
49}
50
51#[derive(Debug, Clone, Copy)]
52enum WakeReason {
53 Notify,
54 Capacity,
55 Poll,
56}
57
58impl WakeReason {
59 fn as_str(self) -> &'static str {
60 match self {
61 WakeReason::Notify => "notify",
62 WakeReason::Capacity => "capacity",
63 WakeReason::Poll => "poll",
64 }
65 }
66}
67
68#[derive(Debug, Clone)]
70pub struct RateLimit {
71 pub max_rate: f64,
73 pub burst: u32,
75}
76
77pub(crate) struct TokenBucket {
79 tokens: f64,
80 max_tokens: f64,
81 refill_rate: f64,
82 last_refill: Instant,
83}
84
85impl TokenBucket {
86 fn new(rate_limit: &RateLimit) -> Self {
87 let burst = if rate_limit.burst == 0 {
88 (rate_limit.max_rate.ceil() as u32).max(1)
89 } else {
90 rate_limit.burst
91 };
92 Self {
93 tokens: burst as f64,
94 max_tokens: burst as f64,
95 refill_rate: rate_limit.max_rate,
96 last_refill: Instant::now(),
97 }
98 }
99
100 fn available(&mut self) -> u32 {
102 let now = Instant::now();
103 let elapsed = now.duration_since(self.last_refill).as_secs_f64();
104 self.tokens = (self.tokens + elapsed * self.refill_rate).min(self.max_tokens);
105 self.last_refill = now;
106 self.tokens.floor() as u32
107 }
108
109 fn consume(&mut self, n: u32) {
111 self.tokens -= n as f64;
112 }
113
114 fn refund(&mut self, n: u32) {
115 self.tokens = (self.tokens + n as f64).min(self.max_tokens);
116 }
117}
118
119pub(crate) fn shared_rate_limiter(config: &QueueConfig) -> Option<Arc<StdMutex<TokenBucket>>> {
120 config
121 .rate_limit
122 .as_ref()
123 .map(TokenBucket::new)
124 .map(|bucket| Arc::new(StdMutex::new(bucket)))
125}
126
127#[derive(Debug, Clone)]
129pub struct QueueConfig {
130 pub max_workers: u32,
131 pub poll_interval: Duration,
132 pub deadline_duration: Duration,
133 pub priority_aging_interval: Duration,
134 pub rate_limit: Option<RateLimit>,
136 pub min_workers: u32,
138 pub weight: u32,
140 pub claimers: u16,
146 pub claim_batch_size: usize,
148}
149
150impl Default for QueueConfig {
151 fn default() -> Self {
152 Self {
153 max_workers: 50,
154 poll_interval: Duration::from_millis(200),
155 deadline_duration: Duration::from_secs(300), priority_aging_interval: Duration::from_secs(60),
157 rate_limit: None,
158 min_workers: 0,
159 weight: 1,
160 claimers: 1,
161 claim_batch_size: DEFAULT_CLAIM_BATCH_LIMIT,
162 }
163 }
164}
165
166#[allow(dead_code)]
169pub(crate) enum DispatchPermit {
170 Hard(tokio::sync::OwnedSemaphorePermit),
172 Local(tokio::sync::OwnedSemaphorePermit),
174 Overflow {
176 pool: Arc<OverflowPool>,
177 queue: String,
178 },
179}
180
181impl Drop for DispatchPermit {
182 fn drop(&mut self) {
183 if let DispatchPermit::Overflow { pool, queue } = self {
184 pool.release(queue, 1);
185 }
186 }
188}
189
190pub(crate) enum ConcurrencyMode {
192 HardReserved { semaphore: Arc<Semaphore> },
194 Weighted {
196 local_semaphore: Arc<Semaphore>,
197 overflow_pool: Arc<OverflowPool>,
198 queue_name: String,
199 },
200}
201
202pub(crate) struct OverflowPool {
205 total: u32,
206 state: std::sync::Mutex<OverflowState>,
207}
208
209struct OverflowState {
210 held: HashMap<String, u32>,
212 demand: HashMap<String, u32>,
214 weights: HashMap<String, u32>,
216}
217
218impl OverflowPool {
219 pub fn new(total: u32, weights: HashMap<String, u32>) -> Self {
220 Self {
221 total,
222 state: std::sync::Mutex::new(OverflowState {
223 held: HashMap::new(),
224 demand: HashMap::new(),
225 weights,
226 }),
227 }
228 }
229
230 pub fn try_acquire(&self, queue: &str, wanted: u32) -> u32 {
235 let mut state = self.state.lock().unwrap();
236
237 state.demand.insert(queue.to_string(), wanted);
239
240 if wanted == 0 {
241 return 0;
242 }
243
244 let currently_used: u32 = state.held.values().sum();
245 let available = self.total.saturating_sub(currently_used);
246 if available == 0 {
247 return 0;
248 }
249
250 let my_weight = state.weights.get(queue).copied().unwrap_or(1);
251
252 let contending_weight: u32 = state
254 .weights
255 .iter()
256 .filter(|(q, _)| {
257 state.demand.get(q.as_str()).copied().unwrap_or(0) > 0
258 || state.held.get(q.as_str()).copied().unwrap_or(0) > 0
259 })
260 .map(|(_, w)| *w)
261 .sum();
262
263 if contending_weight == 0 {
264 return 0;
265 }
266
267 let my_fair_share =
269 ((self.total as f64) * (my_weight as f64 / contending_weight as f64)).ceil() as u32;
270 let my_held = state.held.get(queue).copied().unwrap_or(0);
271 let room = my_fair_share.saturating_sub(my_held);
272
273 let granted = wanted.min(available).min(room);
274 if granted > 0 {
275 *state.held.entry(queue.to_string()).or_insert(0) += granted;
276 }
277 granted
278 }
279
280 pub fn release(&self, queue: &str, n: u32) {
282 let mut state = self.state.lock().unwrap();
283 if let Some(held) = state.held.get_mut(queue) {
284 *held = held.saturating_sub(n);
285 }
286 }
287
288 pub fn held(&self, queue: &str) -> u32 {
290 let state = self.state.lock().unwrap();
291 state.held.get(queue).copied().unwrap_or(0)
292 }
293}
294
295pub struct Dispatcher {
297 queue: String,
298 runtime_instance_id: Uuid,
299 config: QueueConfig,
300 pool: PgPool,
301 executor: Arc<JobExecutor>,
302 metrics: crate::metrics::AwaMetrics,
303 _in_flight: InFlightMap,
304 concurrency: ConcurrencyMode,
305 alive: Arc<AtomicBool>,
306 cancel: CancellationToken,
307 job_set: Arc<Mutex<JoinSet<()>>>,
308 rate_limiter: Option<Arc<StdMutex<TokenBucket>>>,
309 storage: RuntimeStorage,
310 capacity_wake: Arc<Notify>,
311 claimer_owner_id: Uuid,
312}
313
314impl Dispatcher {
315 #[allow(clippy::too_many_arguments)]
317 pub(crate) fn with_concurrency(
318 queue: String,
319 runtime_instance_id: Uuid,
320 config: QueueConfig,
321 pool: PgPool,
322 executor: Arc<JobExecutor>,
323 metrics: crate::metrics::AwaMetrics,
324 in_flight: InFlightMap,
325 alive: Arc<AtomicBool>,
326 cancel: CancellationToken,
327 job_set: Arc<Mutex<JoinSet<()>>>,
328 concurrency: ConcurrencyMode,
329 rate_limiter: Option<Arc<StdMutex<TokenBucket>>>,
330 capacity_wake: Arc<Notify>,
331 claimer_owner_id: Uuid,
332 storage: RuntimeStorage,
333 ) -> Self {
334 Self {
335 queue,
336 runtime_instance_id,
337 config,
338 pool,
339 executor,
340 metrics,
341 _in_flight: in_flight,
342 concurrency,
343 alive,
344 cancel,
345 job_set,
346 rate_limiter,
347 storage,
348 capacity_wake,
349 claimer_owner_id,
350 }
351 }
352
353 pub async fn run(mut self) {
355 self.alive.store(true, Ordering::SeqCst);
356 info!(
357 queue = %self.queue,
358 runtime_instance_id = %self.runtime_instance_id,
359 claimer_owner_id = %self.claimer_owner_id,
360 poll_interval_ms = self.config.poll_interval.as_millis(),
361 "Dispatcher started"
362 );
363
364 let notify_channel = format!("awa:{}", self.queue);
366 let mut listener = match sqlx::postgres::PgListener::connect_with(&self.pool).await {
367 Ok(listener) => listener,
368 Err(err) => {
369 warn!(
370 queue = %self.queue,
371 error = %err,
372 "Failed to create PG listener, falling back to polling only"
373 );
374 self.poll_loop_only().await;
376 self.alive.store(false, Ordering::SeqCst);
377 return;
378 }
379 };
380
381 if let Err(err) = listener.listen(¬ify_channel).await {
382 warn!(
383 queue = %self.queue,
384 error = %err,
385 channel = %notify_channel,
386 "Failed to LISTEN, falling back to polling"
387 );
388 self.poll_loop_only().await;
389 self.alive.store(false, Ordering::SeqCst);
390 return;
391 }
392
393 debug!(
394 queue = %self.queue,
395 channel = %notify_channel,
396 "Listening for job notifications"
397 );
398
399 loop {
400 tokio::select! {
401 _ = self.cancel.cancelled() => {
402 debug!(queue = %self.queue, "Dispatcher shutting down");
403 break;
404 }
405 notification = listener.recv() => {
407 match notification {
408 Ok(_) => {
409 debug!(queue = %self.queue, "Woken by NOTIFY");
410 self.drain_ready(WakeReason::Notify, Instant::now()).await;
411 }
412 Err(err) => {
413 warn!(
414 queue = %self.queue,
415 error = %err,
416 "PG listener error, will retry"
417 );
418 tokio::time::sleep(Duration::from_secs(1)).await;
419 }
420 }
421 }
422 _ = self.capacity_wake.notified() => {
423 self.drain_ready(WakeReason::Capacity, Instant::now()).await;
424 }
425 _ = tokio::time::sleep(self.config.poll_interval) => {
426 self.drain_ready(WakeReason::Poll, Instant::now()).await;
427 }
428 }
429 }
430
431 self.alive.store(false, Ordering::SeqCst);
432 }
433
434 async fn poll_loop_only(&mut self) {
436 loop {
437 tokio::select! {
438 _ = self.cancel.cancelled() => {
439 debug!(queue = %self.queue, "Dispatcher (poll-only) shutting down");
440 break;
441 }
442 _ = self.capacity_wake.notified() => {
443 self.drain_ready(WakeReason::Capacity, Instant::now()).await;
444 }
445 _ = tokio::time::sleep(self.config.poll_interval) => {
446 self.drain_ready(WakeReason::Poll, Instant::now()).await;
447 }
448 }
449 }
450 }
451
452 fn acquire_permits(&mut self) -> Vec<DispatchPermit> {
454 let mut permits = Vec::new();
455 match &self.concurrency {
456 ConcurrencyMode::HardReserved { semaphore } => {
457 for _ in 0..self.config.claim_batch_size {
458 match semaphore.clone().try_acquire_owned() {
459 Ok(p) => permits.push(DispatchPermit::Hard(p)),
460 Err(_) => break,
461 }
462 }
463 }
464 ConcurrencyMode::Weighted {
465 local_semaphore,
466 overflow_pool,
467 queue_name,
468 } => {
469 for _ in 0..self.config.claim_batch_size {
471 match local_semaphore.clone().try_acquire_owned() {
472 Ok(p) => permits.push(DispatchPermit::Local(p)),
473 Err(_) => break,
474 }
475 }
476 let overflow_wanted =
478 (self.config.claim_batch_size.saturating_sub(permits.len())) as u32;
479 let granted = overflow_pool.try_acquire(queue_name, overflow_wanted);
480 for _ in 0..granted {
481 permits.push(DispatchPermit::Overflow {
482 pool: overflow_pool.clone(),
483 queue: queue_name.clone(),
484 });
485 }
486 }
487 }
488 permits
489 }
490
491 fn refund_rate_limit(&self, n: usize) {
492 if n == 0 {
493 return;
494 }
495 if let Some(rate_limiter) = &self.rate_limiter {
496 let mut rate_limiter = rate_limiter.lock().expect("rate limiter lock poisoned");
497 rate_limiter.refund(n as u32);
498 }
499 }
500
501 async fn drain_ready(&mut self, wake_reason: WakeReason, woke_at: Instant) {
504 self.metrics
505 .record_dispatch_wake(&self.queue, wake_reason.as_str());
506 let mut first_iteration = true;
507 while !self.cancel.is_cancelled() {
508 let wake_context = first_iteration.then_some((wake_reason, woke_at));
509 if !self.poll_once(wake_context).await {
510 break;
511 }
512 first_iteration = false;
513 }
514 }
515
516 #[tracing::instrument(
521 level = "debug",
522 parent = None,
523 skip(self),
524 fields(queue = %self.queue)
525 )]
526 async fn poll_once(&mut self, wake_context: Option<(WakeReason, Instant)>) -> bool {
527 let mut permits = self.acquire_permits();
529 if permits.is_empty() {
530 return false;
531 }
532 if let Some((reason, woke_at)) = wake_context {
533 self.metrics.record_dispatch_wake_to_claim(
534 &self.queue,
535 reason.as_str(),
536 woke_at.elapsed(),
537 );
538 self.metrics.record_dispatch_capacity_available(
539 &self.queue,
540 reason.as_str(),
541 permits.len() as u64,
542 );
543 }
544
545 let batch_size = if let Some(rate_limiter) = &self.rate_limiter {
547 let mut rate_limiter = rate_limiter.lock().expect("rate limiter lock poisoned");
548 let rate_available = rate_limiter.available() as usize;
549 let batch_size = permits
550 .len()
551 .min(rate_available)
552 .min(self.config.claim_batch_size);
553 if batch_size > 0 {
554 rate_limiter.consume(batch_size as u32);
555 }
556 batch_size
557 } else {
558 permits.len().min(self.config.claim_batch_size)
559 };
560 if batch_size == 0 {
561 if let Some((reason, _)) = wake_context {
563 self.metrics
564 .record_dispatch_rate_limited(&self.queue, reason.as_str());
565 }
566 return false;
567 }
568 while permits.len() > batch_size {
570 permits.pop(); }
572
573 let deadline_secs = self.config.deadline_duration.as_secs_f64();
585 let claim_start = Instant::now();
586
587 let jobs: Vec<DispatchedJob> = match &self.storage {
588 RuntimeStorage::Canonical => match sqlx::query_as::<_, JobRow>(
589 r#"
590 WITH claimed AS (
591 SELECT id
592 FROM awa.jobs_hot
593 WHERE state = 'available'
594 AND queue = $1
595 AND run_at <= now()
596 AND NOT EXISTS (
597 SELECT 1 FROM awa.queue_meta
598 WHERE queue = $1 AND paused = TRUE
599 )
600 ORDER BY priority ASC, run_at ASC, id ASC
601 LIMIT $2
602 FOR UPDATE SKIP LOCKED
603 )
604 UPDATE awa.jobs_hot
605 SET state = 'running',
606 attempt = attempt + 1,
607 run_lease = run_lease + 1,
608 attempted_at = now(),
609 heartbeat_at = now(),
610 deadline_at = now() + make_interval(secs => $3)
611 FROM claimed
612 WHERE awa.jobs_hot.id = claimed.id
613 AND awa.jobs_hot.state = 'available'
614 RETURNING awa.jobs_hot.*
615 "#,
616 )
617 .bind(&self.queue)
618 .bind(batch_size as i32)
619 .bind(deadline_secs)
620 .fetch_all(&self.pool)
621 .await
622 {
623 Ok(jobs) => jobs
624 .into_iter()
625 .map(|job| DispatchedJob {
626 job,
627 queue_storage_claim: None,
628 queue_storage_unique_states: None,
629 })
630 .collect(),
631 Err(err) => {
632 warn!(queue = %self.queue, error = %err, "Failed to claim jobs");
633 self.refund_rate_limit(batch_size);
634 return false;
635 }
636 },
637 RuntimeStorage::QueueStorage(runtime) => match runtime
638 .store
639 .claim_runtime_batch_with_aging_for_instance(
640 &self.pool,
641 &self.queue,
642 batch_size as i64,
643 self.config.deadline_duration,
644 self.config.priority_aging_interval,
645 self.claimer_owner_id,
646 max_claimers_per_queue().max(self.config.claimers as i16),
647 CLAIMER_LEASE_TTL,
648 CLAIMER_IDLE_THRESHOLD,
649 )
650 .await
651 {
652 Ok(jobs) => jobs
653 .into_iter()
654 .map(|claimed| DispatchedJob {
655 job: claimed.job,
656 queue_storage_claim: Some(claimed.claim),
657 queue_storage_unique_states: claimed.unique_states,
658 })
659 .collect(),
660 Err(err) => {
661 warn!(
662 queue = %self.queue,
663 error = %err,
664 "Failed to claim queue storage jobs"
665 );
666 self.refund_rate_limit(batch_size);
667 return false;
668 }
669 },
670 };
671 self.metrics
672 .record_claim_batch(&self.queue, jobs.len() as u64, claim_start.elapsed());
673 if !jobs.is_empty() {
674 let mut by_shard: std::collections::BTreeMap<i16, u64> =
680 std::collections::BTreeMap::new();
681 let mut canonical_count: u64 = 0;
682 for dispatched in &jobs {
683 match &dispatched.queue_storage_claim {
684 Some(claim) => *by_shard.entry(claim.enqueue_shard).or_default() += 1,
685 None => canonical_count += 1,
686 }
687 }
688 for (shard, count) in by_shard {
689 self.metrics
690 .record_job_claimed_by_shard(&self.queue, shard, count);
691 }
692 if canonical_count > 0 {
693 self.metrics
694 .record_job_claimed(&self.queue, canonical_count);
695 }
696 let now = chrono::Utc::now();
709 for job in &jobs {
710 let claim_at = job.job.attempted_at.unwrap_or(now);
711 let wait_secs = (claim_at - job.job.created_at).num_milliseconds() as f64 / 1000.0;
712 if wait_secs >= 0.0 {
713 self.metrics.record_wait_duration(&self.queue, wait_secs);
714 }
715 }
716 }
717 if jobs.len() < batch_size {
718 self.refund_rate_limit(batch_size - jobs.len());
719 }
720
721 let unused_permits = permits.len().saturating_sub(jobs.len());
723 while permits.len() > jobs.len() {
724 permits.pop();
725 }
726 if unused_permits > 0 {
727 self.metrics
728 .record_dispatch_unused_permits(&self.queue, unused_permits as u64);
729 }
730
731 if jobs.is_empty() {
733 if let Some((reason, _)) = wake_context {
734 self.metrics
735 .record_dispatch_empty_claim(&self.queue, reason.as_str());
736 }
737 if let ConcurrencyMode::Weighted {
738 overflow_pool,
739 queue_name,
740 ..
741 } = &self.concurrency
742 {
743 overflow_pool.try_acquire(queue_name, 0);
744 }
745 return false;
746 }
747
748 debug!(queue = %self.queue, count = jobs.len(), "Claimed jobs");
749
750 let mut set = self.job_set.lock().await;
752 while set.try_join_next().is_some() {}
758 for (job, permit) in jobs.into_iter().zip(permits) {
759 let cancel_flag = Arc::new(AtomicBool::new(false));
760 let task = self.executor.execute_task(job, cancel_flag);
761 let capacity_wake = self.capacity_wake.clone();
762 set.spawn(async move {
763 task.await;
764 drop(permit);
765 capacity_wake.notify_one();
766 });
767 }
768
769 true
770 }
771}