Skip to main content

mongreldb_core/
scheduler.rs

1//! Hierarchical scheduler (spec section 13.1, Stage 4A).
2//!
3//! Queues: control, replication, OLTP, interactive SQL, AI retrieval,
4//! analytics, maintenance, backup. Weighted fair scheduling with per-tenant
5//! quotas; control and replication have reserved capacity and are never
6//! fully starved. Deadline and priority propagate to work items (tablet
7//! fragments inherit them at the gateway).
8//!
9//! Pure in-process admission: no threads are spawned here. Callers
10//! `submit` work and `poll` ready items; a cancelled item is dropped from
11//! the queue before dispatch.
12
13use 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/// Errors of the hierarchical scheduler.
22#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
23pub enum SchedulerError {
24    /// Tenant quota exhausted for the class.
25    #[error("tenant {tenant:?} quota exhausted for class {class}")]
26    TenantQuota {
27        /// Tenant key.
28        tenant: String,
29        /// Workload class.
30        class: String,
31    },
32    /// Class queue is full.
33    #[error("queue for class {class} is full ({depth}/{max})")]
34    QueueFull {
35        /// Workload class.
36        class: String,
37        /// Current depth.
38        depth: usize,
39        /// Configured max.
40        max: usize,
41    },
42    /// Unknown work id.
43    #[error("unknown work id {0}")]
44    UnknownWork(u64),
45}
46
47/// Per-class queue configuration.
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct ClassConfig {
50    /// Maximum queued items.
51    pub max_queue: usize,
52    /// Scheduling weight (relative share; must be nonzero).
53    pub weight: u32,
54    /// Reserved concurrency slots (control/replication).
55    pub reserved_slots: usize,
56    /// Maximum concurrent running items for this class.
57    pub max_concurrency: usize,
58}
59
60impl ClassConfig {
61    /// Default config for a workload class (mirrors resource-group defaults).
62    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/// Per-tenant quota across classes.
117#[derive(Debug, Clone, PartialEq, Eq)]
118pub struct TenantQuota {
119    /// Maximum concurrently running items for this tenant (all classes).
120    pub max_running: usize,
121    /// Maximum queued items for this tenant.
122    pub max_queued: usize,
123    /// Optional per-class caps (class name โ†’ max running).
124    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/// One unit of schedulable work.
138#[derive(Debug, Clone, PartialEq, Eq)]
139pub struct WorkItem {
140    /// Stable work id assigned by the scheduler.
141    pub work_id: u64,
142    /// Optional query id for cancellation fan-out.
143    pub query_id: Option<QueryId>,
144    /// Tenant key (empty string = default).
145    pub tenant: String,
146    /// Workload class.
147    pub class: WorkloadClass,
148    /// Higher runs first within a class (0..=255).
149    pub priority: u8,
150    /// Deadline budget remaining at submit (propagated to fragments).
151    pub deadline: Option<Duration>,
152    /// Opaque payload tag for the caller.
153    pub tag: String,
154}
155
156#[derive(Debug, Clone, PartialEq, Eq)]
157struct HeapEntry {
158    priority: u8,
159    /// Lower seq = older = fairer among equal priority.
160    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        // BinaryHeap is max-heap: higher priority first, then lower seq.
173        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/// Per-class stats snapshot.
193#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
194pub struct ClassStats {
195    /// Queued items.
196    pub queued: usize,
197    /// Running items.
198    pub running: usize,
199}
200
201/// Scheduler observability snapshot.
202#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
203pub struct SchedulerStats {
204    /// Per-class stats keyed by class name.
205    pub per_class: BTreeMap<String, ClassStats>,
206    /// Number of tenants with state.
207    pub tenants: usize,
208}
209
210/// Hierarchical weighted-fair scheduler (spec ยง13.1).
211#[derive(Debug)]
212pub struct HierarchicalScheduler {
213    configs: BTreeMap<WorkloadClass, ClassConfig>,
214    queues: BTreeMap<WorkloadClass, ClassQueue>,
215    items: HashMap<u64, WorkItem>,
216    /// Virtual finish times for weighted fair sharing (class โ†’ vtime).
217    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 work metadata (work_id โ†’ tenant, class).
225    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    /// Build with default per-class configs.
236    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    /// Override a class config.
261    pub fn set_class_config(&mut self, class: WorkloadClass, config: ClassConfig) {
262        self.configs.insert(class, config);
263    }
264
265    /// Set or replace a tenant quota.
266    pub fn set_tenant_quota(&mut self, tenant: impl Into<String>, quota: TenantQuota) {
267        self.quotas.insert(tenant.into(), quota);
268    }
269
270    /// Submit work. Returns the assigned work id.
271    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    /// Cancel queued work (or mark so a concurrent poll drops it).
333    ///
334    /// Queued items are removed from the class heap immediately so
335    /// [`ClassConfig::max_queue`] capacity is freed for new submits (not only
336    /// after a later poll drains a cancelled marker).
337    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                // BinaryHeap has no arbitrary remove; rebuild without this id.
344                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    /// Poll up to `limit` ready work items under fairness + reserved rules.
362    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                // Class had demand but tenant caps blocked; try others once.
370                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    /// Mark work finished (frees concurrency).
398    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    /// Snapshot for tests/observability.
416    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    /// Pick the next class: reserved classes first if they have demand and
435    /// free reserved slots; else weighted fair among classes with demand
436    /// and free concurrency.
437    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        // Peek-loop: pop cancelled, re-queue tenant-saturated.
477        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        // Put deferred back.
519        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        // Flood AI.
537        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        // One control item.
550        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        // Other tenant still admitted.
596        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        // Occupy the single concurrency slot.
625        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        // Capacity free without needing a poll to drain a cancelled marker.
641        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    /// P1.1-X3: Compaction/maintenance yields to OLTP under weighted fair scheduling.
663    ///
664    /// Maintenance weight is lower than OLTP, so while both classes compete for
665    /// the first N poll slots OLTP receives a strictly larger share.
666    #[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        // Flood maintenance; keep a full OLTP backlog too so competition lasts.
676        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        // Sample the first 32 dispatches while both queues still have depth.
696        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}