1use 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
36pub(crate) const MAX_JSON_SAFE_PUBLISHER_ID: u64 = (1 << 53) - 1;
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
44#[serde(rename_all = "snake_case")]
45pub enum EventTransportKind {
46 Nats,
48 #[default]
50 Zmq,
51}
52
53impl EventTransportKind {
54 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 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 pub fn default_codec(&self) -> EventCodecKind {
87 match self {
88 Self::Nats => EventCodecKind::Json,
89 Self::Zmq => EventCodecKind::Msgpack,
90 }
91 }
92}
93
94#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
98#[serde(rename_all = "snake_case")]
99pub enum EventCodecKind {
100 Json,
102 Msgpack,
104}
105
106impl EventCodecKind {
107 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), Ok("") => Ok(None), 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 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#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
147#[serde(tag = "kind", content = "config")]
148pub enum EventTransport {
149 Nats {
151 subject_prefix: String,
153 },
154 Zmq {
156 endpoint: String,
158 },
159 ZmqBroker {
161 xsub_endpoints: Vec<String>,
163 xpub_endpoints: Vec<String>,
165 },
166}
167
168impl EventTransport {
169 pub fn kind(&self) -> EventTransportKind {
171 match self {
172 Self::Nats { .. } => EventTransportKind::Nats,
173 Self::Zmq { .. } | Self::ZmqBroker { .. } => EventTransportKind::Zmq,
174 }
175 }
176
177 pub fn nats(subject_prefix: impl Into<String>) -> Self {
179 Self::Nats {
180 subject_prefix: subject_prefix.into(),
181 }
182 }
183
184 pub fn zmq(endpoint: impl Into<String>) -> Self {
186 Self::Zmq {
187 endpoint: endpoint.into(),
188 }
189 }
190
191 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#[derive(Debug, Clone, PartialEq, Eq, Hash)]
207pub enum DiscoveryQuery {
208 AllEndpoints,
210 NamespacedEndpoints {
212 namespace: String,
213 },
214 ComponentEndpoints {
216 namespace: String,
217 component: String,
218 },
219 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 EventChannels(EventChannelQuery),
240 EventSources(EventSourceQuery),
242}
243
244#[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 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 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
338pub(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#[derive(Debug, Clone, PartialEq, Eq, Hash)]
380pub struct EventChannelQuery {
381 scope: Option<EventScope>,
383 topic: Option<String>,
384}
385
386impl EventChannelQuery {
387 pub fn all() -> Self {
389 Self {
390 scope: None,
391 topic: None,
392 }
393 }
394
395 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 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 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 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#[derive(Debug, Clone, PartialEq, Eq, Hash)]
468pub struct EventSourceQuery {
469 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 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#[derive(Debug, Clone, PartialEq, Eq)]
553pub enum DiscoverySpec {
554 Endpoint {
556 namespace: String,
557 component: String,
558 endpoint: String,
559 transport: TransportType,
561 device_type: Option<DeviceType>,
564 request_plane_codec: Option<RequestPlanePayloadCodec>,
567 },
568 Model {
569 namespace: String,
570 component: String,
571 endpoint: String,
572 card_json: serde_json::Value,
576 model_suffix: Option<String>,
579 },
580 EventChannel {
583 scope: EventScope,
584 topic: String,
586 publisher_id: u64,
591 transport: EventTransport,
593 },
594 EventSource {
596 scope: EventScope,
597 topic: String,
598 publisher_id: u64,
600 metadata: serde_json::Value,
602 },
603}
604
605impl DiscoverySpec {
606 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 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 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 pub fn with_instance_id(self, default_instance_id: u64) -> DiscoveryInstance {
706 self.into_instance(default_instance_id)
707 }
708}
709
710#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
713#[serde(tag = "type")]
714pub enum DiscoveryInstance {
715 Endpoint(crate::component::Instance),
717 Model {
718 namespace: String,
719 component: String,
720 endpoint: String,
721 instance_id: u64,
722 card_json: serde_json::Value,
725 #[serde(default, skip_serializing_if = "Option::is_none")]
727 model_suffix: Option<String>,
728 },
729 EventChannel {
731 scope: EventScope,
732 topic: String,
734 instance_id: u64,
735 transport: EventTransport,
737 },
738 EventSource {
740 scope: EventScope,
741 topic: String,
742 publisher_id: u64,
743 metadata: serde_json::Value,
744 },
745}
746
747pub(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 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 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 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 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#[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 pub fn to_path(&self) -> String {
937 format!(
938 "{}/{}/{}/{:x}",
939 self.namespace, self.component, self.endpoint, self.instance_id
940 )
941 }
942
943 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#[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 pub model_suffix: Option<String>,
972}
973
974#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
976pub struct EventChannelInstanceId {
977 pub scope: EventScope,
978 pub topic: String,
980 pub instance_id: u64,
981}
982
983impl EventChannelInstanceId {
984 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 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#[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 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 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 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 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#[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 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 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 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 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 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#[derive(Debug, Clone, PartialEq, Eq)]
1186pub enum DiscoveryEvent {
1187 Added(DiscoveryInstance),
1192 ModelTaintsUpdated(ModelTaintsUpdate),
1194 Removed(DiscoveryInstanceId),
1196 Resync(Vec<DiscoveryInstance>),
1203}
1204
1205#[derive(Debug, Clone, PartialEq, Eq)]
1207pub struct ModelTaintsUpdate {
1208 pub id: ModelCardInstanceId,
1209 pub taints: Vec<String>,
1210}
1211
1212pub 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 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#[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
1411pub(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
1425pub(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
1471pub(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
1514pub(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#[async_trait]
1567pub trait Discovery: Send + Sync {
1568 async fn check_connection(&self) -> Result<()> {
1572 Ok(())
1573 }
1574
1575 fn instance_id(&self) -> u64;
1580
1581 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 async fn register_internal(&self, spec: DiscoverySpec) -> Result<DiscoveryInstance>;
1641
1642 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 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 async fn unregister(&self, instance: DiscoveryInstance) -> Result<()>;
1672
1673 async fn list(&self, query: DiscoveryQuery) -> Result<Vec<DiscoveryInstance>>;
1676
1677 async fn list_and_watch(
1694 &self,
1695 query: DiscoveryQuery,
1696 cancel_token: Option<CancellationToken>,
1697 ) -> Result<DiscoveryStream>;
1698
1699 fn shutdown(&self) {}
1703}
1704
1705#[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 const QUIET_PERIOD: Duration = Duration::from_millis(200);
1717
1718 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 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 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 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}