Skip to main content

dynamo_runtime/
worker.rs

1// SPDX-FileCopyrightText: Copyright (c) 2024-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! The [Worker] class is a convenience wrapper around the construction of the [Runtime]
5//! and execution of the users application.
6//!
7//! In the future, the [Worker] should probably be moved to a procedural macro similar
8//! to the `#[tokio::main]` attribute, where we might annotate an async main function with
9//! `#[dynamo::main]` or similar.
10//!
11//! The [Worker::execute] method is designed to be called once from main and will block
12//! the calling thread until the application completes or is canceled. The method initialized
13//! the signal handler used to trap `SIGINT` and `SIGTERM` signals and trigger a graceful shutdown.
14//!
15//! On termination, the user application is given a graceful shutdown period of controlled by
16//! the `DYN_WORKER_GRACEFUL_SHUTDOWN_TIMEOUT` environment variable. If the application does not
17//! shutdown in time, the worker will terminate the application with an exit code of 911.
18//!
19//! The default values of `DYN_WORKER_GRACEFUL_SHUTDOWN_TIMEOUT` differ between the development
20//! and release builds. In development, the default is [DEFAULT_GRACEFUL_SHUTDOWN_TIMEOUT_DEBUG] and
21//! in release, the default is [DEFAULT_GRACEFUL_SHUTDOWN_TIMEOUT_RELEASE].
22
23use super::{CancellationToken, Runtime, RuntimeConfig};
24
25use futures::Future;
26use once_cell::sync::OnceCell;
27use parking_lot::Mutex;
28use std::sync::atomic::{AtomicBool, Ordering};
29use std::time::Duration;
30use tokio::{signal, task::JoinHandle};
31
32/// The one Tokio runtime for this process.
33///
34/// Holds the runtime itself rather than a `Handle`, because
35/// `pyo3_async_runtimes::tokio::init_with_runtime` needs a `&'static Runtime`.
36///
37/// Set once, by whichever of [`Worker::from_config`] or [`Worker::ensure_process_runtime`] runs
38/// first. Every path goes through this cell, so a process cannot end up with two runtimes.
39static RT: OnceCell<tokio::runtime::Runtime> = OnceCell::new();
40
41/// The config `RT` was built from, so [`Worker::runtime_from_existing`] can attach the matching
42/// compute pool without re-reading the environment.
43///
44/// Only [`Worker::ensure_process_runtime`] fills this in; [`Worker::from_config`] brings its own
45/// config and no pool.
46static RTCONFIG: OnceCell<RuntimeConfig> = OnceCell::new();
47
48/// Whether a [`Runtime`] has already taken the compute pool. One pool per process, however many
49/// wrappers get built.
50static COMPUTE_CLAIMED: AtomicBool = AtomicBool::new(false);
51
52static INIT: OnceCell<Mutex<Option<tokio::task::JoinHandle<anyhow::Result<()>>>>> = OnceCell::new();
53
54use crate::config::environment_names::worker as env_worker;
55
56const SHUTDOWN_MESSAGE: &str =
57    "Application received shutdown signal; attempting to gracefully shutdown";
58const SHUTDOWN_TIMEOUT_MESSAGE: &str =
59    "Use DYN_WORKER_GRACEFUL_SHUTDOWN_TIMEOUT to control the graceful shutdown timeout";
60
61/// Default graceful shutdown timeout in seconds in debug mode
62pub const DEFAULT_GRACEFUL_SHUTDOWN_TIMEOUT_DEBUG: u64 = 5;
63
64/// Default graceful shutdown timeout in seconds in release mode
65pub const DEFAULT_GRACEFUL_SHUTDOWN_TIMEOUT_RELEASE: u64 = 30;
66
67#[derive(Debug, Clone)]
68pub struct Worker {
69    runtime: Runtime,
70    config: RuntimeConfig,
71}
72
73impl Worker {
74    /// Create a new [`Worker`] instance from [`RuntimeConfig`] settings which is sourced from the environment
75    pub fn from_settings() -> anyhow::Result<Worker> {
76        let config = RuntimeConfig::from_settings()?;
77        Worker::from_config(config)
78    }
79
80    /// Create a new [`Worker`] instance from a provided [`RuntimeConfig`]
81    pub fn from_config(config: RuntimeConfig) -> anyhow::Result<Worker> {
82        RuntimeConfig::validate_parser_environment()?;
83        // if the runtime is already initialized, return an error
84        if RT.get().is_some() {
85            return Err(anyhow::anyhow!("Worker already initialized"));
86        }
87
88        // create a new runtime and insert it into the OnceCell
89        // there is still a potential race-condition here, two threads cou have passed the first check
90        // but only one will succeed in inserting the runtime
91        let rt = RT.try_insert(config.create_runtime()?).map_err(|_| {
92            anyhow::anyhow!("Failed to create worker; Only a single Worker should ever be created")
93        })?;
94
95        let runtime = Runtime::from_handle(rt.handle().clone())?;
96        Ok(Worker { runtime, config })
97    }
98
99    /// Share the process-wide runtime, creating it on first use.
100    ///
101    /// The returned [`Runtime`] only wraps a handle to `RT`, so every caller ends up on the same
102    /// Tokio runtime. Creation goes through [`Worker::ensure_process_runtime`] rather than
103    /// happening here, so the runtime stays reachable as a `&'static` for the pyo3 bridge.
104    pub fn runtime_from_existing() -> anyhow::Result<Runtime> {
105        let rt = Self::ensure_process_runtime()?;
106        let handle = rt.handle().clone();
107
108        // Only the first wrapper gets the compute pool and `block_in_place` permits: one Rayon
109        // pool per process, not one per `DistributedRuntime`.
110        //
111        // An atomic swap rather than "did I just build `RT`?", because callers may call
112        // `ensure_process_runtime` first — `DistributedRuntime::new` does — which would make
113        // that question always answer no. The swap also settles the race between two threads.
114        match RTCONFIG.get() {
115            Some(config) if !COMPUTE_CLAIMED.swap(true, Ordering::SeqCst) => {
116                Runtime::from_handle_with_config(handle, config)
117            }
118            _ => Runtime::from_handle(handle),
119        }
120    }
121
122    /// Create the process-wide runtime if it does not exist yet, and return it. Idempotent.
123    ///
124    /// Exists because the pyo3 bridge needs a `&'static tokio::runtime::Runtime`, which
125    /// [`Worker::from_config`] cannot provide — it errors when a runtime already exists.
126    pub fn ensure_process_runtime() -> anyhow::Result<&'static tokio::runtime::Runtime> {
127        RuntimeConfig::validate_parser_environment()?;
128        // Fast path — `get_or_try_init` below would also return it, just less cheaply.
129        if let Some(rt) = RT.get() {
130            return Ok(rt);
131        }
132
133        // If two threads arrive together, one builds and both observe the same runtime.
134        RT.get_or_try_init(|| -> anyhow::Result<tokio::runtime::Runtime> {
135            let config = RuntimeConfig::from_settings()?;
136            tracing::info!("dynamo runtime configuration: {config}");
137            let rt = config.create_runtime()?;
138            let _ = RTCONFIG.set(config);
139            Ok(rt)
140        })
141    }
142
143    /// Whether the process-wide runtime already exists.
144    ///
145    /// Never creates one, unlike [`Worker::runtime_from_existing`], so a caller can use this to
146    /// find out whether it would be the owner.
147    pub fn has_existing_runtime() -> bool {
148        RT.get().is_some()
149    }
150
151    /// The process-wide runtime, if one was ever created. Never creates one.
152    ///
153    /// Unlike [`Worker::ensure_process_runtime`], this returns `None` rather than building a
154    /// runtime, so a caller running at process exit can ask about the runtime without starting
155    /// worker threads on the way out.
156    pub fn existing_process_runtime() -> Option<&'static tokio::runtime::Runtime> {
157        RT.get()
158    }
159
160    pub fn tokio_runtime(&self) -> anyhow::Result<&'static tokio::runtime::Runtime> {
161        RT.get()
162            .ok_or_else(|| anyhow::anyhow!("Worker not initialized"))
163    }
164
165    pub fn runtime(&self) -> &Runtime {
166        &self.runtime
167    }
168
169    pub fn execute<F, Fut>(self, f: F) -> anyhow::Result<()>
170    where
171        F: FnOnce(Runtime) -> Fut + Send + 'static,
172        Fut: Future<Output = anyhow::Result<()>> + Send + 'static,
173    {
174        let runtime = self.runtime.clone();
175        runtime.secondary().block_on(self.execute_internal(f))??;
176        runtime.shutdown();
177        Ok(())
178    }
179
180    pub async fn execute_async<F, Fut>(self, f: F) -> anyhow::Result<()>
181    where
182        F: FnOnce(Runtime) -> Fut + Send + 'static,
183        Fut: Future<Output = anyhow::Result<()>> + Send + 'static,
184    {
185        let runtime = self.runtime.clone();
186        let task = self.execute_internal(f);
187        task.await??;
188        runtime.shutdown();
189        Ok(())
190    }
191
192    /// Executes the provided application/closure on the [`Runtime`].
193    /// This is designed to be called once from main and will block the calling thread until the application completes.
194    fn execute_internal<F, Fut>(self, f: F) -> JoinHandle<anyhow::Result<()>>
195    where
196        F: FnOnce(Runtime) -> Fut + Send + 'static,
197        Fut: Future<Output = anyhow::Result<()>> + Send + 'static,
198    {
199        let runtime = self.runtime.clone();
200        let primary = runtime.primary();
201        let secondary = runtime.secondary();
202
203        let timeout = std::env::var(env_worker::DYN_WORKER_GRACEFUL_SHUTDOWN_TIMEOUT)
204            .ok()
205            .and_then(|s| s.parse::<u64>().ok())
206            .unwrap_or({
207                if cfg!(debug_assertions) {
208                    DEFAULT_GRACEFUL_SHUTDOWN_TIMEOUT_DEBUG
209                } else {
210                    DEFAULT_GRACEFUL_SHUTDOWN_TIMEOUT_RELEASE
211                }
212            });
213
214        INIT.set(Mutex::new(Some(secondary.spawn(async move {
215            // start signal handler
216            tokio::spawn(signal_handler(runtime.primary_token().clone()));
217
218            let cancel_token = runtime.child_token();
219            let (mut app_tx, app_rx) = tokio::sync::oneshot::channel::<()>();
220
221            // spawn a task to run the application
222            let task: JoinHandle<anyhow::Result<()>> = primary.spawn(async move {
223                let _rx = app_rx;
224                f(runtime).await
225            });
226
227            tokio::select! {
228                _ = cancel_token.cancelled() => {
229                    tracing::debug!("{SHUTDOWN_MESSAGE}");
230                    tracing::debug!("{} {} seconds", SHUTDOWN_TIMEOUT_MESSAGE, timeout);
231                }
232
233                _ = app_tx.closed() => {
234                }
235            };
236
237            let result = tokio::select! {
238                result = task => {
239                    result
240                }
241
242                _ = tokio::time::sleep(tokio::time::Duration::from_secs(timeout)) => {
243                    tracing::debug!("Application did not shutdown in time; terminating");
244                    std::process::exit(911);
245                }
246            }?;
247
248            match &result {
249                Ok(_) => {
250                    tracing::debug!("Application shutdown successfully");
251                }
252                Err(e) => {
253                    tracing::error!("Application shutdown with error: {:?}", e);
254                }
255            }
256
257            result
258        }))))
259        .expect("Failed to spawn application task");
260
261        INIT
262            .get()
263            .expect("Application task not initialized")
264            .lock()
265            .take()
266            .expect("Application initialized; but another thread is awaiting it; Worker.execute() can only be called once")
267    }
268
269    pub fn from_current() -> anyhow::Result<Worker> {
270        if RT.get().is_some() {
271            return Err(anyhow::anyhow!("Worker already initialized"));
272        }
273        let runtime = Runtime::from_current()?;
274        let config = RuntimeConfig::from_settings()?;
275        Ok(Worker { runtime, config })
276    }
277}
278
279/// Catch signals and trigger a shutdown
280async fn signal_handler(cancel_token: CancellationToken) -> anyhow::Result<()> {
281    let ctrl_c = async {
282        signal::ctrl_c().await?;
283        anyhow::Ok(())
284    };
285
286    let sigterm = async {
287        signal::unix::signal(signal::unix::SignalKind::terminate())?
288            .recv()
289            .await;
290        anyhow::Ok(())
291    };
292
293    tokio::select! {
294        _ = ctrl_c => {
295            tracing::info!("Ctrl+C received, starting graceful shutdown");
296        },
297        _ = sigterm => {
298            tracing::info!("SIGTERM received, starting graceful shutdown");
299        },
300        _ = cancel_token.cancelled() => {
301            tracing::debug!("CancellationToken triggered; shutting down");
302        },
303    }
304
305    // trigger a shutdown
306    cancel_token.cancel();
307
308    Ok(())
309}