1use std::collections::{HashMap, HashSet};
5use std::pin::Pin;
6use std::sync::Arc;
7
8use anyhow::Result;
9use async_trait::async_trait;
10use futures::{Stream, StreamExt};
11use tokio_util::sync::CancellationToken;
12
13use super::{
14 Discovery, DiscoveryEvent, DiscoveryInstance, DiscoveryInstanceId, DiscoveryQuery,
15 DiscoverySpec, DiscoveryStream, EndpointInstanceId, EventChannelInstanceId, EventScope,
16 EventSourceInstanceId, ModelCardInstanceId, classify_discovery_change, encode_event_segment,
17 model_with_updated_taints, 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
65pub struct KVStoreDiscovery {
67 store: Arc<kv::Manager>,
68 cancel_token: CancellationToken,
69}
70
71impl KVStoreDiscovery {
72 pub fn new(store: kv::Manager, cancel_token: CancellationToken) -> Self {
73 Self {
74 store: Arc::new(store),
75 cancel_token,
76 }
77 }
78
79 fn endpoint_key(instance: &crate::component::Instance) -> String {
81 instance.endpoint_instance_id().to_path()
82 }
83
84 fn model_key(namespace: &str, component: &str, endpoint: &str, instance_id: u64) -> String {
86 format!("{}/{}/{}/{:x}", namespace, component, endpoint, instance_id)
87 }
88
89 fn event_channel_key(scope: &EventScope, topic: &str, instance_id: u64) -> String {
91 format!(
92 "{}/topic/{}/{:x}",
93 scope.path_prefix(),
94 encode_event_segment(topic),
95 instance_id
96 )
97 }
98
99 fn event_source_key(scope: &EventScope, topic: &str, publisher_id: u64) -> String {
101 EventSourceInstanceId {
102 scope: scope.clone(),
103 topic: topic.to_string(),
104 publisher_id,
105 }
106 .to_path()
107 }
108
109 fn query_prefix(query: &DiscoveryQuery) -> String {
111 match query {
112 DiscoveryQuery::AllEndpoints => INSTANCES_BUCKET.to_string(),
113 DiscoveryQuery::NamespacedEndpoints { namespace } => {
114 format!("{}/{}", INSTANCES_BUCKET, namespace)
115 }
116 DiscoveryQuery::ComponentEndpoints {
117 namespace,
118 component,
119 } => {
120 format!("{}/{}/{}", INSTANCES_BUCKET, namespace, component)
121 }
122 DiscoveryQuery::Endpoint {
123 namespace,
124 component,
125 endpoint,
126 } => {
127 format!(
128 "{}/{}/{}/{}",
129 INSTANCES_BUCKET, namespace, component, endpoint
130 )
131 }
132 DiscoveryQuery::AllModels => MODELS_BUCKET.to_string(),
133 DiscoveryQuery::NamespacedModels { namespace } => {
134 format!("{}/{}", MODELS_BUCKET, namespace)
135 }
136 DiscoveryQuery::ComponentModels {
137 namespace,
138 component,
139 } => {
140 format!("{}/{}/{}", MODELS_BUCKET, namespace, component)
141 }
142 DiscoveryQuery::EndpointModels {
143 namespace,
144 component,
145 endpoint,
146 } => {
147 format!("{}/{}/{}/{}", MODELS_BUCKET, namespace, component, endpoint)
148 }
149 DiscoveryQuery::EventChannels(query) => {
150 let mut path = EVENT_CHANNELS_BUCKET.to_string();
151 if let Some(scope) = &query.scope {
152 path.push('/');
153 path.push_str(&scope.path_prefix());
154 if let Some(topic) = &query.topic {
155 path.push_str("/topic/");
156 path.push_str(&encode_event_segment(topic));
157 }
158 }
159 path
160 }
161 DiscoveryQuery::EventSources(query) => {
162 let mut path = EVENT_SOURCES_BUCKET.to_string();
163 if let Some(scope) = &query.scope {
164 path.push('/');
165 path.push_str(&scope.path_prefix());
166 if let Some(topic) = &query.topic {
167 path.push_str("/topic/");
168 path.push_str(&encode_event_segment(topic));
169 }
170 }
171 path
172 }
173 }
174 }
175
176 fn strip_bucket_prefix<'a>(key: &'a str, bucket_name: &str) -> &'a str {
180 if let Some(stripped) = key.strip_prefix(bucket_name) {
182 stripped.strip_prefix('/').unwrap_or(stripped)
184 } else {
185 key
187 }
188 }
189
190 fn matches_prefix(key_str: &str, prefix: &str, bucket_name: &str) -> bool {
193 let relative_key = Self::strip_bucket_prefix(key_str, bucket_name);
195 let relative_prefix = Self::strip_bucket_prefix(prefix, bucket_name);
196
197 if relative_prefix.is_empty() {
199 return true;
200 }
201
202 relative_key == relative_prefix
203 || relative_key
204 .strip_prefix(relative_prefix)
205 .is_some_and(|suffix| suffix.starts_with('/'))
206 }
207
208 fn bucket_for_prefix(prefix: &str) -> &'static str {
209 if prefix == INSTANCES_BUCKET
210 || prefix
211 .strip_prefix(INSTANCES_BUCKET)
212 .is_some_and(|suffix| suffix.starts_with('/'))
213 {
214 INSTANCES_BUCKET
215 } else if prefix == EVENT_CHANNELS_BUCKET
216 || prefix
217 .strip_prefix(EVENT_CHANNELS_BUCKET)
218 .is_some_and(|suffix| suffix.starts_with('/'))
219 {
220 EVENT_CHANNELS_BUCKET
221 } else if prefix == EVENT_SOURCES_BUCKET
222 || prefix
223 .strip_prefix(EVENT_SOURCES_BUCKET)
224 .is_some_and(|suffix| suffix.starts_with('/'))
225 {
226 EVENT_SOURCES_BUCKET
227 } else {
228 MODELS_BUCKET
229 }
230 }
231
232 fn parse_instance(value: &[u8]) -> Result<DiscoveryInstance> {
234 let instance: DiscoveryInstance = serde_json::from_slice(value)?;
235 Ok(instance)
236 }
237
238 fn parse_instance_id_from_key(key_str: &str, bucket_name: &str) -> Option<DiscoveryInstanceId> {
239 let relative_key = Self::strip_bucket_prefix(key_str, bucket_name);
240 let parsed = match bucket_name {
241 INSTANCES_BUCKET => {
242 EndpointInstanceId::from_path(relative_key).map(DiscoveryInstanceId::Endpoint)
243 }
244 MODELS_BUCKET => {
245 ModelCardInstanceId::from_path(relative_key).map(DiscoveryInstanceId::Model)
246 }
247 EVENT_CHANNELS_BUCKET => EventChannelInstanceId::from_path(relative_key)
248 .map(DiscoveryInstanceId::EventChannel),
249 EVENT_SOURCES_BUCKET => {
250 EventSourceInstanceId::from_path(relative_key).map(DiscoveryInstanceId::EventSource)
251 }
252 _ => {
253 tracing::warn!(
254 key = %key_str,
255 bucket = bucket_name,
256 "Unknown discovery bucket for delete/resync key"
257 );
258 return None;
259 }
260 };
261
262 parsed
263 .inspect_err(|err| {
264 tracing::warn!(
265 key = %key_str,
266 relative_key = %relative_key,
267 bucket = bucket_name,
268 error = %err,
269 "Failed to parse discovery instance id from key"
270 );
271 })
272 .ok()
273 }
274
275 fn discovery_events_from_watch_event(
276 event: kv::WatchEvent,
277 prefix: &str,
278 bucket_name: &str,
279 known_instances: &mut HashMap<DiscoveryInstanceId, DiscoveryInstance>,
280 ) -> 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let cancel_token = cancel_token.unwrap_or_else(|| self.cancel_token.clone());
727
728 let (_, mut rx) = self.store.clone().watch(
730 bucket_name,
731 None, cancel_token,
733 );
734
735 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 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 let all = client.list(DiscoveryQuery::AllEndpoints).await.unwrap();
1150 assert_eq!(all.len(), 3);
1151
1152 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 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 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 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}