Skip to main content

dynamo_runtime/
runtime.rs

1// SPDX-FileCopyrightText: Copyright (c) 2024-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! The [Runtime] module is the interface for [crate::component::Component]
5//! to access shared resources. These include thread pool, memory allocators and other shared resources.
6//!
7//! The [Runtime] holds the primary [`CancellationToken`] which can be used to terminate all attached
8//! [`crate::component::Component`].
9//!
10//! We expect in the future to offer topologically aware thread and memory resources, but for now the
11//! set of resources is limited to the thread pool and cancellation token.
12//!
13//! Notes: We will need to do an evaluation on what is fully public, what is pub(crate) and what is
14//! private; however, for now we are exposing most objects as fully public while the API is maturing.
15
16use super::utils::GracefulShutdownTracker;
17use crate::{
18    compute,
19    config::{self, RuntimeConfig},
20};
21
22use futures::Future;
23use once_cell::sync::OnceCell;
24use std::{
25    mem::ManuallyDrop,
26    sync::{Arc, atomic::Ordering},
27    time::Duration,
28};
29use tokio::{signal, sync::Mutex, task::JoinHandle};
30
31pub use tokio_util::sync::CancellationToken;
32
33const DEFAULT_GRACEFUL_SHUTDOWN_TIMEOUT_SECS: u64 = 15 * 60;
34
35pub(crate) fn graceful_shutdown_timeout() -> Duration {
36    let timeout_secs = std::env::var(
37        config::environment_names::runtime::DYN_RUNTIME_GRACEFUL_SHUTDOWN_TIMEOUT_SECS,
38    )
39    .ok()
40    .and_then(|s| s.parse::<u64>().ok())
41    .unwrap_or(DEFAULT_GRACEFUL_SHUTDOWN_TIMEOUT_SECS);
42
43    Duration::from_secs(timeout_secs)
44}
45
46/// Types of Tokio runtimes that can be used to construct a Dynamo [Runtime].
47#[derive(Clone, Debug)]
48enum RuntimeType {
49    Shared(Arc<ManuallyDrop<tokio::runtime::Runtime>>),
50    External(tokio::runtime::Handle),
51}
52
53/// Local [Runtime] which provides access to shared resources local to the physical node/machine.
54#[derive(Debug, Clone)]
55pub struct Runtime {
56    id: Arc<String>,
57    primary: RuntimeType,
58    secondary: RuntimeType,
59    cancellation_token: CancellationToken,
60    endpoint_shutdown_token: CancellationToken,
61    shutdown_started: CancellationToken,
62    graceful_shutdown_tracker: Arc<GracefulShutdownTracker>,
63    compute_pool: Option<Arc<compute::ComputePool>>,
64    block_in_place_permits: Option<Arc<tokio::sync::Semaphore>>,
65}
66
67impl Runtime {
68    fn new(runtime: RuntimeType, secondary: Option<RuntimeType>) -> anyhow::Result<Runtime> {
69        // Initialise NVTX toggle once from environment (no-op when feature is off)
70        crate::nvtx::init();
71
72        // worker id
73        let id = Arc::new(uuid::Uuid::new_v4().to_string());
74
75        // create a cancellation token
76        let cancellation_token = CancellationToken::new();
77
78        // create endpoint shutdown token as a child of the main token
79        let endpoint_shutdown_token = cancellation_token.child_token();
80        let shutdown_started = endpoint_shutdown_token.child_token();
81
82        // secondary runtime for background ectd/nats tasks
83        let secondary = match secondary {
84            Some(secondary) => secondary,
85            None => {
86                tracing::debug!("Created secondary runtime with single thread");
87                RuntimeType::Shared(Arc::new(ManuallyDrop::new(
88                    RuntimeConfig::single_threaded().create_runtime()?,
89                )))
90            }
91        };
92
93        // Initialize compute pool with default config
94        // This will be properly configured when created from RuntimeConfig
95        let compute_pool = None;
96        let block_in_place_permits = None;
97
98        Ok(Runtime {
99            id,
100            primary: runtime,
101            secondary,
102            cancellation_token,
103            endpoint_shutdown_token,
104            shutdown_started,
105            graceful_shutdown_tracker: Arc::new(GracefulShutdownTracker::new()),
106            compute_pool,
107            block_in_place_permits,
108        })
109    }
110
111    fn new_with_config(
112        runtime: RuntimeType,
113        secondary: Option<RuntimeType>,
114        config: &RuntimeConfig,
115    ) -> anyhow::Result<Runtime> {
116        let mut rt = Self::new(runtime, secondary)?;
117
118        // Create compute pool from configuration
119        let compute_config = crate::compute::ComputeConfig {
120            num_threads: config.compute_threads,
121            stack_size: config.compute_stack_size,
122            thread_prefix: config.compute_thread_prefix.clone(),
123            pin_threads: false,
124        };
125
126        // Check if compute pool is explicitly disabled
127        if config.compute_threads == Some(0) {
128            tracing::info!("Compute pool disabled (compute_threads = 0)");
129        } else {
130            match crate::compute::ComputePool::new(compute_config) {
131                Ok(pool) => {
132                    rt.compute_pool = Some(Arc::new(pool));
133                    tracing::debug!(
134                        "Initialized compute pool with {} threads",
135                        rt.compute_pool.as_ref().unwrap().num_threads()
136                    );
137                }
138                Err(e) => {
139                    tracing::warn!(
140                        "Failed to create compute pool: {}. CPU-intensive operations will use spawn_blocking",
141                        e
142                    );
143                }
144            }
145        }
146
147        // Initialize block_in_place semaphore based on actual worker threads
148        let num_workers = config
149            .num_worker_threads
150            .unwrap_or_else(|| std::thread::available_parallelism().unwrap().get());
151        // Reserve at least one thread for async work
152        let permits = num_workers.saturating_sub(1).max(1);
153        rt.block_in_place_permits = Some(Arc::new(tokio::sync::Semaphore::new(permits)));
154        tracing::debug!(
155            "Initialized block_in_place permits: {} (from {} worker threads)",
156            permits,
157            num_workers
158        );
159
160        Ok(rt)
161    }
162
163    /// Initialize thread-local compute context on the current thread
164    /// This should be called on each Tokio worker thread
165    pub fn initialize_thread_local(&self) {
166        if let (Some(pool), Some(permits)) = (&self.compute_pool, &self.block_in_place_permits) {
167            crate::compute::thread_local::initialize_context(Arc::clone(pool), Arc::clone(permits));
168        }
169        // Name this worker thread in the Nsight Systems timeline (no-op when nvtx feature is off)
170        let thread_name = std::thread::current()
171            .name()
172            .map(|n| n.to_string())
173            .unwrap_or_else(|| format!("tokio-worker-{:?}", std::thread::current().id()));
174        crate::nvtx::name_current_thread_impl(&thread_name);
175    }
176
177    /// Initialize thread-local compute context on all worker threads using a barrier
178    /// This ensures every worker thread has its thread-local context initialized
179    pub async fn initialize_all_thread_locals(&self) -> anyhow::Result<()> {
180        if let (Some(pool), Some(permits)) = (&self.compute_pool, &self.block_in_place_permits) {
181            // First, detect how many worker threads we actually have
182            let num_workers = self.detect_worker_thread_count().await;
183
184            if num_workers == 0 {
185                return Err(anyhow::anyhow!("No worker threads detected"));
186            }
187
188            // Create a barrier that all threads must reach
189            let barrier = Arc::new(std::sync::Barrier::new(num_workers));
190            let init_pool = Arc::clone(pool);
191            let init_permits = Arc::clone(permits);
192
193            // Spawn exactly one blocking task per worker thread
194            let mut handles = Vec::new();
195            for i in 0..num_workers {
196                let barrier_clone = Arc::clone(&barrier);
197                let pool_clone = Arc::clone(&init_pool);
198                let permits_clone = Arc::clone(&init_permits);
199
200                let handle = tokio::task::spawn_blocking(move || {
201                    // Wait at barrier - ensures all threads are participating
202                    barrier_clone.wait();
203
204                    // Now initialize thread-local storage
205                    crate::compute::thread_local::initialize_context(pool_clone, permits_clone);
206
207                    // Get thread ID for logging
208                    let thread_id = std::thread::current().id();
209                    tracing::trace!(
210                        "Initialized thread-local compute context on thread {:?} (worker {})",
211                        thread_id,
212                        i
213                    );
214                });
215                handles.push(handle);
216            }
217
218            // Wait for all tasks to complete
219            for handle in handles {
220                handle.await?;
221            }
222
223            tracing::info!(
224                "Successfully initialized thread-local compute context on {} worker threads",
225                num_workers
226            );
227        } else {
228            tracing::debug!("No compute pool configured, skipping thread-local initialization");
229        }
230        Ok(())
231    }
232
233    /// Detect the number of worker threads in the runtime
234    async fn detect_worker_thread_count(&self) -> usize {
235        use parking_lot::Mutex;
236        use std::collections::HashSet;
237
238        let thread_ids = Arc::new(Mutex::new(HashSet::new()));
239        let mut handles = Vec::new();
240
241        // Spawn many blocking tasks to ensure we hit all threads
242        // We use spawn_blocking because it runs on worker threads
243        let num_probes = 100;
244        for _ in 0..num_probes {
245            let ids = Arc::clone(&thread_ids);
246            let handle = tokio::task::spawn_blocking(move || {
247                let thread_id = std::thread::current().id();
248                ids.lock().insert(thread_id);
249            });
250            handles.push(handle);
251        }
252
253        // Wait for all probes to complete
254        for handle in handles {
255            let _ = handle.await;
256        }
257
258        let count = thread_ids.lock().len();
259        tracing::debug!("Detected {count} worker threads in runtime");
260        count
261    }
262
263    pub fn from_current() -> anyhow::Result<Runtime> {
264        Runtime::from_handle(tokio::runtime::Handle::current())
265    }
266
267    pub fn from_handle(handle: tokio::runtime::Handle) -> anyhow::Result<Runtime> {
268        let primary = RuntimeType::External(handle.clone());
269        let secondary = RuntimeType::External(handle);
270        Runtime::new(primary, Some(secondary))
271    }
272
273    /// Like [`Runtime::from_handle`], but also attaches the compute pool and `block_in_place`
274    /// permits that `config` implies, the way [`Runtime::from_settings`] does.
275    ///
276    /// For when the Tokio runtime is owned elsewhere — a process-wide `OnceCell`, say — so only a
277    /// handle can be borrowed, but the [`RuntimeConfig`] behind it is known.
278    pub fn from_handle_with_config(
279        handle: tokio::runtime::Handle,
280        config: &RuntimeConfig,
281    ) -> anyhow::Result<Runtime> {
282        let primary = RuntimeType::External(handle.clone());
283        let secondary = RuntimeType::External(handle);
284        Runtime::new_with_config(primary, Some(secondary), config)
285    }
286
287    /// Create a [`Runtime`] instance from the settings
288    /// See [`config::RuntimeConfig::from_settings`]
289    pub fn from_settings() -> anyhow::Result<Runtime> {
290        let config = config::RuntimeConfig::from_settings()?;
291        let runtime = Arc::new(ManuallyDrop::new(config.create_runtime()?));
292        let primary = RuntimeType::Shared(runtime.clone());
293        let secondary = RuntimeType::External(runtime.handle().clone());
294        Runtime::new_with_config(primary, Some(secondary), &config)
295    }
296
297    /// Create a [`Runtime`] with two single-threaded async tokio runtime
298    pub fn single_threaded() -> anyhow::Result<Runtime> {
299        let config = config::RuntimeConfig::single_threaded();
300        let owned = RuntimeType::Shared(Arc::new(ManuallyDrop::new(config.create_runtime()?)));
301        Runtime::new(owned, None)
302    }
303
304    /// Returns the unique identifier for the [`Runtime`]
305    pub fn id(&self) -> &str {
306        &self.id
307    }
308
309    /// Returns a [`tokio::runtime::Handle`] for the primary/application thread pool
310    pub fn primary(&self) -> tokio::runtime::Handle {
311        self.primary.handle()
312    }
313
314    /// Returns a [`tokio::runtime::Handle`] for the secondary/background thread pool
315    pub fn secondary(&self) -> tokio::runtime::Handle {
316        self.secondary.handle()
317    }
318
319    /// Access the primary [`CancellationToken`] for the [`Runtime`]
320    pub fn primary_token(&self) -> CancellationToken {
321        self.cancellation_token.clone()
322    }
323
324    /// Creates a child [`CancellationToken`] tied to the life-cycle of the [`Runtime`]'s endpoint shutdown token.
325    pub fn child_token(&self) -> CancellationToken {
326        self.endpoint_shutdown_token.child_token()
327    }
328
329    /// Get access to the graceful shutdown tracker
330    pub(crate) fn graceful_shutdown_tracker(&self) -> Arc<GracefulShutdownTracker> {
331        self.graceful_shutdown_tracker.clone()
332    }
333
334    /// Get access to the compute pool for CPU-intensive operations
335    ///
336    /// Returns None if the compute pool was not initialized (e.g., due to configuration error)
337    pub fn compute_pool(&self) -> Option<&Arc<crate::compute::ComputePool>> {
338        self.compute_pool.as_ref()
339    }
340
341    /// Withdraw readiness before application draining without stopping the
342    /// endpoints or transports still needed to complete that drain.
343    pub fn mark_shutting_down(&self) {
344        if !self.shutdown_started.is_cancelled() {
345            tracing::info!("Runtime readiness withdrawn for shutdown");
346            self.shutdown_started.cancel();
347        }
348    }
349
350    pub fn is_shutting_down(&self) -> bool {
351        self.shutdown_started.is_cancelled()
352    }
353
354    /// Observe shutdown from its start, rather than waiting for transport teardown.
355    pub fn shutdown_started_token(&self) -> CancellationToken {
356        self.shutdown_started.child_token()
357    }
358
359    /// Shuts down the [`Runtime`] instance
360    pub fn shutdown(&self) {
361        self.mark_shutting_down();
362        tracing::info!("Runtime shutdown initiated");
363
364        // Spawn the shutdown coordination task BEFORE cancelling tokens
365        let tracker = self.graceful_shutdown_tracker.clone();
366        let main_token = self.cancellation_token.clone();
367        let endpoint_token = self.endpoint_shutdown_token.clone();
368
369        // Use the runtime handle to spawn the task
370        let handle = self.primary();
371        handle.spawn(async move {
372            // Phase 1: Cancel endpoint shutdown token to stop accepting new requests
373            tracing::info!("Phase 1: Cancelling endpoint shutdown token");
374            endpoint_token.cancel();
375
376            // Phase 2: Wait for all graceful endpoints to complete
377            tracing::info!("Phase 2: Waiting for graceful endpoints to complete");
378
379            let count = tracker.get_count();
380            tracing::info!("Active graceful endpoints: {count}");
381
382            if count != 0 {
383                let timeout = graceful_shutdown_timeout();
384                if tokio::time::timeout(timeout, tracker.wait_for_completion())
385                    .await
386                    .is_err()
387                {
388                    let remaining = tracker.get_count();
389                    tracing::error!(
390                        timeout_secs = timeout.as_secs(),
391                        remaining_endpoints = remaining,
392                        "Graceful endpoint shutdown timed out; proceeding with runtime teardown"
393                    );
394                }
395            }
396
397            // Phase 3: Now connections will be disconnected to backend services (e.g. NATS/ETCD) by cancelling the main token
398            tracing::info!("Phase 3: Connections to backend services will now be disconnected");
399            main_token.cancel();
400        });
401    }
402}
403
404impl RuntimeType {
405    /// Get [`tokio::runtime::Handle`] to runtime
406    pub fn handle(&self) -> tokio::runtime::Handle {
407        match self {
408            RuntimeType::External(rt) => rt.clone(),
409            RuntimeType::Shared(rt) => rt.handle().clone(),
410        }
411    }
412}
413
414/// Handle dropping a tokio runtime from an async context.
415///
416/// When used from the Python bindings the runtime will be dropped from (I think) Python's asyncio.
417/// Tokio does not allow this and will panic. That panic prevents logging from printing it's last
418/// messages, which makes knowing what went wrong very difficult.
419///
420/// This is the panic:
421/// > pyo3_runtime.PanicException: Cannot drop a runtime in a context where blocking is not allowed.
422/// > This happens when a runtime is dropped from within an asynchronous context.
423///
424/// Hence we wrap the runtime in a ManuallyDrop and use tokio's alternative shutdown if we detect
425/// that we are inside an async runtime.
426impl Drop for RuntimeType {
427    fn drop(&mut self) {
428        match self {
429            RuntimeType::External(_) => {}
430            RuntimeType::Shared(arc) => {
431                let Some(md_runtime) = Arc::get_mut(arc) else {
432                    // Only drop if we are the only owner of the shared pointer, meaning
433                    // one strong count and no weak count.
434                    return;
435                };
436                if tokio::runtime::Handle::try_current().is_ok() {
437                    // We are inside an async runtime.
438                    let tokio_runtime = unsafe { ManuallyDrop::take(md_runtime) };
439                    tokio_runtime.shutdown_background();
440                } else {
441                    // We are not inside an async context, dropping the runtime is safe.
442                    //
443                    // We never reach this case. I'm not sure why, something about the interaction
444                    // with pyo3 and Python lifetimes.
445                    //
446                    // Process is gone so doesn't really matter, but TODO now that we realize it.
447                    unsafe { ManuallyDrop::drop(md_runtime) };
448                }
449            }
450        }
451    }
452}
453
454#[cfg(test)]
455mod tests {
456    use super::*;
457    use crate::config::environment_names::runtime as env_runtime;
458
459    #[tokio::test(start_paused = true)]
460    async fn shutdown_cancels_main_token_after_graceful_timeout() {
461        temp_env::async_with_vars(
462            [(
463                env_runtime::DYN_RUNTIME_GRACEFUL_SHUTDOWN_TIMEOUT_SECS,
464                Some("5"),
465            )],
466            async {
467                let runtime = Runtime::from_current().unwrap();
468                let tracker = runtime.graceful_shutdown_tracker();
469                let _guard = tracker.register_task();
470                let main_token = runtime.primary_token();
471                let endpoint_token = runtime.child_token();
472
473                runtime.shutdown();
474                tokio::task::yield_now().await;
475
476                assert!(endpoint_token.is_cancelled());
477                assert!(!main_token.is_cancelled());
478                assert_eq!(tracker.get_count(), 1);
479
480                tokio::time::advance(Duration::from_secs(4)).await;
481                tokio::task::yield_now().await;
482
483                assert!(!main_token.is_cancelled());
484
485                tokio::time::advance(Duration::from_secs(1)).await;
486                tokio::task::yield_now().await;
487
488                assert!(main_token.is_cancelled());
489                assert_eq!(tracker.get_count(), 1);
490            },
491        )
492        .await;
493    }
494}