saluki_core/runtime/
dedicated.rs

1//! Dedicated runtime support for supervisors.
2
3use std::{
4    future::Future,
5    io,
6    pin::Pin,
7    sync::{
8        atomic::{AtomicUsize, Ordering},
9        Arc,
10    },
11    task::{ready, Context, Poll},
12    thread::JoinHandle,
13};
14
15use saluki_common::sync::shutdown::ShutdownHandle;
16use saluki_error::{generic_error, GenericError};
17use tokio::sync::oneshot;
18
19use super::{
20    state::DataspaceRegistry,
21    supervisor::{Supervisor, SupervisorError},
22};
23
24/// Configuration for a dedicated Tokio runtime.
25#[derive(Clone, Debug)]
26pub struct RuntimeConfiguration {
27    /// Number of worker threads.
28    worker_threads: usize,
29}
30
31impl RuntimeConfiguration {
32    /// Creates a new single-threaded `RuntimeConfiguration`.
33    ///
34    /// The underlying executor implementation is based on `tokio`'s "current thread" runtime.
35    pub const fn single_threaded() -> Self {
36        Self { worker_threads: 1 }
37    }
38
39    /// Creates a new multi-threaded `RuntimeConfiguration` with the given number of worker threads.
40    ///
41    /// The underlying executor implementation is based on `tokio`'s multi-threaded runtime.
42    pub const fn multi_threaded(worker_threads: usize) -> Self {
43        Self { worker_threads }
44    }
45
46    pub(crate) fn worker_threads(&self) -> usize {
47        self.worker_threads
48    }
49
50    /// Builds the Tokio runtime from this configuration.
51    pub(crate) fn build(&self, supervisor_id: &str) -> io::Result<tokio::runtime::Runtime> {
52        let supervisor_id = supervisor_id.to_string();
53        let thread_id = Arc::new(AtomicUsize::new(0));
54
55        if self.worker_threads == 1 {
56            tokio::runtime::Builder::new_current_thread().enable_all().build()
57        } else {
58            tokio::runtime::Builder::new_multi_thread()
59                .enable_all()
60                .enable_alt_timer()
61                .worker_threads(self.worker_threads)
62                .thread_name_fn(move || {
63                    let new_thread_id = thread_id.fetch_add(1, Ordering::SeqCst);
64                    format!("{}-sup-{:02}", supervisor_id, new_thread_id)
65                })
66                .build()
67        }
68    }
69}
70
71/// Controls which runtime a supervisor runs on.
72#[derive(Clone, Debug, Default)]
73pub enum RuntimeMode {
74    /// Run on the ambient runtime (default).
75    ///
76    /// The supervisor runs on whatever Tokio runtime is currently active when it's spawned.
77    #[default]
78    Ambient,
79
80    /// Run on a dedicated runtime with the given configuration.
81    ///
82    /// The supervisor spawns its own OS threads and Tokio runtime, providing runtime isolation from the parent
83    /// supervisor.
84    Dedicated(RuntimeConfiguration),
85}
86
87/// A handle to a supervisor running in a dedicated runtime.
88///
89/// Allows capturing any runtime initialization failures as well as the result of the supervisor's execution.
90pub(crate) struct DedicatedRuntimeHandle {
91    supervisor_id: String,
92    init_rx: Option<oneshot::Receiver<Result<(), GenericError>>>,
93    result_rx: oneshot::Receiver<Result<(), SupervisorError>>,
94    thread_handle: Option<JoinHandle<()>>,
95}
96
97impl Future for DedicatedRuntimeHandle {
98    type Output = Result<(), SupervisorError>;
99
100    fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
101        // First, check if initialization is still pending.
102        //
103        // NOTE: This is runtime-level initialization (building the Tokio runtime and OS thread), not
104        // supervisor child initialization. These errors are always fatal and non-restartable.
105        if let Some(init_rx) = self.init_rx.as_mut() {
106            let init_result = ready!(Pin::new(init_rx).poll(cx));
107            let maybe_init_error = match init_result {
108                Ok(Ok(())) => None,
109                Ok(Err(e)) => Some(e),
110                Err(_) => Some(generic_error!(
111                    "no initialization result received; runtime creation likely panicked"
112                )),
113            };
114
115            self.init_rx = None;
116
117            if let Some(error) = maybe_init_error {
118                // Join on the thread to clean up.
119                if let Some(handle) = self.thread_handle.take() {
120                    let _ = handle.join();
121                }
122
123                return Poll::Ready(Err(SupervisorError::FailedToInitialize {
124                    child_name: self.supervisor_id.clone(),
125                    source: error.into(),
126                }));
127            }
128        }
129
130        // Check for a final result from the supervisor. The structured `SupervisorError` is preserved
131        // so the parent can distinguish init failures from runtime failures.
132        //
133        // If the channel is closed without a result, the runtime thread panicked or exited after
134        // successful init — this is a runtime failure, not an initialization failure.
135        let result = ready!(Pin::new(&mut self.result_rx).poll(cx)).unwrap_or_else(|_| Err(SupervisorError::Shutdown));
136
137        // Join on the thread to clean up.
138        if let Some(handle) = self.thread_handle.take() {
139            let _ = handle.join();
140        }
141
142        Poll::Ready(result)
143    }
144}
145
146/// Spawns a supervisor in a dedicated runtime on a new OS thread, returning a handle to await for the result.
147///
148/// The returned handle will resolve if initialization of the runtime failed, or after the supervisor completes,
149/// whether due to shutdown or failure.
150///
151/// A background OS thread is spawned to run the supervisor, named after the supervisor's ID: `<id>-sup-rt`. For
152/// multi-threaded runtimes, additional threads will be spawned following a similar naming convention:
153/// `<id>-sup-[0-9]+`.
154///
155/// # Errors
156///
157/// If the OS thread can't be spawned, an error is returned.
158pub(crate) fn spawn_dedicated_runtime(
159    mut supervisor: Supervisor, config: RuntimeConfiguration, process_shutdown: ShutdownHandle,
160    dataspace: DataspaceRegistry,
161) -> Result<DedicatedRuntimeHandle, GenericError> {
162    let (init_tx, init_rx) = oneshot::channel();
163    let (result_tx, result_rx) = oneshot::channel();
164
165    let supervisor_id = supervisor.id().to_string();
166    let thread_name = format!("{}-sup-rt", supervisor_id);
167    let thread_handle = std::thread::Builder::new()
168        .name(thread_name.clone())
169        .spawn(move || {
170            // Build the runtime.
171            let runtime = match config.build(supervisor.id()) {
172                Ok(rt) => rt,
173                Err(e) => {
174                    let _ = init_tx.send(Err(generic_error!("Failed to build dedicated runtime: {}", e)));
175                    return;
176                }
177            };
178
179            // Signal that initialization succeeded.
180            if init_tx.send(Ok(())).is_err() {
181                // Parent is no longer listening, bail out.
182                return;
183            }
184
185            // Run the supervisor to completion and send the result back.
186            //
187            // We pass the parent's dataspace so the nested supervisor inherits it across the
188            // thread boundary rather than creating a new one.
189            let result = runtime.block_on(supervisor.run_with_shutdown_inner(process_shutdown, Some(dataspace)));
190            let _ = result_tx.send(result);
191        })
192        .map_err(|e| generic_error!("Failed to spawn dedicated runtime thread '{}': {}", thread_name, e))?;
193
194    Ok(DedicatedRuntimeHandle {
195        supervisor_id,
196        init_rx: Some(init_rx),
197        result_rx,
198        thread_handle: Some(thread_handle),
199    })
200}