Skip to main content

common_runtime/
global.rs

1// Copyright 2023 Greptime Team
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Global runtimes
16use std::collections::BTreeMap;
17use std::future::Future;
18use std::num::{NonZeroU32, NonZeroUsize};
19use std::sync::{Mutex, Once};
20
21use catio::{Scheduler, SchedulerStats, TaskClass};
22use common_telemetry::{info, warn};
23use once_cell::sync::Lazy;
24use paste::paste;
25use serde::{Deserialize, Serialize};
26use tokio::runtime::Handle;
27
28use crate::metrics::register_workload_scheduler_metrics;
29use crate::runtime::{BuilderBuild, RuntimeTrait};
30use crate::{Builder, JoinHandle, Runtime};
31
32const GLOBAL_WORKERS: usize = 8;
33const COMPACT_WORKERS: usize = 4;
34const HB_WORKERS: usize = 2;
35/// The minimum number of worker threads for runtimes sized by CPU count.
36/// A single-threaded runtime can easily deadlock in async code.
37const MIN_RUNTIME_THREADS: usize = 2;
38pub(crate) const QUERY_TASK_CLASS: TaskClass = TaskClass::new(1);
39pub(crate) const WRITE_TASK_CLASS: TaskClass = TaskClass::new(2);
40
41/// Experimental options for sharing Tokio capacity between query and write
42/// workloads.
43#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
44#[serde(default)]
45pub struct WorkloadSchedulerOptions {
46    /// Enables policy-controlled query and write task spawning.
47    pub enable: bool,
48    /// Relative share for query polls while writes are also backlogged.
49    pub query_weight: NonZeroU32,
50    /// Relative share for write polls while queries are also backlogged.
51    pub write_weight: NonZeroU32,
52    /// Number of polls between scheduler fairness samples.
53    pub sample_every_polls: NonZeroUsize,
54}
55
56impl Default for WorkloadSchedulerOptions {
57    fn default() -> Self {
58        Self {
59            enable: false,
60            query_weight: NonZeroU32::new(2).unwrap(),
61            write_weight: NonZeroU32::new(8).unwrap(),
62            sample_every_polls: NonZeroUsize::new(16).unwrap(),
63        }
64    }
65}
66
67/// The options for the global runtimes.
68#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
69#[serde(default)]
70pub struct RuntimeOptions {
71    /// The number of threads for the global default runtime.
72    pub global_rt_size: usize,
73    /// The number of threads to execute the runtime for compact operations.
74    pub compact_rt_size: usize,
75    /// The maximum number of blocking threads for compact operations.
76    pub compact_rt_max_blocking_threads: usize,
77    /// The number of threads to execute datanode query operations.
78    pub query_rt_size: usize,
79    /// The number of threads to execute datanode ingestion operations.
80    pub ingest_rt_size: usize,
81    /// Experimental weighted scheduler for query and write workloads.
82    pub experimental_workload_scheduler: WorkloadSchedulerOptions,
83}
84
85impl RuntimeOptions {
86    fn with_num_cpus(cpus: usize) -> Self {
87        let cpus = usize::max(cpus, MIN_RUNTIME_THREADS);
88        Self {
89            global_rt_size: cpus,
90            compact_rt_size: usize::max(cpus / 2, MIN_RUNTIME_THREADS),
91            compact_rt_max_blocking_threads: usize::max(cpus / 2, MIN_RUNTIME_THREADS),
92            query_rt_size: usize::max(cpus.saturating_sub(1), MIN_RUNTIME_THREADS),
93            ingest_rt_size: cpus,
94            experimental_workload_scheduler: WorkloadSchedulerOptions::default(),
95        }
96    }
97}
98
99impl Default for RuntimeOptions {
100    fn default() -> Self {
101        Self::with_num_cpus(num_cpus::get())
102    }
103}
104
105pub fn create_runtime(runtime_name: &str, thread_name: &str, worker_threads: usize) -> Runtime {
106    info!(
107        "Creating runtime with runtime_name: {runtime_name}, thread_name: {thread_name}, work_threads: {worker_threads}."
108    );
109    Builder::default()
110        .runtime_name(runtime_name)
111        .thread_name(thread_name)
112        .worker_threads(worker_threads)
113        .build()
114        .expect("Fail to create runtime")
115}
116
117fn create_compact_runtime(
118    runtime_name: &str,
119    thread_name: &str,
120    worker_threads: usize,
121    max_blocking_threads: usize,
122) -> Runtime {
123    let max_blocking_threads = max_blocking_threads.max(1);
124    info!(
125        "Creating compact runtime with runtime_name: {runtime_name}, thread_name: {thread_name}, work_threads: {worker_threads}, max_blocking_threads: {max_blocking_threads}."
126    );
127    Builder::default()
128        .runtime_name(runtime_name)
129        .thread_name(thread_name)
130        .worker_threads(worker_threads)
131        .max_blocking_threads(max_blocking_threads)
132        .build()
133        .expect("Fail to create runtime")
134}
135
136struct GlobalRuntimes {
137    global_runtime: Runtime,
138    compact_runtime: Runtime,
139    hb_runtime: Runtime,
140    query_runtime: Runtime,
141    ingest_runtime: Runtime,
142    query_handle: Handle,
143    ingest_handle: Handle,
144    workload_scheduler: Option<Scheduler>,
145}
146
147macro_rules! define_spawn {
148    ($type: ident) => {
149        paste! {
150
151            fn [<spawn_ $type>]<F>(&self, future: F) -> JoinHandle<F::Output>
152            where
153                F: Future + Send + 'static,
154                F::Output: Send + 'static,
155            {
156                self.[<$type _runtime>].spawn(future)
157            }
158
159            fn [<spawn_blocking_ $type>]<F, R>(&self, future: F) ->  JoinHandle<R>
160            where
161                F: FnOnce() -> R + Send + 'static,
162                R: Send + 'static,
163            {
164                self.[<$type _runtime>].spawn_blocking(future)
165            }
166
167            fn [<block_on_ $type>]<F: Future>(&self, future: F) -> F::Output {
168                self.[<$type _runtime>].block_on(future)
169            }
170        }
171    };
172}
173
174macro_rules! define_scheduled_spawn {
175    ($type: ident, $class: ident) => {
176        paste! {
177            fn [<spawn_ $type>]<F>(&self, future: F) -> JoinHandle<F::Output>
178            where
179                F: Future + Send + 'static,
180                F::Output: Send + 'static,
181            {
182                match &self.workload_scheduler {
183                    Some(scheduler) => scheduler.spawn_in_on(
184                        &self.[<$type _handle>],
185                        $class,
186                        future,
187                    ),
188                    None => self.[<$type _runtime>].spawn(future),
189                }
190            }
191
192            fn [<spawn_blocking_ $type>]<F, R>(&self, future: F) -> JoinHandle<R>
193            where
194                F: FnOnce() -> R + Send + 'static,
195                R: Send + 'static,
196            {
197                self.[<$type _runtime>].spawn_blocking(future)
198            }
199
200            fn [<block_on_ $type>]<F: Future>(&self, future: F) -> F::Output {
201                self.[<$type _runtime>].block_on(future)
202            }
203        }
204    };
205}
206
207impl GlobalRuntimes {
208    define_spawn!(global);
209    define_spawn!(compact);
210    define_spawn!(hb);
211    define_scheduled_spawn!(query, QUERY_TASK_CLASS);
212    define_scheduled_spawn!(ingest, WRITE_TASK_CLASS);
213
214    fn new(
215        global: Option<Runtime>,
216        compact: Option<Runtime>,
217        heartbeat: Option<Runtime>,
218        query: Option<Runtime>,
219        ingest: Option<Runtime>,
220        workload_scheduler: Option<Scheduler>,
221    ) -> Self {
222        let global_runtime =
223            global.unwrap_or_else(|| create_runtime("global", "global-worker", GLOBAL_WORKERS));
224        let query_runtime = query.unwrap_or_else(|| global_runtime.clone());
225        let ingest_runtime = ingest.unwrap_or_else(|| global_runtime.clone());
226        let query_handle = query_runtime.handle();
227        let ingest_handle = ingest_runtime.handle();
228        Self {
229            global_runtime,
230            compact_runtime: compact.unwrap_or_else(|| {
231                let max_blocking_threads =
232                    RuntimeOptions::default().compact_rt_max_blocking_threads;
233                create_compact_runtime(
234                    "compact",
235                    "compact-worker",
236                    COMPACT_WORKERS,
237                    max_blocking_threads,
238                )
239            }),
240            hb_runtime: heartbeat
241                .unwrap_or_else(|| create_runtime("heartbeat", "hb-worker", HB_WORKERS)),
242            query_runtime,
243            ingest_runtime,
244            query_handle,
245            ingest_handle,
246            workload_scheduler,
247        }
248    }
249}
250
251#[derive(Default)]
252struct ConfigRuntimes {
253    global_runtime: Option<Runtime>,
254    compact_runtime: Option<Runtime>,
255    hb_runtime: Option<Runtime>,
256    query_runtime: Option<Runtime>,
257    ingest_runtime: Option<Runtime>,
258    workload_scheduler: Option<Scheduler>,
259    already_init: bool,
260}
261
262static GLOBAL_RUNTIMES: Lazy<GlobalRuntimes> = Lazy::new(|| {
263    let mut c = CONFIG_RUNTIMES.lock().unwrap();
264    let global = c.global_runtime.take();
265    let compact = c.compact_runtime.take();
266    let heartbeat = c.hb_runtime.take();
267    let query = c.query_runtime.take();
268    let ingest = c.ingest_runtime.take();
269    let workload_scheduler = c.workload_scheduler.take();
270    c.already_init = true;
271
272    GlobalRuntimes::new(
273        global,
274        compact,
275        heartbeat,
276        query,
277        ingest,
278        workload_scheduler,
279    )
280});
281
282static CONFIG_RUNTIMES: Lazy<Mutex<ConfigRuntimes>> =
283    Lazy::new(|| Mutex::new(ConfigRuntimes::default()));
284static START: Once = Once::new();
285
286/// Initialize runtimes for frontend, metasrv, and flownode processes.
287///
288/// Query and ingest work share the global runtime and no workload scheduler is
289/// constructed for these process roles.
290pub fn init_global_runtimes(options: &RuntimeOptions) {
291    START.call_once(|| {
292        let mut c = CONFIG_RUNTIMES.lock().unwrap();
293        assert!(!c.already_init, "Global runtimes already initialized");
294        init_common_runtimes(&mut c, options);
295        c.already_init = true;
296    });
297}
298
299/// Initialize runtimes for a standalone process.
300///
301/// Query and ingest work share the global runtime. The scheduler is always
302/// constructed, with the global runtime size as its internal bound, and starts
303/// enabled according to configuration.
304pub fn init_standalone_runtimes(options: &RuntimeOptions) {
305    START.call_once(|| {
306        let mut c = CONFIG_RUNTIMES.lock().unwrap();
307        assert!(!c.already_init, "Global runtimes already initialized");
308        init_common_runtimes(&mut c, options);
309        c.workload_scheduler = Some(create_workload_scheduler(options, options.global_rt_size));
310        c.already_init = true;
311    });
312}
313
314/// Initialize runtimes for a datanode process.
315///
316/// Query and ingest use dedicated runtimes. The scheduler is always
317/// constructed with their checked combined size as its internal bound, and
318/// starts enabled according to configuration.
319///
320/// # Panics
321///
322/// Panics if the configured query and ingest runtime sizes overflow `usize`
323/// when combined.
324pub fn init_datanode_runtimes(options: &RuntimeOptions) {
325    let capacity = options
326        .query_rt_size
327        .checked_add(options.ingest_rt_size)
328        .expect("datanode workload scheduler runtime capacity overflowed usize");
329    START.call_once(|| {
330        let mut c = CONFIG_RUNTIMES.lock().unwrap();
331        assert!(!c.already_init, "Global runtimes already initialized");
332        init_common_runtimes(&mut c, options);
333        c.query_runtime = Some(create_runtime(
334            "query",
335            "query-worker",
336            options.query_rt_size,
337        ));
338        c.ingest_runtime = Some(create_runtime(
339            "ingest",
340            "ingest-worker",
341            options.ingest_rt_size,
342        ));
343        c.workload_scheduler = Some(create_workload_scheduler(options, capacity));
344        c.already_init = true;
345    });
346}
347
348fn init_common_runtimes(c: &mut ConfigRuntimes, options: &RuntimeOptions) {
349    c.global_runtime = Some(create_runtime(
350        "global",
351        "global-worker",
352        options.global_rt_size,
353    ));
354    c.compact_runtime = Some(create_compact_runtime(
355        "compact",
356        "compact-worker",
357        options.compact_rt_size,
358        options.compact_rt_max_blocking_threads,
359    ));
360    c.hb_runtime = Some(create_runtime("heartbeat", "hb-worker", HB_WORKERS));
361}
362
363fn create_workload_scheduler(options: &RuntimeOptions, capacity: usize) -> Scheduler {
364    assert!(
365        capacity > 0,
366        "experimental workload scheduler capacity must be greater than zero"
367    );
368    let scheduler_options = &options.experimental_workload_scheduler;
369    let scheduler = Scheduler::builder()
370        // This is deliberately an internal scheduler bound, not public config.
371        .max_concurrent_polls(capacity)
372        .sample_every_polls(scheduler_options.sample_every_polls.get())
373        .weight(QUERY_TASK_CLASS, scheduler_options.query_weight.get())
374        .weight(WRITE_TASK_CLASS, scheduler_options.write_weight.get())
375        .build();
376    scheduler.set_enabled(scheduler_options.enable);
377    register_workload_scheduler_metrics(scheduler.clone());
378    info!(
379        "Constructed the experimental workload scheduler: internal_capacity={}, \
380         query_weight={}, write_weight={}, sample_every_polls={}, enabled={}",
381        capacity,
382        scheduler_options.query_weight,
383        scheduler_options.write_weight,
384        scheduler_options.sample_every_polls,
385        scheduler_options.enable
386    );
387    scheduler
388}
389
390macro_rules! define_global_runtime_spawn {
391    ($type: ident) => {
392        paste! {
393            #[doc = "Returns the global `" $type "` thread pool."]
394            pub fn [<$type _runtime>]() -> Runtime {
395                GLOBAL_RUNTIMES.[<$type _runtime>].clone()
396            }
397
398            #[doc = "Spawn a future and execute it in `" $type "` thread pool."]
399            pub fn [<spawn_ $type>]<F>(future: F) -> JoinHandle<F::Output>
400            where
401                F: Future + Send + 'static,
402                F::Output: Send + 'static,
403            {
404                GLOBAL_RUNTIMES.[<spawn_ $type>](future)
405            }
406
407            #[doc = "Run the blocking operation in `" $type "` thread pool."]
408            pub fn [<spawn_blocking_ $type>]<F, R>(future: F) ->  JoinHandle<R>
409            where
410                F: FnOnce() -> R + Send + 'static,
411                R: Send + 'static,
412            {
413                GLOBAL_RUNTIMES.[<spawn_blocking_ $type>](future)
414            }
415
416            #[doc = "Run a future to complete in `" $type "` thread pool."]
417            pub fn [<block_on_ $type>]<F: Future>(future: F) -> F::Output {
418                GLOBAL_RUNTIMES.[<block_on_ $type>](future)
419            }
420        }
421    };
422}
423
424define_global_runtime_spawn!(global);
425define_global_runtime_spawn!(compact);
426define_global_runtime_spawn!(hb);
427define_global_runtime_spawn!(query);
428define_global_runtime_spawn!(ingest);
429
430/// Returns whether the experimental workload scheduler is currently enabled.
431/// Returns `false` when no scheduler was constructed at startup.
432pub fn workload_scheduler_enabled() -> bool {
433    GLOBAL_RUNTIMES
434        .workload_scheduler
435        .as_ref()
436        .is_some_and(Scheduler::is_enabled)
437}
438
439/// Enables or disables the experimental workload scheduler for new spawn
440/// submissions. Returns `false` when no scheduler was constructed at startup.
441pub fn set_workload_scheduler_enabled(enabled: bool) -> bool {
442    let Some(scheduler) = GLOBAL_RUNTIMES.workload_scheduler.as_ref() else {
443        warn!(
444            "The experimental workload scheduler was not constructed at startup; ignoring enabled={enabled}"
445        );
446        return false;
447    };
448
449    scheduler.set_enabled(enabled);
450    info!("Experimental workload scheduler enabled={enabled}");
451    true
452}
453
454/// Sets the query and write weights atomically. Returns `false` when no
455/// scheduler was constructed at startup.
456pub fn set_workload_scheduler_weights(query: NonZeroU32, write: NonZeroU32) -> bool {
457    let Some(scheduler) = GLOBAL_RUNTIMES.workload_scheduler.as_ref() else {
458        warn!(
459            "The experimental workload scheduler was not constructed at startup; ignoring query_weight={query}, write_weight={write}"
460        );
461        return false;
462    };
463
464    let weights = BTreeMap::from([(QUERY_TASK_CLASS, query), (WRITE_TASK_CLASS, write)]);
465    scheduler.set_weights(&weights);
466    info!("Experimental workload scheduler weights query={query}, write={write}");
467    true
468}
469
470/// Returns scheduler counters when the experimental workload scheduler was
471/// constructed at startup, including while it is dynamically disabled.
472pub fn workload_scheduler_stats() -> Option<SchedulerStats> {
473    GLOBAL_RUNTIMES
474        .workload_scheduler
475        .as_ref()
476        .map(Scheduler::stats)
477}
478
479#[cfg(test)]
480mod tests {
481    use std::future::Future;
482    use std::pin::Pin;
483    use std::sync::atomic::{AtomicBool, Ordering};
484    use std::sync::{Arc, mpsc};
485    use std::task::{Context, Poll};
486    use std::time::{Duration, Instant};
487
488    use tokio_test::assert_ok;
489
490    use super::*;
491
492    struct CooperativePolls {
493        stop: Arc<AtomicBool>,
494    }
495
496    impl Future for CooperativePolls {
497        type Output = ();
498
499        fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
500            let deadline = Instant::now() + Duration::from_micros(100);
501            while Instant::now() < deadline {
502                std::hint::spin_loop();
503            }
504
505            if self.stop.load(Ordering::Relaxed) {
506                Poll::Ready(())
507            } else {
508                cx.waker().wake_by_ref();
509                Poll::Pending
510            }
511        }
512    }
513
514    fn wait_until<F>(description: &str, condition: F)
515    where
516        F: Fn() -> bool,
517    {
518        let deadline = Instant::now() + Duration::from_secs(5);
519        while !condition() {
520            assert!(
521                Instant::now() < deadline,
522                "timed out waiting for {description}"
523            );
524            std::thread::sleep(Duration::from_millis(1));
525        }
526    }
527
528    #[test]
529    fn test_datanode_runtime_options_default() {
530        let options = RuntimeOptions::default();
531        let cpus = usize::max(num_cpus::get(), MIN_RUNTIME_THREADS);
532
533        assert_eq!(cpus, options.global_rt_size);
534        assert_eq!(
535            usize::max(cpus / 2, MIN_RUNTIME_THREADS),
536            options.compact_rt_size
537        );
538        assert_eq!(
539            usize::max(cpus / 2, MIN_RUNTIME_THREADS),
540            options.compact_rt_max_blocking_threads
541        );
542        assert_eq!(
543            usize::max(cpus.saturating_sub(1), MIN_RUNTIME_THREADS),
544            options.query_rt_size
545        );
546        assert_eq!(cpus, options.ingest_rt_size);
547        assert_eq!(
548            WorkloadSchedulerOptions::default(),
549            options.experimental_workload_scheduler
550        );
551    }
552
553    #[test]
554    fn test_runtime_options_min_threads() {
555        for cpus in [0, 1, 2] {
556            let options = RuntimeOptions::with_num_cpus(cpus);
557            assert!(
558                options.global_rt_size >= MIN_RUNTIME_THREADS,
559                "global_rt_size {} < {MIN_RUNTIME_THREADS} with {cpus} cpus",
560                options.global_rt_size
561            );
562            assert!(
563                options.compact_rt_size >= MIN_RUNTIME_THREADS,
564                "compact_rt_size {} < {MIN_RUNTIME_THREADS} with {cpus} cpus",
565                options.compact_rt_size
566            );
567            assert!(
568                options.compact_rt_max_blocking_threads >= MIN_RUNTIME_THREADS,
569                "compact_rt_max_blocking_threads {} < {MIN_RUNTIME_THREADS} with {cpus} cpus",
570                options.compact_rt_max_blocking_threads
571            );
572            assert!(
573                options.query_rt_size >= MIN_RUNTIME_THREADS,
574                "query_rt_size {} < {MIN_RUNTIME_THREADS} with {cpus} cpus",
575                options.query_rt_size
576            );
577            assert!(
578                options.ingest_rt_size >= MIN_RUNTIME_THREADS,
579                "ingest_rt_size {} < {MIN_RUNTIME_THREADS} with {cpus} cpus",
580                options.ingest_rt_size
581            );
582        }
583    }
584
585    #[test]
586    fn test_datanode_runtimes_fallback_to_global_runtime() {
587        let runtimes = GlobalRuntimes::new(
588            Some(create_runtime("test-global", "test-global-worker", 1)),
589            None,
590            None,
591            None,
592            None,
593            None,
594        );
595
596        assert_eq!("test-global", runtimes.global_runtime.name());
597        assert_eq!("test-global", runtimes.query_runtime.name());
598        assert_eq!("test-global", runtimes.ingest_runtime.name());
599    }
600
601    #[test]
602    fn test_create_compact_runtime_with_zero_max_blocking_threads() {
603        let runtime = create_compact_runtime("test-compact", "test-compact-worker", 1, 0);
604        let handle = runtime.spawn_blocking(|| 1 + 1);
605
606        assert_eq!(2, runtime.block_on(handle).unwrap());
607    }
608
609    #[test]
610    fn test_compact_runtime_limits_blocking_threads() {
611        let runtime = create_compact_runtime("test-compact", "test-compact-worker", 1, 1);
612        let (first_started_tx, first_started_rx) = mpsc::channel();
613        let (release_first_tx, release_first_rx) = mpsc::channel();
614        let first = runtime.spawn_blocking(move || {
615            first_started_tx.send(()).unwrap();
616            release_first_rx.recv().unwrap();
617        });
618        first_started_rx
619            .recv_timeout(Duration::from_secs(5))
620            .unwrap();
621
622        let (second_started_tx, second_started_rx) = mpsc::channel();
623        let second = runtime.spawn_blocking(move || second_started_tx.send(()).unwrap());
624        assert!(
625            second_started_rx
626                .recv_timeout(Duration::from_secs(1))
627                .is_err()
628        );
629
630        release_first_tx.send(()).unwrap();
631        second_started_rx
632            .recv_timeout(Duration::from_secs(5))
633            .unwrap();
634        runtime.block_on(async {
635            first.await.unwrap();
636            second.await.unwrap();
637        });
638    }
639
640    #[test]
641    fn test_workload_scheduler_builds_with_initial_enabled_state() {
642        let mut options = RuntimeOptions::default();
643        options.experimental_workload_scheduler.enable = false;
644        options.experimental_workload_scheduler.sample_every_polls = NonZeroUsize::new(7).unwrap();
645        let scheduler = create_workload_scheduler(&options, options.global_rt_size);
646        assert_eq!(7, scheduler.stats().sample_every_polls);
647        assert!(!scheduler.is_enabled());
648
649        scheduler.set_enabled(true);
650        assert!(scheduler.is_enabled());
651    }
652
653    #[test]
654    fn test_workload_scheduler_bypasses_disabled_query_and_write_spawns() {
655        let runtime = create_runtime("test-workload-bypass", "test-workload-bypass-worker", 2);
656        let scheduler = Scheduler::builder()
657            .max_concurrent_polls(2)
658            .weight(QUERY_TASK_CLASS, 2)
659            .weight(WRITE_TASK_CLASS, 8)
660            .build();
661        scheduler.set_enabled(false);
662        let runtimes = GlobalRuntimes::new(
663            Some(runtime.clone()),
664            Some(runtime.clone()),
665            Some(runtime.clone()),
666            Some(runtime.clone()),
667            Some(runtime.clone()),
668            Some(scheduler.clone()),
669        );
670
671        let query = runtimes.spawn_query(async { "query" });
672        let write = runtimes.spawn_ingest(async { "write" });
673        let (query, write) =
674            runtime.block_on(async { (query.await.unwrap(), write.await.unwrap()) });
675
676        assert_eq!("query", query);
677        assert_eq!("write", write);
678        let stats = scheduler.stats();
679        for class in [QUERY_TASK_CLASS, WRITE_TASK_CLASS] {
680            let class_stats = &stats.classes[&class];
681            assert_eq!(0, class_stats.tasks);
682            assert_eq!(0, class_stats.admitted);
683            assert_eq!(0, class_stats.polls);
684        }
685    }
686
687    #[test]
688    fn test_workload_scheduler_wraps_query_and_write_spawns() {
689        let runtime = create_runtime("test-workload", "test-workload-worker", 2);
690        let scheduler = Scheduler::builder()
691            .max_concurrent_polls(2)
692            .weight(QUERY_TASK_CLASS, 2)
693            .weight(WRITE_TASK_CLASS, 8)
694            .build();
695        let runtimes = GlobalRuntimes::new(
696            Some(runtime.clone()),
697            Some(runtime.clone()),
698            Some(runtime.clone()),
699            Some(runtime.clone()),
700            Some(runtime.clone()),
701            Some(scheduler.clone()),
702        );
703
704        let query = runtimes.spawn_query(async { "query" });
705        let write = runtimes.spawn_ingest(async { "write" });
706        let (query, write) =
707            runtime.block_on(async { (query.await.unwrap(), write.await.unwrap()) });
708
709        assert_eq!("query", query);
710        assert_eq!("write", write);
711        let stats = scheduler.stats();
712        assert_eq!(1, stats.classes[&QUERY_TASK_CLASS].polls);
713        assert_eq!(1, stats.classes[&WRITE_TASK_CLASS].polls);
714    }
715
716    #[test]
717    fn test_datanode_query_backlog_does_not_starve_ingest() {
718        let query_runtime = create_runtime("test-datanode-query", "test-query-worker", 1);
719        let ingest_runtime = create_runtime("test-datanode-ingest", "test-ingest-worker", 1);
720        let scheduler = Scheduler::builder()
721            .max_concurrent_polls(2)
722            .sample_every_polls(16)
723            .weight(QUERY_TASK_CLASS, 2)
724            .weight(WRITE_TASK_CLASS, 8)
725            .build();
726        let runtimes = GlobalRuntimes::new(
727            Some(query_runtime.clone()),
728            Some(query_runtime.clone()),
729            Some(query_runtime.clone()),
730            Some(query_runtime),
731            Some(ingest_runtime),
732            Some(scheduler.clone()),
733        );
734
735        let stop_queries = Arc::new(AtomicBool::new(false));
736        let query_tasks = (0..3)
737            .map(|_| {
738                runtimes.spawn_query(CooperativePolls {
739                    stop: stop_queries.clone(),
740                })
741            })
742            .collect::<Vec<_>>();
743
744        // Establish the actual datanode topology: two query polls occupy the
745        // scheduler's capacity while the third self-waking query is queued.
746        wait_until("two active query polls and a queued query", || {
747            let stats = scheduler.stats();
748            stats.active_polls == 2
749                && stats
750                    .classes
751                    .get(&QUERY_TASK_CLASS)
752                    .is_some_and(|class| class.tasks == 3 && class.queued >= 1)
753        });
754
755        let write = runtimes.spawn_ingest(async {});
756        wait_until("write body", || write.is_finished());
757
758        stop_queries.store(true, Ordering::Relaxed);
759        for query in &query_tasks {
760            query.abort();
761        }
762        runtimes.block_on_query(async {
763            for query in query_tasks {
764                let _ = query.await;
765            }
766        });
767        runtimes.block_on_ingest(async {
768            write.await.unwrap();
769        });
770        wait_until("scheduler polls to drain", || {
771            scheduler.stats().active_polls == 0
772        });
773    }
774
775    #[test]
776    fn test_datanode_runtime_spawn_block_on() {
777        let handle = spawn_query(async { 1 + 1 });
778        assert_eq!(2, block_on_query(handle).unwrap());
779
780        let handle = spawn_ingest(async { 2 + 2 });
781        assert_eq!(4, block_on_ingest(handle).unwrap());
782    }
783
784    #[test]
785    fn test_spawn_block_on() {
786        let handle = spawn_global(async { 1 + 1 });
787        assert_eq!(2, block_on_global(handle).unwrap());
788
789        let handle = spawn_compact(async { 2 + 2 });
790        assert_eq!(4, block_on_compact(handle).unwrap());
791
792        let handle = spawn_hb(async { 4 + 4 });
793        assert_eq!(8, block_on_hb(handle).unwrap());
794    }
795
796    macro_rules! define_spawn_blocking_test {
797        ($type: ident) => {
798            paste! {
799                #[test]
800                fn [<test_spawn_ $type _from_blocking>]() {
801                    let runtime = [<$type _runtime>]();
802                    let out = runtime.block_on(async move {
803                        let inner = assert_ok!(
804                            [<spawn_blocking_  $type>](move || {
805                                [<spawn_ $type>](async move { "hello" })
806                            }).await
807                        );
808
809                        assert_ok!(inner.await)
810                    });
811
812                    assert_eq!(out, "hello")
813                }
814            }
815        };
816    }
817
818    define_spawn_blocking_test!(global);
819    define_spawn_blocking_test!(compact);
820    define_spawn_blocking_test!(hb);
821}