1use std::collections::{HashMap, HashSet};
5use std::pin::Pin;
6use std::sync::Arc;
7
8use anyhow::Result;
9use async_trait::async_trait;
10use futures::{Stream, StreamExt};
11use tokio_util::sync::CancellationToken;
12
13use super::{
14 Discovery, DiscoveryEvent, DiscoveryInstance, DiscoveryInstanceId, DiscoveryQuery,
15 DiscoverySpec, DiscoveryStream, EndpointInstanceId, EventChannelInstanceId, EventScope,
16 EventSourceInstanceId, ModelCardInstanceId, classify_discovery_change, encode_event_segment,
17 model_with_updated_taints, resync_discovery_events, validate_event_source_reregistration,
18 validate_model_reregistration,
19};
20use crate::storage::kv;
21
22const INSTANCES_BUCKET: &str = "v1/instances";
23const MODELS_BUCKET: &str = "v1/mdc";
24const EVENT_CHANNELS_BUCKET: &str = "v1/event_channels";
25const EVENT_SOURCES_BUCKET: &str = "v1/event_sources";
26const UPDATE_MODEL_TAINTS_MAX_ATTEMPTS: usize = 8;
27
28async fn update_model_taints_in_bucket(
29 bucket: &dyn kv::Bucket,
30 key: &kv::Key,
31 target_id: &DiscoveryInstanceId,
32 taints: &HashSet<String>,
33) -> Result<()> {
34 for _ in 0..UPDATE_MODEL_TAINTS_MAX_ATTEMPTS {
35 let existing_json = bucket
36 .get(key)
37 .await?
38 .ok_or_else(|| kv::StoreError::MissingKey(key.to_string()))?;
39 let existing: DiscoveryInstance = serde_json::from_slice(&existing_json)?;
40 if &existing.id() != target_id {
41 anyhow::bail!(
42 "model discovery record {target_id:?} contains mismatched identity {:?}",
43 existing.id()
44 )
45 }
46 let candidate = model_with_updated_taints(&existing, taints.clone())?;
47 if candidate == existing {
48 return Ok(());
49 }
50
51 let candidate_json = serde_json::to_vec(&candidate)?.into();
52 match bucket
53 .compare_and_replace(key, existing_json, candidate_json)
54 .await
55 {
56 Ok(_) => return Ok(()),
57 Err(kv::StoreError::Retry) => continue,
58 Err(error) => return Err(error.into()),
59 }
60 }
61
62 Err(kv::StoreError::Retry.into())
63}
64
65pub struct KVStoreDiscovery {
67 store: Arc<kv::Manager>,
68 cancel_token: CancellationToken,
69}
70
71impl KVStoreDiscovery {
72 pub fn new(store: kv::Manager, cancel_token: CancellationToken) -> Self {
73 Self {
74 store: Arc::new(store),
75 cancel_token,
76 }
77 }
78
79 fn endpoint_key(instance: &crate::component::Instance) -> String {
81 instance.endpoint_instance_id().to_path()
82 }
83
84 fn model_key(namespace: &str, component: &str, endpoint: &str, instance_id: u64) -> String {
86 format!("{}/{}/{}/{:x}", namespace, component, endpoint, instance_id)
87 }
88
89 fn event_channel_key(scope: &EventScope, topic: &str, instance_id: u64) -> String {
91 format!(
92 "{}/topic/{}/{:x}",
93 scope.path_prefix(),
94 encode_event_segment(topic),
95 instance_id
96 )
97 }
98
99 fn event_source_key(scope: &EventScope, topic: &str, publisher_id: u64) -> String {
101 EventSourceInstanceId {
102 scope: scope.clone(),
103 topic: topic.to_string(),
104 publisher_id,
105 }
106 .to_path()
107 }
108
109 fn query_prefix(query: &DiscoveryQuery) -> String {
111 match query {
112 DiscoveryQuery::AllEndpoints => INSTANCES_BUCKET.to_string(),
113 DiscoveryQuery::NamespacedEndpoints { namespace } => {
114 format!("{}/{}", INSTANCES_BUCKET, namespace)
115 }
116 DiscoveryQuery::ComponentEndpoints {
117 namespace,
118 component,
119 } => {
120 format!("{}/{}/{}", INSTANCES_BUCKET, namespace, component)
121 }
122 DiscoveryQuery::Endpoint {
123 namespace,
124 component,
125 endpoint,
126 } => {
127 format!(
128 "{}/{}/{}/{}",
129 INSTANCES_BUCKET, namespace, component, endpoint
130 )
131 }
132 DiscoveryQuery::AllModels => MODELS_BUCKET.to_string(),
133 DiscoveryQuery::NamespacedModels { namespace } => {
134 format!("{}/{}", MODELS_BUCKET, namespace)
135 }
136 DiscoveryQuery::ComponentModels {
137 namespace,
138 component,
139 } => {
140 format!("{}/{}/{}", MODELS_BUCKET, namespace, component)
141 }
142 DiscoveryQuery::EndpointModels {
143 namespace,
144 component,
145 endpoint,
146 } => {
147 format!("{}/{}/{}/{}", MODELS_BUCKET, namespace, component, endpoint)
148 }
149 DiscoveryQuery::EventChannels(query) => {
150 let mut path = EVENT_CHANNELS_BUCKET.to_string();
151 if let Some(scope) = &query.scope {
152 path.push('/');
153 path.push_str(&scope.path_prefix());
154 if let Some(topic) = &query.topic {
155 path.push_str("/topic/");
156 path.push_str(&encode_event_segment(topic));
157 }
158 }
159 path
160 }
161 DiscoveryQuery::EventSources(query) => {
162 let mut path = EVENT_SOURCES_BUCKET.to_string();
163 if let Some(scope) = &query.scope {
164 path.push('/');
165 path.push_str(&scope.path_prefix());
166 if let Some(topic) = &query.topic {
167 path.push_str("/topic/");
168 path.push_str(&encode_event_segment(topic));
169 }
170 }
171 path
172 }
173 }
174 }
175
176 fn strip_bucket_prefix<'a>(key: &'a str, bucket_name: &str) -> &'a str {
180 if let Some(stripped) = key.strip_prefix(bucket_name) {
182 stripped.strip_prefix('/').unwrap_or(stripped)
184 } else {
185 key
187 }
188 }
189
190 fn matches_prefix(key_str: &str, prefix: &str, bucket_name: &str) -> bool {
193 let relative_key = Self::strip_bucket_prefix(key_str, bucket_name);
195 let relative_prefix = Self::strip_bucket_prefix(prefix, bucket_name);
196
197 if relative_prefix.is_empty() {
199 return true;
200 }
201
202 relative_key == relative_prefix
203 || relative_key
204 .strip_prefix(relative_prefix)
205 .is_some_and(|suffix| suffix.starts_with('/'))
206 }
207
208 fn bucket_for_prefix(prefix: &str) -> &'static str {
209 if prefix == INSTANCES_BUCKET
210 || prefix
211 .strip_prefix(INSTANCES_BUCKET)
212 .is_some_and(|suffix| suffix.starts_with('/'))
213 {
214 INSTANCES_BUCKET
215 } else if prefix == EVENT_CHANNELS_BUCKET
216 || prefix
217 .strip_prefix(EVENT_CHANNELS_BUCKET)
218 .is_some_and(|suffix| suffix.starts_with('/'))
219 {
220 EVENT_CHANNELS_BUCKET
221 } else if prefix == EVENT_SOURCES_BUCKET
222 || prefix
223 .strip_prefix(EVENT_SOURCES_BUCKET)
224 .is_some_and(|suffix| suffix.starts_with('/'))
225 {
226 EVENT_SOURCES_BUCKET
227 } else {
228 MODELS_BUCKET
229 }
230 }
231
232 fn parse_instance(value: &[u8]) -> Result<DiscoveryInstance> {
234 let instance: DiscoveryInstance = serde_json::from_slice(value)?;
235 Ok(instance)
236 }
237
238 fn parse_instance_id_from_key(key_str: &str, bucket_name: &str) -> Option<DiscoveryInstanceId> {
239 let relative_key = Self::strip_bucket_prefix(key_str, bucket_name);
240 let parsed = match bucket_name {
241 INSTANCES_BUCKET => {
242 EndpointInstanceId::from_path(relative_key).map(DiscoveryInstanceId::Endpoint)
243 }
244 MODELS_BUCKET => {
245 ModelCardInstanceId::from_path(relative_key).map(DiscoveryInstanceId::Model)
246 }
247 EVENT_CHANNELS_BUCKET => EventChannelInstanceId::from_path(relative_key)
248 .map(DiscoveryInstanceId::EventChannel),
249 EVENT_SOURCES_BUCKET => {
250 EventSourceInstanceId::from_path(relative_key).map(DiscoveryInstanceId::EventSource)
251 }
252 _ => {
253 tracing::warn!(
254 key = %key_str,
255 bucket = bucket_name,
256 "Unknown discovery bucket for delete/resync key"
257 );
258 return None;
259 }
260 };
261
262 parsed
263 .inspect_err(|err| {
264 tracing::warn!(
265 key = %key_str,
266 relative_key = %relative_key,
267 bucket = bucket_name,
268 error = %err,
269 "Failed to parse discovery instance id from key"
270 );
271 })
272 .ok()
273 }
274
275 fn discovery_events_from_watch_event(
276 event: kv::WatchEvent,
277 prefix: &str,
278 bucket_name: &str,
279 known_instances: &mut HashMap<DiscoveryInstanceId, DiscoveryInstance>,
280 is_established: bool,
281 ) -> Vec<DiscoveryEvent> {
282 match event {
283 kv::WatchEvent::Put(kv) => {
284 if !Self::matches_prefix(kv.key_str(), prefix, bucket_name) {
285 return vec![];
286 }
287
288 match Self::parse_instance(kv.value()) {
289 Ok(instance) => {
290 let id = instance.id();
291 match classify_discovery_change(known_instances.get(&id), &instance) {
292 Ok(Some(event)) => {
293 known_instances.insert(id, instance);
294 vec![event]
295 }
296 Ok(None) => vec![],
297 Err(error) => {
298 tracing::error!(
299 key = %kv.key_str(),
300 ?id,
301 %error,
302 "Rejecting immutable discovery model-card mutation"
303 );
304 vec![]
305 }
306 }
307 }
308 Err(e) => {
309 tracing::warn!(
310 key = %kv.key_str(),
311 error = %e,
312 "Failed to parse discovery instance from watch event"
313 );
314 vec![]
315 }
316 }
317 }
318 kv::WatchEvent::Delete(kv) => {
319 let key_str = kv.as_ref();
320 if !Self::matches_prefix(key_str, prefix, bucket_name) {
321 return vec![];
322 }
323
324 let Some(id) = Self::parse_instance_id_from_key(key_str, bucket_name) else {
325 return vec![];
326 };
327
328 known_instances.remove(&id);
329 tracing::debug!(
330 "KVStoreDiscovery::list_and_watch: Emitting Removed event for {:?}, key={}",
331 id,
332 key_str
333 );
334 vec![DiscoveryEvent::Removed(id)]
335 }
336 kv::WatchEvent::Resync(snapshot) => {
337 let mut next_instances = HashMap::<DiscoveryInstanceId, DiscoveryInstance>::new();
338
339 for (key, value) in snapshot {
340 let key_str = key.as_ref();
341 if !Self::matches_prefix(key_str, prefix, bucket_name) {
342 continue;
343 }
344
345 match Self::parse_instance(value.as_ref()) {
346 Ok(instance) => {
347 next_instances.insert(instance.id(), instance);
348 }
349 Err(e) => {
350 tracing::warn!(
351 key = %key_str,
352 error = %e,
353 "Failed to parse discovery instance from resync event"
354 );
355 if let Some(id) = Self::parse_instance_id_from_key(key_str, bucket_name)
358 && let Some(existing) = known_instances.get(&id)
359 {
360 next_instances.insert(id, existing.clone());
361 }
362 }
363 }
364 }
365
366 let old_count = known_instances.len();
367 let events = resync_discovery_events(known_instances, next_instances);
368
369 if is_established {
370 tracing::warn!(
371 prefix,
372 old_count,
373 new_count = known_instances.len(),
374 emitted_events = events.len(),
375 "KVStoreDiscovery::list_and_watch resynced discovery state"
376 );
377 } else {
378 tracing::debug!(
379 prefix,
380 count = known_instances.len(),
381 "KVStoreDiscovery::list_and_watch established from the initial snapshot"
382 );
383 }
384
385 events
386 }
387 }
388 }
389}
390
391#[async_trait]
392impl Discovery for KVStoreDiscovery {
393 async fn check_connection(&self) -> Result<()> {
394 anyhow::ensure!(
395 !self.cancel_token.is_cancelled(),
396 "discovery is shutting down"
397 );
398 self.store.check_connection().await?;
399 Ok(())
400 }
401
402 fn instance_id(&self) -> u64 {
403 self.store.connection_id()
404 }
405
406 async fn register_internal(&self, spec: DiscoverySpec) -> Result<DiscoveryInstance> {
407 let instance = spec.into_instance(self.instance_id());
408 let instance_id = instance.instance_id();
409 let is_event_source = matches!(&instance, DiscoveryInstance::EventSource { .. });
410 let is_model = matches!(&instance, DiscoveryInstance::Model { .. });
411
412 let (bucket_name, key_path) = match &instance {
413 DiscoveryInstance::Endpoint(inst) => {
414 let key = Self::endpoint_key(inst);
415 tracing::debug!(
416 "KVStoreDiscovery::register: Registering endpoint instance_id={}, namespace={}, component={}, endpoint={}, key={}",
417 inst.instance_id,
418 inst.namespace,
419 inst.component,
420 inst.endpoint,
421 key
422 );
423 (INSTANCES_BUCKET, key)
424 }
425 DiscoveryInstance::Model {
426 namespace,
427 component,
428 endpoint,
429 instance_id,
430 model_suffix,
431 ..
432 } => {
433 let mut key = Self::model_key(namespace, component, endpoint, *instance_id);
434
435 if let Some(suffix) = model_suffix
438 && !suffix.is_empty()
439 {
440 key = format!("{}/{}", key, suffix);
441 tracing::debug!(
442 "KVStoreDiscovery::register: Registering LoRA model with suffix={}, instance_id={}, namespace={}, component={}, endpoint={}, key={}",
443 suffix,
444 instance_id,
445 namespace,
446 component,
447 endpoint,
448 key
449 );
450 }
451
452 if model_suffix.as_ref().is_none_or(|s| s.is_empty()) {
454 tracing::debug!(
455 "KVStoreDiscovery::register: Registering base model instance_id={}, namespace={}, component={}, endpoint={}, key={}",
456 instance_id,
457 namespace,
458 component,
459 endpoint,
460 key
461 );
462 }
463 (MODELS_BUCKET, key)
464 }
465 DiscoveryInstance::EventChannel {
466 scope,
467 topic,
468 instance_id,
469 ..
470 } => {
471 let key = Self::event_channel_key(scope, topic, *instance_id);
472 tracing::info!(
474 "KVStoreDiscovery::register: EventChannel bucket={}, key={}",
475 EVENT_CHANNELS_BUCKET,
476 key
477 );
478 tracing::debug!(
479 "KVStoreDiscovery::register: Registering event channel instance_id={}, scope={:?}, topic={}, key={}",
480 instance_id,
481 scope,
482 topic,
483 key
484 );
485 (EVENT_CHANNELS_BUCKET, key)
486 }
487 DiscoveryInstance::EventSource {
488 scope,
489 topic,
490 publisher_id,
491 ..
492 } => {
493 let key = Self::event_source_key(scope, topic, *publisher_id);
494 tracing::debug!(
495 "KVStoreDiscovery::register: Registering event source publisher_id={}, scope={:?}, topic={}, key={}",
496 publisher_id,
497 scope,
498 topic,
499 key
500 );
501 (EVENT_SOURCES_BUCKET, key)
502 }
503 };
504
505 let instance_json = serde_json::to_vec(&instance)?;
507 tracing::debug!(
508 "KVStoreDiscovery::register: Serialized instance to {} bytes for key={}",
509 instance_json.len(),
510 key_path
511 );
512
513 tracing::debug!(
515 "KVStoreDiscovery::register: Getting/creating bucket={} for key={}",
516 bucket_name,
517 key_path
518 );
519 let bucket = self.store.get_or_create_bucket(bucket_name, None).await?;
520 let key = kv::Key::new(key_path.clone());
521
522 if is_event_source && let Some(existing) = bucket.get(&key).await? {
523 let existing: DiscoveryInstance = serde_json::from_slice(existing.as_ref())?;
524 validate_event_source_reregistration(&existing, &instance)?;
525 return Ok(existing);
526 }
527
528 tracing::debug!(
529 "KVStoreDiscovery::register: Inserting into bucket={}, key={}",
530 bucket_name,
531 key_path
532 );
533 let outcome = match bucket.insert(&key, instance_json.into(), 0).await {
535 Ok(outcome) => outcome,
536 Err(error) if is_event_source => {
537 let Some(existing) = bucket.get(&key).await? else {
538 return Err(error.into());
539 };
540 let existing: DiscoveryInstance = serde_json::from_slice(existing.as_ref())?;
541 validate_event_source_reregistration(&existing, &instance)?;
542 return Ok(existing);
543 }
544 Err(error) => return Err(error.into()),
545 };
546 tracing::debug!(
547 "KVStoreDiscovery::register: Registration insert completed instance_id={}, key={}, outcome={:?}",
548 instance_id,
549 key_path,
550 outcome
551 );
552
553 if is_model && matches!(outcome, kv::StoreOutcome::Exists(_)) {
554 let existing = bucket.get(&key).await?.ok_or_else(|| {
555 anyhow::anyhow!(
556 "model discovery record disappeared during same-ID registration replay"
557 )
558 })?;
559 let existing: DiscoveryInstance = serde_json::from_slice(existing.as_ref())?;
560 validate_model_reregistration(&existing, &instance)?;
561 return Ok(existing);
562 }
563
564 Ok(instance)
565 }
566
567 async fn update_model_taints_internal(
568 &self,
569 id: ModelCardInstanceId,
570 taints: HashSet<String>,
571 ) -> Result<()> {
572 let bucket = self
573 .store
574 .get_bucket(MODELS_BUCKET)
575 .await?
576 .ok_or_else(|| anyhow::anyhow!("model discovery bucket is not registered"))?;
577 let key = kv::Key::new(id.to_path());
578 let target_id = DiscoveryInstanceId::Model(id);
579
580 update_model_taints_in_bucket(bucket.as_ref(), &key, &target_id, &taints).await
581 }
582
583 async fn unregister(&self, instance: DiscoveryInstance) -> Result<()> {
584 let (bucket_name, key_path) = match &instance {
585 DiscoveryInstance::Endpoint(inst) => {
586 let key = Self::endpoint_key(inst);
587 tracing::debug!(
588 "Unregistering endpoint instance_id={}, namespace={}, component={}, endpoint={}, key={}",
589 inst.instance_id,
590 inst.namespace,
591 inst.component,
592 inst.endpoint,
593 key
594 );
595 (INSTANCES_BUCKET, key)
596 }
597 DiscoveryInstance::Model {
598 namespace,
599 component,
600 endpoint,
601 instance_id,
602 model_suffix,
603 ..
604 } => {
605 let mut key = Self::model_key(namespace, component, endpoint, *instance_id);
606
607 if let Some(suffix) = model_suffix
609 && !suffix.is_empty()
610 {
611 key = format!("{}/{}", key, suffix);
612 tracing::debug!(
613 "KVStoreDiscovery::unregister: Unregistering LoRA model with suffix={}, instance_id={}, namespace={}, component={}, endpoint={}, key={}",
614 suffix,
615 instance_id,
616 namespace,
617 component,
618 endpoint,
619 key
620 );
621 }
622
623 if model_suffix.as_ref().is_none_or(|s| s.is_empty()) {
625 tracing::debug!(
626 "Unregistering base model instance_id={}, namespace={}, component={}, endpoint={}, key={}",
627 instance_id,
628 namespace,
629 component,
630 endpoint,
631 key
632 );
633 }
634 (MODELS_BUCKET, key)
635 }
636 DiscoveryInstance::EventChannel {
637 scope,
638 topic,
639 instance_id,
640 ..
641 } => {
642 let key = Self::event_channel_key(scope, topic, *instance_id);
643 tracing::debug!(
644 "KVStoreDiscovery::unregister: Unregistering event channel instance_id={}, scope={:?}, topic={}, key={}",
645 instance_id,
646 scope,
647 topic,
648 key
649 );
650 (EVENT_CHANNELS_BUCKET, key)
651 }
652 DiscoveryInstance::EventSource {
653 scope,
654 topic,
655 publisher_id,
656 ..
657 } => {
658 let key = Self::event_source_key(scope, topic, *publisher_id);
659 tracing::debug!(
660 "KVStoreDiscovery::unregister: Unregistering event source publisher_id={}, scope={:?}, topic={}, key={}",
661 publisher_id,
662 scope,
663 topic,
664 key
665 );
666 (EVENT_SOURCES_BUCKET, key)
667 }
668 };
669
670 let Some(bucket) = self.store.get_bucket(bucket_name).await? else {
672 tracing::warn!(
673 "Bucket {} does not exist, instance already removed",
674 bucket_name
675 );
676 return Ok(());
677 };
678
679 let key = kv::Key::new(key_path.clone());
680
681 bucket.delete(&key).await?;
683
684 Ok(())
685 }
686
687 async fn list(&self, query: DiscoveryQuery) -> Result<Vec<DiscoveryInstance>> {
688 let prefix = Self::query_prefix(&query);
689 let bucket_name = Self::bucket_for_prefix(&prefix);
690
691 let Some(bucket) = self.store.get_bucket(bucket_name).await? else {
693 tracing::debug!(
694 "KVStoreDiscovery::list: bucket missing for query={:?}, prefix={}, bucket={}",
695 query,
696 prefix,
697 bucket_name
698 );
699 return Ok(Vec::new());
700 };
701
702 let entries = bucket.entries().await?;
704 tracing::debug!(
705 "KVStoreDiscovery::list: query={:?}, prefix={}, bucket={}, entries={}",
706 query,
707 prefix,
708 bucket_name,
709 entries.len()
710 );
711
712 let mut instances = Vec::new();
714 for (key, value) in entries {
715 if Self::matches_prefix(key.as_ref(), &prefix, bucket_name) {
716 match Self::parse_instance(&value) {
717 Ok(instance) => instances.push(instance),
718 Err(e) => {
719 tracing::warn!(%key, error = %e, "Failed to parse discovery instance");
720 }
721 }
722 }
723 }
724
725 Ok(instances)
726 }
727
728 async fn list_and_watch(
729 &self,
730 query: DiscoveryQuery,
731 cancel_token: Option<CancellationToken>,
732 ) -> Result<DiscoveryStream> {
733 let prefix = Self::query_prefix(&query);
734 let bucket_name = Self::bucket_for_prefix(&prefix);
735
736 tracing::trace!(
737 "KVStoreDiscovery::list_and_watch: Starting watch for query={:?}, prefix={}, bucket={}",
738 query,
739 prefix,
740 bucket_name
741 );
742
743 let cancel_token = cancel_token.unwrap_or_else(|| self.cancel_token.clone());
745
746 let (_, mut rx) = self
747 .store
748 .clone()
749 .watch(
750 bucket_name,
751 None, cancel_token,
753 )
754 .await?;
755
756 let stream = async_stream::stream! {
758 let mut known_instances = HashMap::<DiscoveryInstanceId, DiscoveryInstance>::new();
759 let mut is_established = false;
761
762 while let Some(event) = rx.recv().await {
763 let discovery_events = Self::discovery_events_from_watch_event(
764 event,
765 &prefix,
766 bucket_name,
767 &mut known_instances,
768 is_established,
769 );
770 is_established = true;
771
772 for event in discovery_events {
773 yield Ok(event);
774 }
775 }
776 };
777 Ok(Box::pin(stream))
778 }
779
780 fn shutdown(&self) {
781 self.store.shutdown();
782 }
783}
784
785#[cfg(test)]
786mod tests {
787 use super::*;
788 use crate::component::TransportType;
789 use crate::discovery::startup_contract as contract;
790 use crate::discovery::{
791 EventChannelQuery, EventSourceQuery, EventTransport, ModelTaintsUpdate,
792 };
793 use crate::protocols::EndpointId;
794 use std::collections::HashSet;
795 use std::sync::atomic::{AtomicUsize, Ordering};
796
797 fn endpoint_instance(instance_id: u64) -> DiscoveryInstance {
798 DiscoveryInstance::Endpoint(crate::component::Instance {
799 namespace: "ns".to_string(),
800 component: "component".to_string(),
801 endpoint: "endpoint".to_string(),
802 instance_id,
803 transport: TransportType::Nats("nats://127.0.0.1:4222".to_string()),
804 device_type: None,
805 request_plane_codec: None,
806 })
807 }
808
809 fn endpoint_kv(instance_id: u64) -> kv::KeyValue {
810 let instance = endpoint_instance(instance_id);
811 kv::KeyValue::new(
812 kv::Key::new(format!(
813 "{}/{}/{}/{:x}",
814 "ns", "component", "endpoint", instance_id
815 )),
816 serde_json::to_vec(&instance).unwrap().into(),
817 )
818 }
819
820 fn resync_ids(event: &DiscoveryEvent) -> HashSet<DiscoveryInstanceId> {
821 let DiscoveryEvent::Resync(instances) = event else {
822 panic!("expected a resync event, got {event:?}");
823 };
824 instances.iter().map(DiscoveryInstance::id).collect()
825 }
826
827 #[test]
828 fn test_resync_removes_missing_discovery_instances() {
829 let prefix = format!("{}/{}/{}", INSTANCES_BUCKET, "ns", "component");
830 let mut known_instances = HashMap::new();
831
832 let first = endpoint_instance(1);
833 let second = endpoint_instance(2);
834 let third = endpoint_instance(3);
835 known_instances.insert(first.id(), first);
836 known_instances.insert(second.id(), second.clone());
837
838 let mut snapshot = HashMap::new();
839 let second_kv = endpoint_kv(2);
840 snapshot.insert(
841 kv::Key::new(second_kv.key()),
842 second_kv.value().to_vec().into(),
843 );
844 let third_kv = endpoint_kv(3);
845 snapshot.insert(
846 kv::Key::new(third_kv.key()),
847 third_kv.value().to_vec().into(),
848 );
849
850 let events = KVStoreDiscovery::discovery_events_from_watch_event(
851 kv::WatchEvent::Resync(snapshot),
852 &prefix,
853 INSTANCES_BUCKET,
854 &mut known_instances,
855 true,
856 );
857
858 assert!(!events.contains(&DiscoveryEvent::Added(second)));
859 assert_eq!(events.len(), 3);
860 assert_eq!(
861 events[..2],
862 [
863 DiscoveryEvent::Removed(endpoint_instance(1).id()),
864 DiscoveryEvent::Added(third),
865 ]
866 );
867 assert_eq!(
868 resync_ids(&events[2]),
869 HashSet::from([endpoint_instance(2).id(), endpoint_instance(3).id()])
870 );
871 assert_eq!(known_instances.len(), 2);
872 assert!(known_instances.contains_key(&endpoint_instance(2).id()));
873 assert!(known_instances.contains_key(&endpoint_instance(3).id()));
874 }
875
876 #[test]
877 fn test_resync_retains_known_instance_on_parse_failure() {
878 let prefix = format!("{}/{}/{}", INSTANCES_BUCKET, "ns", "component");
879 let mut known_instances = HashMap::new();
880
881 let first = endpoint_instance(1);
882 known_instances.insert(first.id(), first.clone());
883
884 let mut snapshot = HashMap::new();
885 snapshot.insert(
886 kv::Key::new(format!("ns/component/endpoint/{:x}", 1)),
887 bytes::Bytes::from_static(b"not json"),
888 );
889
890 let events = KVStoreDiscovery::discovery_events_from_watch_event(
891 kv::WatchEvent::Resync(snapshot),
892 &prefix,
893 INSTANCES_BUCKET,
894 &mut known_instances,
895 true,
896 );
897
898 assert_eq!(events.len(), 1);
899 assert_eq!(resync_ids(&events[0]), HashSet::from([first.id()]));
900 assert_eq!(known_instances.len(), 1);
901 assert_eq!(known_instances.get(&first.id()), Some(&first));
902 }
903
904 #[test]
905 fn resync_changed_model_taints_emits_scoped_event() {
906 let prefix = format!("{}/{}/{}/{}", MODELS_BUCKET, "ns", "worker", "generate");
907 let old = model_spec("first").into_instance(7);
908 let updated = model_spec("second").into_instance(7);
909 let DiscoveryInstanceId::Model(id) = updated.id() else {
910 unreachable!()
911 };
912 let mut known_instances = HashMap::from([(old.id(), old)]);
913 let snapshot = HashMap::from([(
914 kv::Key::new(id.to_path()),
915 serde_json::to_vec(&updated).unwrap().into(),
916 )]);
917
918 let events = KVStoreDiscovery::discovery_events_from_watch_event(
919 kv::WatchEvent::Resync(snapshot),
920 &prefix,
921 MODELS_BUCKET,
922 &mut known_instances,
923 true,
924 );
925
926 assert_eq!(events.len(), 2);
927 assert_eq!(
928 events[0],
929 DiscoveryEvent::ModelTaintsUpdated(ModelTaintsUpdate {
930 id: id.clone(),
931 taints: vec![
932 "dynamo.topology/zone=west".to_string(),
933 "second".to_string(),
934 ],
935 })
936 );
937 assert_eq!(
938 resync_ids(&events[1]),
939 HashSet::from([DiscoveryInstanceId::Model(id.clone())])
940 );
941 assert_eq!(
942 known_instances.get(&DiscoveryInstanceId::Model(id)),
943 Some(&updated)
944 );
945 }
946
947 #[test]
948 fn test_matches_prefix_requires_path_boundary() {
949 let prefix = format!("{}/{}/{}", INSTANCES_BUCKET, "ns", "component");
950
951 assert!(KVStoreDiscovery::matches_prefix(
952 "ns/component/endpoint/1",
953 &prefix,
954 INSTANCES_BUCKET
955 ));
956 assert!(KVStoreDiscovery::matches_prefix(
957 "ns/component",
958 &prefix,
959 INSTANCES_BUCKET
960 ));
961 assert!(!KVStoreDiscovery::matches_prefix(
962 "ns/component2/endpoint/1",
963 &prefix,
964 INSTANCES_BUCKET
965 ));
966 }
967
968 #[test]
969 fn test_bucket_for_prefix_requires_path_boundary() {
970 assert_eq!(
971 KVStoreDiscovery::bucket_for_prefix("v1/instances/ns/component"),
972 INSTANCES_BUCKET
973 );
974 assert_eq!(
975 KVStoreDiscovery::bucket_for_prefix("v1/event_channels/ns/component/topic"),
976 EVENT_CHANNELS_BUCKET
977 );
978 assert_eq!(
979 KVStoreDiscovery::bucket_for_prefix("v1/event_sources/ns/component/topic"),
980 EVENT_SOURCES_BUCKET
981 );
982 assert_eq!(
983 KVStoreDiscovery::bucket_for_prefix("v1/instances2/ns/component"),
984 MODELS_BUCKET
985 );
986 }
987
988 #[tokio::test]
989 async fn event_channel_keys_and_queries_preserve_exact_endpoint_scope() {
990 let store = kv::Manager::memory();
991 let client = KVStoreDiscovery::new(store, CancellationToken::new());
992 let endpoint_a = EndpointId {
993 namespace: "ns/one".to_string(),
994 component: "worker.component".to_string(),
995 name: "a/*".to_string(),
996 };
997 let endpoint_b = EndpointId {
998 name: "b/>".to_string(),
999 ..endpoint_a.clone()
1000 };
1001
1002 for (publisher_id, endpoint) in [(1, endpoint_a.clone()), (2, endpoint_b.clone())] {
1003 client
1004 .register(DiscoverySpec::EventChannel {
1005 scope: EventScope::Endpoint { endpoint },
1006 topic: "kv/events".to_string(),
1007 publisher_id,
1008 transport: EventTransport::zmq(format!(
1009 "tcp://127.0.0.1:{}",
1010 5000 + publisher_id
1011 )),
1012 })
1013 .await
1014 .unwrap();
1015 }
1016
1017 let mut a = client
1018 .list(DiscoveryQuery::EventChannels(
1019 EventChannelQuery::endpoint_topic(endpoint_a.clone(), "kv/events"),
1020 ))
1021 .await
1022 .unwrap();
1023 assert_eq!(a.len(), 1);
1024 assert_eq!(a[0].instance_id(), 1);
1025 client.unregister(a.pop().unwrap()).await.unwrap();
1026 assert!(
1027 client
1028 .list(DiscoveryQuery::EventChannels(
1029 EventChannelQuery::endpoint_topic(endpoint_a, "kv/events"),
1030 ))
1031 .await
1032 .unwrap()
1033 .is_empty()
1034 );
1035
1036 let b = client
1037 .list(DiscoveryQuery::EventChannels(
1038 EventChannelQuery::endpoint_topic(endpoint_b, "kv/events"),
1039 ))
1040 .await
1041 .unwrap();
1042 assert_eq!(b.len(), 1);
1043 assert_eq!(b[0].instance_id(), 2);
1044 }
1045
1046 async fn assert_event_source_lifecycle(store: kv::Manager) {
1047 let client = KVStoreDiscovery::new(store, CancellationToken::new());
1048 let endpoint = EndpointId {
1049 namespace: "ns/one".to_string(),
1050 component: "worker.component".to_string(),
1051 name: "decode/*".to_string(),
1052 };
1053 let query = DiscoveryQuery::EventSources(EventSourceQuery::endpoint_topic(
1054 endpoint.clone(),
1055 "kv/events",
1056 ));
1057 let spec = |publisher_id, worker_id| DiscoverySpec::EventSource {
1058 scope: EventScope::Endpoint {
1059 endpoint: endpoint.clone(),
1060 },
1061 topic: "kv/events".to_string(),
1062 publisher_id,
1063 metadata: serde_json::json!({"worker_id": worker_id, "dp_rank": 0}),
1064 };
1065
1066 let first = client.register(spec(100, 7)).await.unwrap();
1067 assert_eq!(client.register(spec(100, 7)).await.unwrap(), first);
1068 assert!(client.register(spec(100, 8)).await.is_err());
1069 assert_eq!(
1070 client.list(query.clone()).await.unwrap(),
1071 vec![first.clone()]
1072 );
1073
1074 let second = client.register(spec(205, 7)).await.unwrap();
1075 assert_eq!(client.list(query.clone()).await.unwrap().len(), 2);
1076
1077 client.unregister(first).await.unwrap();
1078 assert_eq!(client.list(query).await.unwrap(), vec![second]);
1079 }
1080
1081 #[tokio::test]
1082 async fn event_source_lifecycle_round_trips_through_memory_kv_discovery() {
1083 assert_event_source_lifecycle(kv::Manager::memory()).await;
1084 }
1085
1086 #[tokio::test]
1087 async fn event_source_lifecycle_round_trips_through_file_kv_discovery() {
1088 let tempdir = tempfile::tempdir().unwrap();
1089 let store_cancel = CancellationToken::new();
1090 let store = kv::Manager::file(store_cancel.clone(), tempdir.path());
1091 assert_event_source_lifecycle(store).await;
1092 store_cancel.cancel();
1093 }
1094
1095 #[tokio::test]
1096 async fn event_source_watch_removes_exact_publisher_incarnation() {
1097 let client = KVStoreDiscovery::new(kv::Manager::memory(), CancellationToken::new());
1098 let endpoint = EndpointId {
1099 namespace: "ns".to_string(),
1100 component: "worker".to_string(),
1101 name: "decode".to_string(),
1102 };
1103 let query = DiscoveryQuery::EventSources(EventSourceQuery::endpoint_topic(
1104 endpoint.clone(),
1105 "kv-events",
1106 ));
1107 let mut stream = client.list_and_watch(query, None).await.unwrap();
1108 contract::expect_empty_snapshot(&mut stream).await;
1109 let spec = |publisher_id| DiscoverySpec::EventSource {
1110 scope: EventScope::Endpoint {
1111 endpoint: endpoint.clone(),
1112 },
1113 topic: "kv-events".to_string(),
1114 publisher_id,
1115 metadata: serde_json::json!({"dp_rank": 0}),
1116 };
1117
1118 let first = client.register(spec(100)).await.unwrap();
1119 let second = client.register(spec(205)).await.unwrap();
1120 let mut added = std::collections::HashSet::new();
1121 for _ in 0..2 {
1122 let DiscoveryEvent::Added(instance) = stream.next().await.unwrap().unwrap() else {
1123 panic!("expected source addition");
1124 };
1125 added.insert(instance.id());
1126 }
1127 assert_eq!(
1128 added,
1129 std::collections::HashSet::from([first.id(), second.id()])
1130 );
1131
1132 client.unregister(first).await.unwrap();
1133 let removed = tokio::time::timeout(tokio::time::Duration::from_secs(1), async {
1134 loop {
1135 if let DiscoveryEvent::Removed(id) = stream.next().await.unwrap().unwrap() {
1136 break id;
1137 }
1138 }
1139 })
1140 .await
1141 .unwrap();
1142 assert_eq!(
1143 removed,
1144 DiscoveryInstanceId::EventSource(EventSourceInstanceId {
1145 scope: EventScope::Endpoint { endpoint },
1146 topic: "kv-events".to_string(),
1147 publisher_id: 100,
1148 })
1149 );
1150 assert_eq!(
1151 client
1152 .list(DiscoveryQuery::EventSources(EventSourceQuery::all()))
1153 .await
1154 .unwrap(),
1155 vec![second]
1156 );
1157 }
1158
1159 #[tokio::test]
1160 async fn test_kv_store_discovery_list() {
1161 let store = kv::Manager::memory();
1162 let cancel_token = CancellationToken::new();
1163 let client = KVStoreDiscovery::new(store, cancel_token);
1164
1165 let spec1 = DiscoverySpec::Endpoint {
1167 namespace: "ns1".to_string(),
1168 component: "comp1".to_string(),
1169 endpoint: "ep1".to_string(),
1170 device_type: None,
1171 request_plane_codec: None,
1172 transport: TransportType::Nats("nats://localhost:4222".to_string()),
1173 };
1174 client.register(spec1).await.unwrap();
1175
1176 let spec2 = DiscoverySpec::Endpoint {
1177 namespace: "ns1".to_string(),
1178 component: "comp1".to_string(),
1179 device_type: None,
1180 request_plane_codec: None,
1181 endpoint: "ep2".to_string(),
1182 transport: TransportType::Nats("nats://localhost:4222".to_string()),
1183 };
1184 client.register(spec2).await.unwrap();
1185
1186 let spec3 = DiscoverySpec::Endpoint {
1187 namespace: "ns2".to_string(),
1188 device_type: None,
1189 request_plane_codec: None,
1190 component: "comp2".to_string(),
1191 endpoint: "ep1".to_string(),
1192 transport: TransportType::Nats("nats://localhost:4222".to_string()),
1193 };
1194 client.register(spec3).await.unwrap();
1195
1196 let all = client.list(DiscoveryQuery::AllEndpoints).await.unwrap();
1198 assert_eq!(all.len(), 3);
1199
1200 let ns1 = client
1202 .list(DiscoveryQuery::NamespacedEndpoints {
1203 namespace: "ns1".to_string(),
1204 })
1205 .await
1206 .unwrap();
1207 assert_eq!(ns1.len(), 2);
1208
1209 let comp1 = client
1211 .list(DiscoveryQuery::ComponentEndpoints {
1212 namespace: "ns1".to_string(),
1213 component: "comp1".to_string(),
1214 })
1215 .await
1216 .unwrap();
1217 assert_eq!(comp1.len(), 2);
1218 }
1219
1220 #[tokio::test]
1221 async fn test_kv_store_discovery_watch() {
1222 let store = kv::Manager::memory();
1223 let cancel_token = CancellationToken::new();
1224 let client = Arc::new(KVStoreDiscovery::new(store, cancel_token.clone()));
1225
1226 let mut stream = client
1228 .list_and_watch(DiscoveryQuery::AllEndpoints, None)
1229 .await
1230 .unwrap();
1231 contract::expect_empty_snapshot(&mut stream).await;
1232
1233 let client_clone = client.clone();
1234 let register_task = tokio::spawn(async move {
1235 tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
1236
1237 let spec = DiscoverySpec::Endpoint {
1238 device_type: None,
1239 request_plane_codec: None,
1240 namespace: "test".to_string(),
1241 component: "comp1".to_string(),
1242 endpoint: "ep1".to_string(),
1243 transport: TransportType::Nats("nats://localhost:4222".to_string()),
1244 };
1245 client_clone.register(spec).await.unwrap();
1246 });
1247
1248 let event = stream.next().await.unwrap().unwrap();
1250 match event {
1251 DiscoveryEvent::Added(instance) => match instance {
1252 DiscoveryInstance::Endpoint(inst) => {
1253 assert_eq!(inst.namespace, "test");
1254 assert_eq!(inst.component, "comp1");
1255 assert_eq!(inst.endpoint, "ep1");
1256 }
1257 _ => panic!("Expected Endpoint instance"),
1258 },
1259 _ => panic!("Expected Added event"),
1260 }
1261
1262 register_task.await.unwrap();
1263 cancel_token.cancel();
1264 }
1265
1266 #[tokio::test]
1267 async fn watch_reports_an_unregister_that_follows_establishment() {
1268 let client = KVStoreDiscovery::new(kv::Manager::memory(), CancellationToken::new());
1269 let instance = client
1270 .register(DiscoverySpec::Endpoint {
1271 namespace: "ns".to_string(),
1272 component: "comp".to_string(),
1273 endpoint: "ep".to_string(),
1274 device_type: None,
1275 request_plane_codec: None,
1276 transport: TransportType::Nats("nats://127.0.0.1:4222".to_string()),
1277 })
1278 .await
1279 .unwrap();
1280
1281 let mut stream = client
1282 .list_and_watch(DiscoveryQuery::AllEndpoints, None)
1283 .await
1284 .unwrap();
1285 client.unregister(instance.clone()).await.unwrap();
1286
1287 let added = tokio::time::timeout(tokio::time::Duration::from_secs(1), stream.next())
1288 .await
1289 .unwrap()
1290 .unwrap()
1291 .unwrap();
1292 assert_eq!(added, DiscoveryEvent::Added(instance.clone()));
1293 assert_eq!(
1294 contract::next(&mut stream).await,
1295 DiscoveryEvent::Resync(vec![instance.clone()])
1296 );
1297
1298 let removed = tokio::time::timeout(tokio::time::Duration::from_secs(1), stream.next())
1299 .await
1300 .unwrap()
1301 .unwrap()
1302 .unwrap();
1303 assert_eq!(
1304 removed,
1305 DiscoveryEvent::Removed(instance.id()),
1306 "an unregister after establishment must reach the stream"
1307 );
1308 }
1309
1310 #[tokio::test]
1311 async fn watch_reports_a_resync_after_the_backend_lags() {
1312 use crate::storage::kv::Bucket;
1313
1314 let store = kv::Manager::memory();
1315 let client = KVStoreDiscovery::new(store.clone(), CancellationToken::new());
1316 let mut stream = client
1317 .list_and_watch(DiscoveryQuery::AllEndpoints, None)
1318 .await
1319 .unwrap();
1320 contract::expect_empty_snapshot(&mut stream).await;
1322
1323 let instance = client
1324 .register(DiscoverySpec::Endpoint {
1325 namespace: "ns".to_string(),
1326 component: "comp".to_string(),
1327 endpoint: "ep".to_string(),
1328 device_type: None,
1329 request_plane_codec: None,
1330 transport: TransportType::Nats("nats://127.0.0.1:4222".to_string()),
1331 })
1332 .await
1333 .unwrap();
1334 assert_eq!(
1335 client.list(DiscoveryQuery::AllEndpoints).await.unwrap(),
1336 vec![instance.clone()]
1337 );
1338 client.unregister(instance).await.unwrap();
1339
1340 let filler = store.get_or_create_bucket("filler", None).await.unwrap();
1344 let key = kv::Key::new("key".to_string());
1345 for revision in 1..=(kv::MEMORY_EVENT_BUFFER_CAPACITY + 1) as u64 {
1346 filler.insert(&key, "value".into(), revision).await.unwrap();
1347 }
1348
1349 let event = tokio::time::timeout(tokio::time::Duration::from_secs(1), stream.next())
1350 .await
1351 .expect("the stream must report the resync")
1352 .unwrap()
1353 .unwrap();
1354 assert_eq!(event, DiscoveryEvent::Resync(vec![]));
1355 }
1356
1357 #[tokio::test]
1358 async fn memory_backend_keeps_the_startup_contract() {
1359 let client = KVStoreDiscovery::new(kv::Manager::memory(), CancellationToken::new());
1360 contract::check(&client).await;
1361 }
1362
1363 #[tokio::test]
1364 async fn file_backend_keeps_the_startup_contract() {
1365 let cancel_token = CancellationToken::new();
1366 let root = tempfile::tempdir().unwrap();
1367 let store = kv::Manager::file(cancel_token.clone(), root.path());
1368 let client = KVStoreDiscovery::new(store, cancel_token.clone());
1369 contract::check(&client).await;
1370 cancel_token.cancel();
1371 }
1372
1373 fn model_spec(taint: &str) -> DiscoverySpec {
1374 DiscoverySpec::Model {
1375 namespace: "ns".to_string(),
1376 component: "worker".to_string(),
1377 endpoint: "generate".to_string(),
1378 card_json: serde_json::json!({
1379 "display_name": "model",
1380 "runtime_config": {
1381 "taints": [taint, "dynamo.topology/zone=west"],
1382 "topology_domains": {"zone": "west"}
1383 }
1384 }),
1385 model_suffix: None,
1386 }
1387 }
1388
1389 struct AlwaysConflictingBucket {
1390 value: bytes::Bytes,
1391 compare_attempts: AtomicUsize,
1392 }
1393
1394 #[async_trait]
1395 impl kv::Bucket for AlwaysConflictingBucket {
1396 async fn insert(
1397 &self,
1398 _key: &kv::Key,
1399 _value: bytes::Bytes,
1400 _revision: u64,
1401 ) -> Result<kv::StoreOutcome, kv::StoreError> {
1402 unreachable!("insert is not used by this test")
1403 }
1404
1405 async fn get(&self, _key: &kv::Key) -> Result<Option<bytes::Bytes>, kv::StoreError> {
1406 Ok(Some(self.value.clone()))
1407 }
1408
1409 async fn compare_and_replace(
1410 &self,
1411 _key: &kv::Key,
1412 _expected: bytes::Bytes,
1413 _value: bytes::Bytes,
1414 ) -> Result<kv::StoreOutcome, kv::StoreError> {
1415 self.compare_attempts.fetch_add(1, Ordering::Relaxed);
1416 Err(kv::StoreError::Retry)
1417 }
1418
1419 async fn delete(&self, _key: &kv::Key) -> Result<(), kv::StoreError> {
1420 unreachable!("delete is not used by this test")
1421 }
1422
1423 async fn watch(
1424 &self,
1425 ) -> Result<Pin<Box<dyn futures::Stream<Item = kv::WatchEvent> + Send + '_>>, kv::StoreError>
1426 {
1427 unreachable!("watch is not used by this test")
1428 }
1429
1430 async fn entries(&self) -> Result<HashMap<kv::Key, bytes::Bytes>, kv::StoreError> {
1431 unreachable!("entries is not used by this test")
1432 }
1433 }
1434
1435 #[tokio::test]
1436 async fn model_taint_update_stops_after_bounded_conflicts() {
1437 let existing = model_spec("first").into_instance(7);
1438 let target_id = existing.id();
1439 let DiscoveryInstanceId::Model(id) = &target_id else {
1440 unreachable!()
1441 };
1442 let key = kv::Key::new(id.to_path());
1443 let bucket = AlwaysConflictingBucket {
1444 value: serde_json::to_vec(&existing).unwrap().into(),
1445 compare_attempts: AtomicUsize::new(0),
1446 };
1447
1448 let error = update_model_taints_in_bucket(
1449 &bucket,
1450 &key,
1451 &target_id,
1452 &HashSet::from(["second".to_string()]),
1453 )
1454 .await
1455 .unwrap_err();
1456
1457 assert!(matches!(
1458 error.downcast_ref::<kv::StoreError>(),
1459 Some(kv::StoreError::Retry)
1460 ));
1461 assert_eq!(
1462 bucket.compare_attempts.load(Ordering::Relaxed),
1463 UPDATE_MODEL_TAINTS_MAX_ATTEMPTS
1464 );
1465 }
1466
1467 #[tokio::test]
1468 async fn model_taint_updates_replace_existing_value_and_emit_scoped_event() {
1469 let client = KVStoreDiscovery::new(kv::Manager::memory(), CancellationToken::new());
1470 let query = DiscoveryQuery::EndpointModels {
1471 namespace: "ns".to_string(),
1472 component: "worker".to_string(),
1473 endpoint: "generate".to_string(),
1474 };
1475 let mut stream = client.list_and_watch(query.clone(), None).await.unwrap();
1476 contract::expect_empty_snapshot(&mut stream).await;
1477
1478 client.register(model_spec("first")).await.unwrap();
1479 let DiscoveryEvent::Added(first) = stream.next().await.unwrap().unwrap() else {
1480 panic!("expected initial model addition");
1481 };
1482
1483 let DiscoveryInstanceId::Model(id) = first.id() else {
1484 unreachable!()
1485 };
1486 for taint in ["second", "third"] {
1487 client
1488 .update_model_taints(id.clone(), HashSet::from([taint.to_string()]))
1489 .await
1490 .unwrap();
1491 let event = tokio::time::timeout(tokio::time::Duration::from_secs(1), stream.next())
1492 .await
1493 .unwrap()
1494 .unwrap()
1495 .unwrap();
1496 assert_eq!(
1497 event,
1498 DiscoveryEvent::ModelTaintsUpdated(ModelTaintsUpdate {
1499 id: id.clone(),
1500 taints: vec!["dynamo.topology/zone=west".to_string(), taint.to_string(),],
1501 })
1502 );
1503 }
1504
1505 let replayed = client.register(model_spec("first")).await.unwrap();
1506 let DiscoveryInstance::Model { card_json, .. } = &replayed else {
1507 panic!("expected model instance");
1508 };
1509 let replayed_taints = card_json["runtime_config"]["taints"].as_array().unwrap();
1510 assert!(replayed_taints.contains(&serde_json::json!("third")));
1511 assert!(!replayed_taints.contains(&serde_json::json!("first")));
1512 assert!(
1513 tokio::time::timeout(tokio::time::Duration::from_millis(50), stream.next())
1514 .await
1515 .is_err()
1516 );
1517
1518 client
1519 .update_model_taints(id, HashSet::from(["third".to_string()]))
1520 .await
1521 .unwrap();
1522 assert!(
1523 tokio::time::timeout(tokio::time::Duration::from_millis(50), stream.next())
1524 .await
1525 .is_err()
1526 );
1527
1528 let listed = client.list(query).await.unwrap();
1529 assert_eq!(listed.len(), 1);
1530 assert_eq!(listed[0].id(), first.id());
1531 let DiscoveryInstance::Model { card_json, .. } = &listed[0] else {
1532 panic!("expected model instance");
1533 };
1534 let taints = card_json["runtime_config"]["taints"].as_array().unwrap();
1535 assert!(taints.contains(&serde_json::json!("third")));
1536 assert!(!taints.contains(&serde_json::json!("second")));
1537 }
1538}