Skip to main content

awa_worker/
dispatcher.rs

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/// Rate limit configuration for a queue.
69#[derive(Debug, Clone)]
70pub struct RateLimit {
71    /// Maximum sustained dispatch rate (jobs per second).
72    pub max_rate: f64,
73    /// Maximum burst size. Defaults to ceil(max_rate) if 0.
74    pub burst: u32,
75}
76
77/// Internal token bucket state for rate limiting.
78pub(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    /// Return how many whole tokens are available after refilling.
101    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    /// Consume `n` tokens (caller must ensure n <= available()).
110    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/// Configuration for a single queue.
128#[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    /// Optional rate limit for this queue. None means unlimited.
135    pub rate_limit: Option<RateLimit>,
136    /// Minimum guaranteed workers in weighted mode (default: 0).
137    pub min_workers: u32,
138    /// Weight for overflow allocation in weighted mode (default: 1).
139    pub weight: u32,
140    /// Number of dispatcher/claimer loops for this queue.
141    ///
142    /// `max_workers` / `min_workers` still define total queue capacity;
143    /// claimers share those permits. Raising this above 1 lets one runtime
144    /// use multiple queue-storage claimer leases for hot queues.
145    pub claimers: u16,
146    /// Maximum jobs a dispatcher attempts to claim in one DB round-trip.
147    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), // 5 minutes
156            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/// Wraps permits so the correct resource is released on drop.
167/// The OwnedSemaphorePermit fields are held purely for their Drop behavior.
168#[allow(dead_code)]
169pub(crate) enum DispatchPermit {
170    /// Hard-reserved semaphore permit (current default behavior).
171    Hard(tokio::sync::OwnedSemaphorePermit),
172    /// Local (guaranteed minimum) semaphore permit in weighted mode.
173    Local(tokio::sync::OwnedSemaphorePermit),
174    /// Overflow permit from the shared OverflowPool.
175    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        // OwnedSemaphorePermit auto-releases on drop for Hard/Local
187    }
188}
189
190/// Concurrency mode for a dispatcher.
191pub(crate) enum ConcurrencyMode {
192    /// Each queue has its own semaphore. No sharing. Default behavior.
193    HardReserved { semaphore: Arc<Semaphore> },
194    /// Queues share a global overflow pool with per-queue minimum guarantees.
195    Weighted {
196        local_semaphore: Arc<Semaphore>,
197        overflow_pool: Arc<OverflowPool>,
198        queue_name: String,
199    },
200}
201
202/// Centralized overflow capacity allocator for weighted mode.
203/// Thread-safe: called from multiple dispatcher poll loops via Mutex.
204pub(crate) struct OverflowPool {
205    total: u32,
206    state: std::sync::Mutex<OverflowState>,
207}
208
209struct OverflowState {
210    /// Per-queue: currently held overflow permits (decremented on release).
211    held: HashMap<String, u32>,
212    /// Per-queue: last-declared demand (updated every try_acquire call).
213    demand: HashMap<String, u32>,
214    /// Per-queue: configured weight (immutable after construction).
215    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    /// Try to acquire up to `wanted` overflow permits for `queue`.
231    /// Returns the number actually granted (0..=wanted).
232    ///
233    /// Calling with wanted=0 is valid — it clears this queue's demand signal.
234    pub fn try_acquire(&self, queue: &str, wanted: u32) -> u32 {
235        let mut state = self.state.lock().unwrap();
236
237        // Always update demand — this is the key signal for fairness
238        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        // Contending = queues with demand > 0 OR held > 0
253        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        // My fair share of the TOTAL pool (not just available)
268        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    /// Release `n` overflow permits back to the pool.
281    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    /// Get the number of overflow permits currently held by a queue.
289    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
295/// Dispatcher polls a single queue for available jobs and dispatches them.
296pub 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    /// Create a dispatcher with a specific concurrency mode (used for weighted mode).
316    #[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    /// Run the poll loop. Returns when cancelled.
354    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        // Set up LISTEN/NOTIFY for this queue
365        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                // Fall back to poll-only mode
375                self.poll_loop_only().await;
376                self.alive.store(false, Ordering::SeqCst);
377                return;
378            }
379        };
380
381        if let Err(err) = listener.listen(&notify_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                // Wait for either a notification or the poll interval
406                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    /// Poll-only fallback (no LISTEN/NOTIFY).
435    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    /// Pre-acquire permits (non-blocking). Returns a vec of permits.
453    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                // First: local (guaranteed) permits
470                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                // Then: overflow permits up to the claim batch limit.
477                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    /// Drain immediately available work after a wake-up until the queue is empty,
502    /// capacity is exhausted, rate limiting stops us, or shutdown is requested.
503    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    /// Single poll iteration: pre-acquire permits, claim jobs, dispatch.
517    ///
518    /// Returns whether the caller should keep polling.
519    // Debug-level root span: see #449, #455.
520    #[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        // Phase 1: Pre-acquire permits (non-blocking)
528        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        // Phase 2: Apply rate limit
546        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            // Drop all permits — rate limited
562            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        // Release excess permits beyond what rate limit allows
569        while permits.len() > batch_size {
570            permits.pop(); // Drop releases the permit
571        }
572
573        // Phase 3: Claim jobs from DB.
574        //
575        // Uses a CTE (not a FROM-subquery) so the LIMIT is enforced as a
576        // materialization barrier. PostgreSQL's planner can merge a
577        // FROM-subquery with the UPDATE target when both reference the same
578        // table, which under concurrent load causes the LIMIT to be ignored.
579        //
580        // Single index scan on idx_awa_jobs_hot_dequeue with FOR UPDATE SKIP LOCKED
581        // acquires row locks during the scan. Priority ordering is strict
582        // (priority ASC, run_at ASC, id ASC); cross-priority fairness is handled
583        // by the maintenance leader's age_waiting_priorities task (ADR-005).
584        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            // Queue-storage carries an `enqueue_shard` on every claim;
675            // bucket the batch so dashboards can sum
676            // `awa.job.claimed` by `awa.enqueue.shard` and confirm the
677            // claim ordering rotates across shards. Canonical claims
678            // have no shard and report through the un-decorated form.
679            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            // Wait duration = created_at → now() (claim moment).
697            //
698            // Earlier this used `attempted_at - created_at`, but the
699            // queue_storage claim path only populates `attempted_at`
700            // when deadline_duration > 0. Receipt-plane jobs (zero
701            // deadline, the 0.6 default) get NULL attempted_at on the
702            // ClaimedRuntimeJob, so the previous version silently
703            // skipped the metric — it never appeared on the
704            // dashboard. Falling back to `Utc::now()` measures the
705            // same operator-visible quantity ("time from enqueue to
706            // claim") regardless of whether the receipt-plane
707            // optimisation skipped the attempted_at write.
708            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        // Phase 4: Release excess permits if DB had fewer jobs
722        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        // Phase 5: Clear overflow demand if no jobs found
732        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        // Phase 6: Dispatch (each job takes one pre-acquired permit)
751        let mut set = self.job_set.lock().await;
752        // Reap completed task handles. JoinSet retains JoinHandles (and the
753        // task Cell they keep alive) until join_next() consumes them; under
754        // steady-state load that pins the entire execute_task closure
755        // captures and leaks ~3 GB/h/replica. Drain here so the set only
756        // holds in-flight tasks.
757        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}