Skip to main content

dynamo_runtime/discovery/
mod.rs

1// SPDX-FileCopyrightText: Copyright (c) 2024-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4use anyhow::{Context, Result};
5use async_trait::async_trait;
6use futures::Stream;
7use serde::{Deserialize, Serialize};
8
9use crate::protocols::EndpointId;
10use std::collections::{HashMap, HashSet};
11use std::pin::Pin;
12use tokio_util::sync::CancellationToken;
13
14mod metadata;
15pub use metadata::{DiscoveryMetadata, MetadataSnapshot};
16
17mod registration;
18pub use registration::EndpointRegistrationLease;
19pub(crate) use registration::EndpointRegistrationManager;
20
21mod mock;
22pub use mock::{MockDiscovery, SharedMockRegistry};
23mod kv_store;
24pub use kv_store::KVStoreDiscovery;
25
26mod kube;
27pub use kube::{KubeDiscoveryClient, hash_container_name, hash_pod_name};
28
29pub mod utils;
30use crate::{
31    component::{DeviceType, Instance, TransportType},
32    pipeline::network::RequestPlanePayloadCodec,
33};
34pub use utils::watch_and_extract_field;
35
36/// Largest publisher ID exactly representable by float64-backed JSON metadata.
37pub(crate) const MAX_JSON_SAFE_PUBLISHER_ID: u64 = (1 << 53) - 1;
38
39/// Transport kind for event plane - used for configuration and env var selection.
40///
41/// This enum represents the *type* of transport without connection details.
42/// Use `EventTransport` when you need the full transport configuration.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
44#[serde(rename_all = "snake_case")]
45pub enum EventTransportKind {
46    /// NATS Core pub/sub
47    Nats,
48    /// ZMQ pub/sub
49    #[default]
50    Zmq,
51}
52
53impl EventTransportKind {
54    /// Parse from environment variable `DYN_EVENT_PLANE`.
55    ///
56    /// Returns `Zmq` if the variable is not set or is empty: ZMQ is the default
57    /// event plane for all backends. NATS remains available as an explicit opt-in
58    /// (`DYN_EVENT_PLANE=nats`). When you have access to a runtime, prefer
59    /// `DistributedRuntime::default_event_transport_kind`, which resolves the same
60    /// default through the configured discovery backend.
61    ///
62    /// Returns an error for unrecognised values.
63    pub fn from_env() -> Result<Self> {
64        match std::env::var(crate::config::environment_names::event_plane::DYN_EVENT_PLANE)
65            .as_deref()
66        {
67            Ok("nats") => Ok(Self::Nats),
68            Ok("zmq") | Ok("") | Err(_) => Ok(Self::Zmq),
69            Ok(other) => anyhow::bail!(
70                "Invalid DYN_EVENT_PLANE value '{}'. Valid values: 'nats', 'zmq'",
71                other
72            ),
73        }
74    }
75
76    /// Logs a warning if an invalid value is encountered.
77    pub fn from_env_or_default() -> Self {
78        Self::from_env().unwrap_or_else(|e| {
79            tracing::warn!("{e}, defaulting to ZMQ");
80            Self::Zmq
81        })
82    }
83
84    /// Get the default codec for this transport kind.
85    /// NATS defaults to JSON, ZMQ defaults to MsgPack.
86    pub fn default_codec(&self) -> EventCodecKind {
87        match self {
88            Self::Nats => EventCodecKind::Json,
89            Self::Zmq => EventCodecKind::Msgpack,
90        }
91    }
92}
93
94/// Codec kind for event plane serialization.
95///
96/// This enum represents the serialization format for event envelopes and payloads.
97#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
98#[serde(rename_all = "snake_case")]
99pub enum EventCodecKind {
100    /// JSON codec - human-readable, good for debugging
101    Json,
102    /// MessagePack codec - compact binary format
103    Msgpack,
104}
105
106impl EventCodecKind {
107    /// Parse from environment variable `DYN_EVENT_PLANE_CODEC`.
108    /// Returns None if not set, allowing transport to select default.
109    /// Returns error for invalid values.
110    pub fn from_env() -> Result<Option<Self>> {
111        match std::env::var(crate::config::environment_names::event_plane::DYN_EVENT_PLANE_CODEC)
112            .as_deref()
113        {
114            Err(_) => Ok(None), // Not set
115            Ok("") => Ok(None), // Empty
116            Ok("json") => Ok(Some(Self::Json)),
117            Ok("msgpack") => Ok(Some(Self::Msgpack)),
118            Ok(other) => anyhow::bail!(
119                "Invalid DYN_EVENT_PLANE_CODEC value '{}'. Valid values: 'json', 'msgpack'",
120                other
121            ),
122        }
123    }
124
125    /// Parse from environment variable with transport-specific default.
126    /// Logs a warning if an invalid value is encountered.
127    pub fn from_env_or_transport_default(transport: EventTransportKind) -> Self {
128        Self::from_env()
129            .unwrap_or_else(|e| {
130                tracing::warn!(
131                    "{}, defaulting to {:?} for {:?}",
132                    e,
133                    transport.default_codec(),
134                    transport
135                );
136                None
137            })
138            .unwrap_or_else(|| transport.default_codec())
139    }
140}
141
142/// Transport configuration for event plane channels.
143///
144/// This enum carries both the transport kind and its connection configuration.
145/// Kept separate from `TransportType` (request plane) to distinguish event semantics.
146#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
147#[serde(tag = "kind", content = "config")]
148pub enum EventTransport {
149    /// NATS Core pub/sub - subject prefix for the channel
150    Nats {
151        /// Subject prefix (e.g., "namespace.dynamo.component.backend")
152        subject_prefix: String,
153    },
154    /// ZMQ pub/sub - endpoint address (direct mode)
155    Zmq {
156        /// ZMQ endpoint (e.g., "tcp://host:port")
157        endpoint: String,
158    },
159    /// ZMQ broker endpoints (broker mode) - for discovery of brokers
160    ZmqBroker {
161        /// XSUB endpoints (publishers connect here)
162        xsub_endpoints: Vec<String>,
163        /// XPUB endpoints (subscribers connect here)
164        xpub_endpoints: Vec<String>,
165    },
166}
167
168impl EventTransport {
169    /// Get the transport kind
170    pub fn kind(&self) -> EventTransportKind {
171        match self {
172            Self::Nats { .. } => EventTransportKind::Nats,
173            Self::Zmq { .. } | Self::ZmqBroker { .. } => EventTransportKind::Zmq,
174        }
175    }
176
177    /// Create a NATS transport with the given subject prefix
178    pub fn nats(subject_prefix: impl Into<String>) -> Self {
179        Self::Nats {
180            subject_prefix: subject_prefix.into(),
181        }
182    }
183
184    /// Create a ZMQ transport with the given endpoint
185    pub fn zmq(endpoint: impl Into<String>) -> Self {
186        Self::Zmq {
187            endpoint: endpoint.into(),
188        }
189    }
190
191    /// Get the subject prefix (NATS) or endpoint (ZMQ)
192    /// For ZmqBroker, returns the first XSUB endpoint
193    pub fn address(&self) -> &str {
194        match self {
195            Self::Nats { subject_prefix } => subject_prefix,
196            Self::Zmq { endpoint } => endpoint,
197            Self::ZmqBroker { xsub_endpoints, .. } => {
198                xsub_endpoints.first().map(|s| s.as_str()).unwrap_or("")
199            }
200        }
201    }
202}
203
204/// Query key for prefix-based discovery queries
205/// Supports hierarchical queries from all endpoints down to specific endpoints
206#[derive(Debug, Clone, PartialEq, Eq, Hash)]
207pub enum DiscoveryQuery {
208    /// Query all endpoints in the system
209    AllEndpoints,
210    /// Query all endpoints in a specific namespace
211    NamespacedEndpoints {
212        namespace: String,
213    },
214    /// Query all endpoints in a namespace/component
215    ComponentEndpoints {
216        namespace: String,
217        component: String,
218    },
219    /// Query a specific endpoint
220    Endpoint {
221        namespace: String,
222        component: String,
223        endpoint: String,
224    },
225    AllModels,
226    NamespacedModels {
227        namespace: String,
228    },
229    ComponentModels {
230        namespace: String,
231        component: String,
232    },
233    EndpointModels {
234        namespace: String,
235        component: String,
236        endpoint: String,
237    },
238    /// Unified event channel query with optional scope filters
239    EventChannels(EventChannelQuery),
240    /// Semantic event source query with optional scope filters.
241    EventSources(EventSourceQuery),
242}
243
244/// Scope of an event channel.
245///
246/// Event scopes are exact ownership domains. A namespace-scoped query does not
247/// match component- or endpoint-scoped publishers in that namespace.
248#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
249#[serde(tag = "kind", rename_all = "snake_case")]
250pub enum EventScope {
251    Namespace {
252        name: String,
253    },
254    Component {
255        namespace: String,
256        component: String,
257    },
258    Endpoint {
259        endpoint: EndpointId,
260    },
261}
262
263impl EventScope {
264    pub fn namespace(&self) -> &str {
265        match self {
266            Self::Namespace { name } => name,
267            Self::Component { namespace, .. } => namespace,
268            Self::Endpoint { endpoint } => &endpoint.namespace,
269        }
270    }
271
272    pub fn component(&self) -> Option<&str> {
273        match self {
274            Self::Namespace { .. } => None,
275            Self::Component { component, .. } => Some(component),
276            Self::Endpoint { endpoint } => Some(&endpoint.component),
277        }
278    }
279
280    pub fn endpoint(&self) -> Option<&EndpointId> {
281        match self {
282            Self::Endpoint { endpoint } => Some(endpoint),
283            Self::Namespace { .. } | Self::Component { .. } => None,
284        }
285    }
286
287    /// Canonical NATS/ZMQ-broker subject prefix for this scope.
288    pub fn subject_prefix(&self) -> String {
289        match self {
290            Self::Namespace { name } => {
291                format!("namespace.{}", encode_event_segment(name))
292            }
293            Self::Component {
294                namespace,
295                component,
296            } => format!(
297                "namespace.{}.component.{}",
298                encode_event_segment(namespace),
299                encode_event_segment(component)
300            ),
301            Self::Endpoint { endpoint } => format!(
302                "namespace.{}.component.{}.endpoint.{}",
303                encode_event_segment(&endpoint.namespace),
304                encode_event_segment(&endpoint.component),
305                encode_event_segment(&endpoint.name)
306            ),
307        }
308    }
309
310    /// Canonical subject/routing key for a topic in this exact scope.
311    pub fn subject(&self, topic: &str) -> String {
312        format!("{}.{}", self.subject_prefix(), encode_event_segment(topic))
313    }
314
315    pub(crate) fn path_prefix(&self) -> String {
316        match self {
317            Self::Namespace { name } => {
318                format!("namespace/{}", encode_event_segment(name))
319            }
320            Self::Component {
321                namespace,
322                component,
323            } => format!(
324                "namespace/{}/component/{}",
325                encode_event_segment(namespace),
326                encode_event_segment(component)
327            ),
328            Self::Endpoint { endpoint } => format!(
329                "namespace/{}/component/{}/endpoint/{}",
330                encode_event_segment(&endpoint.namespace),
331                encode_event_segment(&endpoint.component),
332                encode_event_segment(&endpoint.name)
333            ),
334        }
335    }
336}
337
338/// Percent-encode a subject/path segment while keeping common identifier
339/// characters readable. The encoding is reversible and prevents NATS wildcard
340/// or discovery path delimiters from changing the channel identity.
341pub(crate) fn encode_event_segment(value: &str) -> String {
342    let mut encoded = String::with_capacity(value.len());
343    for byte in value.bytes() {
344        if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_') {
345            encoded.push(char::from(byte));
346        } else {
347            use std::fmt::Write as _;
348            write!(encoded, "%{byte:02X}").expect("writing to String cannot fail");
349        }
350    }
351    encoded
352}
353
354fn decode_event_segment(value: &str) -> Result<String> {
355    let bytes = value.as_bytes();
356    let mut decoded = Vec::with_capacity(bytes.len());
357    let mut index = 0;
358    while index < bytes.len() {
359        if bytes[index] != b'%' {
360            decoded.push(bytes[index]);
361            index += 1;
362            continue;
363        }
364        if index + 2 >= bytes.len() {
365            anyhow::bail!("invalid percent-encoded event segment: {value}");
366        }
367        let hex = std::str::from_utf8(&bytes[index + 1..index + 3])?;
368        decoded
369            .push(u8::from_str_radix(hex, 16).map_err(|error| {
370                anyhow::anyhow!("invalid event segment escape %{hex}: {error}")
371            })?);
372        index += 3;
373    }
374    String::from_utf8(decoded)
375        .map_err(|error| anyhow::anyhow!("event segment is not valid UTF-8: {error}"))
376}
377
378/// Unified query for event channels with an optional exact scope and topic.
379#[derive(Debug, Clone, PartialEq, Eq, Hash)]
380pub struct EventChannelQuery {
381    /// Exact scope. `None` is reserved for administrative all-channel queries.
382    scope: Option<EventScope>,
383    topic: Option<String>,
384}
385
386impl EventChannelQuery {
387    /// Query all event channels (no filters)
388    pub fn all() -> Self {
389        Self {
390            scope: None,
391            topic: None,
392        }
393    }
394
395    /// Query event channels in a specific namespace
396    pub fn namespace(namespace: impl Into<String>) -> Self {
397        Self {
398            scope: Some(EventScope::Namespace {
399                name: namespace.into(),
400            }),
401            topic: None,
402        }
403    }
404
405    pub fn namespace_topic(namespace: impl Into<String>, topic: impl Into<String>) -> Self {
406        Self {
407            scope: Some(EventScope::Namespace {
408                name: namespace.into(),
409            }),
410            topic: Some(topic.into()),
411        }
412    }
413
414    /// Query event channels for a specific component
415    pub fn component(namespace: impl Into<String>, component: impl Into<String>) -> Self {
416        Self {
417            scope: Some(EventScope::Component {
418                namespace: namespace.into(),
419                component: component.into(),
420            }),
421            topic: None,
422        }
423    }
424
425    /// Query event channels for a specific topic
426    pub fn topic(
427        namespace: impl Into<String>,
428        component: impl Into<String>,
429        topic: impl Into<String>,
430    ) -> Self {
431        Self {
432            scope: Some(EventScope::Component {
433                namespace: namespace.into(),
434                component: component.into(),
435            }),
436            topic: Some(topic.into()),
437        }
438    }
439
440    pub fn endpoint(endpoint: EndpointId) -> Self {
441        Self {
442            scope: Some(EventScope::Endpoint { endpoint }),
443            topic: None,
444        }
445    }
446
447    pub fn endpoint_topic(endpoint: EndpointId, topic: impl Into<String>) -> Self {
448        Self {
449            scope: Some(EventScope::Endpoint { endpoint }),
450            topic: Some(topic.into()),
451        }
452    }
453
454    /// Get the query specificity (0=all, 1=scope, 2=scope+topic).
455    pub fn scope_level(&self) -> u8 {
456        if self.topic.is_some() {
457            2
458        } else if self.scope.is_some() {
459            1
460        } else {
461            0
462        }
463    }
464}
465
466/// Unified query for semantic event sources with an optional exact scope and topic.
467#[derive(Debug, Clone, PartialEq, Eq, Hash)]
468pub struct EventSourceQuery {
469    /// Exact scope. `None` is reserved for administrative all-source queries.
470    scope: Option<EventScope>,
471    topic: Option<String>,
472}
473
474impl EventSourceQuery {
475    pub fn all() -> Self {
476        Self {
477            scope: None,
478            topic: None,
479        }
480    }
481
482    pub fn namespace(namespace: impl Into<String>) -> Self {
483        Self {
484            scope: Some(EventScope::Namespace {
485                name: namespace.into(),
486            }),
487            topic: None,
488        }
489    }
490
491    pub fn namespace_topic(namespace: impl Into<String>, topic: impl Into<String>) -> Self {
492        Self {
493            scope: Some(EventScope::Namespace {
494                name: namespace.into(),
495            }),
496            topic: Some(topic.into()),
497        }
498    }
499
500    pub fn component(namespace: impl Into<String>, component: impl Into<String>) -> Self {
501        Self {
502            scope: Some(EventScope::Component {
503                namespace: namespace.into(),
504                component: component.into(),
505            }),
506            topic: None,
507        }
508    }
509
510    pub fn topic(
511        namespace: impl Into<String>,
512        component: impl Into<String>,
513        topic: impl Into<String>,
514    ) -> Self {
515        Self {
516            scope: Some(EventScope::Component {
517                namespace: namespace.into(),
518                component: component.into(),
519            }),
520            topic: Some(topic.into()),
521        }
522    }
523
524    pub fn endpoint(endpoint: EndpointId) -> Self {
525        Self {
526            scope: Some(EventScope::Endpoint { endpoint }),
527            topic: None,
528        }
529    }
530
531    pub fn endpoint_topic(endpoint: EndpointId, topic: impl Into<String>) -> Self {
532        Self {
533            scope: Some(EventScope::Endpoint { endpoint }),
534            topic: Some(topic.into()),
535        }
536    }
537
538    /// Get the query specificity (0=all, 1=scope, 2=scope+topic).
539    pub fn scope_level(&self) -> u8 {
540        if self.topic.is_some() {
541            2
542        } else if self.scope.is_some() {
543            1
544        } else {
545            0
546        }
547    }
548}
549
550/// Specification for registering objects in the discovery plane
551/// Represents the input to the register() operation
552#[derive(Debug, Clone, PartialEq, Eq)]
553pub enum DiscoverySpec {
554    /// Endpoint specification for registration
555    Endpoint {
556        namespace: String,
557        component: String,
558        endpoint: String,
559        /// Transport type and routing information
560        transport: TransportType,
561        /// Optional execution device for this endpoint instance.
562        /// Used by hetero routing to distinguish CPU and CUDA workers.
563        device_type: Option<DeviceType>,
564        /// Payload codec accepted by this endpoint's request-plane worker.
565        /// `None` represents a legacy JSON-only worker.
566        request_plane_codec: Option<RequestPlanePayloadCodec>,
567    },
568    Model {
569        namespace: String,
570        component: String,
571        endpoint: String,
572        /// ModelDeploymentCard serialized as JSON
573        /// This allows lib/runtime to remain independent of lib/llm types
574        /// DiscoverySpec.from_model() and DiscoveryInstance.deserialize_model() are ergonomic helpers to create and deserialize the model card.
575        card_json: serde_json::Value,
576        /// Optional suffix appended after instance_id in the key path (e.g., for LoRA adapters)
577        /// Key format: {namespace}/{component}/{endpoint}/{instance_id}[/{model_suffix}]
578        model_suffix: Option<String>,
579    },
580    /// Event plane channel specification
581    /// Used for registering event publishers/subscribers for discovery
582    EventChannel {
583        scope: EventScope,
584        /// Topic name for this channel (e.g., "kv-events", "kv-metrics")
585        topic: String,
586        /// Unique identity of this publisher incarnation.
587        ///
588        /// A process can host multiple publishers for the same topic, so event
589        /// channels cannot use the process-level discovery instance ID.
590        publisher_id: u64,
591        /// Event transport type (NATS subject prefix or ZMQ endpoint)
592        transport: EventTransport,
593    },
594    /// Semantic source of events, independent of event transport discovery.
595    EventSource {
596        scope: EventScope,
597        topic: String,
598        /// Unique identity of this source incarnation.
599        publisher_id: u64,
600        /// Domain-specific source descriptor owned by the consuming crate.
601        metadata: serde_json::Value,
602    },
603}
604
605impl DiscoverySpec {
606    /// Creates a Model discovery spec from a serializable type
607    /// The card will be serialized to JSON to avoid cross-crate dependencies
608    pub fn from_model<T>(
609        namespace: String,
610        component: String,
611        endpoint: String,
612        card: &T,
613    ) -> Result<Self>
614    where
615        T: Serialize,
616    {
617        Self::from_model_with_suffix(namespace, component, endpoint, card, None)
618    }
619
620    /// Creates a Model discovery spec with an optional suffix (e.g., for LoRA adapters)
621    /// The suffix is appended after the instance_id in the key path
622    pub fn from_model_with_suffix<T>(
623        namespace: String,
624        component: String,
625        endpoint: String,
626        card: &T,
627        model_suffix: Option<String>,
628    ) -> Result<Self>
629    where
630        T: Serialize,
631    {
632        let card_json = serde_json::to_value(card)?;
633        Ok(Self::Model {
634            namespace,
635            component,
636            endpoint,
637            card_json,
638            model_suffix,
639        })
640    }
641
642    /// Converts this registration spec into a discovery instance.
643    ///
644    /// Endpoint and model specs use `default_instance_id`, normally the
645    /// discovery client's process-level ID. Event channel and source specs
646    /// already carry a publisher-level ID, so they use that instead.
647    pub fn into_instance(self, default_instance_id: u64) -> DiscoveryInstance {
648        match self {
649            Self::Endpoint {
650                namespace,
651                component,
652                endpoint,
653                transport,
654                device_type,
655                request_plane_codec,
656            } => DiscoveryInstance::Endpoint(crate::component::Instance {
657                namespace,
658                component,
659                endpoint,
660                instance_id: default_instance_id,
661                transport,
662                device_type,
663                request_plane_codec,
664            }),
665            Self::Model {
666                namespace,
667                component,
668                endpoint,
669                card_json,
670                model_suffix,
671            } => DiscoveryInstance::Model {
672                namespace,
673                component,
674                endpoint,
675                instance_id: default_instance_id,
676                card_json,
677                model_suffix,
678            },
679            Self::EventChannel {
680                scope,
681                topic,
682                publisher_id,
683                transport,
684            } => DiscoveryInstance::EventChannel {
685                scope,
686                topic,
687                instance_id: publisher_id,
688                transport,
689            },
690            Self::EventSource {
691                scope,
692                topic,
693                publisher_id,
694                metadata,
695            } => DiscoveryInstance::EventSource {
696                scope,
697                topic,
698                publisher_id,
699                metadata,
700            },
701        }
702    }
703
704    /// Compatibility alias for [`DiscoverySpec::into_instance`].
705    pub fn with_instance_id(self, default_instance_id: u64) -> DiscoveryInstance {
706        self.into_instance(default_instance_id)
707    }
708}
709
710/// Registered instances in the discovery plane
711/// Represents objects that have been successfully registered with an instance ID
712#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
713#[serde(tag = "type")]
714pub enum DiscoveryInstance {
715    /// Registered endpoint instance - wraps the component::Instance directly
716    Endpoint(crate::component::Instance),
717    Model {
718        namespace: String,
719        component: String,
720        endpoint: String,
721        instance_id: u64,
722        /// ModelDeploymentCard serialized as JSON
723        /// This allows lib/runtime to remain independent of lib/llm types
724        card_json: serde_json::Value,
725        /// Optional suffix appended after instance_id in the key path (e.g., for LoRA adapters)
726        #[serde(default, skip_serializing_if = "Option::is_none")]
727        model_suffix: Option<String>,
728    },
729    /// Registered event channel instance for event plane pub/sub
730    EventChannel {
731        scope: EventScope,
732        /// Topic name for this channel (e.g., "kv-events", "kv-metrics")
733        topic: String,
734        instance_id: u64,
735        /// Event transport type (NATS subject prefix or ZMQ endpoint)
736        transport: EventTransport,
737    },
738    /// Registered semantic event source.
739    EventSource {
740        scope: EventScope,
741        topic: String,
742        publisher_id: u64,
743        metadata: serde_json::Value,
744    },
745}
746
747/// Validate an idempotent registration for one semantic event-source incarnation.
748///
749/// NOTE: Descriptor immutability belongs to the generic discovery contract. Backends still
750/// perform their own atomic lookup/insert, but all of them use this comparison so an identical
751/// registration succeeds and a changed descriptor preserves the original record.
752pub(crate) fn validate_event_source_reregistration(
753    existing: &DiscoveryInstance,
754    candidate: &DiscoveryInstance,
755) -> Result<()> {
756    let DiscoveryInstanceId::EventSource(existing_id) = existing.id() else {
757        anyhow::bail!("existing discovery record is not an event source")
758    };
759    if candidate.id() != DiscoveryInstanceId::EventSource(existing_id.clone()) {
760        anyhow::bail!("event source re-registration changed its identity")
761    }
762    if existing != candidate {
763        anyhow::bail!(
764            "Event source incarnation '{}' cannot change its descriptor",
765            existing_id.to_path()
766        )
767    }
768    Ok(())
769}
770
771impl DiscoveryInstance {
772    /// Returns the instance ID for this discovery instance
773    pub fn instance_id(&self) -> u64 {
774        match self {
775            Self::Endpoint(inst) => inst.instance_id,
776            Self::Model { instance_id, .. } => *instance_id,
777            Self::EventChannel { instance_id, .. } => *instance_id,
778            Self::EventSource { publisher_id, .. } => *publisher_id,
779        }
780    }
781
782    /// Deserializes the model JSON into the specified type T
783    /// Returns an error if this is not a Model instance or if deserialization fails
784    pub fn deserialize_model<T>(&self) -> Result<T>
785    where
786        T: for<'de> Deserialize<'de>,
787    {
788        match self {
789            Self::Model { card_json, .. } => Ok(serde_json::from_value(card_json.clone())?),
790            Self::Endpoint(_) => {
791                anyhow::bail!("Cannot deserialize model from Endpoint instance")
792            }
793            Self::EventChannel { .. } => {
794                anyhow::bail!("Cannot deserialize model from EventChannel instance")
795            }
796            Self::EventSource { .. } => {
797                anyhow::bail!("Cannot deserialize model from EventSource instance")
798            }
799        }
800    }
801
802    /// Extracts the unique identifier for this discovery instance
803    /// Used for tracking, diffing, and removal events
804    pub fn id(&self) -> DiscoveryInstanceId {
805        match self {
806            Self::Endpoint(inst) => DiscoveryInstanceId::Endpoint(EndpointInstanceId {
807                namespace: inst.namespace.clone(),
808                component: inst.component.clone(),
809                endpoint: inst.endpoint.clone(),
810                instance_id: inst.instance_id,
811            }),
812            Self::Model {
813                namespace,
814                component,
815                endpoint,
816                instance_id,
817                model_suffix,
818                ..
819            } => DiscoveryInstanceId::Model(ModelCardInstanceId {
820                namespace: namespace.clone(),
821                component: component.clone(),
822                endpoint: endpoint.clone(),
823                instance_id: *instance_id,
824                model_suffix: model_suffix.clone(),
825            }),
826            Self::EventChannel {
827                scope,
828                topic,
829                instance_id,
830                ..
831            } => DiscoveryInstanceId::EventChannel(EventChannelInstanceId {
832                scope: scope.clone(),
833                topic: topic.clone(),
834                instance_id: *instance_id,
835            }),
836            Self::EventSource {
837                scope,
838                topic,
839                publisher_id,
840                ..
841            } => DiscoveryInstanceId::EventSource(EventSourceInstanceId {
842                scope: scope.clone(),
843                topic: topic.clone(),
844                publisher_id: *publisher_id,
845            }),
846        }
847    }
848
849    /// Returns true if this instance satisfies `query`.
850    pub fn matches(&self, query: &DiscoveryQuery) -> bool {
851        match (self, query) {
852            (Self::Endpoint(_), DiscoveryQuery::AllEndpoints) => true,
853            (Self::Endpoint(i), DiscoveryQuery::NamespacedEndpoints { namespace }) => {
854                &i.namespace == namespace
855            }
856            (
857                Self::Endpoint(i),
858                DiscoveryQuery::ComponentEndpoints {
859                    namespace,
860                    component,
861                },
862            ) => &i.namespace == namespace && &i.component == component,
863            (
864                Self::Endpoint(i),
865                DiscoveryQuery::Endpoint {
866                    namespace,
867                    component,
868                    endpoint,
869                },
870            ) => &i.namespace == namespace && &i.component == component && &i.endpoint == endpoint,
871
872            (Self::Model { .. }, DiscoveryQuery::AllModels) => true,
873            (Self::Model { namespace: ns, .. }, DiscoveryQuery::NamespacedModels { namespace }) => {
874                ns == namespace
875            }
876            (
877                Self::Model {
878                    namespace: ns,
879                    component: comp,
880                    ..
881                },
882                DiscoveryQuery::ComponentModels {
883                    namespace,
884                    component,
885                },
886            ) => ns == namespace && comp == component,
887            (
888                Self::Model {
889                    namespace: ns,
890                    component: comp,
891                    endpoint: ep,
892                    ..
893                },
894                DiscoveryQuery::EndpointModels {
895                    namespace,
896                    component,
897                    endpoint,
898                },
899            ) => ns == namespace && comp == component && ep == endpoint,
900
901            (
902                Self::EventChannel {
903                    scope, topic: t, ..
904                },
905                DiscoveryQuery::EventChannels(q),
906            ) => {
907                q.scope.as_ref().is_none_or(|expected| expected == scope)
908                    && q.topic.as_ref().is_none_or(|qt| qt == t)
909            }
910            (
911                Self::EventSource {
912                    scope, topic: t, ..
913                },
914                DiscoveryQuery::EventSources(q),
915            ) => {
916                q.scope.as_ref().is_none_or(|expected| expected == scope)
917                    && q.topic.as_ref().is_none_or(|qt| qt == t)
918            }
919
920            _ => false,
921        }
922    }
923}
924
925/// Unique identifier for an endpoint instance
926#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
927pub struct EndpointInstanceId {
928    pub namespace: String,
929    pub component: String,
930    pub endpoint: String,
931    pub instance_id: u64,
932}
933
934impl EndpointInstanceId {
935    /// Converts to a path string.
936    pub fn to_path(&self) -> String {
937        format!(
938            "{}/{}/{}/{:x}",
939            self.namespace, self.component, self.endpoint, self.instance_id
940        )
941    }
942
943    /// Parses an endpoint instance path.
944    pub fn from_path(path: &str) -> Result<Self> {
945        let parts: Vec<&str> = path.split('/').collect();
946        if parts.len() != 4 {
947            anyhow::bail!(
948                "Invalid EndpointInstanceId path: expected 4 parts, got {}",
949                parts.len()
950            );
951        }
952        Ok(Self {
953            namespace: parts[0].to_string(),
954            component: parts[1].to_string(),
955            endpoint: parts[2].to_string(),
956            instance_id: u64::from_str_radix(parts[3], 16)
957                .map_err(|e| anyhow::anyhow!("Invalid instance_id hex: {}", e))?,
958        })
959    }
960}
961
962/// Unique identifier for a model card instance
963/// The combination of (namespace, component, endpoint, instance_id, model_suffix) uniquely identifies a model card
964#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
965pub struct ModelCardInstanceId {
966    pub namespace: String,
967    pub component: String,
968    pub endpoint: String,
969    pub instance_id: u64,
970    /// None for base models, Some(slug) for LoRA adapters
971    pub model_suffix: Option<String>,
972}
973
974/// Unique identifier for an event channel instance
975#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
976pub struct EventChannelInstanceId {
977    pub scope: EventScope,
978    /// Topic name for this channel (e.g., "kv-events", "kv-metrics")
979    pub topic: String,
980    pub instance_id: u64,
981}
982
983impl EventChannelInstanceId {
984    /// Converts to a delimiter-safe path containing the exact channel scope.
985    pub fn to_path(&self) -> String {
986        format!(
987            "{}/topic/{}/{:x}",
988            self.scope.path_prefix(),
989            encode_event_segment(&self.topic),
990            self.instance_id
991        )
992    }
993
994    /// Parses a path produced by [`Self::to_path`].
995    pub fn from_path(path: &str) -> Result<Self> {
996        let parts: Vec<&str> = path.split('/').collect();
997        let (scope, topic_index, instance_index) = match parts.as_slice() {
998            ["namespace", namespace, "topic", _, _] => (
999                EventScope::Namespace {
1000                    name: decode_event_segment(namespace)?,
1001                },
1002                3,
1003                4,
1004            ),
1005            [
1006                "namespace",
1007                namespace,
1008                "component",
1009                component,
1010                "topic",
1011                _,
1012                _,
1013            ] => (
1014                EventScope::Component {
1015                    namespace: decode_event_segment(namespace)?,
1016                    component: decode_event_segment(component)?,
1017                },
1018                5,
1019                6,
1020            ),
1021            [
1022                "namespace",
1023                namespace,
1024                "component",
1025                component,
1026                "endpoint",
1027                endpoint,
1028                "topic",
1029                _,
1030                _,
1031            ] => (
1032                EventScope::Endpoint {
1033                    endpoint: EndpointId {
1034                        namespace: decode_event_segment(namespace)?,
1035                        component: decode_event_segment(component)?,
1036                        name: decode_event_segment(endpoint)?,
1037                    },
1038                },
1039                7,
1040                8,
1041            ),
1042            _ => anyhow::bail!("invalid EventChannelInstanceId path: {path}"),
1043        };
1044        Ok(Self {
1045            scope,
1046            topic: decode_event_segment(parts[topic_index])?,
1047            instance_id: u64::from_str_radix(parts[instance_index], 16)
1048                .map_err(|e| anyhow::anyhow!("Invalid instance_id hex: {}", e))?,
1049        })
1050    }
1051}
1052
1053/// Unique identifier for a semantic event source incarnation.
1054#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
1055pub struct EventSourceInstanceId {
1056    pub scope: EventScope,
1057    pub topic: String,
1058    pub publisher_id: u64,
1059}
1060
1061impl EventSourceInstanceId {
1062    /// Converts to a delimiter-safe path containing the exact source scope.
1063    pub fn to_path(&self) -> String {
1064        format!(
1065            "{}/topic/{}/{:x}",
1066            self.scope.path_prefix(),
1067            encode_event_segment(&self.topic),
1068            self.publisher_id
1069        )
1070    }
1071
1072    /// Parses a path produced by [`Self::to_path`].
1073    pub fn from_path(path: &str) -> Result<Self> {
1074        let channel_id = EventChannelInstanceId::from_path(path)
1075            .with_context(|| format!("invalid EventSourceInstanceId path: {path}"))?;
1076        Ok(Self {
1077            scope: channel_id.scope,
1078            topic: channel_id.topic,
1079            publisher_id: channel_id.instance_id,
1080        })
1081    }
1082}
1083
1084impl ModelCardInstanceId {
1085    /// Converts to a path string: `{namespace}/{component}/{endpoint}/{instance_id:x}[/{model_suffix}]`
1086    pub fn to_path(&self) -> String {
1087        match &self.model_suffix {
1088            Some(suffix) => format!(
1089                "{}/{}/{}/{:x}/{}",
1090                self.namespace, self.component, self.endpoint, self.instance_id, suffix
1091            ),
1092            None => format!(
1093                "{}/{}/{}/{:x}",
1094                self.namespace, self.component, self.endpoint, self.instance_id
1095            ),
1096        }
1097    }
1098
1099    /// Parses from a path string: `{namespace}/{component}/{endpoint}/{instance_id:x}[/{model_suffix}]`
1100    pub fn from_path(path: &str) -> Result<Self> {
1101        let parts: Vec<&str> = path.split('/').collect();
1102        if parts.len() < 4 || parts.len() > 5 {
1103            anyhow::bail!(
1104                "Invalid ModelCardInstanceId path: expected 4 or 5 parts, got {}",
1105                parts.len()
1106            );
1107        }
1108        Ok(Self {
1109            namespace: parts[0].to_string(),
1110            component: parts[1].to_string(),
1111            endpoint: parts[2].to_string(),
1112            instance_id: u64::from_str_radix(parts[3], 16)
1113                .map_err(|e| anyhow::anyhow!("Invalid instance_id hex: {}", e))?,
1114            model_suffix: parts.get(4).map(|s| s.to_string()),
1115        })
1116    }
1117}
1118
1119/// Union of instance identifiers for different discovery object types
1120#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
1121pub enum DiscoveryInstanceId {
1122    Endpoint(EndpointInstanceId),
1123    Model(ModelCardInstanceId),
1124    EventChannel(EventChannelInstanceId),
1125    EventSource(EventSourceInstanceId),
1126}
1127
1128impl DiscoveryInstanceId {
1129    /// Returns the raw instance_id regardless of variant type
1130    pub fn instance_id(&self) -> u64 {
1131        match self {
1132            Self::Endpoint(eid) => eid.instance_id,
1133            Self::Model(mid) => mid.instance_id,
1134            Self::EventChannel(ecid) => ecid.instance_id,
1135            Self::EventSource(esid) => esid.publisher_id,
1136        }
1137    }
1138
1139    /// Extracts the EndpointInstanceId, returning an error if this is a Model or EventChannel variant
1140    pub fn extract_endpoint_id(&self) -> Result<&EndpointInstanceId> {
1141        match self {
1142            Self::Endpoint(eid) => Ok(eid),
1143            Self::Model(_) => anyhow::bail!("Expected Endpoint variant, got Model"),
1144            Self::EventChannel(_) => anyhow::bail!("Expected Endpoint variant, got EventChannel"),
1145            Self::EventSource(_) => anyhow::bail!("Expected Endpoint variant, got EventSource"),
1146        }
1147    }
1148
1149    /// Extracts the ModelCardInstanceId, returning an error if this is an Endpoint or EventChannel variant
1150    pub fn extract_model_id(&self) -> Result<&ModelCardInstanceId> {
1151        match self {
1152            Self::Model(mid) => Ok(mid),
1153            Self::Endpoint(_) => anyhow::bail!("Expected Model variant, got Endpoint"),
1154            Self::EventChannel(_) => anyhow::bail!("Expected Model variant, got EventChannel"),
1155            Self::EventSource(_) => anyhow::bail!("Expected Model variant, got EventSource"),
1156        }
1157    }
1158
1159    /// Extracts the EventChannelInstanceId, returning an error if this is an Endpoint or Model variant
1160    pub fn extract_event_channel_id(&self) -> Result<&EventChannelInstanceId> {
1161        match self {
1162            Self::EventChannel(ecid) => Ok(ecid),
1163            Self::Endpoint(_) => anyhow::bail!("Expected EventChannel variant, got Endpoint"),
1164            Self::Model(_) => anyhow::bail!("Expected EventChannel variant, got Model"),
1165            Self::EventSource(_) => {
1166                anyhow::bail!("Expected EventChannel variant, got EventSource")
1167            }
1168        }
1169    }
1170
1171    /// Extracts the EventSourceInstanceId, returning an error for other variants.
1172    pub fn extract_event_source_id(&self) -> Result<&EventSourceInstanceId> {
1173        match self {
1174            Self::EventSource(esid) => Ok(esid),
1175            Self::Endpoint(_) => anyhow::bail!("Expected EventSource variant, got Endpoint"),
1176            Self::Model(_) => anyhow::bail!("Expected EventSource variant, got Model"),
1177            Self::EventChannel(_) => {
1178                anyhow::bail!("Expected EventSource variant, got EventChannel")
1179            }
1180        }
1181    }
1182}
1183
1184/// Events emitted by the discovery watch stream
1185#[derive(Debug, Clone, PartialEq, Eq)]
1186pub enum DiscoveryEvent {
1187    /// A new instance was added.
1188    ///
1189    /// Endpoint watches also emit this event when the endpoint data changes without changing its
1190    /// [`DiscoveryInstanceId`]. Consumers of endpoint watches must replace the previous value.
1191    Added(DiscoveryInstance),
1192    /// The complete normalized taint set for an existing model card changed.
1193    ModelTaintsUpdated(ModelTaintsUpdate),
1194    /// An instance was removed (identified by its unique ID)
1195    Removed(DiscoveryInstanceId),
1196    /// The full set of instances that match the query at one moment.
1197    ///
1198    /// Sent once at startup, after the `Added` events of the same snapshot, and again after a
1199    /// backend resync. A consumer that holds state from another source, such as an earlier
1200    /// stream or a [`Discovery::list`] call, replaces it with the payload. One that builds its
1201    /// state from this stream alone can ignore the event.
1202    Resync(Vec<DiscoveryInstance>),
1203}
1204
1205/// A scoped, idempotent update to an existing model card's routing taints.
1206#[derive(Debug, Clone, PartialEq, Eq)]
1207pub struct ModelTaintsUpdate {
1208    pub id: ModelCardInstanceId,
1209    pub taints: Vec<String>,
1210}
1211
1212/// Stream type for discovery events
1213pub type DiscoveryStream = Pin<Box<dyn Stream<Item = Result<DiscoveryEvent>> + Send>>;
1214
1215#[derive(Clone, Debug, PartialEq, Eq)]
1216struct ModelRegistrationIdentity {
1217    display_name: String,
1218    aliases: Vec<String>,
1219    source_path: Option<String>,
1220    is_lora: bool,
1221}
1222
1223impl ModelRegistrationIdentity {
1224    fn base_identity(&self) -> &str {
1225        self.source_path.as_deref().unwrap_or(&self.display_name)
1226    }
1227
1228    fn is_compatible_with(&self, other: &Self) -> bool {
1229        if self.is_lora != other.is_lora {
1230            let (adapter, base) = if self.is_lora {
1231                (self, other)
1232            } else {
1233                (other, self)
1234            };
1235            adapter.base_identity() == base.base_identity()
1236                && adapter.display_name != base.display_name
1237                && !base.aliases.contains(&adapter.display_name)
1238        } else if self.is_lora {
1239            self.base_identity() == other.base_identity()
1240        } else {
1241            // Preserve existing same-name registration compatibility across local model paths.
1242            self.display_name == other.display_name
1243                || self.source_path.as_deref().is_some_and(|source| {
1244                    !source.is_empty()
1245                        && other.source_path.as_deref() == Some(source)
1246                        && !self.aliases.contains(&other.display_name)
1247                        && !other.aliases.contains(&self.display_name)
1248                        && !self
1249                            .aliases
1250                            .iter()
1251                            .any(|alias| other.aliases.contains(alias))
1252                })
1253        }
1254    }
1255}
1256
1257fn extract_model_registration_identity(
1258    card_json: &serde_json::Value,
1259    model_suffix: Option<&str>,
1260) -> Result<ModelRegistrationIdentity> {
1261    let display_name = card_json
1262        .get("display_name")
1263        .and_then(serde_json::Value::as_str)
1264        .map(str::to_owned)
1265        .ok_or_else(|| {
1266            anyhow::anyhow!("failed to deserialize model display_name from card_json")
1267        })?;
1268    let source_path = card_json
1269        .get("source_path")
1270        .and_then(serde_json::Value::as_str)
1271        .map(str::to_owned);
1272    let aliases = card_json
1273        .get("aliases")
1274        .and_then(serde_json::Value::as_array)
1275        .into_iter()
1276        .flatten()
1277        .filter_map(serde_json::Value::as_str)
1278        .map(str::to_owned)
1279        .collect();
1280    let is_lora =
1281        model_suffix.is_some() || card_json.get("lora").is_some_and(|value| !value.is_null());
1282
1283    Ok(ModelRegistrationIdentity {
1284        display_name,
1285        aliases,
1286        source_path,
1287        is_lora,
1288    })
1289}
1290
1291fn find_conflicting_model_name(
1292    instances: &[DiscoveryInstance],
1293    requested_identity: &ModelRegistrationIdentity,
1294) -> Result<Option<String>> {
1295    for instance in instances {
1296        if let DiscoveryInstance::Model {
1297            card_json,
1298            model_suffix,
1299            ..
1300        } = instance
1301        {
1302            let existing_identity =
1303                extract_model_registration_identity(card_json, model_suffix.as_deref())?;
1304            if !requested_identity.is_compatible_with(&existing_identity) {
1305                return Ok(Some(existing_identity.display_name));
1306            }
1307        }
1308    }
1309
1310    Ok(None)
1311}
1312
1313const TOPOLOGY_TAINT_PREFIX: &str = "dynamo.topology/";
1314
1315fn model_card_without_taints(
1316    instance: &DiscoveryInstance,
1317) -> Result<(serde_json::Value, HashSet<String>)> {
1318    let DiscoveryInstance::Model { card_json, .. } = instance else {
1319        anyhow::bail!("model update requires a model discovery instance")
1320    };
1321
1322    let mut card = card_json.clone();
1323    let runtime_config = card
1324        .get_mut("runtime_config")
1325        .and_then(serde_json::Value::as_object_mut)
1326        .context("model card is missing runtime_config")?;
1327    let taints = runtime_config
1328        .remove("taints")
1329        .unwrap_or_else(|| serde_json::Value::Array(Vec::new()));
1330    let taints = taints
1331        .as_array()
1332        .context("model card runtime_config.taints must be an array")?
1333        .iter()
1334        .map(|value| {
1335            value
1336                .as_str()
1337                .map(str::to_owned)
1338                .context("model card runtime_config.taints entries must be strings")
1339        })
1340        .collect::<Result<HashSet<_>>>()?;
1341
1342    Ok((card, taints))
1343}
1344
1345fn expected_topology_taints(card: &serde_json::Value) -> Result<HashSet<String>> {
1346    let Some(domains) = card
1347        .pointer("/runtime_config/topology_domains")
1348        .and_then(serde_json::Value::as_object)
1349    else {
1350        return Ok(HashSet::new());
1351    };
1352
1353    domains
1354        .iter()
1355        .map(|(domain, value)| {
1356            let value = value
1357                .as_str()
1358                .context("model card runtime_config.topology_domains values must be strings")?;
1359            Ok(format!(
1360                "{TOPOLOGY_TAINT_PREFIX}{}={}",
1361                domain.trim(),
1362                value.trim()
1363            ))
1364        })
1365        .collect()
1366}
1367
1368/// Validate the discovery-layer mutable boundary for model-card updates.
1369///
1370/// Model cards remain immutable after registration except for worker-managed
1371/// `runtime_config.taints`. Reserved topology taints are derived from the
1372/// immutable `topology_domains` map and must stay canonical.
1373#[derive(Debug)]
1374struct ValidatedModelTaintUpdate {
1375    existing_taints: HashSet<String>,
1376    candidate_taints: HashSet<String>,
1377}
1378
1379fn validate_model_taint_update(
1380    existing: &DiscoveryInstance,
1381    candidate: &DiscoveryInstance,
1382) -> Result<ValidatedModelTaintUpdate> {
1383    if existing.id() != candidate.id() {
1384        anyhow::bail!("model update cannot change discovery identity")
1385    }
1386
1387    let (existing_card, existing_taints) = model_card_without_taints(existing)?;
1388    let (candidate_card, candidate_taints) = model_card_without_taints(candidate)?;
1389    if existing_card != candidate_card {
1390        anyhow::bail!("model update can only change runtime_config.taints")
1391    }
1392
1393    let expected_topology = expected_topology_taints(&candidate_card)?;
1394    let actual_topology = candidate_taints
1395        .iter()
1396        .filter(|taint| taint.starts_with(TOPOLOGY_TAINT_PREFIX))
1397        .cloned()
1398        .collect::<HashSet<_>>();
1399    if actual_topology != expected_topology {
1400        anyhow::bail!(
1401            "reserved {TOPOLOGY_TAINT_PREFIX} taints must match runtime_config.topology_domains"
1402        )
1403    }
1404
1405    Ok(ValidatedModelTaintUpdate {
1406        existing_taints,
1407        candidate_taints,
1408    })
1409}
1410
1411/// Validate a same-ID model registration replay without replacing authoritative taints.
1412pub(crate) fn validate_model_reregistration(
1413    existing: &DiscoveryInstance,
1414    candidate: &DiscoveryInstance,
1415) -> Result<()> {
1416    validate_model_taint_update(existing, candidate).map(|_| ())
1417}
1418
1419fn sorted_taints(taints: HashSet<String>) -> Vec<String> {
1420    let mut taints = taints.into_iter().collect::<Vec<_>>();
1421    taints.sort_unstable();
1422    taints
1423}
1424
1425/// Classify a discovery value transition without widening `Added` into an upsert.
1426///
1427/// Model cards are immutable after registration except for `runtime_config.taints`.
1428/// Endpoints retain their existing replacement-as-Added behavior. Same-ID changes
1429/// to other discovery object types remain ignored.
1430pub(crate) fn classify_discovery_change(
1431    existing: Option<&DiscoveryInstance>,
1432    candidate: &DiscoveryInstance,
1433) -> Result<Option<DiscoveryEvent>> {
1434    let Some(existing) = existing else {
1435        return Ok(Some(DiscoveryEvent::Added(candidate.clone())));
1436    };
1437
1438    if existing == candidate {
1439        return Ok(None);
1440    }
1441
1442    if matches!(existing, DiscoveryInstance::Model { .. })
1443        && matches!(candidate, DiscoveryInstance::Model { .. })
1444    {
1445        let ValidatedModelTaintUpdate {
1446            existing_taints,
1447            candidate_taints,
1448        } = validate_model_taint_update(existing, candidate)?;
1449        if existing_taints == candidate_taints {
1450            return Ok(None);
1451        }
1452
1453        let DiscoveryInstanceId::Model(id) = candidate.id() else {
1454            unreachable!("model discovery instance must have a model id")
1455        };
1456        return Ok(Some(DiscoveryEvent::ModelTaintsUpdated(
1457            ModelTaintsUpdate {
1458                id,
1459                taints: sorted_taints(candidate_taints),
1460            },
1461        )));
1462    }
1463
1464    if matches!(candidate, DiscoveryInstance::Endpoint(_)) {
1465        Ok(Some(DiscoveryEvent::Added(candidate.clone())))
1466    } else {
1467        Ok(None)
1468    }
1469}
1470
1471/// Reconcile an authoritative snapshot while retaining the last valid value for
1472/// any model card that attempts an immutable mutation.
1473pub(crate) fn reconcile_discovery_snapshot(
1474    known: &HashMap<DiscoveryInstanceId, DiscoveryInstance>,
1475    current: HashMap<DiscoveryInstanceId, DiscoveryInstance>,
1476) -> (
1477    Vec<DiscoveryEvent>,
1478    HashMap<DiscoveryInstanceId, DiscoveryInstance>,
1479) {
1480    let mut events = known
1481        .keys()
1482        .filter(|id| !current.contains_key(*id))
1483        .cloned()
1484        .map(DiscoveryEvent::Removed)
1485        .collect::<Vec<_>>();
1486    let mut next = HashMap::with_capacity(current.len());
1487
1488    for (id, candidate) in current {
1489        match classify_discovery_change(known.get(&id), &candidate) {
1490            Ok(Some(event)) => {
1491                events.push(event);
1492                next.insert(id, candidate);
1493            }
1494            Ok(None) => {
1495                let retained = known.get(&id).cloned().unwrap_or(candidate);
1496                next.insert(id, retained);
1497            }
1498            Err(error) => {
1499                tracing::error!(
1500                    ?id,
1501                    %error,
1502                    "Rejecting immutable discovery model-card mutation"
1503                );
1504                if let Some(existing) = known.get(&id) {
1505                    next.insert(id, existing.clone());
1506                }
1507            }
1508        }
1509    }
1510
1511    (events, next)
1512}
1513
1514/// Reconcile an authoritative snapshot after a backend resync.
1515///
1516/// Returns the changes between `known` and the snapshot, then one [`DiscoveryEvent::Resync`]
1517/// with the reconciled set. `known` becomes the reconciled set.
1518pub(crate) fn resync_discovery_events(
1519    known: &mut HashMap<DiscoveryInstanceId, DiscoveryInstance>,
1520    current: HashMap<DiscoveryInstanceId, DiscoveryInstance>,
1521) -> Vec<DiscoveryEvent> {
1522    let (mut events, reconciled) = reconcile_discovery_snapshot(known, current);
1523    *known = reconciled;
1524    events.push(DiscoveryEvent::Resync(known.values().cloned().collect()));
1525    events
1526}
1527
1528fn model_with_updated_taints(
1529    existing: &DiscoveryInstance,
1530    mut taints: HashSet<String>,
1531) -> Result<DiscoveryInstance> {
1532    if let Some(taint) = taints
1533        .iter()
1534        .find(|taint| taint.starts_with(TOPOLOGY_TAINT_PREFIX))
1535    {
1536        anyhow::bail!("taint '{taint}' uses reserved prefix '{TOPOLOGY_TAINT_PREFIX}'")
1537    }
1538
1539    let (card_without_taints, existing_taints) = model_card_without_taints(existing)?;
1540    taints.extend(expected_topology_taints(&card_without_taints)?);
1541    if taints == existing_taints {
1542        return Ok(existing.clone());
1543    }
1544
1545    let mut candidate = existing.clone();
1546    let DiscoveryInstance::Model { card_json, .. } = &mut candidate else {
1547        anyhow::bail!("model taint update requires a model discovery instance")
1548    };
1549    let runtime_config = card_json
1550        .get_mut("runtime_config")
1551        .and_then(serde_json::Value::as_object_mut)
1552        .context("model card is missing runtime_config")?;
1553    runtime_config.insert(
1554        "taints".to_string(),
1555        serde_json::Value::Array(
1556            sorted_taints(taints)
1557                .into_iter()
1558                .map(serde_json::Value::String)
1559                .collect(),
1560        ),
1561    );
1562    Ok(candidate)
1563}
1564
1565/// Discovery trait for service discovery across different backends
1566#[async_trait]
1567pub trait Discovery: Send + Sync {
1568    /// Probe required discovery connectivity without registering a worker.
1569    /// In-process implementations have no remote dependency. Remote backends
1570    /// override this with a read against their authoritative store.
1571    async fn check_connection(&self) -> Result<()> {
1572        Ok(())
1573    }
1574
1575    /// Returns a unique identifier for this worker (e.g lease id if using etcd or generated id for memory store)
1576    /// Endpoint and model objects created by this worker use this ID. Event
1577    /// channels and sources use a publisher-level ID because a worker can own
1578    /// more than one publisher for the same topic.
1579    fn instance_id(&self) -> u64;
1580
1581    /// Registers an object in the discovery plane with the instance id
1582    async fn register(&self, spec: DiscoverySpec) -> Result<DiscoveryInstance> {
1583        let (namespace, component, endpoint, requested_identity) = match &spec {
1584            DiscoverySpec::Model {
1585                namespace,
1586                component,
1587                endpoint,
1588                card_json,
1589                model_suffix,
1590                ..
1591            } => (
1592                namespace.clone(),
1593                component.clone(),
1594                endpoint.clone(),
1595                extract_model_registration_identity(card_json, model_suffix.as_deref())?,
1596            ),
1597            _ => return self.register_internal(spec).await,
1598        };
1599
1600        let query = DiscoveryQuery::EndpointModels {
1601            namespace: namespace.clone(),
1602            component: component.clone(),
1603            endpoint: endpoint.clone(),
1604        };
1605
1606        if let Some(conflicting_name) =
1607            find_conflicting_model_name(&self.list(query.clone()).await?, &requested_identity)?
1608        {
1609            let requested_name = &requested_identity.display_name;
1610            anyhow::bail!(
1611                "Cannot register model '{requested_name}' on endpoint '{namespace}/{component}/{endpoint}': a different model '{conflicting_name}' is already registered there"
1612            );
1613        }
1614
1615        let instance = self.register_internal(spec).await?;
1616
1617        if let Some(conflicting_name) =
1618            find_conflicting_model_name(&self.list(query).await?, &requested_identity)?
1619        {
1620            let requested_name = &requested_identity.display_name;
1621            if let Err(unregister_err) = self.unregister(instance.clone()).await {
1622                return Err(anyhow::anyhow!(
1623                    "Cannot register model '{requested_name}' on endpoint '{namespace}/{component}/{endpoint}': a different model '{conflicting_name}' is already registered there"
1624                ))
1625                .context(format!(
1626                    "failed to roll back conflicting model registration for instance {instance_id}: {unregister_err}",
1627                    instance_id = instance.instance_id()
1628                ));
1629            }
1630
1631            anyhow::bail!(
1632                "Cannot register model '{requested_name}' on endpoint '{namespace}/{component}/{endpoint}': a different model '{conflicting_name}' is already registered there"
1633            );
1634        }
1635
1636        Ok(instance)
1637    }
1638
1639    /// Backend-specific raw registration implementation.
1640    async fn register_internal(&self, spec: DiscoverySpec) -> Result<DiscoveryInstance>;
1641
1642    /// Replace the caller-managed taints of this worker's existing base model card.
1643    async fn update_model_taints(
1644        &self,
1645        id: ModelCardInstanceId,
1646        taints: HashSet<String>,
1647    ) -> Result<()> {
1648        if id.instance_id != self.instance_id() {
1649            anyhow::bail!(
1650                "cannot update model taints for worker {}; this discovery client owns worker {}",
1651                id.instance_id,
1652                self.instance_id()
1653            )
1654        }
1655        if id.model_suffix.is_some() {
1656            anyhow::bail!("model taint updates are supported only for base model cards")
1657        }
1658        self.update_model_taints_internal(id, taints).await
1659    }
1660
1661    /// Backend-specific authoritative read, taint-only mutation, and persistence.
1662    async fn update_model_taints_internal(
1663        &self,
1664        _id: ModelCardInstanceId,
1665        _taints: HashSet<String>,
1666    ) -> Result<()> {
1667        anyhow::bail!("model taint updates are not supported by this discovery backend")
1668    }
1669
1670    /// Unregisters an instance from the discovery plane
1671    async fn unregister(&self, instance: DiscoveryInstance) -> Result<()>;
1672
1673    /// Returns a list of currently registered instances for the given discovery query
1674    /// This is a one-time snapshot without watching for changes
1675    async fn list(&self, query: DiscoveryQuery) -> Result<Vec<DiscoveryInstance>>;
1676
1677    /// Returns a stream of discovery events for the given discovery query
1678    ///
1679    /// The watch is established before this returns. A backend still initializing waits for it,
1680    /// and fails the call if that initialization ends first.
1681    ///
1682    /// The stream starts with the state at establishment: one `Added` per matching instance,
1683    /// then one [`DiscoveryEvent::Resync`] holding the same set, empty when nothing matches.
1684    /// Later changes follow that `Resync`, which never replays over them.
1685    ///
1686    /// A backend that falls behind sends the changes it missed, then one `Resync` of the full
1687    /// set. A change that started and ended inside the gap is not reported.
1688    ///
1689    /// Replace any state held from an earlier stream or a [`Discovery::list`] call on every
1690    /// `Resync`. Instances that left are absent from it, with no `Removed` event to report them.
1691    ///
1692    /// The optional cancellation token can be used to stop the watch stream
1693    async fn list_and_watch(
1694        &self,
1695        query: DiscoveryQuery,
1696        cancel_token: Option<CancellationToken>,
1697    ) -> Result<DiscoveryStream>;
1698
1699    /// Clean up resources held by this discovery backend.
1700    /// For KV store backends, this deletes owned registrations immediately rather than
1701    /// waiting for TTL expiry. Default is a no-op for backends that don't need cleanup.
1702    fn shutdown(&self) {}
1703}
1704
1705/// The `list_and_watch` startup contract, checked the same way against every backend, and the
1706/// helpers backend tests use to consume the startup events.
1707#[cfg(test)]
1708pub(crate) mod startup_contract {
1709    use super::*;
1710    use futures::StreamExt;
1711    use std::collections::HashSet;
1712    use std::time::Duration;
1713
1714    const EVENT_TIMEOUT: Duration = Duration::from_secs(2);
1715    /// Long enough for a late event to arrive on the memory and file stores.
1716    const QUIET_PERIOD: Duration = Duration::from_millis(200);
1717
1718    /// The contract watches model cards, the one instance kind every backend updates in place.
1719    fn query() -> DiscoveryQuery {
1720        DiscoveryQuery::ComponentModels {
1721            namespace: "contract".to_string(),
1722            component: "comp".to_string(),
1723        }
1724    }
1725
1726    fn model_spec(endpoint: &str) -> DiscoverySpec {
1727        DiscoverySpec::Model {
1728            namespace: "contract".to_string(),
1729            component: "comp".to_string(),
1730            endpoint: endpoint.to_string(),
1731            card_json: serde_json::json!({
1732                "display_name": "model",
1733                "runtime_config": {"taints": ["initial"]}
1734            }),
1735            model_suffix: None,
1736        }
1737    }
1738
1739    pub(crate) async fn next(stream: &mut DiscoveryStream) -> DiscoveryEvent {
1740        tokio::time::timeout(EVENT_TIMEOUT, stream.next())
1741            .await
1742            .expect("the stream sent no event")
1743            .expect("the stream ended")
1744            .expect("the stream reported an error")
1745    }
1746
1747    /// Consumes the startup snapshot of a watch opened on an empty registry.
1748    pub(crate) async fn expect_empty_snapshot(stream: &mut DiscoveryStream) {
1749        assert_eq!(next(stream).await, DiscoveryEvent::Resync(vec![]));
1750    }
1751
1752    async fn assert_quiet(stream: &mut DiscoveryStream) {
1753        if let Ok(event) = tokio::time::timeout(QUIET_PERIOD, stream.next()).await {
1754            panic!("unexpected event after the sequence: {event:?}");
1755        }
1756    }
1757
1758    /// Checks the startup contract against an empty `discovery`.
1759    ///
1760    /// A watch on the empty registry sends one empty `Resync` and nothing else. A populated
1761    /// registry arrives as `Added` events then one `Resync` of the same set. A registration, an
1762    /// update, and a removal made right after `list_and_watch` returns follow that snapshot,
1763    /// which still holds the pre-update state, in whatever order the backend chooses.
1764    pub(crate) async fn check(discovery: &dyn Discovery) {
1765        let mut stream = discovery.list_and_watch(query(), None).await.unwrap();
1766        expect_empty_snapshot(&mut stream).await;
1767        assert_quiet(&mut stream).await;
1768        drop(stream);
1769
1770        let first = discovery.register(model_spec("first")).await.unwrap();
1771        let second = discovery.register(model_spec("second")).await.unwrap();
1772        let DiscoveryInstanceId::Model(second_id) = second.id() else {
1773            panic!("expected a model instance");
1774        };
1775        let mut stream = discovery.list_and_watch(query(), None).await.unwrap();
1776        // Mutate before the stream is read: the snapshot is already captured.
1777        let third = discovery.register(model_spec("third")).await.unwrap();
1778        discovery
1779            .update_model_taints(second_id.clone(), HashSet::from(["updated".to_string()]))
1780            .await
1781            .unwrap();
1782        discovery.unregister(first.clone()).await.unwrap();
1783
1784        let initial = HashSet::from([first.id(), second.id()]);
1785        let mut added = HashSet::new();
1786        for _ in 0..2 {
1787            let DiscoveryEvent::Added(instance) = next(&mut stream).await else {
1788                panic!("expected an Added event in the startup burst");
1789            };
1790            added.insert(instance.id());
1791        }
1792        assert_eq!(added, initial);
1793        let DiscoveryEvent::Resync(snapshot) = next(&mut stream).await else {
1794            panic!("expected one Resync after the Added burst");
1795        };
1796        assert_eq!(
1797            snapshot
1798                .iter()
1799                .map(DiscoveryInstance::id)
1800                .collect::<HashSet<_>>(),
1801            initial
1802        );
1803        assert!(
1804            snapshot.contains(&second),
1805            "the snapshot must hold the pre-update instance, got {snapshot:?}"
1806        );
1807
1808        let changes = [
1809            next(&mut stream).await,
1810            next(&mut stream).await,
1811            next(&mut stream).await,
1812        ];
1813        let expected = [
1814            DiscoveryEvent::Added(third),
1815            DiscoveryEvent::ModelTaintsUpdated(ModelTaintsUpdate {
1816                id: second_id,
1817                taints: vec!["updated".to_string()],
1818            }),
1819            DiscoveryEvent::Removed(first.id()),
1820        ];
1821        assert!(
1822            expected.iter().all(|event| changes.contains(event)),
1823            "expected {expected:?} after the snapshot, got {changes:?}"
1824        );
1825        assert_quiet(&mut stream).await;
1826    }
1827}
1828
1829#[cfg(test)]
1830mod tests {
1831    use super::*;
1832
1833    #[test]
1834    fn endpoint_channel_id_path_round_trips_reserved_segments() {
1835        let id = EventChannelInstanceId {
1836            scope: EventScope::Endpoint {
1837                endpoint: EndpointId {
1838                    namespace: "ns.with/slash".to_string(),
1839                    component: "component.*".to_string(),
1840                    name: "endpoint.>/%".to_string(),
1841                },
1842            },
1843            topic: "kv.events/>".to_string(),
1844            instance_id: 0xfeed,
1845        };
1846
1847        let path = id.to_path();
1848        assert!(!path.contains("ns.with/slash"));
1849        assert_eq!(EventChannelInstanceId::from_path(&path).unwrap(), id);
1850    }
1851
1852    #[test]
1853    fn endpoint_source_id_path_round_trips_reserved_segments() {
1854        let id = EventSourceInstanceId {
1855            scope: EventScope::Endpoint {
1856                endpoint: EndpointId {
1857                    namespace: "ns.with/slash".to_string(),
1858                    component: "component.*".to_string(),
1859                    name: "endpoint.>/%".to_string(),
1860                },
1861            },
1862            topic: "kv.events/>".to_string(),
1863            publisher_id: 0xfeed,
1864        };
1865
1866        let path = id.to_path();
1867        assert!(!path.contains("ns.with/slash"));
1868        assert_eq!(EventSourceInstanceId::from_path(&path).unwrap(), id);
1869    }
1870
1871    #[test]
1872    fn endpoint_codec_metadata_round_trips_and_defaults_when_omitted() {
1873        let instance = DiscoverySpec::Endpoint {
1874            namespace: "default".to_string(),
1875            component: "worker".to_string(),
1876            endpoint: "generate".to_string(),
1877            transport: TransportType::Nats("worker.generate".to_string()),
1878            device_type: None,
1879            request_plane_codec: Some(RequestPlanePayloadCodec::Msgpack),
1880        }
1881        .into_instance(42);
1882
1883        let mut metadata = serde_json::to_value(&instance).unwrap();
1884        assert_eq!(metadata["request_plane_codec"], "msgpack");
1885        let round_trip: DiscoveryInstance = serde_json::from_value(metadata.clone()).unwrap();
1886        match round_trip {
1887            DiscoveryInstance::Endpoint(instance) => assert_eq!(
1888                instance.request_plane_codec,
1889                Some(RequestPlanePayloadCodec::Msgpack)
1890            ),
1891            _ => panic!("expected endpoint discovery metadata"),
1892        }
1893
1894        metadata
1895            .as_object_mut()
1896            .unwrap()
1897            .remove("request_plane_codec");
1898        let legacy: DiscoveryInstance = serde_json::from_value(metadata).unwrap();
1899        match legacy {
1900            DiscoveryInstance::Endpoint(instance) => {
1901                assert_eq!(instance.request_plane_codec, None)
1902            }
1903            _ => panic!("expected endpoint discovery metadata"),
1904        }
1905    }
1906
1907    #[test]
1908    fn matches_routes_by_query_scope() {
1909        let endpoint = DiscoveryInstance::Endpoint(Instance {
1910            namespace: "ns".to_string(),
1911            component: "comp".to_string(),
1912            endpoint: "ep".to_string(),
1913            instance_id: 1,
1914            transport: TransportType::Tcp("127.0.0.1:1234".to_string()),
1915            device_type: None,
1916            request_plane_codec: None,
1917        });
1918        let model = DiscoveryInstance::Model {
1919            namespace: "ns".to_string(),
1920            component: "comp".to_string(),
1921            endpoint: "ep".to_string(),
1922            instance_id: 1,
1923            card_json: serde_json::json!({}),
1924            model_suffix: None,
1925        };
1926
1927        assert!(endpoint.matches(&DiscoveryQuery::AllEndpoints));
1928        assert!(endpoint.matches(&DiscoveryQuery::NamespacedEndpoints {
1929            namespace: "ns".to_string()
1930        }));
1931        assert!(endpoint.matches(&DiscoveryQuery::ComponentEndpoints {
1932            namespace: "ns".to_string(),
1933            component: "comp".to_string(),
1934        }));
1935        assert!(endpoint.matches(&DiscoveryQuery::Endpoint {
1936            namespace: "ns".to_string(),
1937            component: "comp".to_string(),
1938            endpoint: "ep".to_string(),
1939        }));
1940
1941        assert!(!endpoint.matches(&DiscoveryQuery::NamespacedEndpoints {
1942            namespace: "other".to_string()
1943        }));
1944        assert!(!endpoint.matches(&DiscoveryQuery::AllModels));
1945
1946        assert!(model.matches(&DiscoveryQuery::AllModels));
1947        assert!(model.matches(&DiscoveryQuery::NamespacedModels {
1948            namespace: "ns".to_string()
1949        }));
1950        assert!(model.matches(&DiscoveryQuery::ComponentModels {
1951            namespace: "ns".to_string(),
1952            component: "comp".to_string(),
1953        }));
1954        assert!(model.matches(&DiscoveryQuery::EndpointModels {
1955            namespace: "ns".to_string(),
1956            component: "comp".to_string(),
1957            endpoint: "ep".to_string(),
1958        }));
1959
1960        assert!(!model.matches(&DiscoveryQuery::AllEndpoints));
1961    }
1962}
1963
1964#[cfg(test)]
1965mod model_taint_update_tests {
1966    use super::*;
1967
1968    fn model_instance(taints: &[&str]) -> DiscoveryInstance {
1969        DiscoveryInstance::Model {
1970            namespace: "ns".to_string(),
1971            component: "worker".to_string(),
1972            endpoint: "generate".to_string(),
1973            instance_id: 7,
1974            card_json: serde_json::json!({
1975                "display_name": "model",
1976                "runtime_config": {
1977                    "taints": taints,
1978                    "topology_domains": {"zone": "west"}
1979                }
1980            }),
1981            model_suffix: None,
1982        }
1983    }
1984
1985    #[test]
1986    fn model_update_accepts_only_caller_managed_taint_changes() {
1987        let existing = model_instance(&["old", "dynamo.topology/zone=west"]);
1988        let candidate = model_instance(&["new", "dynamo.topology/zone=west"]);
1989
1990        validate_model_taint_update(&existing, &candidate).unwrap();
1991    }
1992
1993    #[test]
1994    fn model_update_rejects_immutable_card_changes() {
1995        let existing = model_instance(&["dynamo.topology/zone=west"]);
1996        let mut candidate = model_instance(&["dynamo.topology/zone=west"]);
1997        let DiscoveryInstance::Model { card_json, .. } = &mut candidate else {
1998            unreachable!()
1999        };
2000        card_json["display_name"] = serde_json::json!("other-model");
2001
2002        let error = validate_model_taint_update(&existing, &candidate).unwrap_err();
2003        assert!(
2004            error
2005                .to_string()
2006                .contains("can only change runtime_config.taints")
2007        );
2008    }
2009
2010    #[test]
2011    fn model_update_rejects_reserved_topology_taint_changes() {
2012        let existing = model_instance(&["dynamo.topology/zone=west"]);
2013        let candidate = model_instance(&["dynamo.topology/zone=east"]);
2014
2015        let error = validate_model_taint_update(&existing, &candidate).unwrap_err();
2016        assert!(
2017            error
2018                .to_string()
2019                .contains("must match runtime_config.topology_domains")
2020        );
2021    }
2022
2023    #[test]
2024    fn changed_taints_are_classified_as_a_scoped_normalized_event() {
2025        let existing = model_instance(&["old", "dynamo.topology/zone=west"]);
2026        let candidate = model_instance(&["gpu", "blue", "dynamo.topology/zone=west"]);
2027        let DiscoveryInstanceId::Model(id) = candidate.id() else {
2028            unreachable!()
2029        };
2030
2031        assert_eq!(
2032            classify_discovery_change(Some(&existing), &candidate).unwrap(),
2033            Some(DiscoveryEvent::ModelTaintsUpdated(ModelTaintsUpdate {
2034                id,
2035                taints: vec![
2036                    "blue".to_string(),
2037                    "dynamo.topology/zone=west".to_string(),
2038                    "gpu".to_string(),
2039                ],
2040            }))
2041        );
2042    }
2043
2044    #[test]
2045    fn taint_order_only_changes_are_no_ops() {
2046        let existing = model_instance(&["gpu", "dynamo.topology/zone=west"]);
2047        let candidate = model_instance(&["dynamo.topology/zone=west", "gpu"]);
2048
2049        assert_eq!(
2050            classify_discovery_change(Some(&existing), &candidate).unwrap(),
2051            None
2052        );
2053    }
2054
2055    #[test]
2056    fn update_api_derives_topology_taints_and_rejects_reserved_input() {
2057        let existing = model_instance(&["old", "dynamo.topology/zone=west"]);
2058        let updated =
2059            model_with_updated_taints(&existing, HashSet::from(["new".to_string()])).unwrap();
2060        let (_, taints) = model_card_without_taints(&updated).unwrap();
2061        assert_eq!(
2062            taints,
2063            HashSet::from(["new".to_string(), "dynamo.topology/zone=west".to_string()])
2064        );
2065        assert!(
2066            model_with_updated_taints(
2067                &existing,
2068                HashSet::from(["dynamo.topology/zone=east".to_string()])
2069            )
2070            .is_err()
2071        );
2072    }
2073}