1use crate::executor::JobExecutor;
2use crate::runtime::InFlightMap;
3use awa_model::JobRow;
4use sqlx::PgPool;
5use std::collections::HashMap;
6use std::sync::atomic::{AtomicBool, Ordering};
7use std::sync::Arc;
8use std::time::{Duration, Instant};
9use tokio::sync::{Mutex, Semaphore};
10use tokio::task::JoinSet;
11use tokio_util::sync::CancellationToken;
12use tracing::{debug, error, info, warn};
13
14const CLAIM_BATCH_LIMIT: usize = 128;
15const CLAIM_CANDIDATES_PER_PRIORITY_MULTIPLIER: usize = 4;
16
17#[derive(Debug, Clone)]
19pub struct RateLimit {
20 pub max_rate: f64,
22 pub burst: u32,
24}
25
26struct TokenBucket {
28 tokens: f64,
29 max_tokens: f64,
30 refill_rate: f64,
31 last_refill: Instant,
32}
33
34impl TokenBucket {
35 fn new(rate_limit: &RateLimit) -> Self {
36 let burst = if rate_limit.burst == 0 {
37 (rate_limit.max_rate.ceil() as u32).max(1)
38 } else {
39 rate_limit.burst
40 };
41 Self {
42 tokens: burst as f64,
43 max_tokens: burst as f64,
44 refill_rate: rate_limit.max_rate,
45 last_refill: Instant::now(),
46 }
47 }
48
49 fn available(&mut self) -> u32 {
51 let now = Instant::now();
52 let elapsed = now.duration_since(self.last_refill).as_secs_f64();
53 self.tokens = (self.tokens + elapsed * self.refill_rate).min(self.max_tokens);
54 self.last_refill = now;
55 self.tokens.floor() as u32
56 }
57
58 fn consume(&mut self, n: u32) {
60 self.tokens -= n as f64;
61 }
62}
63
64#[derive(Debug, Clone)]
66pub struct QueueConfig {
67 pub max_workers: u32,
68 pub poll_interval: Duration,
69 pub deadline_duration: Duration,
70 pub priority_aging_interval: Duration,
71 pub rate_limit: Option<RateLimit>,
73 pub min_workers: u32,
75 pub weight: u32,
77}
78
79impl Default for QueueConfig {
80 fn default() -> Self {
81 Self {
82 max_workers: 50,
83 poll_interval: Duration::from_millis(200),
84 deadline_duration: Duration::from_secs(300), priority_aging_interval: Duration::from_secs(60),
86 rate_limit: None,
87 min_workers: 0,
88 weight: 1,
89 }
90 }
91}
92
93#[allow(dead_code)]
96pub(crate) enum DispatchPermit {
97 Hard(tokio::sync::OwnedSemaphorePermit),
99 Local(tokio::sync::OwnedSemaphorePermit),
101 Overflow {
103 pool: Arc<OverflowPool>,
104 queue: String,
105 },
106}
107
108impl Drop for DispatchPermit {
109 fn drop(&mut self) {
110 if let DispatchPermit::Overflow { pool, queue } = self {
111 pool.release(queue, 1);
112 }
113 }
115}
116
117pub(crate) enum ConcurrencyMode {
119 HardReserved { semaphore: Arc<Semaphore> },
121 Weighted {
123 local_semaphore: Arc<Semaphore>,
124 overflow_pool: Arc<OverflowPool>,
125 queue_name: String,
126 },
127}
128
129pub(crate) struct OverflowPool {
132 total: u32,
133 state: std::sync::Mutex<OverflowState>,
134}
135
136struct OverflowState {
137 held: HashMap<String, u32>,
139 demand: HashMap<String, u32>,
141 weights: HashMap<String, u32>,
143}
144
145impl OverflowPool {
146 pub fn new(total: u32, weights: HashMap<String, u32>) -> Self {
147 Self {
148 total,
149 state: std::sync::Mutex::new(OverflowState {
150 held: HashMap::new(),
151 demand: HashMap::new(),
152 weights,
153 }),
154 }
155 }
156
157 pub fn try_acquire(&self, queue: &str, wanted: u32) -> u32 {
162 let mut state = self.state.lock().unwrap();
163
164 state.demand.insert(queue.to_string(), wanted);
166
167 if wanted == 0 {
168 return 0;
169 }
170
171 let currently_used: u32 = state.held.values().sum();
172 let available = self.total.saturating_sub(currently_used);
173 if available == 0 {
174 return 0;
175 }
176
177 let my_weight = state.weights.get(queue).copied().unwrap_or(1);
178
179 let contending_weight: u32 = state
181 .weights
182 .iter()
183 .filter(|(q, _)| {
184 state.demand.get(q.as_str()).copied().unwrap_or(0) > 0
185 || state.held.get(q.as_str()).copied().unwrap_or(0) > 0
186 })
187 .map(|(_, w)| *w)
188 .sum();
189
190 if contending_weight == 0 {
191 return 0;
192 }
193
194 let my_fair_share =
196 ((self.total as f64) * (my_weight as f64 / contending_weight as f64)).ceil() as u32;
197 let my_held = state.held.get(queue).copied().unwrap_or(0);
198 let room = my_fair_share.saturating_sub(my_held);
199
200 let granted = wanted.min(available).min(room);
201 if granted > 0 {
202 *state.held.entry(queue.to_string()).or_insert(0) += granted;
203 }
204 granted
205 }
206
207 pub fn release(&self, queue: &str, n: u32) {
209 let mut state = self.state.lock().unwrap();
210 if let Some(held) = state.held.get_mut(queue) {
211 *held = held.saturating_sub(n);
212 }
213 }
214
215 pub fn held(&self, queue: &str) -> u32 {
217 let state = self.state.lock().unwrap();
218 state.held.get(queue).copied().unwrap_or(0)
219 }
220}
221
222pub struct Dispatcher {
224 queue: String,
225 config: QueueConfig,
226 pool: PgPool,
227 executor: Arc<JobExecutor>,
228 metrics: crate::metrics::AwaMetrics,
229 _in_flight: InFlightMap,
230 concurrency: ConcurrencyMode,
231 alive: Arc<AtomicBool>,
232 cancel: CancellationToken,
233 job_set: Arc<Mutex<JoinSet<()>>>,
234 rate_limiter: Option<TokenBucket>,
235}
236
237impl Dispatcher {
238 #[allow(clippy::too_many_arguments)]
239 pub(crate) fn new(
240 queue: String,
241 config: QueueConfig,
242 pool: PgPool,
243 executor: Arc<JobExecutor>,
244 metrics: crate::metrics::AwaMetrics,
245 in_flight: InFlightMap,
246 alive: Arc<AtomicBool>,
247 cancel: CancellationToken,
248 job_set: Arc<Mutex<JoinSet<()>>>,
249 ) -> Self {
250 let concurrency = ConcurrencyMode::HardReserved {
251 semaphore: Arc::new(Semaphore::new(config.max_workers as usize)),
252 };
253 let rate_limiter = config.rate_limit.as_ref().map(TokenBucket::new);
254 Self {
255 queue,
256 config,
257 pool,
258 executor,
259 metrics,
260 _in_flight: in_flight,
261 concurrency,
262 alive,
263 cancel,
264 job_set,
265 rate_limiter,
266 }
267 }
268
269 #[allow(clippy::too_many_arguments)]
271 pub(crate) fn with_concurrency(
272 queue: String,
273 config: QueueConfig,
274 pool: PgPool,
275 executor: Arc<JobExecutor>,
276 metrics: crate::metrics::AwaMetrics,
277 in_flight: InFlightMap,
278 alive: Arc<AtomicBool>,
279 cancel: CancellationToken,
280 job_set: Arc<Mutex<JoinSet<()>>>,
281 concurrency: ConcurrencyMode,
282 ) -> Self {
283 let rate_limiter = config.rate_limit.as_ref().map(TokenBucket::new);
284 Self {
285 queue,
286 config,
287 pool,
288 executor,
289 metrics,
290 _in_flight: in_flight,
291 concurrency,
292 alive,
293 cancel,
294 job_set,
295 rate_limiter,
296 }
297 }
298
299 #[tracing::instrument(skip(self), fields(queue = %self.queue))]
301 pub async fn run(mut self) {
302 self.alive.store(true, Ordering::SeqCst);
303 info!(
304 queue = %self.queue,
305 poll_interval_ms = self.config.poll_interval.as_millis(),
306 "Dispatcher started"
307 );
308
309 let notify_channel = format!("awa:{}", self.queue);
311 let mut listener = match sqlx::postgres::PgListener::connect_with(&self.pool).await {
312 Ok(listener) => listener,
313 Err(err) => {
314 error!(error = %err, "Failed to create PG listener, falling back to polling only");
315 self.poll_loop_only().await;
317 self.alive.store(false, Ordering::SeqCst);
318 return;
319 }
320 };
321
322 if let Err(err) = listener.listen(¬ify_channel).await {
323 warn!(error = %err, channel = %notify_channel, "Failed to LISTEN, falling back to polling");
324 self.poll_loop_only().await;
325 self.alive.store(false, Ordering::SeqCst);
326 return;
327 }
328
329 debug!(channel = %notify_channel, "Listening for job notifications");
330
331 loop {
332 tokio::select! {
333 _ = self.cancel.cancelled() => {
334 debug!(queue = %self.queue, "Dispatcher shutting down");
335 break;
336 }
337 notification = listener.recv() => {
339 match notification {
340 Ok(_) => {
341 debug!(queue = %self.queue, "Woken by NOTIFY");
342 self.drain_ready().await;
343 }
344 Err(err) => {
345 warn!(error = %err, "PG listener error, will retry");
346 tokio::time::sleep(Duration::from_secs(1)).await;
347 }
348 }
349 }
350 _ = tokio::time::sleep(self.config.poll_interval) => {
351 self.drain_ready().await;
352 }
353 }
354 }
355
356 self.alive.store(false, Ordering::SeqCst);
357 }
358
359 async fn poll_loop_only(&mut self) {
361 loop {
362 tokio::select! {
363 _ = self.cancel.cancelled() => {
364 debug!(queue = %self.queue, "Dispatcher (poll-only) shutting down");
365 break;
366 }
367 _ = tokio::time::sleep(self.config.poll_interval) => {
368 self.drain_ready().await;
369 }
370 }
371 }
372 }
373
374 fn acquire_permits(&mut self) -> Vec<DispatchPermit> {
376 let mut permits = Vec::new();
377 match &self.concurrency {
378 ConcurrencyMode::HardReserved { semaphore } => {
379 for _ in 0..CLAIM_BATCH_LIMIT {
380 match semaphore.clone().try_acquire_owned() {
381 Ok(p) => permits.push(DispatchPermit::Hard(p)),
382 Err(_) => break,
383 }
384 }
385 }
386 ConcurrencyMode::Weighted {
387 local_semaphore,
388 overflow_pool,
389 queue_name,
390 } => {
391 for _ in 0..CLAIM_BATCH_LIMIT {
393 match local_semaphore.clone().try_acquire_owned() {
394 Ok(p) => permits.push(DispatchPermit::Local(p)),
395 Err(_) => break,
396 }
397 }
398 let overflow_wanted = (CLAIM_BATCH_LIMIT.saturating_sub(permits.len())) as u32;
400 let granted = overflow_pool.try_acquire(queue_name, overflow_wanted);
401 for _ in 0..granted {
402 permits.push(DispatchPermit::Overflow {
403 pool: overflow_pool.clone(),
404 queue: queue_name.clone(),
405 });
406 }
407 }
408 }
409 permits
410 }
411
412 async fn drain_ready(&mut self) {
415 while !self.cancel.is_cancelled() {
416 if !self.poll_once().await {
417 break;
418 }
419 }
420 }
421
422 #[tracing::instrument(skip(self), fields(queue = %self.queue))]
424 async fn poll_once(&mut self) -> bool {
425 let mut permits = self.acquire_permits();
427 if permits.is_empty() {
428 return false;
429 }
430
431 let rate_available = self
433 .rate_limiter
434 .as_mut()
435 .map(|rl| rl.available() as usize)
436 .unwrap_or(usize::MAX);
437 let batch_size = permits.len().min(rate_available).min(CLAIM_BATCH_LIMIT);
438 let candidate_limit_per_priority =
439 (batch_size * CLAIM_CANDIDATES_PER_PRIORITY_MULTIPLIER).max(batch_size);
440 if batch_size == 0 {
441 return false;
443 }
444 while permits.len() > batch_size {
446 permits.pop(); }
448
449 let deadline_secs = self.config.deadline_duration.as_secs_f64();
451 let aging_secs = self.config.priority_aging_interval.as_secs_f64();
452 let claim_start = Instant::now();
453
454 let jobs: Vec<JobRow> = match sqlx::query_as::<_, JobRow>(
455 r#"
456 WITH candidates AS (
457 (
458 SELECT id, priority, run_at
459 FROM awa.jobs_hot
460 WHERE state = 'available'
461 AND queue = $1
462 AND priority = 1
463 AND run_at <= now()
464 AND NOT EXISTS (
465 SELECT 1 FROM awa.queue_meta
466 WHERE queue = $1 AND paused = TRUE
467 )
468 ORDER BY run_at ASC, id ASC
469 LIMIT $5
470 )
471
472 UNION ALL
473
474 (
475 SELECT id, priority, run_at
476 FROM awa.jobs_hot
477 WHERE state = 'available'
478 AND queue = $1
479 AND priority = 2
480 AND run_at <= now()
481 AND NOT EXISTS (
482 SELECT 1 FROM awa.queue_meta
483 WHERE queue = $1 AND paused = TRUE
484 )
485 ORDER BY run_at ASC, id ASC
486 LIMIT $5
487 )
488
489 UNION ALL
490
491 (
492 SELECT id, priority, run_at
493 FROM awa.jobs_hot
494 WHERE state = 'available'
495 AND queue = $1
496 AND priority = 3
497 AND run_at <= now()
498 AND NOT EXISTS (
499 SELECT 1 FROM awa.queue_meta
500 WHERE queue = $1 AND paused = TRUE
501 )
502 ORDER BY run_at ASC, id ASC
503 LIMIT $5
504 )
505
506 UNION ALL
507
508 (
509 SELECT id, priority, run_at
510 FROM awa.jobs_hot
511 WHERE state = 'available'
512 AND queue = $1
513 AND priority = 4
514 AND run_at <= now()
515 AND NOT EXISTS (
516 SELECT 1 FROM awa.queue_meta
517 WHERE queue = $1 AND paused = TRUE
518 )
519 ORDER BY run_at ASC, id ASC
520 LIMIT $5
521 )
522 ),
523 claimed AS (
524 SELECT jobs.id
525 FROM awa.jobs_hot AS jobs
526 JOIN candidates ON candidates.id = jobs.id
527 ORDER BY
528 GREATEST(1, candidates.priority - FLOOR(EXTRACT(EPOCH FROM (now() - candidates.run_at)) / $4)::int) ASC,
529 candidates.run_at ASC,
530 candidates.id ASC
531 LIMIT $2
532 FOR UPDATE OF jobs SKIP LOCKED
533 )
534 UPDATE awa.jobs_hot
535 SET state = 'running',
536 attempt = attempt + 1,
537 run_lease = run_lease + 1,
538 attempted_at = now(),
539 heartbeat_at = now(),
540 deadline_at = now() + make_interval(secs => $3)
541 FROM claimed
542 WHERE awa.jobs_hot.id = claimed.id
543 RETURNING awa.jobs_hot.*
544 "#,
545 )
546 .bind(&self.queue)
547 .bind(batch_size as i32)
548 .bind(deadline_secs)
549 .bind(aging_secs)
550 .bind(candidate_limit_per_priority as i32)
551 .fetch_all(&self.pool)
552 .await
553 {
554 Ok(jobs) => jobs,
555 Err(err) => {
556 warn!(queue = %self.queue, error = %err, "Failed to claim jobs");
557 return false;
558 }
559 };
560 self.metrics
561 .record_claim_batch(&self.queue, jobs.len() as u64, claim_start.elapsed());
562 if !jobs.is_empty() {
563 self.metrics
564 .record_job_claimed(&self.queue, jobs.len() as u64);
565 for job in &jobs {
566 if let Some(attempted_at) = job.attempted_at {
567 let wait_secs =
568 (attempted_at - job.created_at).num_milliseconds() as f64 / 1000.0;
569 if wait_secs >= 0.0 {
570 self.metrics.record_wait_duration(&self.queue, wait_secs);
571 }
572 }
573 }
574 }
575
576 while permits.len() > jobs.len() {
578 permits.pop();
579 }
580
581 if jobs.is_empty() {
583 if let ConcurrencyMode::Weighted {
584 overflow_pool,
585 queue_name,
586 ..
587 } = &self.concurrency
588 {
589 overflow_pool.try_acquire(queue_name, 0);
590 }
591 return false;
592 }
593
594 debug!(queue = %self.queue, count = jobs.len(), "Claimed jobs");
595
596 if let Some(rl) = &mut self.rate_limiter {
598 rl.consume(jobs.len() as u32);
599 }
600
601 let mut set = self.job_set.lock().await;
603 for (job, permit) in jobs.into_iter().zip(permits) {
604 let cancel_flag = Arc::new(AtomicBool::new(false));
605 let task = self.executor.execute_task(job, cancel_flag);
606 set.spawn(async move {
607 task.await;
608 drop(permit);
609 });
610 }
611
612 true
613 }
614}