Skip to main content

dynamo_runtime/discovery/
kv_store.rs

1// SPDX-FileCopyrightText: Copyright (c) 2024-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4use 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
65/// Discovery implementation backed by a kv::Store
66pub 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    /// Build the key path for an endpoint (relative to bucket, not absolute)
80    fn endpoint_key(instance: &crate::component::Instance) -> String {
81        instance.endpoint_instance_id().to_path()
82    }
83
84    /// Build the key path for a model (relative to bucket, not absolute)
85    fn model_key(namespace: &str, component: &str, endpoint: &str, instance_id: u64) -> String {
86        format!("{}/{}/{}/{:x}", namespace, component, endpoint, instance_id)
87    }
88
89    /// Build the key path for an event channel relative to bucket, not absolute)
90    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    /// Build the key path for an event source relative to its bucket.
100    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    /// Extract prefix for querying based on discovery query
110    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    /// Strip bucket prefix from a key if present, returning the relative path within the bucket
177    /// For example: "v1/instances/ns/comp/ep" -> "ns/comp/ep"
178    /// Or if already relative: "ns/comp/ep" -> "ns/comp/ep"
179    fn strip_bucket_prefix<'a>(key: &'a str, bucket_name: &str) -> &'a str {
180        // Try to strip "bucket_name/" from the beginning
181        if let Some(stripped) = key.strip_prefix(bucket_name) {
182            // Strip the leading slash if present
183            stripped.strip_prefix('/').unwrap_or(stripped)
184        } else {
185            // Key is already relative to bucket
186            key
187        }
188    }
189
190    /// Check if a key matches the given prefix, handling both absolute and relative key formats
191    /// This works regardless of whether keys include the bucket prefix (etcd) or not (memory)
192    fn matches_prefix(key_str: &str, prefix: &str, bucket_name: &str) -> bool {
193        // Normalize both the key and prefix to relative paths (without bucket prefix)
194        let relative_key = Self::strip_bucket_prefix(key_str, bucket_name);
195        let relative_prefix = Self::strip_bucket_prefix(prefix, bucket_name);
196
197        // Empty prefix matches everything in the bucket
198        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    /// Parse and deserialize a discovery instance from KV store entry
233    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                            // The key is still present in the authoritative snapshot; keep
356                            // the previous value if only this local parse failed.
357                            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 there's a model_suffix (e.g., for LoRA adapters), append it after the instance_id
436                // Key format: {namespace}/{component}/{endpoint}/{instance_id:x}/{model_suffix}
437                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                // Log for base models (no suffix or empty suffix)
453                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                // TODO: bis - remove this info log
473                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        // Serialize the instance
506        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        // Store in the KV store with no TTL (instances persist until explicitly removed)
514        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        // Use revision 0 for initial registration
534        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 there's a model_suffix (e.g., for LoRA adapters), append it after the instance_id
608                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                // Log for base models (no suffix or empty suffix)
624                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        // Get the bucket - if it doesn't exist, the instance is already removed from the KV store
671        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        // Delete the entry from the bucket
682        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        // Get bucket - if it doesn't exist, return empty list
692        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        // Get all entries from the bucket
703        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        // Filter by prefix and deserialize
713        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        // Use the provided cancellation token, or fall back to the default token
744        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, // No TTL
752                cancel_token,
753            )
754            .await?;
755
756        // Create a stream that filters and transforms WatchEvents to DiscoveryEvents
757        let stream = async_stream::stream! {
758            let mut known_instances = HashMap::<DiscoveryInstanceId, DiscoveryInstance>::new();
759            // The first storage event is the establishment snapshot.
760            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        // Register multiple endpoints
1166        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        // List all endpoints
1197        let all = client.list(DiscoveryQuery::AllEndpoints).await.unwrap();
1198        assert_eq!(all.len(), 3);
1199
1200        // List namespaced endpoints
1201        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        // List component endpoints
1210        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        // Start watching before registering
1227        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        // Wait for the added event
1249        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        // Consume the startup snapshot so the resync asserted below can only come from the lag.
1321        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        // The memory store has one change buffer for all buckets. These writes push the register
1341        // and the unregister out of the buffer before the watch task reads them: on the
1342        // current-thread test runtime that task cannot run before this test yields.
1343        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}