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    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    /// Poll up to `limit` ready work items under fairness + reserved rules.
349    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                // Class had demand but tenant caps blocked; try others once.
357                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    /// Mark work finished (frees concurrency).
385    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    /// Snapshot for tests/observability.
403    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    /// Pick the next class: reserved classes first if they have demand and
422    /// free reserved slots; else weighted fair among classes with demand
423    /// and free concurrency.
424    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        // Peek-loop: pop cancelled, re-queue tenant-saturated.
464        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        // Put deferred back.
506        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        // Flood AI.
524        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        // One control item.
537        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        // Other tenant still admitted.
583        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}