Skip to main content

a3s_code_core/
task_scheduler.rs

1//! Agent-wide admission scheduler for top-level and background work.
2//!
3//! Session transcript admission remains single-flight. This scheduler adds a
4//! shared capacity boundary across every session created by one [`Agent`](crate::Agent),
5//! using `a3s-lane`'s stable priority queue for exact priority/FIFO ordering.
6
7use crate::execution_identity::ExecutionIdentityV1;
8use a3s_lane::{Priority, PriorityItem, PriorityQueue};
9use serde::{Deserialize, Serialize};
10use std::collections::{HashMap, HashSet, VecDeque};
11use std::str::FromStr;
12use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
13use std::sync::Arc;
14use thiserror::Error;
15use tokio::sync::{mpsc, oneshot};
16use tokio::time::Instant;
17use tokio_util::sync::CancellationToken;
18
19const DEFAULT_MAX_ACTIVE: usize = 4;
20const DEFAULT_AGING_INTERVAL_MS: u64 = 30_000;
21// The actor retains at most MAX_PENDING_ADMISSIONS queued items. The ingress
22// buffer is deliberately smaller; release notifications use a separate
23// control channel and therefore cannot be starved by admission traffic.
24const MAX_PENDING_ADMISSIONS: usize = 4_096;
25const ADMISSION_CHANNEL_CAPACITY: usize = 256;
26/// Maximum bytes accepted while deriving a scheduler owner scope.
27///
28/// The raw scope is never sent to the scheduler actor or included in a
29/// snapshot; the bound only prevents an untrusted host from forcing an
30/// unbounded identity-derivation allocation.
31pub const TASK_SCHEDULER_MAX_SCOPE_BYTES: usize = 512;
32/// Maximum number of independent quota dimensions accepted by one admission.
33///
34/// Keeping this bound small makes the scheduler actor's validation and live
35/// accounting predictable even when a host composes owner, provider, tenant,
36/// or other typed capacity descriptors.
37pub const TASK_SCHEDULER_MAX_QUOTAS: usize = 8;
38/// Maximum number of idle quota health epochs retained by one scheduler.
39///
40/// Live quotas are always observable. Once their final reservation and waiter
41/// settle, only this many most-recent digest-only records remain available for
42/// post-run diagnostics. The bound prevents ephemeral Run or provider
43/// identities from becoming an unbounded process history.
44pub const TASK_SCHEDULER_QUOTA_HEALTH_RETENTION: usize = 64;
45
46/// Relative importance of work admitted through an agent's shared scheduler.
47///
48/// Lower values run first. `Urgent` is reserved for explicit host control
49/// actions and never participates in aging. Older work from the other classes
50/// can age up to `Interactive`, but never ahead of `Urgent`.
51#[derive(
52    Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize, PartialOrd, Ord,
53)]
54#[serde(rename_all = "camelCase")]
55#[repr(u8)]
56pub enum TaskPriority {
57    Urgent = 0,
58    #[default]
59    Interactive = 1,
60    Foreground = 2,
61    Background = 3,
62    Maintenance = 4,
63}
64
65impl TaskPriority {
66    const ALL: [Self; 5] = [
67        Self::Urgent,
68        Self::Interactive,
69        Self::Foreground,
70        Self::Background,
71        Self::Maintenance,
72    ];
73
74    fn lane_priority(self) -> Priority {
75        self as Priority
76    }
77}
78
79impl FromStr for TaskPriority {
80    type Err = TaskSchedulerError;
81
82    fn from_str(value: &str) -> Result<Self, Self::Err> {
83        match value.trim().to_ascii_lowercase().replace(['-', '_'], "").as_str() {
84            "urgent" => Ok(Self::Urgent),
85            "interactive" | "user" => Ok(Self::Interactive),
86            "foreground" => Ok(Self::Foreground),
87            "background" => Ok(Self::Background),
88            "maintenance" => Ok(Self::Maintenance),
89            _ => Err(TaskSchedulerError::InvalidConfig(format!(
90                "unknown task priority '{value}'; expected urgent, interactive, foreground, background, or maintenance"
91            ))),
92        }
93    }
94}
95
96/// Agent-wide task scheduler settings.
97#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
98#[serde(rename_all = "camelCase")]
99pub struct TaskSchedulerConfig {
100    /// Maximum number of independently admitted tasks across all sessions.
101    #[serde(default = "default_max_active", alias = "max_active")]
102    pub max_active: usize,
103    /// Time before queued work is promoted by one priority level.
104    #[serde(default = "default_aging_interval_ms", alias = "aging_interval_ms")]
105    pub aging_interval_ms: u64,
106}
107
108impl Default for TaskSchedulerConfig {
109    fn default() -> Self {
110        Self {
111            max_active: default_max_active(),
112            aging_interval_ms: default_aging_interval_ms(),
113        }
114    }
115}
116
117impl TaskSchedulerConfig {
118    /// Validate configuration before starting the scheduler actor.
119    pub fn validate(&self) -> Result<(), TaskSchedulerError> {
120        if self.max_active == 0 {
121            return Err(TaskSchedulerError::InvalidConfig(
122                "maxActive must be greater than zero".to_string(),
123            ));
124        }
125        if self.aging_interval_ms == 0 {
126            return Err(TaskSchedulerError::InvalidConfig(
127                "agingIntervalMs must be greater than zero".to_string(),
128            ));
129        }
130        Ok(())
131    }
132}
133
134/// Immutable capacity quota carried by one scheduler admission request.
135///
136/// The quota is deliberately a descriptor rather than a second queue or
137/// semaphore. The scheduler actor remains the only authority that decides
138/// whether work owns a global slot or a quota-only reservation; it additionally
139/// refuses to admit more than `max_active` requests for this digest-only
140/// capacity identity at once.
141#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
142#[serde(rename_all = "camelCase", deny_unknown_fields)]
143pub struct TaskSchedulerQuota {
144    /// Digest-only identity of the run/host/provider scope consuming capacity.
145    pub identity: ExecutionIdentityV1,
146    /// Maximum reservations this capacity identity may hold concurrently.
147    pub max_active: usize,
148}
149
150impl TaskSchedulerQuota {
151    /// Build and validate a capacity quota descriptor.
152    pub fn new(
153        identity: ExecutionIdentityV1,
154        max_active: usize,
155    ) -> Result<Self, TaskSchedulerError> {
156        let quota = Self {
157            identity,
158            max_active,
159        };
160        quota.validate()?;
161        Ok(quota)
162    }
163
164    /// Validate a quota received from a host or a deserialized boundary.
165    pub fn validate(&self) -> Result<(), TaskSchedulerError> {
166        self.identity.validate().map_err(|error| {
167            TaskSchedulerError::InvalidConfig(format!(
168                "scheduler quota identity is invalid: {error}"
169            ))
170        })?;
171        if self.max_active == 0 {
172            return Err(TaskSchedulerError::InvalidConfig(
173                "scheduler quota maxActive must be greater than zero".to_string(),
174            ));
175        }
176        Ok(())
177    }
178
179    /// Derive a digest-only quota identity from a bounded host/run scope.
180    ///
181    /// The scope is used only during derivation and is never retained or
182    /// emitted by scheduler diagnostics. Callers should use a stable run or
183    /// host identifier, not a prompt or tool payload.
184    pub fn for_scope(scope: &str, max_active: usize) -> Result<Self, TaskSchedulerError> {
185        if scope.is_empty()
186            || scope.len() > TASK_SCHEDULER_MAX_SCOPE_BYTES
187            || scope.chars().any(|character| {
188                character.is_control() || matches!(character, '\u{2028}' | '\u{2029}')
189            })
190        {
191            return Err(TaskSchedulerError::InvalidConfig(
192                format!(
193                    "scheduler quota scope must be one non-empty line of at most {TASK_SCHEDULER_MAX_SCOPE_BYTES} bytes"
194                ),
195            ));
196        }
197        let identity = ExecutionIdentityV1::derive(
198            crate::execution_identity::TASK_ADMISSION_SCOPE_IDENTITY_DOMAIN_V1,
199            &serde_json::json!({ "scope": scope }),
200        )
201        .map_err(|error| {
202            TaskSchedulerError::InvalidConfig(format!("derive scheduler quota identity: {error}"))
203        })?;
204        Self::new(identity, max_active)
205    }
206
207    /// Return the immutable owner identity.
208    pub fn identity(&self) -> &ExecutionIdentityV1 {
209        &self.identity
210    }
211}
212
213/// Live, digest-only occupancy projection for one scheduler quota.
214///
215/// Counters are intentionally point-in-time. Idle owner state is discarded by
216/// the scheduler actor, so an unbounded history of ephemeral run identities
217/// cannot accumulate in the process. Global cumulative admission/fairness
218/// counters remain available through [`TaskScheduler::health`].
219#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
220#[serde(rename_all = "camelCase", deny_unknown_fields)]
221pub struct TaskSchedulerQuotaSnapshot {
222    /// Digest-only owner identity requested by the caller.
223    pub identity: ExecutionIdentityV1,
224    /// Immutable owner limit used for this live projection.
225    pub max_active: usize,
226    /// Active reservations currently owned by this quota identity. This may
227    /// include quota-only leaf leases in addition to global scheduler slots.
228    pub active: usize,
229    /// Requests from this owner waiting in the global queue.
230    pub pending: usize,
231    /// Whether pending work is currently blocked by the owner quota.
232    pub blocked: bool,
233}
234
235/// Bounded live-or-recent health for one scheduler quota identity.
236///
237/// Unlike [`TaskSchedulerQuotaSnapshot`], this projection retains cumulative
238/// counters for a small bounded window after a quota becomes idle. It contains
239/// only the validated digest identity and numeric capacity data; scheduler
240/// labels, provider routing text, prompts, and payloads are never retained.
241#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
242#[serde(rename_all = "camelCase", deny_unknown_fields)]
243pub struct TaskSchedulerQuotaHealthSnapshot {
244    /// Digest-only quota identity requested by the caller.
245    pub identity: ExecutionIdentityV1,
246    /// Immutable limit for this observed configuration epoch.
247    pub max_active: usize,
248    /// Whether this scheduler has observed the requested identity/limit epoch.
249    pub observed: bool,
250    /// Whether the quota currently has an active reservation or queued waiter.
251    pub live: bool,
252    /// Current active reservations for this identity.
253    pub active: usize,
254    /// Current queued requests for this identity.
255    pub pending: usize,
256    /// Whether pending work is currently blocked by this quota.
257    pub blocked: bool,
258    /// Successful admissions observed in the retained epoch.
259    pub admitted: u64,
260    /// Normally released reservations observed in the retained epoch.
261    pub released: u64,
262    /// Queued or active admissions cancelled by their caller.
263    pub cancelled: u64,
264    /// Pending admissions rejected while the scheduler was closing.
265    pub rejected: u64,
266    /// Highest simultaneous reservation count observed for this identity.
267    pub peak_active: usize,
268    /// Saturating sum of successful admission wait time in microseconds.
269    pub total_wait_micros: u64,
270    /// Mean successful admission wait time in microseconds.
271    pub average_wait_micros: u64,
272    /// Longest successful admission wait time in microseconds.
273    pub max_wait_micros: u64,
274}
275
276const fn default_max_active() -> usize {
277    DEFAULT_MAX_ACTIVE
278}
279
280const fn default_aging_interval_ms() -> u64 {
281    DEFAULT_AGING_INTERVAL_MS
282}
283
284/// Scheduler admission failures.
285#[derive(Debug, Clone, Error, PartialEq, Eq)]
286pub enum TaskSchedulerError {
287    #[error("task scheduler configuration is invalid: {0}")]
288    InvalidConfig(String),
289    #[error("task admission was cancelled")]
290    Cancelled,
291    #[error("task scheduler is closed")]
292    Closed,
293    #[error("task admission queue is full (limit {limit})")]
294    AtCapacity { limit: usize },
295}
296
297/// Counts grouped by the stable public priority classes.
298#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
299#[serde(rename_all = "camelCase")]
300pub struct TaskPriorityCounts {
301    pub urgent: usize,
302    pub interactive: usize,
303    pub foreground: usize,
304    pub background: usize,
305    pub maintenance: usize,
306}
307
308impl TaskPriorityCounts {
309    fn increment(&mut self, priority: TaskPriority) {
310        match priority {
311            TaskPriority::Urgent => self.urgent += 1,
312            TaskPriority::Interactive => self.interactive += 1,
313            TaskPriority::Foreground => self.foreground += 1,
314            TaskPriority::Background => self.background += 1,
315            TaskPriority::Maintenance => self.maintenance += 1,
316        }
317    }
318}
319
320/// Point-in-time scheduler occupancy for hosts and diagnostics.
321#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
322#[serde(rename_all = "camelCase")]
323pub struct TaskSchedulerStats {
324    pub max_active: usize,
325    pub active: usize,
326    pub pending: usize,
327    pub active_by_priority: TaskPriorityCounts,
328    pub pending_by_priority: TaskPriorityCounts,
329    pub closed: bool,
330}
331
332/// Bounded cumulative admission and fairness diagnostics for one scheduler.
333///
334/// The counters are owned by the scheduler actor and never retain task labels,
335/// execution identities, or queue entries.  They therefore remain safe to
336/// expose to a host while still making starvation and lifecycle leaks
337/// measurable.  Occupancy fields are sampled at the same actor turn as the
338/// cumulative counters.
339#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
340#[serde(rename_all = "camelCase")]
341pub struct TaskSchedulerHealthSnapshot {
342    /// Configured global capacity.
343    pub max_active: usize,
344    /// Number of global scheduler slots currently held. Quota-only leaf
345    /// reservations are visible through their quota snapshots but do not
346    /// consume this global occupancy counter.
347    pub active: usize,
348    /// Number of requests waiting for a lease.
349    pub pending: usize,
350    /// Current occupancy grouped by base priority.
351    pub active_by_priority: TaskPriorityCounts,
352    /// Current pending work grouped by base priority.
353    pub pending_by_priority: TaskPriorityCounts,
354    /// Number of requests that acquired a lease since scheduler creation.
355    pub admitted: u64,
356    /// Number of admitted leases whose ownership was released.
357    pub released: u64,
358    /// Number of admission requests cancelled before normal release,
359    /// including queued requests and active leases cancelled by their caller.
360    pub cancelled: u64,
361    /// Number of requests rejected because the scheduler was closing.
362    pub rejected: u64,
363    /// Number of queued requests promoted by the aging policy.
364    pub aging_promotions: u64,
365    /// Highest number of simultaneously active leases observed.
366    pub peak_active: usize,
367    /// Sum of admission wait time in microseconds, saturating at `u64::MAX`.
368    /// This is useful for host-side rate calculations without retaining a
369    /// latency histogram in the execution kernel.
370    pub total_wait_micros: u64,
371    /// Mean admission wait time in microseconds (`total / admitted`).
372    pub average_wait_micros: u64,
373    /// Longest observed admission wait in microseconds.
374    pub max_wait_micros: u64,
375    /// Whether the scheduler is draining or has finished shutdown.
376    pub closed: bool,
377}
378
379/// Shared actor handle. One instance belongs to each `Agent`.
380#[derive(Debug)]
381pub struct TaskScheduler {
382    tx: mpsc::Sender<SchedulerMessage>,
383    release_tx: mpsc::UnboundedSender<u64>,
384    shutdown_tx: mpsc::UnboundedSender<oneshot::Sender<()>>,
385    next_id: AtomicU64,
386    closed: Arc<AtomicBool>,
387}
388
389impl TaskScheduler {
390    /// Start a scheduler on the current Tokio runtime.
391    pub fn new(config: TaskSchedulerConfig) -> Result<Self, TaskSchedulerError> {
392        config.validate()?;
393        let (tx, rx) = mpsc::channel(ADMISSION_CHANNEL_CAPACITY);
394        let (release_tx, release_rx) = mpsc::unbounded_channel();
395        let (shutdown_tx, shutdown_rx) = mpsc::unbounded_channel();
396        let closed = Arc::new(AtomicBool::new(false));
397        tokio::spawn(run_scheduler(
398            rx,
399            release_rx,
400            shutdown_rx,
401            config,
402            Arc::clone(&closed),
403        ));
404        Ok(Self {
405            tx,
406            release_tx,
407            shutdown_tx,
408            next_id: AtomicU64::new(1),
409            closed,
410        })
411    }
412
413    /// Wait until this task owns one global execution slot.
414    pub async fn acquire(
415        &self,
416        priority: TaskPriority,
417        label: impl Into<String>,
418        cancellation: &CancellationToken,
419    ) -> Result<TaskLease, TaskSchedulerError> {
420        self.acquire_with_identity(priority, label, None, cancellation)
421            .await
422    }
423
424    /// Wait until this task owns one global execution slot and carry its
425    /// semantic execution identity through the admission boundary.
426    ///
427    /// The identity is optional for backwards compatibility with callers that
428    /// only need capacity. When present it is validated before anything is
429    /// queued, and the resulting lease retains it for tracing and downstream
430    /// adapters.
431    pub async fn acquire_with_identity(
432        &self,
433        priority: TaskPriority,
434        label: impl Into<String>,
435        identity: Option<ExecutionIdentityV1>,
436        cancellation: &CancellationToken,
437    ) -> Result<TaskLease, TaskSchedulerError> {
438        self.acquire_inner(
439            priority,
440            label.into(),
441            Vec::new(),
442            identity,
443            true,
444            cancellation,
445        )
446        .await
447    }
448
449    /// Wait for one or more quota dimensions without consuming another
450    /// global execution slot.
451    ///
452    /// This is used at leaf resource boundaries (for example, one model
453    /// generation inside an already-admitted session run). The request still
454    /// enters the same priority queue and actor as global admissions, so a
455    /// provider limit cannot be bypassed with a local semaphore and a
456    /// max-active=1 session does not deadlock while a nested model call waits.
457    pub async fn acquire_quota(
458        &self,
459        priority: TaskPriority,
460        label: impl Into<String>,
461        quota: &TaskSchedulerQuota,
462        cancellation: &CancellationToken,
463    ) -> Result<TaskLease, TaskSchedulerError> {
464        self.acquire_quotas(priority, label, std::slice::from_ref(quota), cancellation)
465            .await
466    }
467
468    /// Multi-dimensional quota-only counterpart of [`Self::acquire_quota`].
469    pub async fn acquire_quotas(
470        &self,
471        priority: TaskPriority,
472        label: impl Into<String>,
473        quotas: &[TaskSchedulerQuota],
474        cancellation: &CancellationToken,
475    ) -> Result<TaskLease, TaskSchedulerError> {
476        self.acquire_inner(
477            priority,
478            label.into(),
479            quotas.to_vec(),
480            None,
481            false,
482            cancellation,
483        )
484        .await
485    }
486
487    /// Wait until this task owns a global execution slot subject to a capacity
488    /// quota. The quota reservation is made in the same scheduler actor as
489    /// global admission, so a caller cannot bypass it by creating a fresh local
490    /// semaphore or executor handle.
491    pub async fn acquire_with_quota(
492        &self,
493        priority: TaskPriority,
494        label: impl Into<String>,
495        quota: &TaskSchedulerQuota,
496        identity: Option<ExecutionIdentityV1>,
497        cancellation: &CancellationToken,
498    ) -> Result<TaskLease, TaskSchedulerError> {
499        self.acquire_inner(
500            priority,
501            label.into(),
502            vec![quota.clone()],
503            identity,
504            true,
505            cancellation,
506        )
507        .await
508    }
509
510    /// Wait until this task owns a global execution slot subject to multiple
511    /// immutable quota dimensions. All dimensions are evaluated by the same
512    /// scheduler actor, so a caller cannot bypass one limit by splitting the
513    /// request across independent local gates.
514    pub async fn acquire_with_quotas(
515        &self,
516        priority: TaskPriority,
517        label: impl Into<String>,
518        quotas: &[TaskSchedulerQuota],
519        identity: Option<ExecutionIdentityV1>,
520        cancellation: &CancellationToken,
521    ) -> Result<TaskLease, TaskSchedulerError> {
522        self.acquire_inner(
523            priority,
524            label.into(),
525            quotas.to_vec(),
526            identity,
527            true,
528            cancellation,
529        )
530        .await
531    }
532
533    async fn acquire_inner(
534        &self,
535        priority: TaskPriority,
536        label: String,
537        quotas: Vec<TaskSchedulerQuota>,
538        identity: Option<ExecutionIdentityV1>,
539        global_slot: bool,
540        cancellation: &CancellationToken,
541    ) -> Result<TaskLease, TaskSchedulerError> {
542        if self.closed.load(Ordering::Acquire) {
543            return Err(TaskSchedulerError::Closed);
544        }
545        if cancellation.is_cancelled() {
546            return Err(TaskSchedulerError::Cancelled);
547        }
548        if let Some(identity) = &identity {
549            identity.validate().map_err(|error| {
550                TaskSchedulerError::InvalidConfig(format!("execution identity is invalid: {error}"))
551            })?;
552        }
553        if quotas.len() > TASK_SCHEDULER_MAX_QUOTAS {
554            return Err(TaskSchedulerError::InvalidConfig(format!(
555                "task admission cannot contain more than {TASK_SCHEDULER_MAX_QUOTAS} quota dimensions"
556            )));
557        }
558        if !global_slot && quotas.is_empty() {
559            return Err(TaskSchedulerError::InvalidConfig(
560                "quota-only admission requires at least one quota dimension".to_string(),
561            ));
562        }
563        let mut quota_digests = HashSet::with_capacity(quotas.len());
564        for quota in &quotas {
565            quota.validate()?;
566            if !quota_digests.insert(quota.identity.digest.clone()) {
567                return Err(TaskSchedulerError::InvalidConfig(
568                    "task admission contains duplicate quota identities".to_string(),
569                ));
570            }
571        }
572
573        let id = self.next_id.fetch_add(1, Ordering::Relaxed);
574        let quota_identities = quotas
575            .iter()
576            .map(|quota| quota.identity.clone())
577            .collect::<Vec<_>>();
578        let (ready_tx, ready_rx) = oneshot::channel();
579        let mut lease = TaskLease {
580            id,
581            release_tx: self.release_tx.clone(),
582            released: false,
583            armed: false,
584            identity: identity.clone(),
585            quota_identities: quota_identities.clone(),
586            global_slot,
587        };
588
589        tokio::select! {
590            biased;
591            _ = cancellation.cancelled() => {
592                // An unarmed lease emits no Release. Once Enqueue has been
593                // accepted, the armed lease removes either the queued item or
594                // the just-admitted slot through the control channel.
595                Err(TaskSchedulerError::Cancelled)
596            }
597            sent = self.tx.send(SchedulerMessage::Enqueue(QueuedAdmission {
598                id,
599                priority,
600                effective_priority: priority.lane_priority(),
601                label,
602                identity: identity.clone(),
603                quotas,
604                global_slot,
605                enqueued_at: Instant::now(),
606                ready: ready_tx,
607            })) => {
608                sent.map_err(|_| TaskSchedulerError::Closed)?;
609                // Release notifications use a separate control channel. Arm
610                // the lease only after Enqueue was accepted so cancellation
611                // before the send cannot publish an unmatched Release.
612                lease.armed = true;
613                tokio::select! {
614                    biased;
615                    _ = cancellation.cancelled() => Err(TaskSchedulerError::Cancelled),
616                    ready = ready_rx => {
617                        ready.map_err(|_| TaskSchedulerError::Closed)??;
618                        Ok(lease)
619                    }
620                }
621            }
622        }
623    }
624
625    /// Return the live occupancy projection for one owner quota.
626    pub async fn quota_snapshot(
627        &self,
628        quota: &TaskSchedulerQuota,
629    ) -> Result<TaskSchedulerQuotaSnapshot, TaskSchedulerError> {
630        if self.closed.load(Ordering::Acquire) {
631            return Err(TaskSchedulerError::Closed);
632        }
633        quota.validate()?;
634        let (tx, rx) = oneshot::channel();
635        self.tx
636            .send(SchedulerMessage::QuotaStats {
637                quota: quota.clone(),
638                reply: tx,
639            })
640            .await
641            .map_err(|_| TaskSchedulerError::Closed)?;
642        rx.await.map_err(|_| TaskSchedulerError::Closed)?
643    }
644
645    /// Return bounded cumulative health for one quota identity.
646    ///
647    /// The scheduler keeps a fixed number of recent idle quota epochs so a
648    /// host can inspect a completed provider generation without turning the
649    /// actor into an unbounded metrics store. A descriptor that has never been
650    /// admitted returns `observed = false` and zero counters.
651    pub async fn quota_health(
652        &self,
653        quota: &TaskSchedulerQuota,
654    ) -> Result<TaskSchedulerQuotaHealthSnapshot, TaskSchedulerError> {
655        if self.closed.load(Ordering::Acquire) {
656            return Err(TaskSchedulerError::Closed);
657        }
658        quota.validate()?;
659        let (tx, rx) = oneshot::channel();
660        self.tx
661            .send(SchedulerMessage::QuotaHealth {
662                quota: quota.clone(),
663                reply: tx,
664            })
665            .await
666            .map_err(|_| TaskSchedulerError::Closed)?;
667        rx.await.map_err(|_| TaskSchedulerError::Closed)?
668    }
669
670    /// Return a consistent actor-owned occupancy snapshot.
671    pub async fn stats(&self) -> Result<TaskSchedulerStats, TaskSchedulerError> {
672        if self.closed.load(Ordering::Acquire) {
673            return Err(TaskSchedulerError::Closed);
674        }
675        let (tx, rx) = oneshot::channel();
676        self.tx
677            .send(SchedulerMessage::Stats(tx))
678            .await
679            .map_err(|_| TaskSchedulerError::Closed)?;
680        rx.await.map_err(|_| TaskSchedulerError::Closed)
681    }
682
683    /// Return occupancy plus bounded cumulative admission/fairness counters.
684    ///
685    /// This is intentionally a separate method from [`Self::stats`] so the
686    /// long-lived counters can be added without changing the established
687    /// occupancy wire shape consumed by older SDKs.
688    pub async fn health(&self) -> Result<TaskSchedulerHealthSnapshot, TaskSchedulerError> {
689        if self.closed.load(Ordering::Acquire) {
690            return Err(TaskSchedulerError::Closed);
691        }
692        let (tx, rx) = oneshot::channel();
693        self.tx
694            .send(SchedulerMessage::Health(tx))
695            .await
696            .map_err(|_| TaskSchedulerError::Closed)?;
697        rx.await.map_err(|_| TaskSchedulerError::Closed)
698    }
699
700    /// Reject pending work and wait for already-admitted leases to finish.
701    pub async fn shutdown(&self) {
702        if self.closed.swap(true, Ordering::AcqRel) {
703            return;
704        }
705        let (tx, rx) = oneshot::channel();
706        if self.shutdown_tx.send(tx).is_ok() {
707            let _ = rx.await;
708        }
709    }
710}
711
712/// RAII ownership of one scheduler admission.
713///
714/// A normal lease consumes one global execution slot. A lease returned by
715/// [`TaskScheduler::acquire_quota`] reserves only its quota dimensions, which
716/// lets a leaf resource (such as a model generation) compose with an already
717/// held session slot without recursive scheduler deadlock.
718#[derive(Debug)]
719pub struct TaskLease {
720    id: u64,
721    release_tx: mpsc::UnboundedSender<u64>,
722    released: bool,
723    armed: bool,
724    identity: Option<ExecutionIdentityV1>,
725    quota_identities: Vec<ExecutionIdentityV1>,
726    global_slot: bool,
727}
728
729impl TaskLease {
730    /// Stable admission identifier, useful for tracing.
731    pub fn id(&self) -> u64 {
732        self.id
733    }
734
735    /// Semantic identity carried by this admission, when one was supplied.
736    pub fn identity(&self) -> Option<&ExecutionIdentityV1> {
737        self.identity.as_ref()
738    }
739
740    /// Digest-only owner quota identity applied to this admission, when any.
741    pub fn quota_identity(&self) -> Option<&ExecutionIdentityV1> {
742        self.quota_identities.first()
743    }
744
745    /// All digest-only quota identities applied to this admission.
746    ///
747    /// The slice is empty for an unconstrained global admission. The first
748    /// identity is retained by [`Self::quota_identity`] for compatibility
749    /// with callers that only used the original single-quota API.
750    pub fn quota_identities(&self) -> &[ExecutionIdentityV1] {
751        &self.quota_identities
752    }
753
754    /// Whether this lease consumes one of the scheduler's global slots.
755    pub const fn consumes_global_slot(&self) -> bool {
756        self.global_slot
757    }
758}
759
760impl Drop for TaskLease {
761    fn drop(&mut self) {
762        if self.armed && !self.released {
763            self.released = true;
764            let _ = self.release_tx.send(self.id);
765        }
766    }
767}
768
769struct QueuedAdmission {
770    id: u64,
771    priority: TaskPriority,
772    effective_priority: Priority,
773    label: String,
774    identity: Option<ExecutionIdentityV1>,
775    quotas: Vec<TaskSchedulerQuota>,
776    global_slot: bool,
777    enqueued_at: Instant,
778    ready: oneshot::Sender<Result<(), TaskSchedulerError>>,
779}
780
781enum SchedulerMessage {
782    Enqueue(QueuedAdmission),
783    Release(u64),
784    Stats(oneshot::Sender<TaskSchedulerStats>),
785    Health(oneshot::Sender<TaskSchedulerHealthSnapshot>),
786    QuotaStats {
787        quota: TaskSchedulerQuota,
788        reply: oneshot::Sender<Result<TaskSchedulerQuotaSnapshot, TaskSchedulerError>>,
789    },
790    QuotaHealth {
791        quota: TaskSchedulerQuota,
792        reply: oneshot::Sender<Result<TaskSchedulerQuotaHealthSnapshot, TaskSchedulerError>>,
793    },
794    Shutdown(oneshot::Sender<()>),
795}
796
797#[derive(Default)]
798struct SchedulerCounters {
799    admitted: u64,
800    released: u64,
801    cancelled: u64,
802    rejected: u64,
803    aging_promotions: u64,
804    peak_active: usize,
805    total_wait_micros: u64,
806    max_wait_micros: u64,
807}
808
809struct SchedulerState {
810    config: TaskSchedulerConfig,
811    pending: PriorityQueue<QueuedAdmission>,
812    active: HashMap<u64, ActiveAdmission>,
813    quotas: HashMap<String, QuotaState>,
814    /// Recently idle quota epochs, bounded by
815    /// [`TASK_SCHEDULER_QUOTA_HEALTH_RETENTION`].
816    retained_quota_health: HashMap<String, QuotaState>,
817    retained_quota_order: VecDeque<String>,
818    closing: bool,
819    shutdown_waiters: Vec<oneshot::Sender<()>>,
820    counters: SchedulerCounters,
821}
822
823struct ActiveAdmission {
824    priority: TaskPriority,
825    quota_identities: Vec<String>,
826    global_slot: bool,
827}
828
829struct QuotaState {
830    identity: ExecutionIdentityV1,
831    max_active: usize,
832    active: usize,
833    pending: usize,
834    admitted: u64,
835    released: u64,
836    cancelled: u64,
837    rejected: u64,
838    peak_active: usize,
839    total_wait_micros: u64,
840    max_wait_micros: u64,
841}
842
843impl QuotaState {
844    fn new(identity: ExecutionIdentityV1, max_active: usize) -> Self {
845        Self {
846            identity,
847            max_active,
848            active: 0,
849            pending: 0,
850            admitted: 0,
851            released: 0,
852            cancelled: 0,
853            rejected: 0,
854            peak_active: 0,
855            total_wait_micros: 0,
856            max_wait_micros: 0,
857        }
858    }
859
860    fn health_snapshot(&self, live: bool) -> TaskSchedulerQuotaHealthSnapshot {
861        TaskSchedulerQuotaHealthSnapshot {
862            identity: self.identity.clone(),
863            max_active: self.max_active,
864            observed: true,
865            live,
866            active: self.active,
867            pending: self.pending,
868            blocked: self.pending > 0 && self.active >= self.max_active,
869            admitted: self.admitted,
870            released: self.released,
871            cancelled: self.cancelled,
872            rejected: self.rejected,
873            peak_active: self.peak_active,
874            total_wait_micros: self.total_wait_micros,
875            average_wait_micros: self
876                .total_wait_micros
877                .checked_div(self.admitted)
878                .unwrap_or(0),
879            max_wait_micros: self.max_wait_micros,
880        }
881    }
882}
883
884async fn run_scheduler(
885    mut rx: mpsc::Receiver<SchedulerMessage>,
886    mut release_rx: mpsc::UnboundedReceiver<u64>,
887    mut shutdown_rx: mpsc::UnboundedReceiver<oneshot::Sender<()>>,
888    config: TaskSchedulerConfig,
889    closed: Arc<AtomicBool>,
890) {
891    let mut state = SchedulerState {
892        config,
893        pending: PriorityQueue::new(),
894        active: HashMap::new(),
895        retained_quota_health: HashMap::new(),
896        retained_quota_order: VecDeque::new(),
897        quotas: HashMap::new(),
898        closing: false,
899        shutdown_waiters: Vec::new(),
900        counters: SchedulerCounters::default(),
901    };
902
903    loop {
904        let message = tokio::select! {
905            biased;
906            Some(id) = release_rx.recv() => SchedulerMessage::Release(id),
907            Some(reply) = shutdown_rx.recv() => SchedulerMessage::Shutdown(reply),
908            Some(message) = rx.recv() => message,
909            else => break,
910        };
911        match message {
912            SchedulerMessage::Enqueue(item) => {
913                state.enqueue(item);
914            }
915            SchedulerMessage::Release(id) => {
916                if let Some(active) = state.active.remove(&id) {
917                    state.counters.released = state.counters.released.saturating_add(1);
918                    state.release_active_quotas(&active.quota_identities, false);
919                } else if let Some(item) = state.remove_pending(id) {
920                    // An armed lease dropped before admission releases its
921                    // queued reservation through the same control channel.
922                    state.cancel_pending(item);
923                }
924                state.dispatch();
925                state.finish_shutdown_if_idle();
926            }
927            SchedulerMessage::Stats(reply) => {
928                let _ = reply.send(state.snapshot());
929            }
930            SchedulerMessage::Health(reply) => {
931                // A health read is also a scheduling observation point. Apply
932                // elapsed aging before taking the snapshot so operators see
933                // promotions that became eligible while capacity was full,
934                // even when no new admission or release arrived yet.
935                state.apply_aging();
936                let _ = reply.send(state.health_snapshot());
937            }
938            SchedulerMessage::QuotaStats { quota, reply } => {
939                let result = state.quota_snapshot(&quota);
940                let _ = reply.send(result);
941            }
942            SchedulerMessage::QuotaHealth { quota, reply } => {
943                let result = state.quota_health(&quota);
944                let _ = reply.send(result);
945            }
946            SchedulerMessage::Shutdown(reply) => {
947                state.closing = true;
948                closed.store(true, Ordering::Release);
949                while let Some(item) = state.pending.pop() {
950                    let item = item.into_value();
951                    state.counters.rejected = state.counters.rejected.saturating_add(1);
952                    state.reject_pending_quotas(&item.quotas);
953                    let _ = item.ready.send(Err(TaskSchedulerError::Closed));
954                }
955                state.shutdown_waiters.push(reply);
956                state.finish_shutdown_if_idle();
957            }
958        }
959
960        if state.closing && state.active.is_empty() && state.shutdown_waiters.is_empty() {
961            break;
962        }
963    }
964
965    closed.store(true, Ordering::Release);
966}
967
968impl SchedulerState {
969    fn enqueue(&mut self, item: QueuedAdmission) {
970        if self.closing {
971            let _ = item.ready.send(Err(TaskSchedulerError::Closed));
972        } else if self.pending.len() >= MAX_PENDING_ADMISSIONS {
973            let _ = item.ready.send(Err(TaskSchedulerError::AtCapacity {
974                limit: MAX_PENDING_ADMISSIONS,
975            }));
976        } else if let Err(error) = self.register_pending_quotas(&item.quotas) {
977            self.counters.rejected = self.counters.rejected.saturating_add(1);
978            let _ = item.ready.send(Err(error));
979        } else {
980            for quota in &item.quotas {
981                if let Some(quota_state) = self.quotas.get_mut(&quota.identity.digest) {
982                    quota_state.pending = quota_state.pending.saturating_add(1);
983                }
984            }
985            self.pending.push(item.effective_priority, item);
986            self.dispatch();
987        }
988    }
989
990    /// Remove one still-queued admission by id, preserving queue order.
991    ///
992    /// Returns the removed item so the caller can settle its quota pending
993    /// reservations through [`Self::cancel_pending`]; admission ids are never
994    /// reused, so an unmatched release cannot remove a future item.
995    fn remove_pending(&mut self, id: u64) -> Option<QueuedAdmission> {
996        if self.pending.is_empty() {
997            return None;
998        }
999        let mut retained = Vec::with_capacity(self.pending.len());
1000        let mut removed = None;
1001        while let Some(item) = self.pending.pop() {
1002            if item.value().id == id {
1003                removed = Some(item.into_value());
1004            } else {
1005                retained.push(item);
1006            }
1007        }
1008        for item in retained {
1009            self.pending.restore(item);
1010        }
1011        removed
1012    }
1013
1014    fn retain_quota_health(&mut self, key: String, state: QuotaState) {
1015        self.retained_quota_health.remove(&key);
1016        self.retained_quota_order
1017            .retain(|candidate| candidate != &key);
1018        self.retained_quota_health.insert(key.clone(), state);
1019        self.retained_quota_order.push_back(key);
1020        while self.retained_quota_order.len() > TASK_SCHEDULER_QUOTA_HEALTH_RETENTION {
1021            let Some(evicted) = self.retained_quota_order.pop_front() else {
1022                break;
1023            };
1024            self.retained_quota_health.remove(&evicted);
1025        }
1026    }
1027
1028    fn take_retained_quota_health(
1029        &mut self,
1030        key: &str,
1031        identity: &ExecutionIdentityV1,
1032        max_active: usize,
1033    ) -> Option<QuotaState> {
1034        let state = self.retained_quota_health.remove(key)?;
1035        self.retained_quota_order
1036            .retain(|candidate| candidate != key);
1037        if state.identity == *identity && state.max_active == max_active {
1038            Some(state)
1039        } else {
1040            None
1041        }
1042    }
1043
1044    fn register_pending_quotas(
1045        &mut self,
1046        quotas: &[TaskSchedulerQuota],
1047    ) -> Result<(), TaskSchedulerError> {
1048        // Validate every existing registration before inserting any new state;
1049        // a later conflict must not leave a partially registered descriptor.
1050        for quota in quotas {
1051            let key = quota.identity.digest.as_str();
1052            if let Some(existing) = self.quotas.get(key) {
1053                if existing.identity != quota.identity || existing.max_active != quota.max_active {
1054                    return Err(TaskSchedulerError::InvalidConfig(
1055                        "scheduler quota identity is already registered with a different limit"
1056                            .to_string(),
1057                    ));
1058                }
1059            }
1060        }
1061        for quota in quotas {
1062            let key = quota.identity.digest.clone();
1063            if self.quotas.contains_key(&key) {
1064                continue;
1065            }
1066            let state = self
1067                .take_retained_quota_health(&key, &quota.identity, quota.max_active)
1068                .unwrap_or_else(|| QuotaState::new(quota.identity.clone(), quota.max_active));
1069            self.quotas.insert(key, state);
1070        }
1071        Ok(())
1072    }
1073
1074    fn reject_pending_quotas(&mut self, quotas: &[TaskSchedulerQuota]) {
1075        for quota in quotas {
1076            let key = quota.identity.digest.as_str();
1077            if let Some(state) = self.quotas.get_mut(key) {
1078                state.pending = state.pending.saturating_sub(1);
1079                state.rejected = state.rejected.saturating_add(1);
1080            }
1081            self.prune_idle_quota(key);
1082        }
1083    }
1084
1085    fn cancel_pending(&mut self, item: QueuedAdmission) {
1086        self.counters.cancelled = self.counters.cancelled.saturating_add(1);
1087        for quota in &item.quotas {
1088            let key = quota.identity.digest.as_str();
1089            if let Some(state) = self.quotas.get_mut(key) {
1090                state.pending = state.pending.saturating_sub(1);
1091                state.cancelled = state.cancelled.saturating_add(1);
1092            }
1093            self.prune_idle_quota(key);
1094        }
1095        let _ = item.ready.send(Err(TaskSchedulerError::Cancelled));
1096    }
1097
1098    fn release_active_quotas(&mut self, keys: &[String], cancelled: bool) {
1099        for key in keys {
1100            if let Some(state) = self.quotas.get_mut(key) {
1101                state.active = state.active.saturating_sub(1);
1102                if cancelled {
1103                    state.cancelled = state.cancelled.saturating_add(1);
1104                } else {
1105                    state.released = state.released.saturating_add(1);
1106                }
1107            }
1108            self.prune_idle_quota(key);
1109        }
1110    }
1111
1112    fn prune_idle_quota(&mut self, key: &str) {
1113        let remove = self
1114            .quotas
1115            .get(key)
1116            .is_some_and(|state| state.active == 0 && state.pending == 0);
1117        if remove {
1118            if let Some(state) = self.quotas.remove(key) {
1119                self.retain_quota_health(key.to_owned(), state);
1120            }
1121        }
1122    }
1123
1124    fn quota_allows(&self, quotas: &[TaskSchedulerQuota]) -> bool {
1125        quotas.iter().all(|quota| {
1126            self.quotas
1127                .get(&quota.identity.digest)
1128                .is_some_and(|state| state.active < state.max_active)
1129        })
1130    }
1131
1132    fn quota_snapshot(
1133        &self,
1134        quota: &TaskSchedulerQuota,
1135    ) -> Result<TaskSchedulerQuotaSnapshot, TaskSchedulerError> {
1136        quota.validate()?;
1137        if let Some(state) = self.quotas.get(&quota.identity.digest) {
1138            if state.identity != quota.identity || state.max_active != quota.max_active {
1139                return Err(TaskSchedulerError::InvalidConfig(
1140                    "scheduler quota identity is already registered with a different limit"
1141                        .to_string(),
1142                ));
1143            }
1144            return Ok(TaskSchedulerQuotaSnapshot {
1145                identity: state.identity.clone(),
1146                max_active: state.max_active,
1147                active: state.active,
1148                pending: state.pending,
1149                blocked: state.pending > 0 && state.active >= state.max_active,
1150            });
1151        }
1152        Ok(TaskSchedulerQuotaSnapshot {
1153            identity: quota.identity.clone(),
1154            max_active: quota.max_active,
1155            active: 0,
1156            pending: 0,
1157            blocked: false,
1158        })
1159    }
1160
1161    fn quota_health(
1162        &self,
1163        quota: &TaskSchedulerQuota,
1164    ) -> Result<TaskSchedulerQuotaHealthSnapshot, TaskSchedulerError> {
1165        quota.validate()?;
1166        if let Some(state) = self.quotas.get(&quota.identity.digest) {
1167            if state.identity != quota.identity || state.max_active != quota.max_active {
1168                return Err(TaskSchedulerError::InvalidConfig(
1169                    "scheduler quota identity is already registered with a different limit"
1170                        .to_string(),
1171                ));
1172            }
1173            return Ok(state.health_snapshot(true));
1174        }
1175        if let Some(state) = self.retained_quota_health.get(&quota.identity.digest) {
1176            if state.identity == quota.identity && state.max_active == quota.max_active {
1177                return Ok(state.health_snapshot(false));
1178            }
1179        }
1180        Ok(TaskSchedulerQuotaHealthSnapshot {
1181            identity: quota.identity.clone(),
1182            max_active: quota.max_active,
1183            observed: false,
1184            live: false,
1185            active: 0,
1186            pending: 0,
1187            blocked: false,
1188            admitted: 0,
1189            released: 0,
1190            cancelled: 0,
1191            rejected: 0,
1192            peak_active: 0,
1193            total_wait_micros: 0,
1194            average_wait_micros: 0,
1195            max_wait_micros: 0,
1196        })
1197    }
1198
1199    fn dispatch(&mut self) {
1200        if self.closing {
1201            return;
1202        }
1203        self.apply_aging();
1204        loop {
1205            // Recompute after every admission: quota-only leases may continue
1206            // while global capacity is full, but a second global lease must
1207            // never slip past the configured max-active bound.
1208            let global_capacity_available = self.global_active_count() < self.config.max_active;
1209            let Some(item) = self.pop_admissible(global_capacity_available) else {
1210                break;
1211            };
1212            let id = item.id;
1213            let priority = item.priority;
1214            let label = item.label;
1215            let identity = item.identity;
1216            let quota_identities = item
1217                .quotas
1218                .iter()
1219                .map(|quota| quota.identity.digest.clone())
1220                .collect::<Vec<_>>();
1221            for quota_key in &quota_identities {
1222                if let Some(quota_state) = self.quotas.get_mut(quota_key) {
1223                    quota_state.pending = quota_state.pending.saturating_sub(1);
1224                    quota_state.active = quota_state.active.saturating_add(1);
1225                }
1226            }
1227            let wait_micros = item
1228                .enqueued_at
1229                .elapsed()
1230                .as_micros()
1231                .min(u128::from(u64::MAX)) as u64;
1232            self.active.insert(
1233                id,
1234                ActiveAdmission {
1235                    priority,
1236                    quota_identities: quota_identities.clone(),
1237                    global_slot: item.global_slot,
1238                },
1239            );
1240            if item.ready.send(Ok(())).is_err() {
1241                self.active.remove(&id);
1242                self.counters.cancelled = self.counters.cancelled.saturating_add(1);
1243                self.release_active_quotas(&quota_identities, true);
1244                continue;
1245            }
1246            self.counters.admitted = self.counters.admitted.saturating_add(1);
1247            self.counters.total_wait_micros =
1248                self.counters.total_wait_micros.saturating_add(wait_micros);
1249            self.counters.max_wait_micros = self.counters.max_wait_micros.max(wait_micros);
1250            self.counters.peak_active = self.counters.peak_active.max(self.global_active_count());
1251            for quota_key in &quota_identities {
1252                if let Some(quota_state) = self.quotas.get_mut(quota_key) {
1253                    quota_state.admitted = quota_state.admitted.saturating_add(1);
1254                    quota_state.total_wait_micros =
1255                        quota_state.total_wait_micros.saturating_add(wait_micros);
1256                    quota_state.max_wait_micros = quota_state.max_wait_micros.max(wait_micros);
1257                    quota_state.peak_active = quota_state.peak_active.max(quota_state.active);
1258                }
1259            }
1260            tracing::trace!(
1261                admission_id = id,
1262                ?priority,
1263                %label,
1264                execution_identity = identity.as_ref().map(ExecutionIdentityV1::key).unwrap_or(""),
1265                "task admitted"
1266            );
1267        }
1268    }
1269
1270    /// Claim the first queued item that is eligible under both global capacity
1271    /// and all of its capacity quotas. Items blocked by one identity remain
1272    /// queued while independent identities can make progress, preventing a
1273    /// single fan-out from monopolizing the shared scheduler.
1274    fn pop_admissible(&mut self, global_capacity_available: bool) -> Option<QueuedAdmission> {
1275        let mut retained: Vec<PriorityItem<QueuedAdmission>> = Vec::new();
1276        let mut selected = None;
1277        while let Some(item) = self.pending.pop() {
1278            if selected.is_none()
1279                && (!item.value().global_slot || global_capacity_available)
1280                && self.quota_allows(&item.value().quotas)
1281            {
1282                selected = Some(item.into_value());
1283            } else {
1284                retained.push(item);
1285            }
1286        }
1287        for item in retained {
1288            self.pending.restore(item);
1289        }
1290        selected
1291    }
1292
1293    fn apply_aging(&mut self) {
1294        if self.pending.is_empty() {
1295            return;
1296        }
1297        let now = Instant::now();
1298        let interval_ms = self.config.aging_interval_ms as u128;
1299        let mut entries = Vec::with_capacity(self.pending.len());
1300        while let Some(item) = self.pending.pop() {
1301            entries.push((item.sequence(), item.into_value()));
1302        }
1303        // Re-insertion gives Lane fresh sequence numbers. Insert in original
1304        // sequence order so work that ages into the same class remains FIFO.
1305        entries.sort_by_key(|(sequence, _)| *sequence);
1306        for (_, item) in entries {
1307            let elapsed_ms = now.duration_since(item.enqueued_at).as_millis();
1308            let levels = (elapsed_ms / interval_ms).min(u8::MAX as u128) as u8;
1309            let effective = if item.priority == TaskPriority::Urgent {
1310                TaskPriority::Urgent.lane_priority()
1311            } else {
1312                (item.priority as u8).saturating_sub(levels).max(1) as Priority
1313            };
1314            if effective < item.effective_priority {
1315                self.counters.aging_promotions = self.counters.aging_promotions.saturating_add(1);
1316            }
1317            let mut item = item;
1318            item.effective_priority = effective;
1319            self.pending.push(item.effective_priority, item);
1320        }
1321    }
1322
1323    fn snapshot(&self) -> TaskSchedulerStats {
1324        let mut active_by_priority = TaskPriorityCounts::default();
1325        for active in self.active.values() {
1326            if active.global_slot {
1327                active_by_priority.increment(active.priority);
1328            }
1329        }
1330        let mut pending_by_priority = TaskPriorityCounts::default();
1331        for item in self.pending.ordered() {
1332            pending_by_priority.increment(item.value().priority);
1333        }
1334        debug_assert_eq!(
1335            TaskPriority::ALL
1336                .iter()
1337                .map(|priority| match priority {
1338                    TaskPriority::Urgent => active_by_priority.urgent,
1339                    TaskPriority::Interactive => active_by_priority.interactive,
1340                    TaskPriority::Foreground => active_by_priority.foreground,
1341                    TaskPriority::Background => active_by_priority.background,
1342                    TaskPriority::Maintenance => active_by_priority.maintenance,
1343                })
1344                .sum::<usize>(),
1345            self.global_active_count()
1346        );
1347        TaskSchedulerStats {
1348            max_active: self.config.max_active,
1349            active: self.global_active_count(),
1350            pending: self.pending.len(),
1351            active_by_priority,
1352            pending_by_priority,
1353            closed: self.closing,
1354        }
1355    }
1356
1357    fn health_snapshot(&self) -> TaskSchedulerHealthSnapshot {
1358        let stats = self.snapshot();
1359        TaskSchedulerHealthSnapshot {
1360            max_active: stats.max_active,
1361            active: stats.active,
1362            pending: stats.pending,
1363            active_by_priority: stats.active_by_priority,
1364            pending_by_priority: stats.pending_by_priority,
1365            admitted: self.counters.admitted,
1366            released: self.counters.released,
1367            cancelled: self.counters.cancelled,
1368            rejected: self.counters.rejected,
1369            aging_promotions: self.counters.aging_promotions,
1370            peak_active: self.counters.peak_active,
1371            total_wait_micros: self.counters.total_wait_micros,
1372            average_wait_micros: self
1373                .counters
1374                .total_wait_micros
1375                .checked_div(self.counters.admitted)
1376                .unwrap_or(0),
1377            max_wait_micros: self.counters.max_wait_micros,
1378            closed: self.closing,
1379        }
1380    }
1381
1382    fn finish_shutdown_if_idle(&mut self) {
1383        if self.closing && self.active.is_empty() {
1384            for waiter in self.shutdown_waiters.drain(..) {
1385                let _ = waiter.send(());
1386            }
1387        }
1388    }
1389
1390    fn global_active_count(&self) -> usize {
1391        self.active
1392            .values()
1393            .filter(|active| active.global_slot)
1394            .count()
1395    }
1396}
1397
1398#[cfg(test)]
1399mod tests;