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;
15
16/// Rate limit configuration for a queue.
17#[derive(Debug, Clone)]
18pub struct RateLimit {
19    /// Maximum sustained dispatch rate (jobs per second).
20    pub max_rate: f64,
21    /// Maximum burst size. Defaults to ceil(max_rate) if 0.
22    pub burst: u32,
23}
24
25/// Internal token bucket state for rate limiting.
26struct 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    /// Return how many whole tokens are available after refilling.
49    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    /// Consume `n` tokens (caller must ensure n <= available()).
58    fn consume(&mut self, n: u32) {
59        self.tokens -= n as f64;
60    }
61}
62
63/// Configuration for a single queue.
64#[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    /// Optional rate limit for this queue. None means unlimited.
71    pub rate_limit: Option<RateLimit>,
72    /// Minimum guaranteed workers in weighted mode (default: 0).
73    pub min_workers: u32,
74    /// Weight for overflow allocation in weighted mode (default: 1).
75    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), // 5 minutes
84            priority_aging_interval: Duration::from_secs(60),
85            rate_limit: None,
86            min_workers: 0,
87            weight: 1,
88        }
89    }
90}
91
92/// Wraps permits so the correct resource is released on drop.
93/// The OwnedSemaphorePermit fields are held purely for their Drop behavior.
94#[allow(dead_code)]
95pub(crate) enum DispatchPermit {
96    /// Hard-reserved semaphore permit (current default behavior).
97    Hard(tokio::sync::OwnedSemaphorePermit),
98    /// Local (guaranteed minimum) semaphore permit in weighted mode.
99    Local(tokio::sync::OwnedSemaphorePermit),
100    /// Overflow permit from the shared OverflowPool.
101    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        // OwnedSemaphorePermit auto-releases on drop for Hard/Local
113    }
114}
115
116/// Concurrency mode for a dispatcher.
117pub(crate) enum ConcurrencyMode {
118    /// Each queue has its own semaphore. No sharing. Default behavior.
119    HardReserved { semaphore: Arc<Semaphore> },
120    /// Queues share a global overflow pool with per-queue minimum guarantees.
121    Weighted {
122        local_semaphore: Arc<Semaphore>,
123        overflow_pool: Arc<OverflowPool>,
124        queue_name: String,
125    },
126}
127
128/// Centralized overflow capacity allocator for weighted mode.
129/// Thread-safe: called from multiple dispatcher poll loops via Mutex.
130pub(crate) struct OverflowPool {
131    total: u32,
132    state: std::sync::Mutex<OverflowState>,
133}
134
135struct OverflowState {
136    /// Per-queue: currently held overflow permits (decremented on release).
137    held: HashMap<String, u32>,
138    /// Per-queue: last-declared demand (updated every try_acquire call).
139    demand: HashMap<String, u32>,
140    /// Per-queue: configured weight (immutable after construction).
141    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    /// Try to acquire up to `wanted` overflow permits for `queue`.
157    /// Returns the number actually granted (0..=wanted).
158    ///
159    /// Calling with wanted=0 is valid — it clears this queue's demand signal.
160    pub fn try_acquire(&self, queue: &str, wanted: u32) -> u32 {
161        let mut state = self.state.lock().unwrap();
162
163        // Always update demand — this is the key signal for fairness
164        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        // Contending = queues with demand > 0 OR held > 0
179        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        // My fair share of the TOTAL pool (not just available)
194        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    /// Release `n` overflow permits back to the pool.
207    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    /// Get the number of overflow permits currently held by a queue.
215    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
221/// Dispatcher polls a single queue for available jobs and dispatches them.
222pub 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    /// Create a dispatcher with a specific concurrency mode (used for weighted mode).
269    #[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    /// Run the poll loop. Returns when cancelled.
299    #[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        // Set up LISTEN/NOTIFY for this queue
309        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                // Fall back to poll-only mode
315                self.poll_loop_only().await;
316                self.alive.store(false, Ordering::SeqCst);
317                return;
318            }
319        };
320
321        if let Err(err) = listener.listen(&notify_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                // Wait for either a notification or the poll interval
337                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    /// Poll-only fallback (no LISTEN/NOTIFY).
359    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    /// Pre-acquire permits (non-blocking). Returns a vec of permits.
374    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                // First: local (guaranteed) permits
391                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                // Then: overflow permits up to the claim batch limit.
398                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    /// Drain immediately available work after a wake-up until the queue is empty,
412    /// capacity is exhausted, rate limiting stops us, or shutdown is requested.
413    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    /// Single poll iteration: pre-acquire permits, claim jobs, dispatch.
422    #[tracing::instrument(skip(self), fields(queue = %self.queue))]
423    async fn poll_once(&mut self) -> bool {
424        // Phase 1: Pre-acquire permits (non-blocking)
425        let mut permits = self.acquire_permits();
426        if permits.is_empty() {
427            return false;
428        }
429
430        // Phase 2: Apply rate limit
431        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            // Drop all permits — rate limited
439            return false;
440        }
441        // Release excess permits beyond what rate limit allows
442        while permits.len() > batch_size {
443            permits.pop(); // Drop releases the permit
444        }
445
446        // Phase 3: Claim jobs from DB.
447        //
448        // Uses a CTE (not a FROM-subquery) so the LIMIT is enforced as a
449        // materialization barrier. PostgreSQL's planner can merge a
450        // FROM-subquery with the UPDATE target when both reference the same
451        // table, which under concurrent load causes the LIMIT to be ignored.
452        //
453        // Single index scan on idx_awa_jobs_hot_dequeue with FOR UPDATE SKIP LOCKED
454        // acquires row locks during the scan. Priority ordering is strict
455        // (priority ASC, run_at ASC, id ASC); cross-priority fairness is handled
456        // by the maintenance leader's age_waiting_priorities task (ADR-005).
457        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        // Phase 4: Release excess permits if DB had fewer jobs
518        while permits.len() > jobs.len() {
519            permits.pop();
520        }
521
522        // Phase 5: Clear overflow demand if no jobs found
523        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        // Phase 6: Consume rate limit tokens
538        if let Some(rl) = &mut self.rate_limiter {
539            rl.consume(jobs.len() as u32);
540        }
541
542        // Phase 7: Dispatch (each job takes one pre-acquired permit)
543        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}