Skip to main content

dynamo_runtime/
distributed.rs

1// SPDX-FileCopyrightText: Copyright (c) 2024-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4use crate::component::{
5    self, Component, ComponentBuilder, Endpoint, EndpointDiscoverySource, Instance, Namespace,
6    RoutingOccupancyState,
7};
8use crate::config::environment_names::tcp_response_stream;
9use crate::pipeline::PipelineError;
10use crate::pipeline::network::manager::NetworkManager;
11use crate::service::{ServiceClient, ServiceSet};
12use crate::storage::kv;
13use crate::{discovery, system_status_server, transports};
14use crate::{
15    discovery::Discovery,
16    metrics::PrometheusUpdateCallback,
17    metrics::{MetricsHierarchy, MetricsRegistry},
18    transports::{etcd, nats, tcp},
19};
20
21use super::utils::GracefulShutdownTracker;
22use crate::SystemHealth;
23use crate::runtime::Runtime;
24
25// Used instead of std::cell::OnceCell because get_or_try_init there is nightly
26use async_once_cell::OnceCell;
27
28use std::fmt;
29use std::sync::{Arc, OnceLock, Weak};
30use std::time::Duration;
31use tokio::sync::watch::Receiver;
32
33use anyhow::Result;
34use derive_getters::Dissolve;
35use figment::error;
36use std::collections::HashMap;
37use tokio::sync::Mutex;
38use tokio_util::sync::CancellationToken;
39
40type EndpointDiscoverySourceMap = HashMap<Endpoint, Weak<EndpointDiscoverySource>>;
41type RoutingOccupancyMap = HashMap<Endpoint, Weak<RoutingOccupancyState>>;
42
43/// Distributed [Runtime] which provides access to shared resources across the cluster, this includes
44/// communication protocols and transports.
45#[derive(Clone)]
46pub struct DistributedRuntime {
47    // local runtime
48    runtime: Runtime,
49
50    nats_client: Option<transports::nats::Client>,
51    network_manager: Arc<NetworkManager>,
52    tcp_server: Arc<OnceCell<Arc<transports::tcp::server::TcpStreamServer>>>,
53    system_status_server: Arc<OnceLock<Arc<system_status_server::SystemStatusServerInfo>>>,
54    request_plane: RequestPlaneMode,
55
56    // Service discovery client
57    discovery_client: Arc<dyn discovery::Discovery>,
58
59    // Discovery metadata (only used for Kubernetes backend)
60    // Shared with system status server to expose via /metadata endpoint
61    discovery_metadata: Option<Arc<tokio::sync::RwLock<discovery::DiscoveryMetadata>>>,
62
63    // local registry for components
64    // the registry allows us to use share runtime resources across instances of the same component object.
65    // take for example two instances of a client to the same remote component. The registry allows us to use
66    // a single endpoint watcher for both clients, this keeps the number background tasking watching specific
67    // paths in etcd to a minimum.
68    component_registry: component::Registry,
69
70    endpoint_discovery_sources: Arc<tokio::sync::Mutex<EndpointDiscoverySourceMap>>,
71    routing_occupancy_states: Arc<tokio::sync::Mutex<RoutingOccupancyMap>>,
72
73    // Health Status
74    system_health: Arc<parking_lot::Mutex<SystemHealth>>,
75
76    // Local endpoint registry for in-process calls
77    local_endpoint_registry: crate::local_endpoint_registry::LocalEndpointRegistry,
78
79    // This hierarchy's own metrics registry
80    metrics_registry: MetricsRegistry,
81
82    // Registry for /engine/* route callbacks
83    engine_routes: crate::engine_routes::EngineRouteRegistry,
84
85    // Backs `/v1/metadata/{model_slug}/{model_suffix}/{filename}`.
86    metadata_artifacts: crate::metadata_registry::MetadataArtifactRegistry,
87
88    // Resolved event transport kind — set once at construction time from
89    // DYN_EVENT_PLANE + discovery backend; returned by default_event_transport_kind().
90    event_transport_kind: crate::discovery::EventTransportKind,
91}
92
93impl MetricsHierarchy for DistributedRuntime {
94    fn basename(&self) -> String {
95        "".to_string() // drt has no basename. Basename only begins with the Namespace.
96    }
97
98    fn parent_hierarchies(&self) -> Vec<&dyn MetricsHierarchy> {
99        vec![] // drt is the root, so no parent hierarchies
100    }
101
102    fn get_metrics_registry(&self) -> &MetricsRegistry {
103        &self.metrics_registry
104    }
105
106    fn connection_id(&self) -> Option<u64> {
107        Some(self.discovery_client.instance_id())
108    }
109}
110
111impl std::fmt::Debug for DistributedRuntime {
112    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
113        write!(f, "DistributedRuntime")
114    }
115}
116
117impl DistributedRuntime {
118    pub async fn new(runtime: Runtime, config: DistributedConfig) -> Result<Self> {
119        let (discovery_backend, nats_config, request_plane, event_transport_kind) =
120            config.dissolve();
121
122        let nats_client = match nats_config {
123            Some(nc) => Some(nc.connect().await?),
124            None => None,
125        };
126
127        // Start system status server for health and metrics if enabled in configuration
128        let config = crate::config::RuntimeConfig::from_settings().unwrap_or_default();
129        // IMPORTANT: We must extract cancel_token from runtime BEFORE moving runtime into the struct below.
130        // This is because after moving, runtime is no longer accessible in this scope (ownership rules).
131        let cancel_token = if config.system_server_enabled() {
132            Some(runtime.clone().child_token())
133        } else {
134            None
135        };
136        let starting_health_status = config.starting_health_status.clone();
137        let use_endpoint_health_status = config.use_endpoint_health_status.clone();
138        let health_endpoint_path = config.system_health_path.clone();
139        let live_endpoint_path = config.system_live_path.clone();
140        let system_health = Arc::new(parking_lot::Mutex::new(SystemHealth::new(
141            starting_health_status,
142            use_endpoint_health_status,
143            config.health_check_enabled,
144            health_endpoint_path,
145            live_endpoint_path,
146        )));
147
148        // Initialize discovery client based on backend configuration
149        let (discovery_client, discovery_metadata) = match discovery_backend {
150            DiscoveryBackend::Kubernetes => {
151                tracing::info!("Initializing Kubernetes discovery backend");
152                let metadata = Arc::new(tokio::sync::RwLock::new(
153                    crate::discovery::DiscoveryMetadata::new(),
154                ));
155                let client = crate::discovery::KubeDiscoveryClient::new(
156                    metadata.clone(),
157                    runtime.primary_token(),
158                )
159                .await
160                .inspect_err(
161                    |err| tracing::error!(%err, "Failed to initialize Kubernetes discovery client"),
162                )?;
163                (Arc::new(client) as Arc<dyn Discovery>, Some(metadata))
164            }
165            DiscoveryBackend::KvStore(kv_selector) => {
166                tracing::info!("Initializing KV store discovery backend: {kv_selector}");
167                let runtime_clone = runtime.clone();
168                let store = match kv_selector {
169                    kv::Selector::Etcd(etcd_config) => {
170                        let etcd_client = etcd::Client::new(*etcd_config, runtime_clone).await.inspect_err(|err|
171                            tracing::error!(%err, "Could not connect to etcd. Pass `--discovery-backend ..` to use a different backend or start etcd."))?;
172                        kv::Manager::etcd(etcd_client)
173                    }
174                    kv::Selector::File(root) => kv::Manager::file(runtime.primary_token(), root),
175                    kv::Selector::Memory => kv::Manager::memory(),
176                };
177                use crate::discovery::KVStoreDiscovery;
178                (
179                    Arc::new(KVStoreDiscovery::new(store, runtime.primary_token()))
180                        as Arc<dyn Discovery>,
181                    None,
182                )
183            }
184        };
185
186        let component_registry = component::Registry::new();
187
188        // NetworkManager for request plane
189        let network_manager = NetworkManager::new(
190            runtime.child_token(),
191            nats_client.clone().map(|c| c.client().clone()),
192            component_registry.clone(),
193            request_plane,
194        );
195
196        let distributed_runtime = Self {
197            runtime,
198            network_manager: Arc::new(network_manager),
199            nats_client,
200            tcp_server: Arc::new(OnceCell::new()),
201            system_status_server: Arc::new(OnceLock::new()),
202            discovery_client,
203            discovery_metadata,
204            component_registry,
205            endpoint_discovery_sources: Arc::new(Mutex::new(HashMap::new())),
206            routing_occupancy_states: Arc::new(Mutex::new(HashMap::new())),
207            metrics_registry: crate::MetricsRegistry::new(),
208            system_health,
209            request_plane,
210            local_endpoint_registry: crate::local_endpoint_registry::LocalEndpointRegistry::new(),
211            engine_routes: crate::engine_routes::EngineRouteRegistry::new(),
212            metadata_artifacts: crate::metadata_registry::MetadataArtifactRegistry::new(),
213            event_transport_kind,
214        };
215
216        // Initialize the uptime gauge in SystemHealth
217        distributed_runtime
218            .system_health
219            .lock()
220            .initialize_uptime_gauge(&distributed_runtime)?;
221
222        // Register an update callback so the uptime gauge is refreshed before
223        // every Prometheus scrape (both system status server and frontend).
224        {
225            let system_health = distributed_runtime.system_health.clone();
226            distributed_runtime
227                .metrics_registry
228                .add_update_callback(std::sync::Arc::new(move || {
229                    system_health.lock().update_uptime_gauge();
230                    Ok(())
231                }));
232        }
233
234        // Handle system status server initialization
235        if let Some(cancel_token) = cancel_token {
236            // System server is enabled - start both the state and HTTP server
237            let host = config.system_host.clone();
238            let port = config.system_port as u16;
239
240            // Start system status server (it creates SystemStatusState internally)
241            match crate::system_status_server::spawn_system_status_server(
242                &host,
243                port,
244                cancel_token,
245                Arc::new(distributed_runtime.clone()),
246                distributed_runtime.discovery_metadata.clone(),
247            )
248            .await
249            {
250                Ok((addr, handle)) => {
251                    tracing::info!("System status server started successfully on {addr}");
252
253                    // Store system status server information
254                    let system_status_server_info =
255                        crate::system_status_server::SystemStatusServerInfo::new(
256                            addr,
257                            Some(handle),
258                        );
259
260                    // Initialize the system_status_server field
261                    distributed_runtime
262                        .system_status_server
263                        .set(Arc::new(system_status_server_info))
264                        .expect("System status server info should only be set once");
265                }
266                Err(e) => {
267                    tracing::error!("System status server startup failed: {e}");
268                }
269            }
270        } else {
271            // System server HTTP is disabled, but uptime metrics are still being tracked via SystemHealth
272            tracing::debug!(
273                "System status server HTTP endpoints disabled, but uptime metrics are being tracked"
274            );
275        }
276
277        // Start health check manager if enabled
278        if config.health_check_enabled {
279            let health_check_config = crate::health_check::HealthCheckConfig {
280                canary_wait_time: std::time::Duration::from_secs(config.canary_wait_time_secs),
281                request_timeout: std::time::Duration::from_secs(
282                    config.health_check_request_timeout_secs,
283                ),
284            };
285
286            // Start the health check manager (spawns per-endpoint monitoring tasks)
287            match crate::health_check::start_health_check_manager(
288                distributed_runtime.clone(),
289                Some(health_check_config),
290            )
291            .await
292            {
293                Ok(()) => tracing::info!(
294                    "Health check manager started (canary_wait_time: {}s, request_timeout: {}s)",
295                    config.canary_wait_time_secs,
296                    config.health_check_request_timeout_secs
297                ),
298                Err(e) => tracing::error!("Health check manager failed to start: {e}"),
299            }
300        }
301
302        Ok(distributed_runtime)
303    }
304
305    pub async fn from_settings(runtime: Runtime) -> Result<Self> {
306        let config = DistributedConfig::from_settings();
307        Self::new(runtime, config).await
308    }
309
310    pub fn runtime(&self) -> &Runtime {
311        &self.runtime
312    }
313
314    pub fn primary_token(&self) -> CancellationToken {
315        self.runtime.primary_token()
316    }
317
318    // TODO: Don't hand out pointers, instead have methods to use the registry in friendly ways
319    // (without being aware of async locks and so on)
320    pub fn component_registry(&self) -> &component::Registry {
321        &self.component_registry
322    }
323
324    // TODO: Don't hand out pointers, instead provide system health related services.
325    pub fn system_health(&self) -> Arc<parking_lot::Mutex<SystemHealth>> {
326        self.system_health.clone()
327    }
328
329    /// Get the local endpoint registry for in-process endpoint calls
330    pub fn local_endpoint_registry(
331        &self,
332    ) -> &crate::local_endpoint_registry::LocalEndpointRegistry {
333        &self.local_endpoint_registry
334    }
335
336    /// Get the engine route registry for registering custom /engine/* routes
337    pub fn engine_routes(&self) -> &crate::engine_routes::EngineRouteRegistry {
338        &self.engine_routes
339    }
340
341    pub fn metadata_artifacts(&self) -> &crate::metadata_registry::MetadataArtifactRegistry {
342        &self.metadata_artifacts
343    }
344
345    pub fn connection_id(&self) -> u64 {
346        self.discovery_client.instance_id()
347    }
348
349    pub fn shutdown(&self) {
350        self.runtime.shutdown();
351        self.discovery_client.shutdown();
352    }
353
354    /// Create a [`Namespace`]
355    pub fn namespace(&self, name: impl Into<String>) -> Result<Namespace> {
356        Namespace::new(self.clone(), name.into())
357    }
358
359    /// Returns the discovery interface for service registration and discovery
360    pub fn discovery(&self) -> Arc<dyn Discovery> {
361        self.discovery_client.clone()
362    }
363
364    pub async fn tcp_server(&self) -> Result<Arc<tcp::server::TcpStreamServer>> {
365        Ok(self
366            .tcp_server
367            .get_or_try_init(async move {
368                let port = match std::env::var(tcp_response_stream::DYN_TCP_RESPONSE_STREAM_PORT) {
369                    Ok(p) => p.parse::<u16>().map_err(|_| {
370                        PipelineError::Generic(format!(
371                            "invalid {}: '{}' is not a valid port number",
372                            tcp_response_stream::DYN_TCP_RESPONSE_STREAM_PORT,
373                            p
374                        ))
375                    })?,
376                    Err(_) => 0,
377                };
378                let interface = std::env::var(tcp_response_stream::DYN_TCP_RESPONSE_STREAM_HOST)
379                    .ok()
380                    .filter(|h| !h.is_empty());
381
382                let host_suffix = interface
383                    .as_ref()
384                    .map_or(String::new(), |h| format!(" on host {h}"));
385                if port == 0 {
386                    tracing::info!(
387                        "TCP response stream server using OS-assigned port{host_suffix}"
388                    );
389                } else {
390                    tracing::info!(
391                        "TCP response stream server using fixed port {port}{host_suffix}"
392                    );
393                }
394
395                let options = tcp::server::ServerOptions { port, interface };
396                let server = tcp::server::TcpStreamServer::new(options).await?;
397                Ok::<_, PipelineError>(server)
398            })
399            .await?
400            .clone())
401    }
402
403    /// Get the network manager
404    ///
405    /// The network manager consolidates all network configuration and provides
406    /// unified access to request plane servers and clients.
407    pub fn network_manager(&self) -> Arc<NetworkManager> {
408        self.network_manager.clone()
409    }
410
411    /// Get the request plane server (convenience method)
412    ///
413    /// This is a shortcut for `network_manager().await?.server().await`.
414    pub async fn request_plane_server(
415        &self,
416    ) -> Result<Arc<dyn crate::pipeline::network::ingress::unified_server::RequestPlaneServer>>
417    {
418        self.network_manager().server().await
419    }
420
421    /// Get system status server information if available
422    pub fn system_status_server_info(
423        &self,
424    ) -> Option<Arc<crate::system_status_server::SystemStatusServerInfo>> {
425        self.system_status_server.get().cloned()
426    }
427
428    /// How the frontend should talk to the backend.
429    pub fn request_plane(&self) -> RequestPlaneMode {
430        self.request_plane
431    }
432
433    /// Returns the event transport kind this runtime was configured with.
434    ///
435    /// The value is resolved once at construction time by `DiscoveryBackend::resolve_event_transport_kind`:
436    /// if `DYN_EVENT_PLANE` is set explicitly that value wins; otherwise the default is ZMQ.
437    ///
438    /// Use this instead of [`EventTransportKind::from_env_or_default`] wherever you have
439    /// access to a `DistributedRuntime`.
440    pub fn default_event_transport_kind(&self) -> crate::discovery::EventTransportKind {
441        self.event_transport_kind
442    }
443
444    pub fn child_token(&self) -> CancellationToken {
445        self.runtime.child_token()
446    }
447
448    pub(crate) fn graceful_shutdown_tracker(&self) -> Arc<GracefulShutdownTracker> {
449        self.runtime.graceful_shutdown_tracker()
450    }
451
452    pub(crate) fn endpoint_discovery_sources(&self) -> Arc<Mutex<EndpointDiscoverySourceMap>> {
453        self.endpoint_discovery_sources.clone()
454    }
455
456    /// Register an external long-running shutdown task with this runtime's
457    /// graceful-shutdown tracker. While the returned guard is alive,
458    /// `Runtime::shutdown` will keep waiting in Phase 2 (rather than
459    /// advancing to Phase 3 / NATS+etcd teardown). Drop the guard once
460    /// the task has finished.
461    pub fn register_graceful_task(&self) -> crate::utils::GracefulTaskGuard {
462        self.runtime.graceful_shutdown_tracker().register_task()
463    }
464
465    pub(crate) fn routing_occupancy_states(&self) -> Arc<Mutex<RoutingOccupancyMap>> {
466        self.routing_occupancy_states.clone()
467    }
468
469    /// TODO: This is a temporary KV router measure for component/component.rs EventPublisher impl for
470    /// Component, to allow it to publish to NATS. KV Router is the only user.
471    ///
472    /// When NATS is not available (e.g., running in approximate mode with --no-kv-events),
473    /// this function returns Ok(()) silently since publishing is optional in that mode.
474    pub async fn kv_router_nats_publish(
475        &self,
476        subject: String,
477        payload: bytes::Bytes,
478    ) -> anyhow::Result<()> {
479        self.kv_router_nats_publish_subject(subject.into(), payload)
480            .await
481    }
482
483    pub(crate) async fn kv_router_nats_publish_subject(
484        &self,
485        subject: async_nats::Subject,
486        payload: bytes::Bytes,
487    ) -> anyhow::Result<()> {
488        let Some(nats_client) = self.nats_client.as_ref() else {
489            // NATS not available - this is expected in approximate mode (--no-kv-events)
490            tracing::trace!("Skipping NATS publish (NATS not configured): {subject}");
491            return Ok(());
492        };
493        Ok(nats_client.client().publish(subject, payload).await?)
494    }
495
496    /// TODO: This is a temporary KV router measure for component/component.rs EventSubscriber impl for
497    /// Component, to allow it to subscribe to NATS. KV Router is the only user.
498    pub(crate) async fn kv_router_nats_subscribe(
499        &self,
500        subject: String,
501    ) -> Result<async_nats::Subscriber> {
502        let Some(nats_client) = self.nats_client.as_ref() else {
503            anyhow::bail!("KV router's EventSubscriber requires NATS");
504        };
505        Ok(nats_client.client().subscribe(subject).await?)
506    }
507
508    /// TODO (karenc): This is a temporary KV router measure for worker query requests.
509    /// Allows KV Router to perform request/reply with workers. (versus the pub/sub pattern above)
510    /// KV Router is the only user, made public for use in dynamo-llm crate
511    pub async fn kv_router_nats_request(
512        &self,
513        subject: String,
514        payload: bytes::Bytes,
515        timeout: std::time::Duration,
516    ) -> anyhow::Result<async_nats::Message> {
517        let Some(nats_client) = self.nats_client.as_ref() else {
518            anyhow::bail!("KV router's request requires NATS");
519        };
520        let response =
521            tokio::time::timeout(timeout, nats_client.client().request(subject, payload))
522                .await
523                .map_err(|_| anyhow::anyhow!("Request timed out after {:?}", timeout))??;
524        Ok(response)
525    }
526
527    /// DEPRECATED: This method exists only for NATS request plane support.
528    /// Once everything uses the TCP request plane, this can be removed along with
529    /// the NATS service registration infrastructure.
530    ///
531    /// Returns a receiver that signals when the NATS service registration is complete.
532    /// The caller should use `blocking_recv()` to wait for completion.
533    pub fn register_nats_service(
534        &self,
535        component: Component,
536    ) -> tokio::sync::mpsc::Receiver<Result<(), String>> {
537        // Create a oneshot-style channel (capacity 1) to signal completion
538        let (tx, rx) = tokio::sync::mpsc::channel::<Result<(), String>>(1);
539
540        let drt = self.clone();
541        self.runtime().secondary().spawn(async move {
542            let service_name = component.service_name();
543
544            // Pre-check to save cost of creating the service, but don't hold the lock
545            if drt
546                .component_registry()
547                .inner
548                .lock()
549                .await
550                .services
551                .contains_key(&service_name)
552            {
553                // The NATS service is per component, but it is called from `serve_endpoint`, and there
554                // are often multiple endpoints for a component (e.g. `clear_kv_blocks` and `generate`).
555                tracing::trace!("Service {service_name} already exists");
556                // Signal success - service already exists
557                let _ = tx.send(Ok(())).await;
558                return;
559            }
560
561            let Some(nats_client) = drt.nats_client.as_ref() else {
562                tracing::error!("Cannot create NATS service without NATS.");
563                let _ = tx
564                    .send(Err("Cannot create NATS service without NATS".to_string()))
565                    .await;
566                return;
567            };
568            let description = None;
569            let nats_service = match crate::component::service::build_nats_service(
570                nats_client,
571                &component,
572                description,
573            )
574            .await
575            {
576                Ok(service) => service,
577                Err(err) => {
578                    tracing::error!(error = %err, component = service_name, "Failed to build NATS service");
579                    let _ = tx.send(Err(format!("Failed to build NATS service: {err}"))).await;
580                    return;
581                }
582            };
583
584            let mut guard = drt.component_registry().inner.lock().await;
585            if !guard.services.contains_key(&service_name) {
586                // Normal case
587                guard.services.insert(service_name.clone(), nats_service);
588
589                tracing::info!("Added NATS service {service_name}");
590
591                drop(guard);
592            } else {
593                drop(guard);
594                let _ = nats_service.stop().await;
595                // The NATS service is per component, but it is called from `serve_endpoint`, and there
596                // are often multiple endpoints for a component (e.g. `clear_kv_blocks` and `generate`).
597                // TODO: Is this still true?
598            }
599
600            // Signal completion - service registered successfully
601            let _ = tx.send(Ok(())).await;
602        });
603
604        rx
605    }
606}
607
608/// Selects which discovery backend to use and, for KV store backends, which KV store.
609#[derive(Clone, Debug)]
610pub enum DiscoveryBackend {
611    /// Use Kubernetes API for service discovery (no KV store needed)
612    Kubernetes,
613    /// Use a KV store (etcd, file, or memory) for service discovery
614    KvStore(kv::Selector),
615}
616
617impl DiscoveryBackend {
618    /// Returns true if this backend requires no external services (file or in-memory).
619    ///
620    /// Local backends do not need etcd, NATS, or any other infrastructure daemon.
621    pub fn is_local(&self) -> bool {
622        matches!(
623            self,
624            DiscoveryBackend::KvStore(kv::Selector::File(_))
625                | DiscoveryBackend::KvStore(kv::Selector::Memory)
626        )
627    }
628
629    /// Resolve the event transport kind for this backend.
630    ///
631    /// This is the single authoritative mapping of `DYN_EVENT_PLANE` →
632    /// `EventTransportKind`. ZMQ is the default event plane for all backends
633    /// (`file`/`mem`/`etcd`/`kubernetes`); NATS is an explicit opt-in via
634    /// `DYN_EVENT_PLANE=nats`.
635    ///
636    /// Call this once at startup and store the result; do not call it repeatedly.
637    pub fn resolve_event_transport_kind(&self) -> crate::discovery::EventTransportKind {
638        use crate::config::environment_names::event_plane::DYN_EVENT_PLANE;
639        use crate::discovery::EventTransportKind;
640        match std::env::var(DYN_EVENT_PLANE).as_deref() {
641            Ok("nats") => EventTransportKind::Nats,
642            Ok("zmq") => EventTransportKind::Zmq,
643            // Unset or empty: ZMQ is the default for every backend.
644            Ok("") | Err(_) => EventTransportKind::Zmq,
645            Ok(other) => {
646                tracing::warn!(
647                    "Invalid DYN_EVENT_PLANE value '{}'. Valid values: 'nats', 'zmq'. \
648                     Defaulting to ZMQ.",
649                    other
650                );
651                EventTransportKind::Zmq
652            }
653        }
654    }
655}
656
657#[derive(Dissolve)]
658pub struct DistributedConfig {
659    pub discovery_backend: DiscoveryBackend,
660    pub nats_config: Option<nats::ClientOptions>,
661    pub request_plane: RequestPlaneMode,
662    /// Resolved event transport kind — computed once at config time from
663    /// `DYN_EVENT_PLANE` and the discovery backend, then stored on the runtime
664    /// so callers always get the same answer regardless of which other services
665    /// happen to be reachable.
666    pub event_transport_kind: crate::discovery::EventTransportKind,
667}
668
669impl DistributedConfig {
670    pub fn from_settings() -> DistributedConfig {
671        let request_plane = RequestPlaneMode::from_env();
672
673        // Determine the discovery backend first — we need it to compute the NATS default below.
674        // Valid values for DYN_DISCOVERY_BACKEND: "kubernetes", "etcd" (default), "file", "mem"
675        let backend_str =
676            std::env::var("DYN_DISCOVERY_BACKEND").unwrap_or_else(|_| "etcd".to_string());
677
678        let discovery_backend = match backend_str.as_str() {
679            "kubernetes" => {
680                tracing::info!("Using Kubernetes discovery backend");
681                DiscoveryBackend::Kubernetes
682            }
683            other => {
684                let selector: kv::Selector = other.parse().unwrap_or_else(|_| {
685                    panic!(
686                        "Unknown DYN_DISCOVERY_BACKEND value: '{other}'. \
687                         Valid options: kubernetes, etcd, file, mem"
688                    )
689                });
690                DiscoveryBackend::KvStore(selector)
691            }
692        };
693
694        // Resolve event transport kind once — the single source of truth used both to
695        // decide whether to open a NATS connection and to answer
696        // `DistributedRuntime::default_event_transport_kind()` later.
697        let event_transport_kind = discovery_backend.resolve_event_transport_kind();
698
699        // NATS is used for more than just NATS request-plane RPC:
700        // - KV router events (JetStream or NATS core + local indexer)
701        // - inter-router replica sync (NATS core)
702        //
703        // Enable the NATS client when any of these hold:
704        // 1. Request plane is NATS
705        // 2. NATS_SERVER is explicitly configured by the user
706        // 3. The resolved event transport kind is NATS
707        let nats_enabled = request_plane.is_nats()
708            || std::env::var(crate::config::environment_names::nats::NATS_SERVER).is_ok()
709            || matches!(
710                event_transport_kind,
711                crate::discovery::EventTransportKind::Nats
712            );
713
714        DistributedConfig {
715            discovery_backend,
716            nats_config: if nats_enabled {
717                Some(nats::ClientOptions::default())
718            } else {
719                None
720            },
721            request_plane,
722            event_transport_kind,
723        }
724    }
725
726    pub fn for_cli() -> DistributedConfig {
727        let etcd_config = etcd::ClientOptions {
728            attach_lease: false,
729            ..Default::default()
730        };
731        let request_plane = RequestPlaneMode::from_env();
732        let discovery_backend =
733            DiscoveryBackend::KvStore(kv::Selector::Etcd(Box::new(etcd_config)));
734        let event_transport_kind = discovery_backend.resolve_event_transport_kind();
735        let nats_enabled = request_plane.is_nats()
736            || std::env::var(crate::config::environment_names::nats::NATS_SERVER).is_ok()
737            || matches!(
738                event_transport_kind,
739                crate::discovery::EventTransportKind::Nats
740            );
741        DistributedConfig {
742            discovery_backend,
743            nats_config: if nats_enabled {
744                Some(nats::ClientOptions::default())
745            } else {
746                None
747            },
748            request_plane,
749            event_transport_kind,
750        }
751    }
752
753    /// A DistributedConfig that isn't distributed, for when the frontend and backend are in the
754    /// same process.
755    pub fn process_local() -> DistributedConfig {
756        DistributedConfig {
757            discovery_backend: DiscoveryBackend::KvStore(kv::Selector::Memory),
758            nats_config: None,
759            // This won't be used in process local, so we likely need a "none" option to
760            // communicate that and avoid opening the ports.
761            request_plane: RequestPlaneMode::Tcp,
762            event_transport_kind: crate::discovery::EventTransportKind::Zmq,
763        }
764    }
765}
766
767/// Request plane transport mode configuration
768///
769/// This determines how requests are distributed from routers to workers:
770/// - `Nats`: Use NATS for request distribution (legacy)
771/// - `Tcp`: Use raw TCP for request distribution with msgpack support (default)
772#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
773pub enum RequestPlaneMode {
774    /// Use NATS for request plane
775    Nats,
776    /// Use raw TCP for request plane with msgpack support
777    #[default]
778    Tcp,
779}
780
781impl fmt::Display for RequestPlaneMode {
782    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
783        match self {
784            Self::Nats => write!(f, "nats"),
785            Self::Tcp => write!(f, "tcp"),
786        }
787    }
788}
789
790impl std::str::FromStr for RequestPlaneMode {
791    type Err = anyhow::Error;
792
793    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
794        match s.to_lowercase().as_str() {
795            "nats" => Ok(Self::Nats),
796            "tcp" => Ok(Self::Tcp),
797            _ => Err(anyhow::anyhow!(
798                "Invalid request plane mode: '{}'. Valid options are: 'nats', 'tcp'",
799                s
800            )),
801        }
802    }
803}
804
805impl RequestPlaneMode {
806    /// Get the request plane mode from environment variable (uncached)
807    /// Reads from `DYN_REQUEST_PLANE` environment variable.
808    fn from_env() -> Self {
809        std::env::var("DYN_REQUEST_PLANE")
810            .ok()
811            .and_then(|s| s.parse().ok())
812            .unwrap_or_default()
813    }
814
815    pub fn is_nats(&self) -> bool {
816        matches!(self, RequestPlaneMode::Nats)
817    }
818}
819
820pub mod distributed_test_utils {
821    //! Common test helper functions for DistributedRuntime tests
822
823    /// Helper function to create a DRT instance for integration-only tests.
824    /// Uses from_current to leverage existing tokio runtime
825    /// Note: Settings are read from environment variables inside DistributedRuntime::from_settings
826    #[cfg(feature = "integration")]
827    pub async fn create_test_drt_async() -> super::DistributedRuntime {
828        use crate::transports::nats;
829
830        let rt = crate::Runtime::from_current().unwrap();
831        let config = super::DistributedConfig {
832            discovery_backend: super::DiscoveryBackend::KvStore(
833                crate::storage::kv::Selector::Memory,
834            ),
835            nats_config: Some(nats::ClientOptions::default()),
836            request_plane: crate::distributed::RequestPlaneMode::default(),
837            event_transport_kind: crate::discovery::EventTransportKind::Nats,
838        };
839        super::DistributedRuntime::new(rt, config).await.unwrap()
840    }
841
842    /// Helper function to create a DRT instance which points at
843    /// a (shared) file-backed KV store and ephemeral NATS transport so that
844    /// multiple DRT instances may observe the same registration state.
845    /// NOTE: This gets around the fact that create_test_drt_async() is
846    /// hardcoded to spin up a memory-backed discovery store
847    /// which means we can't share discovery state across runtimes.
848    pub async fn create_test_shared_drt_async(
849        store_path: &std::path::Path,
850    ) -> super::DistributedRuntime {
851        use crate::transports::nats;
852
853        let rt = crate::Runtime::from_current().unwrap();
854        let config = super::DistributedConfig {
855            discovery_backend: super::DiscoveryBackend::KvStore(
856                crate::storage::kv::Selector::File(store_path.to_path_buf()),
857            ),
858            nats_config: Some(nats::ClientOptions::default()),
859            request_plane: crate::distributed::RequestPlaneMode::default(),
860            event_transport_kind: crate::discovery::EventTransportKind::Nats,
861        };
862        super::DistributedRuntime::new(rt, config).await.unwrap()
863    }
864}
865
866#[cfg(all(test, feature = "integration"))]
867mod tests {
868    use super::RequestPlaneMode;
869    use super::distributed_test_utils::create_test_drt_async;
870
871    #[tokio::test]
872    async fn test_drt_uptime_after_delay_system_disabled() {
873        use crate::config::environment_names::runtime::system as env_system;
874        // Test uptime with system status server disabled
875        temp_env::async_with_vars([(env_system::DYN_SYSTEM_PORT, None::<&str>)], async {
876            // Start a DRT
877            let drt = create_test_drt_async().await;
878
879            // Wait 50ms
880            tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
881
882            // Check that uptime is 50+ ms
883            let uptime = drt.system_health.lock().uptime();
884            assert!(
885                uptime >= std::time::Duration::from_millis(50),
886                "Expected uptime to be at least 50ms, but got {:?}",
887                uptime
888            );
889
890            println!(
891                "✓ DRT uptime test passed (system disabled): uptime = {:?}",
892                uptime
893            );
894        })
895        .await;
896    }
897
898    #[tokio::test]
899    async fn test_drt_uptime_after_delay_system_enabled() {
900        use crate::config::environment_names::runtime::system as env_system;
901        // Test uptime with system status server enabled
902        temp_env::async_with_vars([(env_system::DYN_SYSTEM_PORT, Some("8081"))], async {
903            // Start a DRT
904            let drt = create_test_drt_async().await;
905
906            // Wait 50ms
907            tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
908
909            // Check that uptime is 50+ ms
910            let uptime = drt.system_health.lock().uptime();
911            assert!(
912                uptime >= std::time::Duration::from_millis(50),
913                "Expected uptime to be at least 50ms, but got {:?}",
914                uptime
915            );
916
917            println!(
918                "✓ DRT uptime test passed (system enabled): uptime = {:?}",
919                uptime
920            );
921        })
922        .await;
923    }
924
925    #[test]
926    fn test_request_plane_mode_from_str() {
927        assert_eq!(
928            "nats".parse::<RequestPlaneMode>().unwrap(),
929            RequestPlaneMode::Nats
930        );
931        assert_eq!(
932            "tcp".parse::<RequestPlaneMode>().unwrap(),
933            RequestPlaneMode::Tcp
934        );
935        assert_eq!(
936            "NATS".parse::<RequestPlaneMode>().unwrap(),
937            RequestPlaneMode::Nats
938        );
939        assert_eq!(
940            "TCP".parse::<RequestPlaneMode>().unwrap(),
941            RequestPlaneMode::Tcp
942        );
943        assert!("invalid".parse::<RequestPlaneMode>().is_err());
944    }
945}