1use std::cmp::Ordering;
14use std::collections::{BTreeMap, BinaryHeap, HashMap};
15use std::time::Duration;
16
17use mongreldb_types::ids::QueryId;
18
19use crate::resource::WorkloadClass;
20
21#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
23pub enum SchedulerError {
24 #[error("tenant {tenant:?} quota exhausted for class {class}")]
26 TenantQuota {
27 tenant: String,
29 class: String,
31 },
32 #[error("queue for class {class} is full ({depth}/{max})")]
34 QueueFull {
35 class: String,
37 depth: usize,
39 max: usize,
41 },
42 #[error("unknown work id {0}")]
44 UnknownWork(u64),
45}
46
47#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct ClassConfig {
50 pub max_queue: usize,
52 pub weight: u32,
54 pub reserved_slots: usize,
56 pub max_concurrency: usize,
58}
59
60impl ClassConfig {
61 pub fn for_class(class: WorkloadClass) -> Self {
63 match class {
64 WorkloadClass::Control => Self {
65 max_queue: 64,
66 weight: 256,
67 reserved_slots: 2,
68 max_concurrency: 8,
69 },
70 WorkloadClass::Replication => Self {
71 max_queue: 64,
72 weight: 256,
73 reserved_slots: 2,
74 max_concurrency: 8,
75 },
76 WorkloadClass::Oltp => Self {
77 max_queue: 256,
78 weight: 128,
79 reserved_slots: 0,
80 max_concurrency: 64,
81 },
82 WorkloadClass::InteractiveSql => Self {
83 max_queue: 64,
84 weight: 64,
85 reserved_slots: 0,
86 max_concurrency: 16,
87 },
88 WorkloadClass::AiRetrieval => Self {
89 max_queue: 64,
90 weight: 32,
91 reserved_slots: 0,
92 max_concurrency: 16,
93 },
94 WorkloadClass::Analytics => Self {
95 max_queue: 32,
96 weight: 16,
97 reserved_slots: 0,
98 max_concurrency: 8,
99 },
100 WorkloadClass::Maintenance => Self {
101 max_queue: 32,
102 weight: 8,
103 reserved_slots: 0,
104 max_concurrency: 4,
105 },
106 WorkloadClass::Backup => Self {
107 max_queue: 16,
108 weight: 8,
109 reserved_slots: 0,
110 max_concurrency: 2,
111 },
112 }
113 }
114}
115
116#[derive(Debug, Clone, PartialEq, Eq)]
118pub struct TenantQuota {
119 pub max_running: usize,
121 pub max_queued: usize,
123 pub per_class_running: BTreeMap<String, usize>,
125}
126
127impl Default for TenantQuota {
128 fn default() -> Self {
129 Self {
130 max_running: 32,
131 max_queued: 128,
132 per_class_running: BTreeMap::new(),
133 }
134 }
135}
136
137#[derive(Debug, Clone, PartialEq, Eq)]
139pub struct WorkItem {
140 pub work_id: u64,
142 pub query_id: Option<QueryId>,
144 pub tenant: String,
146 pub class: WorkloadClass,
148 pub priority: u8,
150 pub deadline: Option<Duration>,
152 pub tag: String,
154}
155
156#[derive(Debug, Clone, PartialEq, Eq)]
157struct HeapEntry {
158 priority: u8,
159 seq: u64,
161 work_id: u64,
162}
163
164impl PartialOrd for HeapEntry {
165 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
166 Some(self.cmp(other))
167 }
168}
169
170impl Ord for HeapEntry {
171 fn cmp(&self, other: &Self) -> Ordering {
172 self.priority
174 .cmp(&other.priority)
175 .then_with(|| other.seq.cmp(&self.seq))
176 }
177}
178
179#[derive(Debug, Default)]
180struct ClassQueue {
181 heap: BinaryHeap<HeapEntry>,
182 running: usize,
183}
184
185#[derive(Debug, Default)]
186struct TenantState {
187 running: usize,
188 queued: usize,
189 per_class_running: BTreeMap<String, usize>,
190}
191
192#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
194pub struct ClassStats {
195 pub queued: usize,
197 pub running: usize,
199}
200
201#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
203pub struct SchedulerStats {
204 pub per_class: BTreeMap<String, ClassStats>,
206 pub tenants: usize,
208}
209
210#[derive(Debug)]
212pub struct HierarchicalScheduler {
213 configs: BTreeMap<WorkloadClass, ClassConfig>,
214 queues: BTreeMap<WorkloadClass, ClassQueue>,
215 items: HashMap<u64, WorkItem>,
216 vtime: BTreeMap<WorkloadClass, u64>,
218 tenants: HashMap<String, TenantState>,
219 quotas: HashMap<String, TenantQuota>,
220 default_quota: TenantQuota,
221 next_id: u64,
222 next_seq: u64,
223 cancelled: HashMap<u64, ()>,
224 running_meta: HashMap<u64, (String, WorkloadClass)>,
226}
227
228impl Default for HierarchicalScheduler {
229 fn default() -> Self {
230 Self::new()
231 }
232}
233
234impl HierarchicalScheduler {
235 pub fn new() -> Self {
237 let mut configs = BTreeMap::new();
238 let mut queues = BTreeMap::new();
239 let mut vtime = BTreeMap::new();
240 for class in WorkloadClass::ALL {
241 configs.insert(class, ClassConfig::for_class(class));
242 queues.insert(class, ClassQueue::default());
243 vtime.insert(class, 0);
244 }
245 Self {
246 configs,
247 queues,
248 items: HashMap::new(),
249 vtime,
250 tenants: HashMap::new(),
251 quotas: HashMap::new(),
252 default_quota: TenantQuota::default(),
253 next_id: 1,
254 next_seq: 1,
255 cancelled: HashMap::new(),
256 running_meta: HashMap::new(),
257 }
258 }
259
260 pub fn set_class_config(&mut self, class: WorkloadClass, config: ClassConfig) {
262 self.configs.insert(class, config);
263 }
264
265 pub fn set_tenant_quota(&mut self, tenant: impl Into<String>, quota: TenantQuota) {
267 self.quotas.insert(tenant.into(), quota);
268 }
269
270 pub fn submit(
272 &mut self,
273 tenant: impl Into<String>,
274 class: WorkloadClass,
275 priority: u8,
276 deadline: Option<Duration>,
277 query_id: Option<QueryId>,
278 tag: impl Into<String>,
279 ) -> Result<u64, SchedulerError> {
280 let tenant = tenant.into();
281 let config = self.configs.get(&class).expect("all classes configured");
282 let queue = self.queues.get(&class).expect("all classes queued");
283 if queue.heap.len() >= config.max_queue {
284 return Err(SchedulerError::QueueFull {
285 class: class.name().into(),
286 depth: queue.heap.len(),
287 max: config.max_queue,
288 });
289 }
290
291 let quota = self
292 .quotas
293 .get(&tenant)
294 .cloned()
295 .unwrap_or_else(|| self.default_quota.clone());
296 let tenant_state = self.tenants.entry(tenant.clone()).or_default();
297 if tenant_state.queued >= quota.max_queued {
298 return Err(SchedulerError::TenantQuota {
299 tenant: tenant.clone(),
300 class: class.name().into(),
301 });
302 }
303
304 let work_id = self.next_id;
305 self.next_id += 1;
306 let seq = self.next_seq;
307 self.next_seq += 1;
308
309 let item = WorkItem {
310 work_id,
311 query_id,
312 tenant: tenant.clone(),
313 class,
314 priority,
315 deadline,
316 tag: tag.into(),
317 };
318 self.items.insert(work_id, item);
319 self.queues
320 .get_mut(&class)
321 .expect("queue")
322 .heap
323 .push(HeapEntry {
324 priority,
325 seq,
326 work_id,
327 });
328 self.tenants.entry(tenant).or_default().queued += 1;
329 Ok(work_id)
330 }
331
332 pub fn cancel(&mut self, work_id: u64) -> Result<(), SchedulerError> {
338 if let Some(item) = self.items.remove(&work_id) {
339 if let Some(t) = self.tenants.get_mut(&item.tenant) {
340 t.queued = t.queued.saturating_sub(1);
341 }
342 if let Some(queue) = self.queues.get_mut(&item.class) {
343 let remaining: BinaryHeap<HeapEntry> = queue
345 .heap
346 .drain()
347 .filter(|entry| entry.work_id != work_id)
348 .collect();
349 queue.heap = remaining;
350 }
351 self.cancelled.remove(&work_id);
352 return Ok(());
353 }
354 if self.running_meta.contains_key(&work_id) {
355 self.cancelled.insert(work_id, ());
356 return Ok(());
357 }
358 Err(SchedulerError::UnknownWork(work_id))
359 }
360
361 pub fn poll(&mut self, limit: usize) -> Vec<WorkItem> {
363 let mut ready = Vec::new();
364 for _ in 0..limit {
365 let Some(class) = self.pick_class() else {
366 break;
367 };
368 let Some(item) = self.pop_class(class) else {
369 continue;
371 };
372 if self.cancelled.remove(&item.work_id).is_some() {
373 continue;
374 }
375 if let Some(t) = self.tenants.get_mut(&item.tenant) {
376 t.queued = t.queued.saturating_sub(1);
377 t.running += 1;
378 *t.per_class_running
379 .entry(item.class.name().into())
380 .or_default() += 1;
381 }
382 if let Some(q) = self.queues.get_mut(&item.class) {
383 q.running += 1;
384 }
385 let weight = self
386 .configs
387 .get(&item.class)
388 .map(|c| c.weight.max(1))
389 .unwrap_or(1);
390 let vt = self.vtime.entry(item.class).or_default();
391 *vt = vt.saturating_add(1_000u64 / u64::from(weight));
392 ready.push(item);
393 }
394 ready
395 }
396
397 pub fn complete(&mut self, work_id: u64) -> Result<(), SchedulerError> {
399 let Some((tenant, class)) = self.running_meta.remove(&work_id) else {
400 return Err(SchedulerError::UnknownWork(work_id));
401 };
402 self.cancelled.remove(&work_id);
403 if let Some(q) = self.queues.get_mut(&class) {
404 q.running = q.running.saturating_sub(1);
405 }
406 if let Some(t) = self.tenants.get_mut(&tenant) {
407 t.running = t.running.saturating_sub(1);
408 if let Some(c) = t.per_class_running.get_mut(class.name()) {
409 *c = c.saturating_sub(1);
410 }
411 }
412 Ok(())
413 }
414
415 pub fn stats(&self) -> SchedulerStats {
417 let mut per_class = BTreeMap::new();
418 for class in WorkloadClass::ALL {
419 let q = self.queues.get(&class);
420 per_class.insert(
421 class.name().to_string(),
422 ClassStats {
423 queued: q.map(|q| q.heap.len()).unwrap_or(0),
424 running: q.map(|q| q.running).unwrap_or(0),
425 },
426 );
427 }
428 SchedulerStats {
429 per_class,
430 tenants: self.tenants.len(),
431 }
432 }
433
434 fn pick_class(&self) -> Option<WorkloadClass> {
438 for class in [WorkloadClass::Control, WorkloadClass::Replication] {
439 let config = self.configs.get(&class)?;
440 let queue = self.queues.get(&class)?;
441 let reserved = config.reserved_slots.max(1);
442 if !queue.heap.is_empty()
443 && queue.running < reserved
444 && queue.running < config.max_concurrency
445 {
446 return Some(class);
447 }
448 }
449 let mut best: Option<(WorkloadClass, u64)> = None;
450 for class in WorkloadClass::ALL {
451 let config = match self.configs.get(&class) {
452 Some(c) => c,
453 None => continue,
454 };
455 let queue = match self.queues.get(&class) {
456 Some(q) => q,
457 None => continue,
458 };
459 if queue.heap.is_empty() || queue.running >= config.max_concurrency {
460 continue;
461 }
462 let vt = *self.vtime.get(&class).unwrap_or(&0);
463 match best {
464 None => best = Some((class, vt)),
465 Some((_, best_vt)) if vt < best_vt => best = Some((class, vt)),
466 Some((best_class, best_vt)) if vt == best_vt && class < best_class => {
467 best = Some((class, vt));
468 }
469 _ => {}
470 }
471 }
472 best.map(|(c, _)| c)
473 }
474
475 fn pop_class(&mut self, class: WorkloadClass) -> Option<WorkItem> {
476 let mut deferred = Vec::new();
478 let result = loop {
479 let entry = {
480 let queue = self.queues.get_mut(&class)?;
481 queue.heap.pop()
482 };
483 let Some(entry) = entry else {
484 break None;
485 };
486 if self.cancelled.remove(&entry.work_id).is_some() {
487 let _ = self.items.remove(&entry.work_id);
488 continue;
489 }
490 let Some(item) = self.items.remove(&entry.work_id) else {
491 continue;
492 };
493 let quota = self
494 .quotas
495 .get(&item.tenant)
496 .cloned()
497 .unwrap_or_else(|| self.default_quota.clone());
498 let tenant_state = self.tenants.entry(item.tenant.clone()).or_default();
499 let over_running = tenant_state.running >= quota.max_running;
500 let over_class = quota
501 .per_class_running
502 .get(class.name())
503 .is_some_and(|cap| {
504 *tenant_state
505 .per_class_running
506 .get(class.name())
507 .unwrap_or(&0)
508 >= *cap
509 });
510 if over_running || over_class {
511 deferred.push((entry, item));
512 continue;
513 }
514 self.running_meta
515 .insert(item.work_id, (item.tenant.clone(), item.class));
516 break Some(item);
517 };
518 if let Some(queue) = self.queues.get_mut(&class) {
520 for (entry, item) in deferred {
521 self.items.insert(item.work_id, item);
522 queue.heap.push(entry);
523 }
524 }
525 result
526 }
527}
528
529#[cfg(test)]
530mod tests {
531 use super::*;
532
533 #[test]
534 fn control_not_starved_under_ai_flood() {
535 let mut sched = HierarchicalScheduler::new();
536 for i in 0..32 {
538 sched
539 .submit(
540 "t1",
541 WorkloadClass::AiRetrieval,
542 100,
543 None,
544 None,
545 format!("ai-{i}"),
546 )
547 .unwrap();
548 }
549 let control_id = sched
551 .submit("system", WorkloadClass::Control, 255, None, None, "ctl")
552 .unwrap();
553
554 let mut saw_control = false;
555 for _ in 0..40 {
556 let batch = sched.poll(1);
557 if batch.is_empty() {
558 break;
559 }
560 for item in batch {
561 if item.work_id == control_id {
562 saw_control = true;
563 }
564 sched.complete(item.work_id).unwrap();
565 }
566 if saw_control {
567 break;
568 }
569 }
570 assert!(saw_control, "control must run despite AI flood");
571 }
572
573 #[test]
574 fn tenant_quota_blocks_adversary() {
575 let mut sched = HierarchicalScheduler::new();
576 sched.set_tenant_quota(
577 "noisy",
578 TenantQuota {
579 max_running: 1,
580 max_queued: 2,
581 per_class_running: BTreeMap::new(),
582 },
583 );
584 sched
585 .submit("noisy", WorkloadClass::Analytics, 50, None, None, "a")
586 .unwrap();
587 sched
588 .submit("noisy", WorkloadClass::Analytics, 50, None, None, "b")
589 .unwrap();
590 let err = sched
591 .submit("noisy", WorkloadClass::Analytics, 50, None, None, "c")
592 .unwrap_err();
593 assert!(matches!(err, SchedulerError::TenantQuota { .. }));
594
595 sched
597 .submit("quiet", WorkloadClass::Oltp, 200, None, None, "ok")
598 .unwrap();
599 }
600
601 #[test]
602 fn cancel_drops_before_dispatch() {
603 let mut sched = HierarchicalScheduler::new();
604 let id = sched
605 .submit("t", WorkloadClass::Oltp, 10, None, None, "x")
606 .unwrap();
607 sched.cancel(id).unwrap();
608 let batch = sched.poll(10);
609 assert!(batch.is_empty());
610 }
611
612 #[test]
613 fn cancel_frees_queue_capacity_immediately() {
614 let mut sched = HierarchicalScheduler::new();
615 sched.set_class_config(
616 WorkloadClass::InteractiveSql,
617 ClassConfig {
618 max_queue: 1,
619 weight: 64,
620 reserved_slots: 0,
621 max_concurrency: 1,
622 },
623 );
624 let running = sched
626 .submit("t", WorkloadClass::InteractiveSql, 1, None, None, "run")
627 .unwrap();
628 let batch = sched.poll(1);
629 assert_eq!(batch.len(), 1);
630 assert_eq!(batch[0].work_id, running);
631
632 let queued = sched
633 .submit("t", WorkloadClass::InteractiveSql, 1, None, None, "q")
634 .unwrap();
635 assert!(matches!(
636 sched.submit("t", WorkloadClass::InteractiveSql, 1, None, None, "full"),
637 Err(SchedulerError::QueueFull { .. })
638 ));
639 sched.cancel(queued).unwrap();
640 sched
642 .submit("t", WorkloadClass::InteractiveSql, 1, None, None, "again")
643 .unwrap();
644 assert_eq!(sched.stats().per_class["interactive_sql"].queued, 1);
645 }
646
647 #[test]
648 fn priority_orders_within_class() {
649 let mut sched = HierarchicalScheduler::new();
650 let low = sched
651 .submit("t", WorkloadClass::Oltp, 1, None, None, "low")
652 .unwrap();
653 let high = sched
654 .submit("t", WorkloadClass::Oltp, 200, None, None, "high")
655 .unwrap();
656 let batch = sched.poll(1);
657 assert_eq!(batch.len(), 1);
658 assert_eq!(batch[0].work_id, high);
659 assert_ne!(batch[0].work_id, low);
660 }
661
662 #[test]
667 fn p11_x3_maintenance_yields_to_oltp() {
668 let mut sched = HierarchicalScheduler::new();
669 for class in [WorkloadClass::Oltp, WorkloadClass::Maintenance] {
670 let mut cfg = ClassConfig::for_class(class);
671 cfg.max_concurrency = 1;
672 cfg.max_queue = 128;
673 sched.set_class_config(class, cfg);
674 }
675 for i in 0..64 {
677 sched
678 .submit(
679 "t",
680 WorkloadClass::Maintenance,
681 50,
682 None,
683 None,
684 format!("m-{i}"),
685 )
686 .unwrap();
687 }
688 for i in 0..64 {
689 sched
690 .submit("t", WorkloadClass::Oltp, 200, None, None, format!("o-{i}"))
691 .unwrap();
692 }
693 let mut oltp = 0usize;
694 let mut maintenance = 0usize;
695 for _ in 0..32 {
697 let batch = sched.poll(1);
698 assert_eq!(batch.len(), 1, "both classes still have demand");
699 match batch[0].class {
700 WorkloadClass::Oltp => oltp += 1,
701 WorkloadClass::Maintenance => maintenance += 1,
702 other => panic!("unexpected class {other:?}"),
703 }
704 sched.complete(batch[0].work_id).unwrap();
705 }
706 assert!(
707 oltp > maintenance,
708 "OLTP must receive more early slots than maintenance (compaction yields): oltp={oltp} maintenance={maintenance}"
709 );
710 assert!(
711 maintenance >= 1,
712 "maintenance must still make progress (not fully starved): maintenance={maintenance}"
713 );
714 }
715}