Skip to main content

awa_worker/
dispatcher.rs

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/// Rate limit configuration for a queue.
18#[derive(Debug, Clone)]
19pub struct RateLimit {
20    /// Maximum sustained dispatch rate (jobs per second).
21    pub max_rate: f64,
22    /// Maximum burst size. Defaults to ceil(max_rate) if 0.
23    pub burst: u32,
24}
25
26/// Internal token bucket state for rate limiting.
27struct 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    /// Return how many whole tokens are available after refilling.
50    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    /// Consume `n` tokens (caller must ensure n <= available()).
59    fn consume(&mut self, n: u32) {
60        self.tokens -= n as f64;
61    }
62}
63
64/// Configuration for a single queue.
65#[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    /// Optional rate limit for this queue. None means unlimited.
72    pub rate_limit: Option<RateLimit>,
73    /// Minimum guaranteed workers in weighted mode (default: 0).
74    pub min_workers: u32,
75    /// Weight for overflow allocation in weighted mode (default: 1).
76    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), // 5 minutes
85            priority_aging_interval: Duration::from_secs(60),
86            rate_limit: None,
87            min_workers: 0,
88            weight: 1,
89        }
90    }
91}
92
93/// Wraps permits so the correct resource is released on drop.
94/// The OwnedSemaphorePermit fields are held purely for their Drop behavior.
95#[allow(dead_code)]
96pub(crate) enum DispatchPermit {
97    /// Hard-reserved semaphore permit (current default behavior).
98    Hard(tokio::sync::OwnedSemaphorePermit),
99    /// Local (guaranteed minimum) semaphore permit in weighted mode.
100    Local(tokio::sync::OwnedSemaphorePermit),
101    /// Overflow permit from the shared OverflowPool.
102    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        // OwnedSemaphorePermit auto-releases on drop for Hard/Local
114    }
115}
116
117/// Concurrency mode for a dispatcher.
118pub(crate) enum ConcurrencyMode {
119    /// Each queue has its own semaphore. No sharing. Default behavior.
120    HardReserved { semaphore: Arc<Semaphore> },
121    /// Queues share a global overflow pool with per-queue minimum guarantees.
122    Weighted {
123        local_semaphore: Arc<Semaphore>,
124        overflow_pool: Arc<OverflowPool>,
125        queue_name: String,
126    },
127}
128
129/// Centralized overflow capacity allocator for weighted mode.
130/// Thread-safe: called from multiple dispatcher poll loops via Mutex.
131pub(crate) struct OverflowPool {
132    total: u32,
133    state: std::sync::Mutex<OverflowState>,
134}
135
136struct OverflowState {
137    /// Per-queue: currently held overflow permits (decremented on release).
138    held: HashMap<String, u32>,
139    /// Per-queue: last-declared demand (updated every try_acquire call).
140    demand: HashMap<String, u32>,
141    /// Per-queue: configured weight (immutable after construction).
142    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    /// Try to acquire up to `wanted` overflow permits for `queue`.
158    /// Returns the number actually granted (0..=wanted).
159    ///
160    /// Calling with wanted=0 is valid — it clears this queue's demand signal.
161    pub fn try_acquire(&self, queue: &str, wanted: u32) -> u32 {
162        let mut state = self.state.lock().unwrap();
163
164        // Always update demand — this is the key signal for fairness
165        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        // Contending = queues with demand > 0 OR held > 0
180        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        // My fair share of the TOTAL pool (not just available)
195        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    /// Release `n` overflow permits back to the pool.
208    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    /// Get the number of overflow permits currently held by a queue.
216    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
222/// Dispatcher polls a single queue for available jobs and dispatches them.
223pub 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    /// Create a dispatcher with a specific concurrency mode (used for weighted mode).
270    #[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    /// Run the poll loop. Returns when cancelled.
300    #[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        // Set up LISTEN/NOTIFY for this queue
310        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                // Fall back to poll-only mode
316                self.poll_loop_only().await;
317                self.alive.store(false, Ordering::SeqCst);
318                return;
319            }
320        };
321
322        if let Err(err) = listener.listen(&notify_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                // Wait for either a notification or the poll interval
338                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    /// Poll-only fallback (no LISTEN/NOTIFY).
360    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    /// Pre-acquire permits (non-blocking). Returns a vec of permits.
375    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                // First: local (guaranteed) permits
392                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                // Then: overflow permits up to the claim batch limit.
399                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    /// Drain immediately available work after a wake-up until the queue is empty,
413    /// capacity is exhausted, rate limiting stops us, or shutdown is requested.
414    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    /// Single poll iteration: pre-acquire permits, claim jobs, dispatch.
423    #[tracing::instrument(skip(self), fields(queue = %self.queue))]
424    async fn poll_once(&mut self) -> bool {
425        // Phase 1: Pre-acquire permits (non-blocking)
426        let mut permits = self.acquire_permits();
427        if permits.is_empty() {
428            return false;
429        }
430
431        // Phase 2: Apply rate limit
432        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            // Drop all permits — rate limited
442            return false;
443        }
444        // Release excess permits beyond what rate limit allows
445        while permits.len() > batch_size {
446            permits.pop(); // Drop releases the permit
447        }
448
449        // Phase 3: Claim from DB
450        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        }
566
567        // Phase 4: Release excess permits if DB had fewer jobs
568        while permits.len() > jobs.len() {
569            permits.pop();
570        }
571
572        // Phase 5: Clear overflow demand if no jobs found
573        if jobs.is_empty() {
574            if let ConcurrencyMode::Weighted {
575                overflow_pool,
576                queue_name,
577                ..
578            } = &self.concurrency
579            {
580                overflow_pool.try_acquire(queue_name, 0);
581            }
582            return false;
583        }
584
585        debug!(queue = %self.queue, count = jobs.len(), "Claimed jobs");
586
587        // Phase 6: Consume rate limit tokens
588        if let Some(rl) = &mut self.rate_limiter {
589            rl.consume(jobs.len() as u32);
590        }
591
592        // Phase 7: Dispatch (each job takes one pre-acquired permit)
593        let mut set = self.job_set.lock().await;
594        for (job, permit) in jobs.into_iter().zip(permits) {
595            let cancel_flag = Arc::new(AtomicBool::new(false));
596            let task = self.executor.execute_task(job, cancel_flag);
597            set.spawn(async move {
598                task.await;
599                drop(permit);
600            });
601        }
602
603        true
604    }
605}