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