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