1use 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;
21const MAX_PENDING_ADMISSIONS: usize = 4_096;
25const ADMISSION_CHANNEL_CAPACITY: usize = 256;
26pub const TASK_SCHEDULER_MAX_SCOPE_BYTES: usize = 512;
32pub const TASK_SCHEDULER_MAX_QUOTAS: usize = 8;
38pub const TASK_SCHEDULER_QUOTA_HEALTH_RETENTION: usize = 64;
45
46#[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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
98#[serde(rename_all = "camelCase")]
99pub struct TaskSchedulerConfig {
100 #[serde(default = "default_max_active", alias = "max_active")]
102 pub max_active: usize,
103 #[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 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
142#[serde(rename_all = "camelCase", deny_unknown_fields)]
143pub struct TaskSchedulerQuota {
144 pub identity: ExecutionIdentityV1,
146 pub max_active: usize,
148}
149
150impl TaskSchedulerQuota {
151 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 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 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 pub fn identity(&self) -> &ExecutionIdentityV1 {
209 &self.identity
210 }
211}
212
213#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
220#[serde(rename_all = "camelCase", deny_unknown_fields)]
221pub struct TaskSchedulerQuotaSnapshot {
222 pub identity: ExecutionIdentityV1,
224 pub max_active: usize,
226 pub active: usize,
229 pub pending: usize,
231 pub blocked: bool,
233}
234
235#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
242#[serde(rename_all = "camelCase", deny_unknown_fields)]
243pub struct TaskSchedulerQuotaHealthSnapshot {
244 pub identity: ExecutionIdentityV1,
246 pub max_active: usize,
248 pub observed: bool,
250 pub live: bool,
252 pub active: usize,
254 pub pending: usize,
256 pub blocked: bool,
258 pub admitted: u64,
260 pub released: u64,
262 pub cancelled: u64,
264 pub rejected: u64,
266 pub peak_active: usize,
268 pub total_wait_micros: u64,
270 pub average_wait_micros: u64,
272 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#[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#[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#[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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
340#[serde(rename_all = "camelCase")]
341pub struct TaskSchedulerHealthSnapshot {
342 pub max_active: usize,
344 pub active: usize,
348 pub pending: usize,
350 pub active_by_priority: TaskPriorityCounts,
352 pub pending_by_priority: TaskPriorityCounts,
354 pub admitted: u64,
356 pub released: u64,
358 pub cancelled: u64,
361 pub rejected: u64,
363 pub aging_promotions: u64,
365 pub peak_active: usize,
367 pub total_wait_micros: u64,
371 pub average_wait_micros: u64,
373 pub max_wait_micros: u64,
375 pub closed: bool,
377}
378
379#[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 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 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 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 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 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 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 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 "as {
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 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 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 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 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 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 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 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#[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 pub fn id(&self) -> u64 {
732 self.id
733 }
734
735 pub fn identity(&self) -> Option<&ExecutionIdentityV1> {
737 self.identity.as_ref()
738 }
739
740 pub fn quota_identity(&self) -> Option<&ExecutionIdentityV1> {
742 self.quota_identities.first()
743 }
744
745 pub fn quota_identities(&self) -> &[ExecutionIdentityV1] {
751 &self.quota_identities
752 }
753
754 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 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 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 state.apply_aging();
936 let _ = reply.send(state.health_snapshot());
937 }
938 SchedulerMessage::QuotaStats { quota, reply } => {
939 let result = state.quota_snapshot("a);
940 let _ = reply.send(result);
941 }
942 SchedulerMessage::QuotaHealth { quota, reply } => {
943 let result = state.quota_health("a);
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("a.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 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 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, "a.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("a.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("a.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("a.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("a.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 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 "a_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("a_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 "a_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 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 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;