nemo_relay/api/runtime/scope_stack.rs
1// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Scope stack storage and propagation helpers.
5//!
6//! The runtime tracks the current scope hierarchy through a shared
7//! [`ScopeStack`] stored in task-local or thread-local state. Advanced callers
8//! can use this module to inspect the active scope chain or propagate scope
9//! context into worker threads.
10
11use std::cell::RefCell;
12use std::sync::{Arc, RwLock};
13
14use uuid::Uuid;
15
16use crate::api::runtime::callbacks::EventSubscriberFn;
17use crate::api::scope::{ScopeHandle, ScopeType};
18use crate::context::registries::ScopeLocalRegistries;
19use crate::error::{FlowError, Result};
20use crate::registry::{RegistryEntry, SortedRegistry};
21
22/// Mutable stack of active scopes plus their scope-local registries.
23///
24/// The stack always contains an implicit root scope. Additional scopes are
25/// pushed as the public API opens lifecycle spans and removed when those spans
26/// close.
27pub struct ScopeStack {
28 stack: Vec<ScopeHandle>,
29 scope_registries: std::collections::HashMap<Uuid, ScopeLocalRegistries>,
30}
31
32impl ScopeStack {
33 /// Create a new scope stack containing only the implicit root scope.
34 ///
35 /// # Returns
36 /// A [`ScopeStack`] initialized with a single root scope and no
37 /// scope-local registries.
38 pub fn new() -> Self {
39 let root = ScopeHandle::builder()
40 .name("root")
41 .scope_type(ScopeType::Agent)
42 .build();
43 Self {
44 stack: vec![root],
45 scope_registries: std::collections::HashMap::new(),
46 }
47 }
48
49 /// Push a scope handle onto the top of the stack.
50 ///
51 /// # Parameters
52 /// - `handle`: Scope handle to make the new top-most active scope.
53 pub fn push(&mut self, handle: ScopeHandle) {
54 self.stack.push(handle);
55 }
56
57 /// Return the current top-most scope handle.
58 ///
59 /// # Returns
60 /// A shared reference to the active scope at the top of the stack.
61 ///
62 /// # Notes
63 /// This function never returns `None` because the implicit root scope is
64 /// always present.
65 pub fn top(&self) -> &ScopeHandle {
66 self.stack
67 .last()
68 .expect("scope stack should never be empty")
69 }
70
71 /// Return the current top-most scope handle mutably.
72 ///
73 /// # Returns
74 /// A mutable reference to the active scope at the top of the stack.
75 pub fn top_mut(&mut self) -> &mut ScopeHandle {
76 self.stack
77 .last_mut()
78 .expect("scope stack should never be empty")
79 }
80
81 /// Return the UUID of the implicit root scope.
82 ///
83 /// # Returns
84 /// The stable UUID of the root scope stored at the bottom of the stack.
85 pub fn root_uuid(&self) -> Uuid {
86 self.stack
87 .first()
88 .expect("scope stack should never be empty")
89 .uuid
90 }
91
92 /// Return the full ordered stack of scope handles.
93 ///
94 /// # Returns
95 /// A slice of scopes ordered from root to the current top-most scope.
96 pub fn scopes(&self) -> &[ScopeHandle] {
97 &self.stack
98 }
99
100 /// Find a scope handle by UUID.
101 ///
102 /// # Parameters
103 /// - `uuid`: UUID of the scope to search for.
104 ///
105 /// # Returns
106 /// `Some(&ScopeHandle)` when the scope is active on this stack and `None`
107 /// otherwise.
108 pub fn find(&self, uuid: &Uuid) -> Option<&ScopeHandle> {
109 self.stack.iter().find(|handle| handle.uuid == *uuid)
110 }
111
112 /// Remove the current top scope if it matches `uuid`.
113 ///
114 /// # Parameters
115 /// - `uuid`: UUID of the scope expected to be at the top of the stack.
116 ///
117 /// # Returns
118 /// A [`Result`] containing the removed [`ScopeHandle`].
119 ///
120 /// # Errors
121 /// Returns [`FlowError::InvalidArgument`] when the scope exists but is not
122 /// the current top of the stack or when the caller attempts to remove the
123 /// implicit root scope. Returns [`FlowError::NotFound`] when the UUID is
124 /// not present on the stack.
125 pub fn remove(&mut self, uuid: &Uuid) -> Result<ScopeHandle> {
126 let top = self
127 .stack
128 .last()
129 .expect("scope stack should never be empty");
130 if top.uuid == *uuid {
131 if self.stack.len() == 1 {
132 return Err(FlowError::InvalidArgument(
133 "root scope cannot be removed".into(),
134 ));
135 }
136 self.scope_registries.remove(uuid);
137 return Ok(self
138 .stack
139 .pop()
140 .expect("scope stack should contain a removable top scope"));
141 }
142
143 if self.stack.iter().any(|handle| handle.uuid == *uuid) {
144 return Err(FlowError::InvalidArgument(
145 "scope handle is not at the top of the stack".into(),
146 ));
147 }
148
149 Err(FlowError::NotFound("scope handle not found".into()))
150 }
151
152 /// Get or create the scope-local registries for an active scope.
153 ///
154 /// # Parameters
155 /// - `uuid`: UUID of an active scope on this stack.
156 ///
157 /// # Returns
158 /// `Some(&mut ScopeLocalRegistries)` when the scope is active and `None`
159 /// otherwise.
160 ///
161 /// # Notes
162 /// When the scope is active but has no registries yet, this function
163 /// creates an empty scope-local registry set first.
164 pub(crate) fn local_registries_mut(
165 &mut self,
166 uuid: &Uuid,
167 ) -> Option<&mut ScopeLocalRegistries> {
168 if !self.stack.iter().any(|handle| handle.uuid == *uuid) {
169 return None;
170 }
171 Some(self.scope_registries.entry(*uuid).or_default())
172 }
173
174 /// Collect one registry field from every active scope that owns it.
175 ///
176 /// # Parameters
177 /// - `field`: Projection function selecting the registry field to collect
178 /// from each scope-local registry.
179 ///
180 /// # Returns
181 /// A vector of registry references ordered from root toward the current
182 /// top-most scope.
183 pub(crate) fn collect_scope_local_registries<'a, T: RegistryEntry>(
184 &'a self,
185 field: impl Fn(&'a ScopeLocalRegistries) -> &'a SortedRegistry<T>,
186 ) -> Vec<&'a SortedRegistry<T>> {
187 self.stack
188 .iter()
189 .filter_map(|handle| self.scope_registries.get(&handle.uuid))
190 .map(field)
191 .collect()
192 }
193
194 /// Collect all scope-local subscribers visible from the active stack.
195 ///
196 /// # Returns
197 /// A vector of subscribers collected from each active scope that owns
198 /// scope-local registries.
199 pub(crate) fn collect_scope_local_subscribers(&self) -> Vec<EventSubscriberFn> {
200 self.stack
201 .iter()
202 .filter_map(|handle| self.scope_registries.get(&handle.uuid))
203 .flat_map(|registries| registries.event_subscribers.values().cloned())
204 .collect()
205 }
206}
207
208impl std::fmt::Debug for ScopeStack {
209 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
210 f.debug_struct("ScopeStack")
211 .field("stack", &self.stack)
212 .field("scope_registries_count", &self.scope_registries.len())
213 .finish()
214 }
215}
216
217impl Default for ScopeStack {
218 fn default() -> Self {
219 Self::new()
220 }
221}
222
223/// Shared handle type for the runtime scope stack.
224///
225/// The runtime stores the active [`ScopeStack`] behind an [`Arc`] and [`RwLock`]
226/// so bindings can propagate it across execution contexts while still allowing
227/// concurrent readers.
228pub type ScopeStackHandle = Arc<RwLock<ScopeStack>>;
229
230/// Captured thread-local scope stack binding.
231///
232/// This preserves both the visible scope stack handle and whether it was
233/// explicitly installed on the current thread.
234#[derive(Clone)]
235pub struct ThreadScopeStackBinding {
236 stack: ScopeStackHandle,
237 explicit: bool,
238}
239
240/// Create a new scope stack handle with an implicit root scope.
241///
242/// The returned handle wraps a freshly initialized [`ScopeStack`] inside an
243/// [`Arc`] and [`RwLock`] so it can be shared across async tasks or threads.
244///
245/// # Returns
246/// A new [`ScopeStackHandle`] containing exactly one implicit root scope.
247///
248/// # Notes
249/// The root scope is always present and cannot be removed.
250pub fn create_scope_stack() -> ScopeStackHandle {
251 Arc::new(RwLock::new(ScopeStack::new()))
252}
253
254tokio::task_local! {
255 /// Task-local scope stack handle used by async execution contexts.
256 pub static TASK_SCOPE_STACK: ScopeStackHandle;
257}
258
259thread_local! {
260 /// Thread-local fallback scope stack for non-task contexts.
261 static THREAD_SCOPE_STACK: RefCell<ScopeStackHandle> = RefCell::new(create_scope_stack());
262 /// Whether the current thread explicitly owns a scope stack.
263 static THREAD_SCOPE_STACK_EXPLICIT: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
264}
265
266/// Return the scope stack visible to the current execution context.
267///
268/// This resolves task-local scope state first and otherwise falls back to the
269/// current thread-local scope stack handle.
270///
271/// # Returns
272/// The active [`ScopeStackHandle`] for the current async task or thread.
273///
274/// # Notes
275/// When no explicit thread-local stack has been installed yet, the default
276/// per-thread root-only stack is returned.
277pub fn current_scope_stack() -> ScopeStackHandle {
278 TASK_SCOPE_STACK
279 .try_with(|stack| stack.clone())
280 .unwrap_or_else(|_| THREAD_SCOPE_STACK.with(|stack| stack.borrow().clone()))
281}
282
283/// Install an explicit scope stack for the current thread.
284///
285/// This replaces the thread-local scope stack handle and marks the current
286/// thread as explicitly scope-aware for later propagation checks.
287///
288/// # Parameters
289/// - `handle`: Scope stack handle to install for the current thread.
290///
291/// # Returns
292/// `()`.
293///
294/// # Notes
295/// Use this when propagating an existing scope stack into worker threads.
296pub fn set_thread_scope_stack(handle: ScopeStackHandle) {
297 THREAD_SCOPE_STACK.with(|stack| *stack.borrow_mut() = handle);
298 THREAD_SCOPE_STACK_EXPLICIT.with(|flag| flag.set(true));
299}
300
301/// Capture the current thread-local scope stack binding.
302///
303/// This is intended for foreign runtimes that temporarily bind a scope stack to
304/// an OS thread and need to restore the exact previous state before releasing
305/// that thread back to their scheduler.
306///
307/// # Returns
308/// A [`ThreadScopeStackBinding`] containing the current thread-local stack and
309/// explicit-binding flag.
310pub fn capture_thread_scope_stack() -> ThreadScopeStackBinding {
311 let stack = THREAD_SCOPE_STACK.with(|stack| stack.borrow().clone());
312 let explicit = THREAD_SCOPE_STACK_EXPLICIT.with(|flag| flag.get());
313 ThreadScopeStackBinding { stack, explicit }
314}
315
316/// Restore a previously captured thread-local scope stack binding.
317///
318/// # Parameters
319/// - `binding`: Captured binding to restore on the current thread.
320///
321/// # Returns
322/// `()`.
323pub fn restore_thread_scope_stack(binding: ThreadScopeStackBinding) {
324 THREAD_SCOPE_STACK.with(|stack| *stack.borrow_mut() = binding.stack);
325 THREAD_SCOPE_STACK_EXPLICIT.with(|flag| flag.set(binding.explicit));
326}
327
328/// Synchronize the thread-local scope stack without marking it explicit.
329///
330/// This updates the thread-local slot used by native runtime code while
331/// preserving whether the thread was explicitly marked as owning a scope stack.
332///
333/// # Parameters
334/// - `handle`: Scope stack handle to synchronize into thread-local storage.
335///
336/// # Returns
337/// `()`.
338///
339/// # Notes
340/// Python bindings use this to mirror `ContextVar` state into Rust without
341/// forcing `scope_stack_active()` to become `true` for the thread.
342pub fn sync_thread_scope_stack(handle: ScopeStackHandle) {
343 THREAD_SCOPE_STACK.with(|stack| *stack.borrow_mut() = handle);
344}
345
346/// Report whether the current context has an explicitly active scope stack.
347///
348/// This checks task-local state first and otherwise falls back to the
349/// thread-local explicit flag.
350///
351/// # Returns
352/// `true` when the current async task or thread already owns an active scope
353/// stack and `false` otherwise.
354///
355/// # Notes
356/// A synchronized thread-local stack does not count as explicit unless it was
357/// installed through [`set_thread_scope_stack`].
358pub fn scope_stack_active() -> bool {
359 TASK_SCOPE_STACK
360 .try_with(|_| true)
361 .unwrap_or_else(|_| THREAD_SCOPE_STACK_EXPLICIT.with(|flag| flag.get()))
362}
363
364/// Capture the current scope stack handle for use in another thread.
365///
366/// This returns the handle currently visible to the caller so it can be passed
367/// into [`set_thread_scope_stack`] elsewhere.
368///
369/// # Returns
370/// A [`Result`] containing the active [`ScopeStackHandle`].
371///
372/// # Errors
373/// Returns an error when the current context does not yet own an active scope
374/// stack.
375///
376/// # Notes
377/// The returned handle is shared; it does not clone the underlying stack.
378pub fn propagate_scope_to_thread() -> Result<ScopeStackHandle> {
379 if !scope_stack_active() {
380 return Err(FlowError::Internal(
381 "no active scope stack in current context; call create_scope_stack() and set_thread_scope_stack() first"
382 .into(),
383 ));
384 }
385 Ok(current_scope_stack())
386}
387
388/// Clone the current top-most scope handle from the active stack.
389///
390/// # Returns
391/// A cloned [`ScopeHandle`] representing the current active scope.
392pub fn task_scope_top() -> ScopeHandle {
393 let stack = current_scope_stack();
394 let guard = stack.read().expect("scope stack lock poisoned");
395 guard.top().clone()
396}
397
398/// Push a scope handle onto the active stack.
399///
400/// # Parameters
401/// - `handle`: Scope handle to push onto the current execution context's stack.
402pub fn task_scope_push(handle: ScopeHandle) {
403 let stack = current_scope_stack();
404 let mut guard = stack.write().expect("scope stack lock poisoned");
405 guard.push(handle);
406}
407
408/// Remove a scope handle from the active stack.
409///
410/// # Parameters
411/// - `uuid`: UUID of the scope expected to be at the top of the active stack.
412///
413/// # Returns
414/// A [`Result`] containing the removed [`ScopeHandle`].
415///
416/// # Errors
417/// Propagates the same errors returned by [`ScopeStack::remove`].
418pub fn task_scope_remove(uuid: &Uuid) -> Result<ScopeHandle> {
419 let stack = current_scope_stack();
420 let mut guard = stack.write().expect("scope stack lock poisoned");
421 guard.remove(uuid)
422}