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, reconcile_discovery_snapshot, 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    ) -> Vec<DiscoveryEvent> {
281        match event {
282            kv::WatchEvent::Put(kv) => {
283                if !Self::matches_prefix(kv.key_str(), prefix, bucket_name) {
284                    return vec![];
285                }
286
287                match Self::parse_instance(kv.value()) {
288                    Ok(instance) => {
289                        let id = instance.id();
290                        match classify_discovery_change(known_instances.get(&id), &instance) {
291                            Ok(Some(event)) => {
292                                known_instances.insert(id, instance);
293                                vec![event]
294                            }
295                            Ok(None) => vec![],
296                            Err(error) => {
297                                tracing::error!(
298                                    key = %kv.key_str(),
299                                    ?id,
300                                    %error,
301                                    "Rejecting immutable discovery model-card mutation"
302                                );
303                                vec![]
304                            }
305                        }
306                    }
307                    Err(e) => {
308                        tracing::warn!(
309                            key = %kv.key_str(),
310                            error = %e,
311                            "Failed to parse discovery instance from watch event"
312                        );
313                        vec![]
314                    }
315                }
316            }
317            kv::WatchEvent::Delete(kv) => {
318                let key_str = kv.as_ref();
319                if !Self::matches_prefix(key_str, prefix, bucket_name) {
320                    return vec![];
321                }
322
323                let Some(id) = Self::parse_instance_id_from_key(key_str, bucket_name) else {
324                    return vec![];
325                };
326
327                known_instances.remove(&id);
328                tracing::debug!(
329                    "KVStoreDiscovery::list_and_watch: Emitting Removed event for {:?}, key={}",
330                    id,
331                    key_str
332                );
333                vec![DiscoveryEvent::Removed(id)]
334            }
335            kv::WatchEvent::Resync(snapshot) => {
336                let mut next_instances = HashMap::<DiscoveryInstanceId, DiscoveryInstance>::new();
337
338                for (key, value) in snapshot {
339                    let key_str = key.as_ref();
340                    if !Self::matches_prefix(key_str, prefix, bucket_name) {
341                        continue;
342                    }
343
344                    match Self::parse_instance(value.as_ref()) {
345                        Ok(instance) => {
346                            next_instances.insert(instance.id(), instance);
347                        }
348                        Err(e) => {
349                            tracing::warn!(
350                                key = %key_str,
351                                error = %e,
352                                "Failed to parse discovery instance from resync event"
353                            );
354                            // The key is still present in the authoritative snapshot; keep
355                            // the previous value if only this local parse failed.
356                            if let Some(id) = Self::parse_instance_id_from_key(key_str, bucket_name)
357                                && let Some(existing) = known_instances.get(&id)
358                            {
359                                next_instances.insert(id, existing.clone());
360                            }
361                        }
362                    }
363                }
364
365                let (events, reconciled) =
366                    reconcile_discovery_snapshot(known_instances, next_instances);
367
368                tracing::warn!(
369                    old_count = known_instances.len(),
370                    new_count = reconciled.len(),
371                    emitted_events = events.len(),
372                    "KVStoreDiscovery::list_and_watch resynced discovery state"
373                );
374
375                *known_instances = reconciled;
376                events
377            }
378        }
379    }
380}
381
382#[async_trait]
383impl Discovery for KVStoreDiscovery {
384    fn instance_id(&self) -> u64 {
385        self.store.connection_id()
386    }
387
388    async fn register_internal(&self, spec: DiscoverySpec) -> Result<DiscoveryInstance> {
389        let instance = spec.into_instance(self.instance_id());
390        let instance_id = instance.instance_id();
391        let is_event_source = matches!(&instance, DiscoveryInstance::EventSource { .. });
392        let is_model = matches!(&instance, DiscoveryInstance::Model { .. });
393
394        let (bucket_name, key_path) = match &instance {
395            DiscoveryInstance::Endpoint(inst) => {
396                let key = Self::endpoint_key(inst);
397                tracing::debug!(
398                    "KVStoreDiscovery::register: Registering endpoint instance_id={}, namespace={}, component={}, endpoint={}, key={}",
399                    inst.instance_id,
400                    inst.namespace,
401                    inst.component,
402                    inst.endpoint,
403                    key
404                );
405                (INSTANCES_BUCKET, key)
406            }
407            DiscoveryInstance::Model {
408                namespace,
409                component,
410                endpoint,
411                instance_id,
412                model_suffix,
413                ..
414            } => {
415                let mut key = Self::model_key(namespace, component, endpoint, *instance_id);
416
417                // If there's a model_suffix (e.g., for LoRA adapters), append it after the instance_id
418                // Key format: {namespace}/{component}/{endpoint}/{instance_id:x}/{model_suffix}
419                if let Some(suffix) = model_suffix
420                    && !suffix.is_empty()
421                {
422                    key = format!("{}/{}", key, suffix);
423                    tracing::debug!(
424                        "KVStoreDiscovery::register: Registering LoRA model with suffix={}, instance_id={}, namespace={}, component={}, endpoint={}, key={}",
425                        suffix,
426                        instance_id,
427                        namespace,
428                        component,
429                        endpoint,
430                        key
431                    );
432                }
433
434                // Log for base models (no suffix or empty suffix)
435                if model_suffix.as_ref().is_none_or(|s| s.is_empty()) {
436                    tracing::debug!(
437                        "KVStoreDiscovery::register: Registering base model instance_id={}, namespace={}, component={}, endpoint={}, key={}",
438                        instance_id,
439                        namespace,
440                        component,
441                        endpoint,
442                        key
443                    );
444                }
445                (MODELS_BUCKET, key)
446            }
447            DiscoveryInstance::EventChannel {
448                scope,
449                topic,
450                instance_id,
451                ..
452            } => {
453                let key = Self::event_channel_key(scope, topic, *instance_id);
454                // TODO: bis - remove this info log
455                tracing::info!(
456                    "KVStoreDiscovery::register: EventChannel bucket={}, key={}",
457                    EVENT_CHANNELS_BUCKET,
458                    key
459                );
460                tracing::debug!(
461                    "KVStoreDiscovery::register: Registering event channel instance_id={}, scope={:?}, topic={}, key={}",
462                    instance_id,
463                    scope,
464                    topic,
465                    key
466                );
467                (EVENT_CHANNELS_BUCKET, key)
468            }
469            DiscoveryInstance::EventSource {
470                scope,
471                topic,
472                publisher_id,
473                ..
474            } => {
475                let key = Self::event_source_key(scope, topic, *publisher_id);
476                tracing::debug!(
477                    "KVStoreDiscovery::register: Registering event source publisher_id={}, scope={:?}, topic={}, key={}",
478                    publisher_id,
479                    scope,
480                    topic,
481                    key
482                );
483                (EVENT_SOURCES_BUCKET, key)
484            }
485        };
486
487        // Serialize the instance
488        let instance_json = serde_json::to_vec(&instance)?;
489        tracing::debug!(
490            "KVStoreDiscovery::register: Serialized instance to {} bytes for key={}",
491            instance_json.len(),
492            key_path
493        );
494
495        // Store in the KV store with no TTL (instances persist until explicitly removed)
496        tracing::debug!(
497            "KVStoreDiscovery::register: Getting/creating bucket={} for key={}",
498            bucket_name,
499            key_path
500        );
501        let bucket = self.store.get_or_create_bucket(bucket_name, None).await?;
502        let key = kv::Key::new(key_path.clone());
503
504        if is_event_source && let Some(existing) = bucket.get(&key).await? {
505            let existing: DiscoveryInstance = serde_json::from_slice(existing.as_ref())?;
506            validate_event_source_reregistration(&existing, &instance)?;
507            return Ok(existing);
508        }
509
510        tracing::debug!(
511            "KVStoreDiscovery::register: Inserting into bucket={}, key={}",
512            bucket_name,
513            key_path
514        );
515        // Use revision 0 for initial registration
516        let outcome = match bucket.insert(&key, instance_json.into(), 0).await {
517            Ok(outcome) => outcome,
518            Err(error) if is_event_source => {
519                let Some(existing) = bucket.get(&key).await? else {
520                    return Err(error.into());
521                };
522                let existing: DiscoveryInstance = serde_json::from_slice(existing.as_ref())?;
523                validate_event_source_reregistration(&existing, &instance)?;
524                return Ok(existing);
525            }
526            Err(error) => return Err(error.into()),
527        };
528        tracing::debug!(
529            "KVStoreDiscovery::register: Registration insert completed instance_id={}, key={}, outcome={:?}",
530            instance_id,
531            key_path,
532            outcome
533        );
534
535        if is_model && matches!(outcome, kv::StoreOutcome::Exists(_)) {
536            let existing = bucket.get(&key).await?.ok_or_else(|| {
537                anyhow::anyhow!(
538                    "model discovery record disappeared during same-ID registration replay"
539                )
540            })?;
541            let existing: DiscoveryInstance = serde_json::from_slice(existing.as_ref())?;
542            validate_model_reregistration(&existing, &instance)?;
543            return Ok(existing);
544        }
545
546        Ok(instance)
547    }
548
549    async fn update_model_taints_internal(
550        &self,
551        id: ModelCardInstanceId,
552        taints: HashSet<String>,
553    ) -> Result<()> {
554        let bucket = self
555            .store
556            .get_bucket(MODELS_BUCKET)
557            .await?
558            .ok_or_else(|| anyhow::anyhow!("model discovery bucket is not registered"))?;
559        let key = kv::Key::new(id.to_path());
560        let target_id = DiscoveryInstanceId::Model(id);
561
562        update_model_taints_in_bucket(bucket.as_ref(), &key, &target_id, &taints).await
563    }
564
565    async fn unregister(&self, instance: DiscoveryInstance) -> Result<()> {
566        let (bucket_name, key_path) = match &instance {
567            DiscoveryInstance::Endpoint(inst) => {
568                let key = Self::endpoint_key(inst);
569                tracing::debug!(
570                    "Unregistering endpoint instance_id={}, namespace={}, component={}, endpoint={}, key={}",
571                    inst.instance_id,
572                    inst.namespace,
573                    inst.component,
574                    inst.endpoint,
575                    key
576                );
577                (INSTANCES_BUCKET, key)
578            }
579            DiscoveryInstance::Model {
580                namespace,
581                component,
582                endpoint,
583                instance_id,
584                model_suffix,
585                ..
586            } => {
587                let mut key = Self::model_key(namespace, component, endpoint, *instance_id);
588
589                // If there's a model_suffix (e.g., for LoRA adapters), append it after the instance_id
590                if let Some(suffix) = model_suffix
591                    && !suffix.is_empty()
592                {
593                    key = format!("{}/{}", key, suffix);
594                    tracing::debug!(
595                        "KVStoreDiscovery::unregister: Unregistering LoRA model with suffix={}, instance_id={}, namespace={}, component={}, endpoint={}, key={}",
596                        suffix,
597                        instance_id,
598                        namespace,
599                        component,
600                        endpoint,
601                        key
602                    );
603                }
604
605                // Log for base models (no suffix or empty suffix)
606                if model_suffix.as_ref().is_none_or(|s| s.is_empty()) {
607                    tracing::debug!(
608                        "Unregistering base model instance_id={}, namespace={}, component={}, endpoint={}, key={}",
609                        instance_id,
610                        namespace,
611                        component,
612                        endpoint,
613                        key
614                    );
615                }
616                (MODELS_BUCKET, key)
617            }
618            DiscoveryInstance::EventChannel {
619                scope,
620                topic,
621                instance_id,
622                ..
623            } => {
624                let key = Self::event_channel_key(scope, topic, *instance_id);
625                tracing::debug!(
626                    "KVStoreDiscovery::unregister: Unregistering event channel instance_id={}, scope={:?}, topic={}, key={}",
627                    instance_id,
628                    scope,
629                    topic,
630                    key
631                );
632                (EVENT_CHANNELS_BUCKET, key)
633            }
634            DiscoveryInstance::EventSource {
635                scope,
636                topic,
637                publisher_id,
638                ..
639            } => {
640                let key = Self::event_source_key(scope, topic, *publisher_id);
641                tracing::debug!(
642                    "KVStoreDiscovery::unregister: Unregistering event source publisher_id={}, scope={:?}, topic={}, key={}",
643                    publisher_id,
644                    scope,
645                    topic,
646                    key
647                );
648                (EVENT_SOURCES_BUCKET, key)
649            }
650        };
651
652        // Get the bucket - if it doesn't exist, the instance is already removed from the KV store
653        let Some(bucket) = self.store.get_bucket(bucket_name).await? else {
654            tracing::warn!(
655                "Bucket {} does not exist, instance already removed",
656                bucket_name
657            );
658            return Ok(());
659        };
660
661        let key = kv::Key::new(key_path.clone());
662
663        // Delete the entry from the bucket
664        bucket.delete(&key).await?;
665
666        Ok(())
667    }
668
669    async fn list(&self, query: DiscoveryQuery) -> Result<Vec<DiscoveryInstance>> {
670        let prefix = Self::query_prefix(&query);
671        let bucket_name = Self::bucket_for_prefix(&prefix);
672
673        // Get bucket - if it doesn't exist, return empty list
674        let Some(bucket) = self.store.get_bucket(bucket_name).await? else {
675            tracing::debug!(
676                "KVStoreDiscovery::list: bucket missing for query={:?}, prefix={}, bucket={}",
677                query,
678                prefix,
679                bucket_name
680            );
681            return Ok(Vec::new());
682        };
683
684        // Get all entries from the bucket
685        let entries = bucket.entries().await?;
686        tracing::debug!(
687            "KVStoreDiscovery::list: query={:?}, prefix={}, bucket={}, entries={}",
688            query,
689            prefix,
690            bucket_name,
691            entries.len()
692        );
693
694        // Filter by prefix and deserialize
695        let mut instances = Vec::new();
696        for (key, value) in entries {
697            if Self::matches_prefix(key.as_ref(), &prefix, bucket_name) {
698                match Self::parse_instance(&value) {
699                    Ok(instance) => instances.push(instance),
700                    Err(e) => {
701                        tracing::warn!(%key, error = %e, "Failed to parse discovery instance");
702                    }
703                }
704            }
705        }
706
707        Ok(instances)
708    }
709
710    async fn list_and_watch(
711        &self,
712        query: DiscoveryQuery,
713        cancel_token: Option<CancellationToken>,
714    ) -> Result<DiscoveryStream> {
715        let prefix = Self::query_prefix(&query);
716        let bucket_name = Self::bucket_for_prefix(&prefix);
717
718        tracing::trace!(
719            "KVStoreDiscovery::list_and_watch: Starting watch for query={:?}, prefix={}, bucket={}",
720            query,
721            prefix,
722            bucket_name
723        );
724
725        // Use the provided cancellation token, or fall back to the default token
726        let cancel_token = cancel_token.unwrap_or_else(|| self.cancel_token.clone());
727
728        // Use the kv::Manager's watch mechanism
729        let (_, mut rx) = self.store.clone().watch(
730            bucket_name,
731            None, // No TTL
732            cancel_token,
733        );
734
735        // Create a stream that filters and transforms WatchEvents to DiscoveryEvents
736        let stream = async_stream::stream! {
737            let mut known_instances = HashMap::<DiscoveryInstanceId, DiscoveryInstance>::new();
738
739            while let Some(event) = rx.recv().await {
740                let discovery_events = Self::discovery_events_from_watch_event(
741                    event,
742                    &prefix,
743                    bucket_name,
744                    &mut known_instances,
745                );
746
747                for event in discovery_events {
748                    yield Ok(event);
749                }
750            }
751        };
752        Ok(Box::pin(stream))
753    }
754
755    fn shutdown(&self) {
756        self.store.shutdown();
757    }
758}
759
760#[cfg(test)]
761mod tests {
762    use super::*;
763    use crate::component::TransportType;
764    use crate::discovery::{
765        EventChannelQuery, EventSourceQuery, EventTransport, ModelTaintsUpdate,
766    };
767    use crate::protocols::EndpointId;
768    use std::collections::HashSet;
769    use std::sync::atomic::{AtomicUsize, Ordering};
770
771    fn endpoint_instance(instance_id: u64) -> DiscoveryInstance {
772        DiscoveryInstance::Endpoint(crate::component::Instance {
773            namespace: "ns".to_string(),
774            component: "component".to_string(),
775            endpoint: "endpoint".to_string(),
776            instance_id,
777            transport: TransportType::Nats("nats://127.0.0.1:4222".to_string()),
778            device_type: None,
779            request_plane_codec: None,
780        })
781    }
782
783    fn endpoint_kv(instance_id: u64) -> kv::KeyValue {
784        let instance = endpoint_instance(instance_id);
785        kv::KeyValue::new(
786            kv::Key::new(format!(
787                "{}/{}/{}/{:x}",
788                "ns", "component", "endpoint", instance_id
789            )),
790            serde_json::to_vec(&instance).unwrap().into(),
791        )
792    }
793
794    #[test]
795    fn test_resync_removes_missing_discovery_instances() {
796        let prefix = format!("{}/{}/{}", INSTANCES_BUCKET, "ns", "component");
797        let mut known_instances = HashMap::new();
798
799        let first = endpoint_instance(1);
800        let second = endpoint_instance(2);
801        let third = endpoint_instance(3);
802        known_instances.insert(first.id(), first);
803        known_instances.insert(second.id(), second.clone());
804
805        let mut snapshot = HashMap::new();
806        let second_kv = endpoint_kv(2);
807        snapshot.insert(
808            kv::Key::new(second_kv.key()),
809            second_kv.value().to_vec().into(),
810        );
811        let third_kv = endpoint_kv(3);
812        snapshot.insert(
813            kv::Key::new(third_kv.key()),
814            third_kv.value().to_vec().into(),
815        );
816
817        let events = KVStoreDiscovery::discovery_events_from_watch_event(
818            kv::WatchEvent::Resync(snapshot),
819            &prefix,
820            INSTANCES_BUCKET,
821            &mut known_instances,
822        );
823
824        assert!(!events.contains(&DiscoveryEvent::Added(second)));
825        assert_eq!(
826            events,
827            vec![
828                DiscoveryEvent::Removed(endpoint_instance(1).id()),
829                DiscoveryEvent::Added(third),
830            ]
831        );
832        assert_eq!(known_instances.len(), 2);
833        assert!(known_instances.contains_key(&endpoint_instance(2).id()));
834        assert!(known_instances.contains_key(&endpoint_instance(3).id()));
835    }
836
837    #[test]
838    fn test_resync_retains_known_instance_on_parse_failure() {
839        let prefix = format!("{}/{}/{}", INSTANCES_BUCKET, "ns", "component");
840        let mut known_instances = HashMap::new();
841
842        let first = endpoint_instance(1);
843        known_instances.insert(first.id(), first.clone());
844
845        let mut snapshot = HashMap::new();
846        snapshot.insert(
847            kv::Key::new(format!("ns/component/endpoint/{:x}", 1)),
848            bytes::Bytes::from_static(b"not json"),
849        );
850
851        let events = KVStoreDiscovery::discovery_events_from_watch_event(
852            kv::WatchEvent::Resync(snapshot),
853            &prefix,
854            INSTANCES_BUCKET,
855            &mut known_instances,
856        );
857
858        assert!(events.is_empty());
859        assert_eq!(known_instances.len(), 1);
860        assert_eq!(known_instances.get(&first.id()), Some(&first));
861    }
862
863    #[test]
864    fn resync_changed_model_taints_emits_scoped_event() {
865        let prefix = format!("{}/{}/{}/{}", MODELS_BUCKET, "ns", "worker", "generate");
866        let old = model_spec("first").into_instance(7);
867        let updated = model_spec("second").into_instance(7);
868        let DiscoveryInstanceId::Model(id) = updated.id() else {
869            unreachable!()
870        };
871        let mut known_instances = HashMap::from([(old.id(), old)]);
872        let snapshot = HashMap::from([(
873            kv::Key::new(id.to_path()),
874            serde_json::to_vec(&updated).unwrap().into(),
875        )]);
876
877        let events = KVStoreDiscovery::discovery_events_from_watch_event(
878            kv::WatchEvent::Resync(snapshot),
879            &prefix,
880            MODELS_BUCKET,
881            &mut known_instances,
882        );
883
884        assert_eq!(
885            events,
886            vec![DiscoveryEvent::ModelTaintsUpdated(ModelTaintsUpdate {
887                id: id.clone(),
888                taints: vec![
889                    "dynamo.topology/zone=west".to_string(),
890                    "second".to_string(),
891                ],
892            })]
893        );
894        assert_eq!(
895            known_instances.get(&DiscoveryInstanceId::Model(id)),
896            Some(&updated)
897        );
898    }
899
900    #[test]
901    fn test_matches_prefix_requires_path_boundary() {
902        let prefix = format!("{}/{}/{}", INSTANCES_BUCKET, "ns", "component");
903
904        assert!(KVStoreDiscovery::matches_prefix(
905            "ns/component/endpoint/1",
906            &prefix,
907            INSTANCES_BUCKET
908        ));
909        assert!(KVStoreDiscovery::matches_prefix(
910            "ns/component",
911            &prefix,
912            INSTANCES_BUCKET
913        ));
914        assert!(!KVStoreDiscovery::matches_prefix(
915            "ns/component2/endpoint/1",
916            &prefix,
917            INSTANCES_BUCKET
918        ));
919    }
920
921    #[test]
922    fn test_bucket_for_prefix_requires_path_boundary() {
923        assert_eq!(
924            KVStoreDiscovery::bucket_for_prefix("v1/instances/ns/component"),
925            INSTANCES_BUCKET
926        );
927        assert_eq!(
928            KVStoreDiscovery::bucket_for_prefix("v1/event_channels/ns/component/topic"),
929            EVENT_CHANNELS_BUCKET
930        );
931        assert_eq!(
932            KVStoreDiscovery::bucket_for_prefix("v1/event_sources/ns/component/topic"),
933            EVENT_SOURCES_BUCKET
934        );
935        assert_eq!(
936            KVStoreDiscovery::bucket_for_prefix("v1/instances2/ns/component"),
937            MODELS_BUCKET
938        );
939    }
940
941    #[tokio::test]
942    async fn event_channel_keys_and_queries_preserve_exact_endpoint_scope() {
943        let store = kv::Manager::memory();
944        let client = KVStoreDiscovery::new(store, CancellationToken::new());
945        let endpoint_a = EndpointId {
946            namespace: "ns/one".to_string(),
947            component: "worker.component".to_string(),
948            name: "a/*".to_string(),
949        };
950        let endpoint_b = EndpointId {
951            name: "b/>".to_string(),
952            ..endpoint_a.clone()
953        };
954
955        for (publisher_id, endpoint) in [(1, endpoint_a.clone()), (2, endpoint_b.clone())] {
956            client
957                .register(DiscoverySpec::EventChannel {
958                    scope: EventScope::Endpoint { endpoint },
959                    topic: "kv/events".to_string(),
960                    publisher_id,
961                    transport: EventTransport::zmq(format!(
962                        "tcp://127.0.0.1:{}",
963                        5000 + publisher_id
964                    )),
965                })
966                .await
967                .unwrap();
968        }
969
970        let mut a = client
971            .list(DiscoveryQuery::EventChannels(
972                EventChannelQuery::endpoint_topic(endpoint_a.clone(), "kv/events"),
973            ))
974            .await
975            .unwrap();
976        assert_eq!(a.len(), 1);
977        assert_eq!(a[0].instance_id(), 1);
978        client.unregister(a.pop().unwrap()).await.unwrap();
979        assert!(
980            client
981                .list(DiscoveryQuery::EventChannels(
982                    EventChannelQuery::endpoint_topic(endpoint_a, "kv/events"),
983                ))
984                .await
985                .unwrap()
986                .is_empty()
987        );
988
989        let b = client
990            .list(DiscoveryQuery::EventChannels(
991                EventChannelQuery::endpoint_topic(endpoint_b, "kv/events"),
992            ))
993            .await
994            .unwrap();
995        assert_eq!(b.len(), 1);
996        assert_eq!(b[0].instance_id(), 2);
997    }
998
999    async fn assert_event_source_lifecycle(store: kv::Manager) {
1000        let client = KVStoreDiscovery::new(store, CancellationToken::new());
1001        let endpoint = EndpointId {
1002            namespace: "ns/one".to_string(),
1003            component: "worker.component".to_string(),
1004            name: "decode/*".to_string(),
1005        };
1006        let query = DiscoveryQuery::EventSources(EventSourceQuery::endpoint_topic(
1007            endpoint.clone(),
1008            "kv/events",
1009        ));
1010        let spec = |publisher_id, worker_id| DiscoverySpec::EventSource {
1011            scope: EventScope::Endpoint {
1012                endpoint: endpoint.clone(),
1013            },
1014            topic: "kv/events".to_string(),
1015            publisher_id,
1016            metadata: serde_json::json!({"worker_id": worker_id, "dp_rank": 0}),
1017        };
1018
1019        let first = client.register(spec(100, 7)).await.unwrap();
1020        assert_eq!(client.register(spec(100, 7)).await.unwrap(), first);
1021        assert!(client.register(spec(100, 8)).await.is_err());
1022        assert_eq!(
1023            client.list(query.clone()).await.unwrap(),
1024            vec![first.clone()]
1025        );
1026
1027        let second = client.register(spec(205, 7)).await.unwrap();
1028        assert_eq!(client.list(query.clone()).await.unwrap().len(), 2);
1029
1030        client.unregister(first).await.unwrap();
1031        assert_eq!(client.list(query).await.unwrap(), vec![second]);
1032    }
1033
1034    #[tokio::test]
1035    async fn event_source_lifecycle_round_trips_through_memory_kv_discovery() {
1036        assert_event_source_lifecycle(kv::Manager::memory()).await;
1037    }
1038
1039    #[tokio::test]
1040    async fn event_source_lifecycle_round_trips_through_file_kv_discovery() {
1041        let tempdir = tempfile::tempdir().unwrap();
1042        let store_cancel = CancellationToken::new();
1043        let store = kv::Manager::file(store_cancel.clone(), tempdir.path());
1044        assert_event_source_lifecycle(store).await;
1045        store_cancel.cancel();
1046    }
1047
1048    #[tokio::test]
1049    async fn event_source_watch_removes_exact_publisher_incarnation() {
1050        let client = KVStoreDiscovery::new(kv::Manager::memory(), CancellationToken::new());
1051        let endpoint = EndpointId {
1052            namespace: "ns".to_string(),
1053            component: "worker".to_string(),
1054            name: "decode".to_string(),
1055        };
1056        let query = DiscoveryQuery::EventSources(EventSourceQuery::endpoint_topic(
1057            endpoint.clone(),
1058            "kv-events",
1059        ));
1060        let mut stream = client.list_and_watch(query, None).await.unwrap();
1061        let spec = |publisher_id| DiscoverySpec::EventSource {
1062            scope: EventScope::Endpoint {
1063                endpoint: endpoint.clone(),
1064            },
1065            topic: "kv-events".to_string(),
1066            publisher_id,
1067            metadata: serde_json::json!({"dp_rank": 0}),
1068        };
1069
1070        let first = client.register(spec(100)).await.unwrap();
1071        let second = client.register(spec(205)).await.unwrap();
1072        let mut added = std::collections::HashSet::new();
1073        for _ in 0..2 {
1074            let DiscoveryEvent::Added(instance) = stream.next().await.unwrap().unwrap() else {
1075                panic!("expected source addition");
1076            };
1077            added.insert(instance.id());
1078        }
1079        assert_eq!(
1080            added,
1081            std::collections::HashSet::from([first.id(), second.id()])
1082        );
1083
1084        client.unregister(first).await.unwrap();
1085        let removed = tokio::time::timeout(tokio::time::Duration::from_secs(1), async {
1086            loop {
1087                if let DiscoveryEvent::Removed(id) = stream.next().await.unwrap().unwrap() {
1088                    break id;
1089                }
1090            }
1091        })
1092        .await
1093        .unwrap();
1094        assert_eq!(
1095            removed,
1096            DiscoveryInstanceId::EventSource(EventSourceInstanceId {
1097                scope: EventScope::Endpoint { endpoint },
1098                topic: "kv-events".to_string(),
1099                publisher_id: 100,
1100            })
1101        );
1102        assert_eq!(
1103            client
1104                .list(DiscoveryQuery::EventSources(EventSourceQuery::all()))
1105                .await
1106                .unwrap(),
1107            vec![second]
1108        );
1109    }
1110
1111    #[tokio::test]
1112    async fn test_kv_store_discovery_list() {
1113        let store = kv::Manager::memory();
1114        let cancel_token = CancellationToken::new();
1115        let client = KVStoreDiscovery::new(store, cancel_token);
1116
1117        // Register multiple endpoints
1118        let spec1 = DiscoverySpec::Endpoint {
1119            namespace: "ns1".to_string(),
1120            component: "comp1".to_string(),
1121            endpoint: "ep1".to_string(),
1122            device_type: None,
1123            request_plane_codec: None,
1124            transport: TransportType::Nats("nats://localhost:4222".to_string()),
1125        };
1126        client.register(spec1).await.unwrap();
1127
1128        let spec2 = DiscoverySpec::Endpoint {
1129            namespace: "ns1".to_string(),
1130            component: "comp1".to_string(),
1131            device_type: None,
1132            request_plane_codec: None,
1133            endpoint: "ep2".to_string(),
1134            transport: TransportType::Nats("nats://localhost:4222".to_string()),
1135        };
1136        client.register(spec2).await.unwrap();
1137
1138        let spec3 = DiscoverySpec::Endpoint {
1139            namespace: "ns2".to_string(),
1140            device_type: None,
1141            request_plane_codec: None,
1142            component: "comp2".to_string(),
1143            endpoint: "ep1".to_string(),
1144            transport: TransportType::Nats("nats://localhost:4222".to_string()),
1145        };
1146        client.register(spec3).await.unwrap();
1147
1148        // List all endpoints
1149        let all = client.list(DiscoveryQuery::AllEndpoints).await.unwrap();
1150        assert_eq!(all.len(), 3);
1151
1152        // List namespaced endpoints
1153        let ns1 = client
1154            .list(DiscoveryQuery::NamespacedEndpoints {
1155                namespace: "ns1".to_string(),
1156            })
1157            .await
1158            .unwrap();
1159        assert_eq!(ns1.len(), 2);
1160
1161        // List component endpoints
1162        let comp1 = client
1163            .list(DiscoveryQuery::ComponentEndpoints {
1164                namespace: "ns1".to_string(),
1165                component: "comp1".to_string(),
1166            })
1167            .await
1168            .unwrap();
1169        assert_eq!(comp1.len(), 2);
1170    }
1171
1172    #[tokio::test]
1173    async fn test_kv_store_discovery_watch() {
1174        let store = kv::Manager::memory();
1175        let cancel_token = CancellationToken::new();
1176        let client = Arc::new(KVStoreDiscovery::new(store, cancel_token.clone()));
1177
1178        // Start watching before registering
1179        let mut stream = client
1180            .list_and_watch(DiscoveryQuery::AllEndpoints, None)
1181            .await
1182            .unwrap();
1183
1184        let client_clone = client.clone();
1185        let register_task = tokio::spawn(async move {
1186            tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
1187
1188            let spec = DiscoverySpec::Endpoint {
1189                device_type: None,
1190                request_plane_codec: None,
1191                namespace: "test".to_string(),
1192                component: "comp1".to_string(),
1193                endpoint: "ep1".to_string(),
1194                transport: TransportType::Nats("nats://localhost:4222".to_string()),
1195            };
1196            client_clone.register(spec).await.unwrap();
1197        });
1198
1199        // Wait for the added event
1200        let event = stream.next().await.unwrap().unwrap();
1201        match event {
1202            DiscoveryEvent::Added(instance) => match instance {
1203                DiscoveryInstance::Endpoint(inst) => {
1204                    assert_eq!(inst.namespace, "test");
1205                    assert_eq!(inst.component, "comp1");
1206                    assert_eq!(inst.endpoint, "ep1");
1207                }
1208                _ => panic!("Expected Endpoint instance"),
1209            },
1210            _ => panic!("Expected Added event"),
1211        }
1212
1213        register_task.await.unwrap();
1214        cancel_token.cancel();
1215    }
1216
1217    fn model_spec(taint: &str) -> DiscoverySpec {
1218        DiscoverySpec::Model {
1219            namespace: "ns".to_string(),
1220            component: "worker".to_string(),
1221            endpoint: "generate".to_string(),
1222            card_json: serde_json::json!({
1223                "display_name": "model",
1224                "runtime_config": {
1225                    "taints": [taint, "dynamo.topology/zone=west"],
1226                    "topology_domains": {"zone": "west"}
1227                }
1228            }),
1229            model_suffix: None,
1230        }
1231    }
1232
1233    struct AlwaysConflictingBucket {
1234        value: bytes::Bytes,
1235        compare_attempts: AtomicUsize,
1236    }
1237
1238    #[async_trait]
1239    impl kv::Bucket for AlwaysConflictingBucket {
1240        async fn insert(
1241            &self,
1242            _key: &kv::Key,
1243            _value: bytes::Bytes,
1244            _revision: u64,
1245        ) -> Result<kv::StoreOutcome, kv::StoreError> {
1246            unreachable!("insert is not used by this test")
1247        }
1248
1249        async fn get(&self, _key: &kv::Key) -> Result<Option<bytes::Bytes>, kv::StoreError> {
1250            Ok(Some(self.value.clone()))
1251        }
1252
1253        async fn compare_and_replace(
1254            &self,
1255            _key: &kv::Key,
1256            _expected: bytes::Bytes,
1257            _value: bytes::Bytes,
1258        ) -> Result<kv::StoreOutcome, kv::StoreError> {
1259            self.compare_attempts.fetch_add(1, Ordering::Relaxed);
1260            Err(kv::StoreError::Retry)
1261        }
1262
1263        async fn delete(&self, _key: &kv::Key) -> Result<(), kv::StoreError> {
1264            unreachable!("delete is not used by this test")
1265        }
1266
1267        async fn watch(
1268            &self,
1269        ) -> Result<Pin<Box<dyn futures::Stream<Item = kv::WatchEvent> + Send + '_>>, kv::StoreError>
1270        {
1271            unreachable!("watch is not used by this test")
1272        }
1273
1274        async fn entries(&self) -> Result<HashMap<kv::Key, bytes::Bytes>, kv::StoreError> {
1275            unreachable!("entries is not used by this test")
1276        }
1277    }
1278
1279    #[tokio::test]
1280    async fn model_taint_update_stops_after_bounded_conflicts() {
1281        let existing = model_spec("first").into_instance(7);
1282        let target_id = existing.id();
1283        let DiscoveryInstanceId::Model(id) = &target_id else {
1284            unreachable!()
1285        };
1286        let key = kv::Key::new(id.to_path());
1287        let bucket = AlwaysConflictingBucket {
1288            value: serde_json::to_vec(&existing).unwrap().into(),
1289            compare_attempts: AtomicUsize::new(0),
1290        };
1291
1292        let error = update_model_taints_in_bucket(
1293            &bucket,
1294            &key,
1295            &target_id,
1296            &HashSet::from(["second".to_string()]),
1297        )
1298        .await
1299        .unwrap_err();
1300
1301        assert!(matches!(
1302            error.downcast_ref::<kv::StoreError>(),
1303            Some(kv::StoreError::Retry)
1304        ));
1305        assert_eq!(
1306            bucket.compare_attempts.load(Ordering::Relaxed),
1307            UPDATE_MODEL_TAINTS_MAX_ATTEMPTS
1308        );
1309    }
1310
1311    #[tokio::test]
1312    async fn model_taint_updates_replace_existing_value_and_emit_scoped_event() {
1313        let client = KVStoreDiscovery::new(kv::Manager::memory(), CancellationToken::new());
1314        let query = DiscoveryQuery::EndpointModels {
1315            namespace: "ns".to_string(),
1316            component: "worker".to_string(),
1317            endpoint: "generate".to_string(),
1318        };
1319        let mut stream = client.list_and_watch(query.clone(), None).await.unwrap();
1320
1321        client.register(model_spec("first")).await.unwrap();
1322        let DiscoveryEvent::Added(first) = stream.next().await.unwrap().unwrap() else {
1323            panic!("expected initial model addition");
1324        };
1325
1326        let DiscoveryInstanceId::Model(id) = first.id() else {
1327            unreachable!()
1328        };
1329        for taint in ["second", "third"] {
1330            client
1331                .update_model_taints(id.clone(), HashSet::from([taint.to_string()]))
1332                .await
1333                .unwrap();
1334            let event = tokio::time::timeout(tokio::time::Duration::from_secs(1), stream.next())
1335                .await
1336                .unwrap()
1337                .unwrap()
1338                .unwrap();
1339            assert_eq!(
1340                event,
1341                DiscoveryEvent::ModelTaintsUpdated(ModelTaintsUpdate {
1342                    id: id.clone(),
1343                    taints: vec!["dynamo.topology/zone=west".to_string(), taint.to_string(),],
1344                })
1345            );
1346        }
1347
1348        let replayed = client.register(model_spec("first")).await.unwrap();
1349        let DiscoveryInstance::Model { card_json, .. } = &replayed else {
1350            panic!("expected model instance");
1351        };
1352        let replayed_taints = card_json["runtime_config"]["taints"].as_array().unwrap();
1353        assert!(replayed_taints.contains(&serde_json::json!("third")));
1354        assert!(!replayed_taints.contains(&serde_json::json!("first")));
1355        assert!(
1356            tokio::time::timeout(tokio::time::Duration::from_millis(50), stream.next())
1357                .await
1358                .is_err()
1359        );
1360
1361        client
1362            .update_model_taints(id, HashSet::from(["third".to_string()]))
1363            .await
1364            .unwrap();
1365        assert!(
1366            tokio::time::timeout(tokio::time::Duration::from_millis(50), stream.next())
1367                .await
1368                .is_err()
1369        );
1370
1371        let listed = client.list(query).await.unwrap();
1372        assert_eq!(listed.len(), 1);
1373        assert_eq!(listed[0].id(), first.id());
1374        let DiscoveryInstance::Model { card_json, .. } = &listed[0] else {
1375            panic!("expected model instance");
1376        };
1377        let taints = card_json["runtime_config"]["taints"].as_array().unwrap();
1378        assert!(taints.contains(&serde_json::json!("third")));
1379        assert!(!taints.contains(&serde_json::json!("second")));
1380    }
1381}