Skip to main content

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