Skip to main content

dynamo_runtime/
component.rs

1// SPDX-FileCopyrightText: Copyright (c) 2024-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! The [Component] module defines the top-level API for building distributed applications.
5//!
6//! A distributed application consists of a set of [Component] that can host one
7//! or more [Endpoint]. Each [Endpoint] is a network-accessible service
8//! that can be accessed by other [Component] in the distributed application.
9//!
10//! A [Component] is made discoverable by registering it with the distributed runtime under
11//! a [`Namespace`].
12//!
13//! A [`Namespace`] is a logical grouping of [Component] that are grouped together.
14//!
15//! We might extend namespace to include grouping behavior, which would define groups of
16//! components that are tightly coupled.
17//!
18//! A [Component] is the core building block of a distributed application. It is a logical
19//! unit of work such as a `Preprocessor` or `SmartRouter` that has a well-defined role in the
20//! distributed application.
21//!
22//! A [Component] can present to the distributed application one or more configuration files
23//! which define how that component was constructed/configured and what capabilities it can
24//! provide.
25//!
26//! Other [Component] can write to watching locations within a [Component] etcd
27//! path. This allows the [Component] to take dynamic actions depending on the watch
28//! triggers.
29//!
30//! TODO: Top-level Overview of Endpoints/Functions
31
32use std::fmt;
33
34use crate::{
35    config::HealthStatus,
36    distributed::RequestPlaneMode,
37    metrics::{MetricsHierarchy, MetricsRegistry, prometheus_names},
38    service::ServiceClient,
39    service::ServiceSet,
40};
41
42use super::{DistributedRuntime, Runtime, traits::*, transports::nats::Slug, utils::Duration};
43
44use crate::pipeline::network::{
45    PushWorkHandler, RequestPlanePayloadCodec, ingress::push_endpoint::PushEndpoint,
46};
47use crate::protocols::EndpointId;
48use async_nats::{
49    rustls::quic,
50    service::{Service, ServiceExt},
51};
52use dashmap::DashMap;
53use derive_builder::Builder;
54use derive_getters::Getters;
55use educe::Educe;
56use serde::{Deserialize, Serialize};
57use std::{collections::HashMap, hash::Hash, sync::Arc};
58use validator::{Validate, ValidationError};
59
60mod client;
61#[allow(clippy::module_inception)]
62mod component;
63mod endpoint;
64mod namespace;
65mod registry;
66pub mod service;
67
68pub(crate) use client::EndpointDiscoverySource;
69pub(crate) use client::RoutingInstances;
70pub use client::{Client, RoutingInstanceCounts};
71pub use endpoint::{StartedEndpoint, build_transport_type};
72
73#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq, Hash)]
74#[serde(rename_all = "snake_case")]
75pub enum TransportType {
76    #[serde(rename = "nats_tcp")]
77    Nats(String),
78    Tcp(String),
79}
80
81impl TransportType {
82    pub fn address(&self) -> &str {
83        match self {
84            TransportType::Nats(address) | TransportType::Tcp(address) => address,
85        }
86    }
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq, Hash)]
90#[serde(rename_all = "snake_case")]
91pub enum DeviceType {
92    Cpu,
93    Cuda,
94}
95
96#[derive(Default)]
97pub struct RegistryInner {
98    pub(crate) services: HashMap<String, Service>,
99}
100
101#[derive(Clone)]
102pub struct Registry {
103    pub(crate) inner: Arc<tokio::sync::Mutex<RegistryInner>>,
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
107pub struct Instance {
108    pub component: String,
109    pub endpoint: String,
110    pub namespace: String,
111    pub instance_id: u64,
112    pub transport: TransportType,
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub device_type: Option<DeviceType>,
115    /// Payload codec accepted by this worker's request-plane endpoint.
116    /// Missing metadata identifies a legacy JSON-only worker.
117    #[serde(default, skip_serializing_if = "Option::is_none")]
118    pub request_plane_codec: Option<RequestPlanePayloadCodec>,
119}
120
121impl Instance {
122    pub fn id(&self) -> u64 {
123        self.instance_id
124    }
125
126    pub fn endpoint_id(&self) -> EndpointId {
127        EndpointId {
128            namespace: self.namespace.clone(),
129            component: self.component.clone(),
130            name: self.endpoint.clone(),
131        }
132    }
133
134    pub fn endpoint_instance_id(&self) -> crate::discovery::EndpointInstanceId {
135        crate::discovery::EndpointInstanceId {
136            namespace: self.namespace.clone(),
137            component: self.component.clone(),
138            endpoint: self.endpoint.clone(),
139            instance_id: self.instance_id,
140        }
141    }
142}
143
144impl fmt::Display for Instance {
145    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
146        write!(
147            f,
148            "{}/{}/{}/{}",
149            self.namespace, self.component, self.endpoint, self.instance_id
150        )
151    }
152}
153
154/// Sort by string name
155impl std::cmp::Ord for Instance {
156    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
157        self.to_string().cmp(&other.to_string())
158    }
159}
160
161impl PartialOrd for Instance {
162    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
163        // Since Ord is fully implemented, the comparison is always total.
164        Some(self.cmp(other))
165    }
166}
167
168/// A [Component] a discoverable entity in the distributed runtime.
169/// You can host [Endpoint] on a [Component] by first creating
170/// a [Service] then adding one or more [Endpoint] to the [Service].
171///
172/// You can also issue a request to a [Component]'s [Endpoint] by creating a [Client].
173#[derive(Educe, Builder, Clone, Validate)]
174#[educe(Debug)]
175#[builder(pattern = "owned", build_fn(private, name = "build_internal"))]
176pub struct Component {
177    #[builder(private)]
178    #[educe(Debug(ignore))]
179    drt: Arc<DistributedRuntime>,
180
181    /// Name of the component
182    #[builder(setter(into))]
183    #[validate(custom(function = "validate_allowed_chars"))]
184    name: String,
185
186    /// Additional labels for metrics
187    #[builder(default = "Vec::new()")]
188    labels: Vec<(String, String)>,
189
190    // todo - restrict the namespace to a-z0-9-_A-Z
191    /// Namespace
192    #[builder(setter(into))]
193    namespace: Namespace,
194
195    /// This hierarchy's own metrics registry
196    #[builder(default = "crate::MetricsRegistry::new()")]
197    metrics_registry: crate::MetricsRegistry,
198}
199
200impl Hash for Component {
201    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
202        self.namespace.name().hash(state);
203        self.name.hash(state);
204    }
205}
206
207impl PartialEq for Component {
208    fn eq(&self, other: &Self) -> bool {
209        self.namespace.name() == other.namespace.name() && self.name == other.name
210    }
211}
212
213impl Eq for Component {}
214
215impl std::fmt::Display for Component {
216    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
217        write!(f, "{}.{}", self.namespace.name(), self.name)
218    }
219}
220
221impl DistributedRuntimeProvider for Component {
222    fn drt(&self) -> &DistributedRuntime {
223        &self.drt
224    }
225}
226
227impl RuntimeProvider for Component {
228    fn rt(&self) -> &Runtime {
229        self.drt.rt()
230    }
231}
232
233impl MetricsHierarchy for Component {
234    fn basename(&self) -> String {
235        self.name.clone()
236    }
237
238    fn parent_hierarchies(&self) -> Vec<&dyn MetricsHierarchy> {
239        let mut parents = vec![];
240
241        // Get all ancestors of namespace (DRT, parent namespaces, etc.)
242        parents.extend(self.namespace.parent_hierarchies());
243
244        // Add namespace itself
245        parents.push(&self.namespace as &dyn MetricsHierarchy);
246
247        parents
248    }
249
250    fn get_metrics_registry(&self) -> &MetricsRegistry {
251        &self.metrics_registry
252    }
253
254    fn connection_id(&self) -> Option<u64> {
255        Some(self.drt.connection_id())
256    }
257}
258
259impl Component {
260    pub fn service_name(&self) -> String {
261        let service_name = format!("{}_{}", self.namespace.name(), self.name);
262        Slug::slugify(&service_name).to_string()
263    }
264
265    pub fn namespace(&self) -> &Namespace {
266        &self.namespace
267    }
268
269    pub fn name(&self) -> &str {
270        &self.name
271    }
272
273    pub fn labels(&self) -> &[(String, String)] {
274        &self.labels
275    }
276
277    pub fn endpoint(&self, endpoint: impl Into<String>) -> Endpoint {
278        let endpoint = Endpoint {
279            component: self.clone(),
280            name: endpoint.into(),
281            labels: Vec::new(),
282            metrics_registry: crate::MetricsRegistry::new(),
283        };
284        // Attach endpoint registry so scrapes traverse separate registries (avoids collisions).
285        self.get_metrics_registry()
286            .add_child_registry(endpoint.get_metrics_registry());
287        endpoint
288    }
289
290    pub async fn list_instances(&self) -> anyhow::Result<Vec<Instance>> {
291        let discovery = self.drt.discovery();
292
293        let discovery_query = crate::discovery::DiscoveryQuery::ComponentEndpoints {
294            namespace: self.namespace.name(),
295            component: self.name.clone(),
296        };
297
298        let discovery_instances = discovery.list(discovery_query).await?;
299
300        // Extract Instance from DiscoveryInstance::Endpoint wrapper
301        let mut instances: Vec<Instance> = discovery_instances
302            .into_iter()
303            .filter_map(|di| match di {
304                crate::discovery::DiscoveryInstance::Endpoint(instance) => Some(instance),
305                _ => None, // Ignore all other variants (ModelCard, etc.)
306            })
307            .collect();
308
309        instances.sort();
310        Ok(instances)
311    }
312}
313
314impl ComponentBuilder {
315    pub fn from_runtime(drt: Arc<DistributedRuntime>) -> Self {
316        Self::default().drt(drt)
317    }
318
319    pub fn build(self) -> Result<Component, anyhow::Error> {
320        let component = self.build_internal()?;
321        // If this component is using NATS, register the NATS service and wait for completion.
322        // This prevents a race condition where serve_endpoint() tries to look up the service
323        // before it's registered in the component registry.
324        let drt = component.drt();
325        if drt.request_plane().is_nats() {
326            let mut rx = drt.register_nats_service(component.clone());
327            // Wait synchronously for the NATS service registration to complete.
328            // Uses block_in_place() to safely call blocking_recv() from async contexts.
329            // This temporarily moves the current task off the runtime thread to allow
330            // blocking without deadlocking the runtime.
331            let result = tokio::task::block_in_place(|| rx.blocking_recv());
332            match result {
333                Some(Ok(())) => {
334                    tracing::debug!(
335                        component = component.service_name(),
336                        "NATS service registration completed"
337                    );
338                }
339                Some(Err(e)) => {
340                    return Err(anyhow::anyhow!(
341                        "NATS service registration failed for component '{}': {}",
342                        component.service_name(),
343                        e
344                    ));
345                }
346                None => {
347                    return Err(anyhow::anyhow!(
348                        "NATS service registration channel closed unexpectedly for component '{}'",
349                        component.service_name()
350                    ));
351                }
352            }
353        }
354        Ok(component)
355    }
356}
357
358#[derive(Debug, Clone)]
359pub struct Endpoint {
360    component: Component,
361
362    // todo - restrict alphabet
363    /// Endpoint name
364    name: String,
365
366    /// Additional labels for metrics
367    labels: Vec<(String, String)>,
368
369    /// This hierarchy's own metrics registry
370    metrics_registry: crate::MetricsRegistry,
371}
372
373impl Hash for Endpoint {
374    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
375        self.component.hash(state);
376        self.name.hash(state);
377    }
378}
379
380impl PartialEq for Endpoint {
381    fn eq(&self, other: &Self) -> bool {
382        self.component == other.component && self.name == other.name
383    }
384}
385
386impl Eq for Endpoint {}
387
388impl DistributedRuntimeProvider for Endpoint {
389    fn drt(&self) -> &DistributedRuntime {
390        self.component.drt()
391    }
392}
393
394impl RuntimeProvider for Endpoint {
395    fn rt(&self) -> &Runtime {
396        self.component.rt()
397    }
398}
399
400impl MetricsHierarchy for Endpoint {
401    fn basename(&self) -> String {
402        self.name.clone()
403    }
404
405    fn parent_hierarchies(&self) -> Vec<&dyn MetricsHierarchy> {
406        let mut parents = vec![];
407
408        // Get all ancestors of component (DRT, Namespace, etc.)
409        parents.extend(self.component.parent_hierarchies());
410
411        // Add component itself
412        parents.push(&self.component as &dyn MetricsHierarchy);
413
414        parents
415    }
416
417    fn get_metrics_registry(&self) -> &MetricsRegistry {
418        &self.metrics_registry
419    }
420
421    fn connection_id(&self) -> Option<u64> {
422        Some(self.component.drt().connection_id())
423    }
424}
425
426impl Endpoint {
427    pub fn id(&self) -> EndpointId {
428        EndpointId {
429            namespace: self.component.namespace().name().to_string(),
430            component: self.component.name().to_string(),
431            name: self.name().to_string(),
432        }
433    }
434
435    pub fn name(&self) -> &str {
436        &self.name
437    }
438
439    pub fn component(&self) -> &Component {
440        &self.component
441    }
442
443    pub async fn client(&self) -> anyhow::Result<client::Client> {
444        client::Client::new(self.clone()).await
445    }
446
447    /// Like [`Self::client`], but the returned `Client`'s background
448    /// instance-reconciliation task is bound to `cancel_token` rather than
449    /// the process-wide primary token. Use this when the `Client` itself is
450    /// scoped to something narrower than the process — a monitor bound to
451    /// one `WorkerSet`'s lifecycle, say — since dropping every handle to a
452    /// `Client` built through [`Self::client`] does not stop that task, and
453    /// it otherwise runs, and leaks, until process shutdown.
454    pub async fn client_with_cancellation(
455        &self,
456        cancel_token: tokio_util::sync::CancellationToken,
457    ) -> anyhow::Result<client::Client> {
458        client::Client::with_cancellation(self.clone(), cancel_token).await
459    }
460
461    pub fn endpoint_builder(&self) -> endpoint::EndpointConfigBuilder {
462        endpoint::EndpointConfigBuilder::from_endpoint(self.clone())
463    }
464}
465
466#[derive(Builder, Clone, Validate)]
467#[builder(pattern = "owned")]
468pub struct Namespace {
469    #[builder(private)]
470    runtime: Arc<DistributedRuntime>,
471
472    #[validate(custom(function = "validate_allowed_chars"))]
473    name: String,
474
475    #[builder(default = "None")]
476    parent: Option<Arc<Namespace>>,
477
478    /// Additional labels for metrics
479    #[builder(default = "Vec::new()")]
480    labels: Vec<(String, String)>,
481
482    /// This hierarchy's own metrics registry
483    #[builder(default = "crate::MetricsRegistry::new()")]
484    metrics_registry: crate::MetricsRegistry,
485
486    /// Cache for components to avoid duplicate registrations and metrics collisions.
487    /// When the same component is requested multiple times, we return the cached instance
488    /// to ensure all endpoints share the same Component and MetricsRegistry.
489    /// Uses DashMap for lock-free reads and automatic handling of concurrent inserts.
490    #[builder(default = "Arc::new(DashMap::new())")]
491    component_cache: Arc<DashMap<String, Component>>,
492}
493
494impl DistributedRuntimeProvider for Namespace {
495    fn drt(&self) -> &DistributedRuntime {
496        &self.runtime
497    }
498}
499
500impl std::fmt::Debug for Namespace {
501    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
502        write!(
503            f,
504            "Namespace {{ name: {}; parent: {:?} }}",
505            self.name, self.parent
506        )
507    }
508}
509
510impl RuntimeProvider for Namespace {
511    fn rt(&self) -> &Runtime {
512        self.runtime.rt()
513    }
514}
515
516impl std::fmt::Display for Namespace {
517    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
518        write!(f, "{}", self.name)
519    }
520}
521
522impl Namespace {
523    pub(crate) fn new(runtime: DistributedRuntime, name: String) -> anyhow::Result<Self> {
524        let ns = NamespaceBuilder::default()
525            .runtime(Arc::new(runtime))
526            .name(name)
527            .build()?;
528        // Attach namespace registry so scrapes traverse separate registries (avoids collisions).
529        ns.drt()
530            .get_metrics_registry()
531            .add_child_registry(ns.get_metrics_registry());
532        Ok(ns)
533    }
534
535    /// Create a [`Component`] in the namespace who's endpoints can be discovered with etcd
536    ///
537    /// Components are cached by name to ensure that multiple calls with the same name
538    /// return the same Component instance. This prevents duplicate metrics registrations
539    /// and ensures all endpoints share the same Component's MetricsRegistry.
540    pub fn component(&self, name: impl Into<String>) -> anyhow::Result<Component> {
541        let name = name.into();
542
543        // Fast path: Check if component exists in cache
544        // DashMap provides lock-free reads via internal sharding
545        if let Some(cached) = self.component_cache.get(&name) {
546            return Ok(cached.value().clone());
547        }
548
549        // Slow path: Create new component
550        let component = ComponentBuilder::from_runtime(self.runtime.clone())
551            .name(&name)
552            .namespace(self.clone())
553            .build()?;
554
555        // Attach component registry so scrapes traverse separate registries (avoids collisions).
556        self.get_metrics_registry()
557            .add_child_registry(component.get_metrics_registry());
558
559        // Cache the component for future calls
560        // DashMap handles race conditions internally - if another thread
561        // inserted the same key concurrently, we just use our created component
562        self.component_cache.insert(name, component.clone());
563
564        Ok(component)
565    }
566
567    /// Create a [`Namespace`] in the parent namespace
568    pub fn namespace(&self, name: impl Into<String>) -> anyhow::Result<Namespace> {
569        let child = NamespaceBuilder::default()
570            .runtime(self.runtime.clone())
571            .name(name.into())
572            .parent(Some(Arc::new(self.clone())))
573            .build()?;
574        // Attach child namespace registry so scrapes traverse separate registries (avoids collisions).
575        self.get_metrics_registry()
576            .add_child_registry(child.get_metrics_registry());
577        Ok(child)
578    }
579
580    pub fn name(&self) -> String {
581        match &self.parent {
582            Some(parent) => format!("{}.{}", parent.name(), self.name),
583            None => self.name.clone(),
584        }
585    }
586}
587
588// Custom validator function
589fn validate_allowed_chars(input: &str) -> Result<(), ValidationError> {
590    // Define the allowed character set using a regex
591    let regex = regex::Regex::new(r"^[a-z0-9-_]+$").unwrap();
592
593    if regex.is_match(input) {
594        Ok(())
595    } else {
596        Err(ValidationError::new("invalid_characters"))
597    }
598}