Skip to main content

dynamo_runtime/component/
endpoint.rs

1// SPDX-FileCopyrightText: Copyright (c) 2024-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4use std::sync::Arc;
5
6use anyhow::Result;
7use derive_builder::Builder;
8use derive_getters::Dissolve;
9use educe::Educe;
10use tokio_util::sync::CancellationToken;
11
12use crate::{
13    component::{DeviceType, Endpoint, Instance, TransportType},
14    distributed::RequestPlaneMode,
15    pipeline::network::{
16        PushWorkHandler, RequestPlanePayloadCodec, ingress::push_endpoint::PushEndpoint,
17    },
18    protocols::EndpointId,
19    traits::DistributedRuntimeProvider,
20    transports::nats,
21};
22
23fn endpoint_device_type() -> Option<DeviceType> {
24    // Common CUDA masks that explicitly disable GPU visibility.
25    if std::env::var("CUDA_VISIBLE_DEVICES")
26        .ok()
27        .map(|v| {
28            let l = v.trim().to_ascii_lowercase();
29            l.is_empty() || l == "-1" || l == "none" || l == "void"
30        })
31        .unwrap_or(false)
32    {
33        return Some(DeviceType::Cpu);
34    }
35
36    // Container runtimes often use NVIDIA_VISIBLE_DEVICES to gate GPU visibility.
37    if std::env::var("NVIDIA_VISIBLE_DEVICES")
38        .ok()
39        .map(|v| {
40            let l = v.trim().to_ascii_lowercase();
41            l == "none" || l == "void"
42        })
43        .unwrap_or(false)
44    {
45        return Some(DeviceType::Cpu);
46    }
47
48    // Default: no explicit CPU override means this endpoint is CUDA-capable.
49    Some(DeviceType::Cuda)
50}
51
52/// A registered endpoint whose exact callable instance is ready for use.
53///
54/// Dropping this handle does not stop the endpoint. Call [`shutdown`](Self::shutdown)
55/// for scoped endpoint lifetimes, or [`wait`](Self::wait) for the traditional
56/// runtime-owned lifetime.
57pub struct StartedEndpoint {
58    instance: Instance,
59    shutdown_token: CancellationToken,
60    task: tokio::task::JoinHandle<anyhow::Result<()>>,
61}
62
63impl StartedEndpoint {
64    pub fn instance(&self) -> &Instance {
65        &self.instance
66    }
67
68    pub async fn shutdown(self) -> Result<()> {
69        self.shutdown_token.cancel();
70        self.task.await??;
71        Ok(())
72    }
73
74    pub async fn wait(self) -> Result<()> {
75        self.task.await??;
76        Ok(())
77    }
78}
79
80#[derive(Educe, Builder, Dissolve)]
81#[educe(Debug)]
82#[builder(pattern = "owned", build_fn(private, name = "build_internal"))]
83pub struct EndpointConfig {
84    #[builder(private)]
85    endpoint: Endpoint,
86
87    /// Endpoint handler
88    #[educe(Debug(ignore))]
89    handler: Arc<dyn PushWorkHandler>,
90
91    /// Additional labels for metrics
92    #[builder(default, setter(into))]
93    metrics_labels: Option<Vec<(String, String)>>,
94
95    /// Whether to wait for inflight requests to complete during shutdown
96    #[builder(default = "true")]
97    graceful_shutdown: bool,
98
99    /// Health check payload for this endpoint
100    /// This payload will be sent to the endpoint during health checks
101    /// to verify it's responding properly
102    #[educe(Debug(ignore))]
103    #[builder(default, setter(into, strip_option))]
104    health_check_payload: Option<serde_json::Value>,
105}
106
107impl EndpointConfigBuilder {
108    pub(crate) fn from_endpoint(endpoint: Endpoint) -> Self {
109        Self::default().endpoint(endpoint)
110    }
111
112    /// Register an async engine in the local endpoint registry for direct in-process calls
113    pub fn register_local_engine(
114        self,
115        engine: crate::local_endpoint_registry::LocalAsyncEngine,
116    ) -> Result<Self> {
117        if let Some(endpoint) = &self.endpoint {
118            let registry = endpoint.drt().local_endpoint_registry();
119            registry.register(endpoint.name.clone(), engine);
120            tracing::debug!(
121                "Registered engine for endpoint '{}' in local registry",
122                endpoint.name
123            );
124        }
125        Ok(self)
126    }
127
128    pub async fn start(self) -> Result<()> {
129        self.start_with_registration().await?.wait().await
130    }
131
132    /// Start an endpoint and return once its exact discovery instance is callable.
133    pub async fn start_with_registration(self) -> Result<StartedEndpoint> {
134        let (endpoint, handler, metrics_labels, graceful_shutdown, health_check_payload) =
135            self.build_internal()?.dissolve();
136        let connection_id = endpoint.drt().connection_id();
137        let endpoint_id = endpoint.id();
138
139        tracing::debug!("Starting endpoint: {endpoint_id}");
140
141        let metrics_labels: Option<Vec<(&str, &str)>> = metrics_labels
142            .as_ref()
143            .map(|v| v.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect());
144        // Add metrics to the handler. The endpoint provides additional information to the handler.
145        handler.add_metrics(&endpoint, metrics_labels.as_deref())?;
146
147        // This creates a child token of the runtime's endpoint_shutdown_token. That token is
148        // cancelled first as part of graceful shutdown. See Runtime::shutdown.
149        let endpoint_shutdown_token = endpoint.drt().child_token();
150
151        let system_health = endpoint.drt().system_health();
152
153        // Create clones for the async closure
154        let namespace_name_for_task = endpoint_id.namespace.clone();
155        let component_name_for_task = endpoint_id.component.clone();
156        let endpoint_name_for_task = endpoint_id.name.clone();
157
158        // Get the unified request plane server
159        let server = endpoint.drt().request_plane_server().await?;
160        let transport = build_transport_type(&endpoint, &endpoint_id, connection_id).await?;
161
162        // Register health check target in SystemHealth if provided
163        if let Some(health_check_payload) = &health_check_payload {
164            if system_health.lock().health_check_enabled()
165                && endpoint
166                    .drt()
167                    .local_endpoint_registry()
168                    .get(&endpoint.name)
169                    .is_none()
170            {
171                anyhow::bail!(
172                    "Endpoint '{}' has a health_check_payload and canary is enabled, \
173                     but no local engine is registered. Call .register_local_engine() \
174                     before .start() so the canary health check can function.",
175                    endpoint.name
176                );
177            }
178
179            let instance = Instance {
180                component: endpoint_id.component.clone(),
181                endpoint: endpoint_id.name.clone(),
182                namespace: endpoint_id.namespace.clone(),
183                instance_id: connection_id,
184                transport: transport.clone(),
185                device_type: endpoint_device_type(),
186                request_plane_codec: Some(RequestPlanePayloadCodec::configured()),
187            };
188            tracing::debug!(endpoint_name = %endpoint.name, "Registering endpoint health check target");
189            let guard = system_health.lock();
190            guard.register_health_check_target(
191                &endpoint.name,
192                instance,
193                health_check_payload.clone(),
194            );
195            if let Some(notifier) = guard.get_endpoint_health_check_notifier(&endpoint.name) {
196                handler.set_endpoint_health_check_notifier(notifier)?;
197            }
198        }
199
200        tracing::debug!(
201            endpoint = %endpoint_name_for_task,
202            transport = server.transport_name(),
203            "Registering endpoint with request plane server"
204        );
205
206        // Register endpoint with the server (unified interface)
207        server
208            .register_endpoint(
209                endpoint_name_for_task.clone(),
210                handler,
211                connection_id,
212                namespace_name_for_task.clone(),
213                component_name_for_task.clone(),
214                system_health.clone(),
215            )
216            .await?;
217
218        let tracker_clone = if graceful_shutdown {
219            tracing::debug!(
220                "Registering endpoint '{}' with graceful shutdown tracker",
221                endpoint.name
222            );
223            let tracker = endpoint.drt().graceful_shutdown_tracker();
224            tracker.register_endpoint();
225            Some(tracker)
226        } else {
227            tracing::debug!("Endpoint '{}' has graceful_shutdown=false", endpoint.name);
228            None
229        };
230
231        // Register this endpoint instance in the discovery plane
232        // The discovery interface abstracts storage backend (etcd, k8s, etc) and provides
233        // consistent registration/discovery across the system.
234        let discovery = endpoint.drt().discovery();
235
236        let discovery_spec = crate::discovery::DiscoverySpec::Endpoint {
237            namespace: endpoint_id.namespace.clone(),
238            component: endpoint_id.component.clone(),
239            endpoint: endpoint_id.name.clone(),
240            transport,
241            device_type: endpoint_device_type(),
242            request_plane_codec: Some(RequestPlanePayloadCodec::configured()),
243        };
244
245        let discovery_instance = match discovery.register(discovery_spec).await {
246            Ok(instance) => instance,
247            Err(e) => {
248                tracing::error!(
249                    %endpoint_id,
250                    error = %e,
251                    "Unable to register service for discovery"
252                );
253                let _ = server.unregister_endpoint(&endpoint_name_for_task).await;
254                if let Some(tracker) = tracker_clone {
255                    tracker.unregister_endpoint();
256                }
257                anyhow::bail!(
258                    "Unable to register service for discovery. Check discovery service status"
259                );
260            }
261        };
262        let instance = match &discovery_instance {
263            crate::discovery::DiscoveryInstance::Endpoint(instance) => instance.clone(),
264            _ => unreachable!("endpoint discovery spec returned a non-endpoint instance"),
265        };
266
267        // Create cleanup task that unregisters on cancellation.
268        let endpoint_name_for_cleanup = endpoint_name_for_task;
269        let server_for_cleanup = server;
270        let cancel_token_for_cleanup = endpoint_shutdown_token.clone();
271        let discovery_for_cleanup = discovery;
272
273        let task: tokio::task::JoinHandle<anyhow::Result<()>> = tokio::spawn(async move {
274            cancel_token_for_cleanup.cancelled().await;
275
276            if let Err(error) = discovery_for_cleanup.unregister(discovery_instance).await {
277                tracing::warn!(%error, "Failed to unregister endpoint from discovery");
278            }
279
280            tracing::debug!(
281                endpoint = %endpoint_name_for_cleanup,
282                "Unregistering endpoint from request plane server"
283            );
284
285            if let Err(e) = server_for_cleanup
286                .unregister_endpoint(&endpoint_name_for_cleanup)
287                .await
288            {
289                tracing::warn!(
290                    endpoint = %endpoint_name_for_cleanup,
291                    error = %e,
292                    "Failed to unregister endpoint"
293                );
294            }
295
296            if let Some(tracker) = tracker_clone {
297                tracing::debug!("Unregister endpoint from graceful shutdown tracker");
298                tracker.unregister_endpoint();
299            }
300
301            anyhow::Ok(())
302        });
303
304        Ok(StartedEndpoint {
305            instance,
306            shutdown_token: endpoint_shutdown_token,
307            task,
308        })
309    }
310}
311
312/// Build transport type based on request plane mode
313///
314/// This function handles both health check and discovery transport building.
315/// All transport modes use consistent addressing:
316/// - TCP: Includes instance_id and endpoint name for routing (e.g., host:port/instance_id_hex/endpoint_name)
317/// - NATS: Uses subject-based addressing (unique per endpoint)
318///
319/// # Errors
320/// Returns an error if TCP mode is used but the TCP server hasn't been started yet.
321fn build_transport_type_inner(
322    mode: RequestPlaneMode,
323    endpoint_id: &EndpointId,
324    connection_id: u64,
325) -> Result<TransportType> {
326    match mode {
327        RequestPlaneMode::Tcp => {
328            let tcp_host = crate::utils::tcp_rpc_host_from_env();
329            // If a fixed port is explicitly configured, use it directly (no init ordering dependency).
330            // Otherwise, use the actual bound port (set by TCP server after binding when port 0 is used).
331            let tcp_port = std::env::var("DYN_TCP_RPC_PORT")
332                .ok()
333                .and_then(|p| p.parse::<u16>().ok())
334                .filter(|&p| p != 0)
335                .unwrap_or(crate::pipeline::network::manager::get_actual_tcp_rpc_port()?);
336
337            // Include instance_id and endpoint name for proper TCP routing.
338            // Format: host:port/instance_id_hex/endpoint_name
339            // This ensures each worker has a unique routing key when multiple workers
340            // share the same TCP server (e.g., --num-workers > 1).
341            let tcp_endpoint = format!(
342                "{}:{}/{:x}/{}",
343                tcp_host, tcp_port, connection_id, endpoint_id.name
344            );
345
346            Ok(TransportType::Tcp(tcp_endpoint))
347        }
348        RequestPlaneMode::Nats => Ok(TransportType::Nats(nats::instance_subject(
349            endpoint_id,
350            connection_id,
351        ))),
352    }
353}
354
355/// Build transport type, ensuring TCP server is initialized when needed.
356///
357/// In TCP mode with an OS-assigned port (`DYN_TCP_RPC_PORT` unset or invalid), the server must bind
358/// before we can construct a correct transport address. This helper ensures that initialization
359/// occurs, then delegates to the internal builder.
360pub async fn build_transport_type(
361    endpoint: &Endpoint,
362    endpoint_id: &EndpointId,
363    connection_id: u64,
364) -> Result<TransportType> {
365    let mode = endpoint.drt().request_plane();
366
367    // For TCP with OS-assigned ports, we must ensure the server is initialized
368    // (bound to a port) before we can construct a correct transport address.
369    let has_fixed_port = match mode {
370        RequestPlaneMode::Tcp => std::env::var("DYN_TCP_RPC_PORT")
371            .ok()
372            .and_then(|p| p.parse::<u16>().ok())
373            .filter(|&p| p != 0)
374            .is_some(),
375        RequestPlaneMode::Nats => true, // NATS doesn't need port init
376    };
377
378    if !has_fixed_port {
379        // Ensure request plane server is initialized before building transport.
380        let _ = endpoint.drt().request_plane_server().await?;
381    }
382
383    build_transport_type_inner(mode, endpoint_id, connection_id)
384}
385
386impl Endpoint {
387    /// Unregister this endpoint instance from discovery.
388    ///
389    /// This removes the endpoint from the instances bucket, preventing the router
390    /// from sending requests to this worker. Use this when a worker is sleeping
391    /// and should not receive any requests.
392    pub async fn unregister_endpoint_instance(&self) -> anyhow::Result<()> {
393        let drt = self.drt();
394        let instance_id = drt.connection_id();
395        let endpoint_id = self.id();
396
397        // Get the transport type for the endpoint
398        let transport = build_transport_type(self, &endpoint_id, instance_id).await?;
399
400        let instance = crate::discovery::DiscoveryInstance::Endpoint(Instance {
401            namespace: endpoint_id.namespace,
402            component: endpoint_id.component,
403            endpoint: endpoint_id.name,
404            instance_id,
405            transport,
406            device_type: endpoint_device_type(),
407            request_plane_codec: Some(RequestPlanePayloadCodec::configured()),
408        });
409
410        let discovery = drt.discovery();
411        if let Err(e) = discovery.unregister(instance).await {
412            let endpoint_id = self.id();
413            tracing::error!(
414                %endpoint_id,
415                error = %e,
416                "Unable to unregister endpoint instance from discovery"
417            );
418            anyhow::bail!(
419                "Unable to unregister endpoint instance from discovery. Check discovery service status"
420            );
421        }
422
423        tracing::info!(
424            instance_id = instance_id,
425            "Successfully unregistered endpoint instance from discovery - worker removed from routing pool"
426        );
427
428        Ok(())
429    }
430
431    /// Re-register this endpoint instance to discovery.
432    ///
433    /// This adds the endpoint back to the instances bucket, allowing the router
434    /// to send requests to this worker again. Use this when a worker wakes up
435    /// and should start receiving requests.
436    pub async fn register_endpoint_instance(&self) -> anyhow::Result<()> {
437        let drt = self.drt();
438        let instance_id = drt.connection_id();
439        let endpoint_id = self.id();
440
441        // Get the transport type for the endpoint
442        let transport = build_transport_type(self, &endpoint_id, instance_id).await?;
443
444        let spec = crate::discovery::DiscoverySpec::Endpoint {
445            namespace: endpoint_id.namespace,
446            component: endpoint_id.component,
447            endpoint: endpoint_id.name,
448            transport,
449            device_type: endpoint_device_type(),
450            request_plane_codec: Some(RequestPlanePayloadCodec::configured()),
451        };
452
453        let discovery = drt.discovery();
454        if let Err(e) = discovery.register(spec).await {
455            let endpoint_id = self.id();
456            tracing::error!(
457                %endpoint_id,
458                error = %e,
459                "Unable to re-register endpoint instance to discovery"
460            );
461            anyhow::bail!(
462                "Unable to re-register endpoint instance to discovery. Check discovery service status"
463            );
464        }
465
466        tracing::info!(
467            instance_id = instance_id,
468            "Successfully re-registered endpoint instance to discovery - worker added back to routing pool"
469        );
470
471        Ok(())
472    }
473}