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