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