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> {
334 if let Some(item) = self.items.remove(&work_id) {
335 if let Some(t) = self.tenants.get_mut(&item.tenant) {
336 t.queued = t.queued.saturating_sub(1);
337 }
338 self.cancelled.insert(work_id, ());
339 return Ok(());
340 }
341 if self.running_meta.contains_key(&work_id) {
342 self.cancelled.insert(work_id, ());
343 return Ok(());
344 }
345 Err(SchedulerError::UnknownWork(work_id))
346 }
347
348 pub fn poll(&mut self, limit: usize) -> Vec<WorkItem> {
350 let mut ready = Vec::new();
351 for _ in 0..limit {
352 let Some(class) = self.pick_class() else {
353 break;
354 };
355 let Some(item) = self.pop_class(class) else {
356 continue;
358 };
359 if self.cancelled.remove(&item.work_id).is_some() {
360 continue;
361 }
362 if let Some(t) = self.tenants.get_mut(&item.tenant) {
363 t.queued = t.queued.saturating_sub(1);
364 t.running += 1;
365 *t.per_class_running
366 .entry(item.class.name().into())
367 .or_default() += 1;
368 }
369 if let Some(q) = self.queues.get_mut(&item.class) {
370 q.running += 1;
371 }
372 let weight = self
373 .configs
374 .get(&item.class)
375 .map(|c| c.weight.max(1))
376 .unwrap_or(1);
377 let vt = self.vtime.entry(item.class).or_default();
378 *vt = vt.saturating_add(1_000u64 / u64::from(weight));
379 ready.push(item);
380 }
381 ready
382 }
383
384 pub fn complete(&mut self, work_id: u64) -> Result<(), SchedulerError> {
386 let Some((tenant, class)) = self.running_meta.remove(&work_id) else {
387 return Err(SchedulerError::UnknownWork(work_id));
388 };
389 self.cancelled.remove(&work_id);
390 if let Some(q) = self.queues.get_mut(&class) {
391 q.running = q.running.saturating_sub(1);
392 }
393 if let Some(t) = self.tenants.get_mut(&tenant) {
394 t.running = t.running.saturating_sub(1);
395 if let Some(c) = t.per_class_running.get_mut(class.name()) {
396 *c = c.saturating_sub(1);
397 }
398 }
399 Ok(())
400 }
401
402 pub fn stats(&self) -> SchedulerStats {
404 let mut per_class = BTreeMap::new();
405 for class in WorkloadClass::ALL {
406 let q = self.queues.get(&class);
407 per_class.insert(
408 class.name().to_string(),
409 ClassStats {
410 queued: q.map(|q| q.heap.len()).unwrap_or(0),
411 running: q.map(|q| q.running).unwrap_or(0),
412 },
413 );
414 }
415 SchedulerStats {
416 per_class,
417 tenants: self.tenants.len(),
418 }
419 }
420
421 fn pick_class(&self) -> Option<WorkloadClass> {
425 for class in [WorkloadClass::Control, WorkloadClass::Replication] {
426 let config = self.configs.get(&class)?;
427 let queue = self.queues.get(&class)?;
428 let reserved = config.reserved_slots.max(1);
429 if !queue.heap.is_empty()
430 && queue.running < reserved
431 && queue.running < config.max_concurrency
432 {
433 return Some(class);
434 }
435 }
436 let mut best: Option<(WorkloadClass, u64)> = None;
437 for class in WorkloadClass::ALL {
438 let config = match self.configs.get(&class) {
439 Some(c) => c,
440 None => continue,
441 };
442 let queue = match self.queues.get(&class) {
443 Some(q) => q,
444 None => continue,
445 };
446 if queue.heap.is_empty() || queue.running >= config.max_concurrency {
447 continue;
448 }
449 let vt = *self.vtime.get(&class).unwrap_or(&0);
450 match best {
451 None => best = Some((class, vt)),
452 Some((_, best_vt)) if vt < best_vt => best = Some((class, vt)),
453 Some((best_class, best_vt)) if vt == best_vt && class < best_class => {
454 best = Some((class, vt));
455 }
456 _ => {}
457 }
458 }
459 best.map(|(c, _)| c)
460 }
461
462 fn pop_class(&mut self, class: WorkloadClass) -> Option<WorkItem> {
463 let mut deferred = Vec::new();
465 let result = loop {
466 let entry = {
467 let queue = self.queues.get_mut(&class)?;
468 queue.heap.pop()
469 };
470 let Some(entry) = entry else {
471 break None;
472 };
473 if self.cancelled.remove(&entry.work_id).is_some() {
474 let _ = self.items.remove(&entry.work_id);
475 continue;
476 }
477 let Some(item) = self.items.remove(&entry.work_id) else {
478 continue;
479 };
480 let quota = self
481 .quotas
482 .get(&item.tenant)
483 .cloned()
484 .unwrap_or_else(|| self.default_quota.clone());
485 let tenant_state = self.tenants.entry(item.tenant.clone()).or_default();
486 let over_running = tenant_state.running >= quota.max_running;
487 let over_class = quota
488 .per_class_running
489 .get(class.name())
490 .is_some_and(|cap| {
491 *tenant_state
492 .per_class_running
493 .get(class.name())
494 .unwrap_or(&0)
495 >= *cap
496 });
497 if over_running || over_class {
498 deferred.push((entry, item));
499 continue;
500 }
501 self.running_meta
502 .insert(item.work_id, (item.tenant.clone(), item.class));
503 break Some(item);
504 };
505 if let Some(queue) = self.queues.get_mut(&class) {
507 for (entry, item) in deferred {
508 self.items.insert(item.work_id, item);
509 queue.heap.push(entry);
510 }
511 }
512 result
513 }
514}
515
516#[cfg(test)]
517mod tests {
518 use super::*;
519
520 #[test]
521 fn control_not_starved_under_ai_flood() {
522 let mut sched = HierarchicalScheduler::new();
523 for i in 0..32 {
525 sched
526 .submit(
527 "t1",
528 WorkloadClass::AiRetrieval,
529 100,
530 None,
531 None,
532 format!("ai-{i}"),
533 )
534 .unwrap();
535 }
536 let control_id = sched
538 .submit("system", WorkloadClass::Control, 255, None, None, "ctl")
539 .unwrap();
540
541 let mut saw_control = false;
542 for _ in 0..40 {
543 let batch = sched.poll(1);
544 if batch.is_empty() {
545 break;
546 }
547 for item in batch {
548 if item.work_id == control_id {
549 saw_control = true;
550 }
551 sched.complete(item.work_id).unwrap();
552 }
553 if saw_control {
554 break;
555 }
556 }
557 assert!(saw_control, "control must run despite AI flood");
558 }
559
560 #[test]
561 fn tenant_quota_blocks_adversary() {
562 let mut sched = HierarchicalScheduler::new();
563 sched.set_tenant_quota(
564 "noisy",
565 TenantQuota {
566 max_running: 1,
567 max_queued: 2,
568 per_class_running: BTreeMap::new(),
569 },
570 );
571 sched
572 .submit("noisy", WorkloadClass::Analytics, 50, None, None, "a")
573 .unwrap();
574 sched
575 .submit("noisy", WorkloadClass::Analytics, 50, None, None, "b")
576 .unwrap();
577 let err = sched
578 .submit("noisy", WorkloadClass::Analytics, 50, None, None, "c")
579 .unwrap_err();
580 assert!(matches!(err, SchedulerError::TenantQuota { .. }));
581
582 sched
584 .submit("quiet", WorkloadClass::Oltp, 200, None, None, "ok")
585 .unwrap();
586 }
587
588 #[test]
589 fn cancel_drops_before_dispatch() {
590 let mut sched = HierarchicalScheduler::new();
591 let id = sched
592 .submit("t", WorkloadClass::Oltp, 10, None, None, "x")
593 .unwrap();
594 sched.cancel(id).unwrap();
595 let batch = sched.poll(10);
596 assert!(batch.is_empty());
597 }
598
599 #[test]
600 fn priority_orders_within_class() {
601 let mut sched = HierarchicalScheduler::new();
602 let low = sched
603 .submit("t", WorkloadClass::Oltp, 1, None, None, "low")
604 .unwrap();
605 let high = sched
606 .submit("t", WorkloadClass::Oltp, 200, None, None, "high")
607 .unwrap();
608 let batch = sched.poll(1);
609 assert_eq!(batch.len(), 1);
610 assert_eq!(batch[0].work_id, high);
611 assert_ne!(batch[0].work_id, low);
612 }
613}