Skip to main content

dynamo_runtime/discovery/
mock.rs

1// SPDX-FileCopyrightText: Copyright (c) 2024-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4use super::{
5    Discovery, DiscoveryEvent, DiscoveryInstance, DiscoveryInstanceId, DiscoveryQuery,
6    DiscoverySpec, DiscoveryStream, ModelCardInstanceId, model_with_updated_taints,
7    reconcile_discovery_snapshot, validate_event_source_reregistration,
8    validate_model_reregistration,
9};
10use anyhow::Result;
11use async_trait::async_trait;
12use std::collections::{HashMap, HashSet};
13use std::sync::{Arc, Mutex};
14use tokio::sync::mpsc::UnboundedSender;
15use tokio_util::sync::CancellationToken;
16
17/// Shared in-memory registry for mock discovery
18#[derive(Clone, Default)]
19pub struct SharedMockRegistry {
20    state: Arc<Mutex<RegistryState>>,
21}
22
23impl SharedMockRegistry {
24    pub fn new() -> Self {
25        Self::default()
26    }
27}
28
29#[derive(Default)]
30struct RegistryState {
31    instances: Vec<DiscoveryInstance>,
32    /// Each watch receives the registry after every change, so a change made between two reads
33    /// of its stream still reaches it.
34    watchers: Vec<UnboundedSender<Arc<Vec<DiscoveryInstance>>>>,
35}
36
37impl RegistryState {
38    fn publish(&mut self) {
39        if self.watchers.is_empty() {
40            return;
41        }
42        let instances = Arc::new(self.instances.clone());
43        self.watchers
44            .retain(|watcher| watcher.send(instances.clone()).is_ok());
45    }
46}
47
48/// Mock implementation of Discovery for testing
49/// We can potentially remove this once we have KVStoreDiscovery fully tested
50pub struct MockDiscovery {
51    instance_id: u64,
52    registry: SharedMockRegistry,
53}
54
55impl MockDiscovery {
56    pub fn new(instance_id: Option<u64>, registry: SharedMockRegistry) -> Self {
57        let instance_id = instance_id.unwrap_or_else(|| {
58            use std::sync::atomic::{AtomicU64, Ordering};
59            static COUNTER: AtomicU64 = AtomicU64::new(1);
60            COUNTER.fetch_add(1, Ordering::SeqCst)
61        });
62
63        Self {
64            instance_id,
65            registry,
66        }
67    }
68}
69
70fn query_snapshot(
71    instances: &[DiscoveryInstance],
72    query: &DiscoveryQuery,
73) -> HashMap<DiscoveryInstanceId, DiscoveryInstance> {
74    instances
75        .iter()
76        .filter(|instance| matches_query(instance, query))
77        .cloned()
78        .map(|instance| (instance.id(), instance))
79        .collect()
80}
81
82/// Helper function to check if an instance matches a discovery query
83fn matches_query(instance: &DiscoveryInstance, query: &DiscoveryQuery) -> bool {
84    match (instance, query) {
85        // Endpoint matching
86        (DiscoveryInstance::Endpoint(_), DiscoveryQuery::AllEndpoints) => true,
87        (DiscoveryInstance::Endpoint(inst), DiscoveryQuery::NamespacedEndpoints { namespace }) => {
88            &inst.namespace == namespace
89        }
90        (
91            DiscoveryInstance::Endpoint(inst),
92            DiscoveryQuery::ComponentEndpoints {
93                namespace,
94                component,
95            },
96        ) => &inst.namespace == namespace && &inst.component == component,
97        (
98            DiscoveryInstance::Endpoint(inst),
99            DiscoveryQuery::Endpoint {
100                namespace,
101                component,
102                endpoint,
103            },
104        ) => {
105            &inst.namespace == namespace
106                && &inst.component == component
107                && &inst.endpoint == endpoint
108        }
109
110        // Model matching
111        (DiscoveryInstance::Model { .. }, DiscoveryQuery::AllModels) => true,
112        (
113            DiscoveryInstance::Model {
114                namespace: inst_ns, ..
115            },
116            DiscoveryQuery::NamespacedModels { namespace },
117        ) => inst_ns == namespace,
118        (
119            DiscoveryInstance::Model {
120                namespace: inst_ns,
121                component: inst_comp,
122                ..
123            },
124            DiscoveryQuery::ComponentModels {
125                namespace,
126                component,
127            },
128        ) => inst_ns == namespace && inst_comp == component,
129        (
130            DiscoveryInstance::Model {
131                namespace: inst_ns,
132                component: inst_comp,
133                endpoint: inst_ep,
134                ..
135            },
136            DiscoveryQuery::EndpointModels {
137                namespace,
138                component,
139                endpoint,
140            },
141        ) => inst_ns == namespace && inst_comp == component && inst_ep == endpoint,
142
143        // EventChannel matching - unified query
144        (
145            DiscoveryInstance::EventChannel {
146                scope: inst_scope,
147                topic: inst_topic,
148                ..
149            },
150            DiscoveryQuery::EventChannels(query),
151        ) => {
152            query.scope.as_ref().is_none_or(|scope| scope == inst_scope)
153                && query.topic.as_ref().is_none_or(|t| t == inst_topic)
154        }
155
156        (
157            DiscoveryInstance::EventSource {
158                scope: inst_scope,
159                topic: inst_topic,
160                ..
161            },
162            DiscoveryQuery::EventSources(query),
163        ) => {
164            query.scope.as_ref().is_none_or(|scope| scope == inst_scope)
165                && query.topic.as_ref().is_none_or(|t| t == inst_topic)
166        }
167
168        // Cross-type matches return false
169        (
170            DiscoveryInstance::Endpoint(_),
171            DiscoveryQuery::AllModels
172            | DiscoveryQuery::NamespacedModels { .. }
173            | DiscoveryQuery::ComponentModels { .. }
174            | DiscoveryQuery::EndpointModels { .. }
175            | DiscoveryQuery::EventChannels(_)
176            | DiscoveryQuery::EventSources(_),
177        ) => false,
178        (
179            DiscoveryInstance::Model { .. },
180            DiscoveryQuery::AllEndpoints
181            | DiscoveryQuery::NamespacedEndpoints { .. }
182            | DiscoveryQuery::ComponentEndpoints { .. }
183            | DiscoveryQuery::Endpoint { .. }
184            | DiscoveryQuery::EventChannels(_)
185            | DiscoveryQuery::EventSources(_),
186        ) => false,
187        (
188            DiscoveryInstance::EventChannel { .. },
189            DiscoveryQuery::AllEndpoints
190            | DiscoveryQuery::NamespacedEndpoints { .. }
191            | DiscoveryQuery::ComponentEndpoints { .. }
192            | DiscoveryQuery::Endpoint { .. }
193            | DiscoveryQuery::AllModels
194            | DiscoveryQuery::NamespacedModels { .. }
195            | DiscoveryQuery::ComponentModels { .. }
196            | DiscoveryQuery::EndpointModels { .. },
197        ) => false,
198        (DiscoveryInstance::EventChannel { .. }, DiscoveryQuery::EventSources(_)) => false,
199        (
200            DiscoveryInstance::EventSource { .. },
201            DiscoveryQuery::AllEndpoints
202            | DiscoveryQuery::NamespacedEndpoints { .. }
203            | DiscoveryQuery::ComponentEndpoints { .. }
204            | DiscoveryQuery::Endpoint { .. }
205            | DiscoveryQuery::AllModels
206            | DiscoveryQuery::NamespacedModels { .. }
207            | DiscoveryQuery::ComponentModels { .. }
208            | DiscoveryQuery::EndpointModels { .. }
209            | DiscoveryQuery::EventChannels(_),
210        ) => false,
211    }
212}
213
214#[async_trait]
215impl Discovery for MockDiscovery {
216    fn instance_id(&self) -> u64 {
217        self.instance_id
218    }
219
220    async fn register_internal(&self, spec: DiscoverySpec) -> Result<DiscoveryInstance> {
221        let instance = spec.into_instance(self.instance_id);
222        let instance_id = instance.id();
223        let mut state = self.registry.state.lock().unwrap();
224        if let Some(existing) = state
225            .instances
226            .iter_mut()
227            .find(|existing| existing.id() == instance_id)
228        {
229            match &instance {
230                DiscoveryInstance::Endpoint(_) => {
231                    *existing = instance.clone();
232                    state.publish();
233                    return Ok(instance);
234                }
235                DiscoveryInstance::EventSource { .. } => {
236                    validate_event_source_reregistration(existing, &instance)?;
237                    return Ok(existing.clone());
238                }
239                DiscoveryInstance::Model { .. } => {
240                    validate_model_reregistration(existing, &instance)?;
241                    return Ok(existing.clone());
242                }
243                DiscoveryInstance::EventChannel { .. } => {}
244            }
245        }
246        state.instances.push(instance.clone());
247        state.publish();
248
249        Ok(instance)
250    }
251
252    async fn update_model_taints_internal(
253        &self,
254        id: ModelCardInstanceId,
255        taints: HashSet<String>,
256    ) -> Result<()> {
257        let target_id = DiscoveryInstanceId::Model(id);
258        let mut state = self.registry.state.lock().unwrap();
259        let existing = state
260            .instances
261            .iter_mut()
262            .find(|existing| existing.id() == target_id)
263            .ok_or_else(|| {
264                anyhow::anyhow!("model discovery record {target_id:?} is not registered")
265            })?;
266        *existing = model_with_updated_taints(existing, taints)?;
267        state.publish();
268        Ok(())
269    }
270
271    async fn unregister(&self, instance: DiscoveryInstance) -> Result<()> {
272        let target_id = instance.id();
273
274        let mut state = self.registry.state.lock().unwrap();
275        state.instances.retain(|i| i.id() != target_id);
276        state.publish();
277
278        Ok(())
279    }
280
281    async fn list(&self, query: DiscoveryQuery) -> Result<Vec<DiscoveryInstance>> {
282        let state = self.registry.state.lock().unwrap();
283        Ok(state
284            .instances
285            .iter()
286            .filter(|instance| matches_query(instance, &query))
287            .cloned()
288            .collect())
289    }
290
291    async fn list_and_watch(
292        &self,
293        query: DiscoveryQuery,
294        _cancel_token: Option<CancellationToken>,
295    ) -> Result<DiscoveryStream> {
296        let (changes_tx, mut changes) = tokio::sync::mpsc::unbounded_channel();
297        // Every change publishes under this lock, so none falls between the snapshot and the
298        // subscription.
299        let initial = {
300            let mut state = self.registry.state.lock().unwrap();
301            state.watchers.push(changes_tx);
302            query_snapshot(&state.instances, &query)
303        };
304
305        let stream = async_stream::stream! {
306            let (events, mut known_instances) =
307                reconcile_discovery_snapshot(&HashMap::new(), initial);
308            for event in events {
309                yield Ok(event);
310            }
311            yield Ok(DiscoveryEvent::Resync(known_instances.values().cloned().collect()));
312
313            while let Some(instances) = changes.recv().await {
314                let (events, reconciled) = reconcile_discovery_snapshot(
315                    &known_instances,
316                    query_snapshot(&instances, &query),
317                );
318                for event in events {
319                    yield Ok(event);
320                }
321                known_instances = reconciled;
322            }
323        };
324
325        Ok(Box::pin(stream))
326    }
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332    use crate::component::TransportType;
333    use crate::discovery::startup_contract as contract;
334    use futures::StreamExt;
335    use tokio::time::{Duration, timeout};
336
337    #[tokio::test]
338    async fn mock_backend_keeps_the_startup_contract() {
339        let client = MockDiscovery::new(Some(1), SharedMockRegistry::new());
340        contract::check(&client).await;
341    }
342
343    #[tokio::test]
344    async fn watch_emits_same_id_endpoint_update() {
345        let client = MockDiscovery::new(Some(1), SharedMockRegistry::new());
346        let query = DiscoveryQuery::Endpoint {
347            namespace: "ns".to_string(),
348            component: "component".to_string(),
349            endpoint: "endpoint".to_string(),
350        };
351        let spec = |transport: &str| DiscoverySpec::Endpoint {
352            namespace: "ns".to_string(),
353            component: "component".to_string(),
354            endpoint: "endpoint".to_string(),
355            transport: TransportType::Tcp(transport.to_string()),
356            device_type: None,
357            request_plane_codec: None,
358        };
359        let mut stream = client.list_and_watch(query.clone(), None).await.unwrap();
360        contract::expect_empty_snapshot(&mut stream).await;
361
362        let original = client.register(spec("127.0.0.1:8000")).await.unwrap();
363        let event = timeout(Duration::from_secs(1), stream.next())
364            .await
365            .expect("mock watch should emit the initial instance")
366            .unwrap()
367            .unwrap();
368        assert_eq!(event, DiscoveryEvent::Added(original));
369
370        let updated = client.register(spec("127.0.0.1:9000")).await.unwrap();
371        let event = timeout(Duration::from_secs(1), stream.next())
372            .await
373            .expect("mock watch should emit the updated instance")
374            .unwrap()
375            .unwrap();
376
377        assert_eq!(event, DiscoveryEvent::Added(updated.clone()));
378        assert_eq!(client.list(query).await.unwrap(), vec![updated]);
379    }
380
381    #[tokio::test]
382    async fn watch_reports_an_unregister_that_follows_establishment() {
383        let client = MockDiscovery::new(Some(1), SharedMockRegistry::new());
384        let instance = client
385            .register(DiscoverySpec::Endpoint {
386                namespace: "ns".to_string(),
387                component: "component".to_string(),
388                endpoint: "endpoint".to_string(),
389                transport: TransportType::Tcp("127.0.0.1:8000".to_string()),
390                device_type: None,
391                request_plane_codec: None,
392            })
393            .await
394            .unwrap();
395
396        let mut stream = client
397            .list_and_watch(DiscoveryQuery::AllEndpoints, None)
398            .await
399            .unwrap();
400        client.unregister(instance.clone()).await.unwrap();
401
402        let added = timeout(Duration::from_secs(1), stream.next())
403            .await
404            .expect("the first snapshot must reach the stream")
405            .unwrap()
406            .unwrap();
407        assert_eq!(added, DiscoveryEvent::Added(instance.clone()));
408        assert_eq!(
409            contract::next(&mut stream).await,
410            DiscoveryEvent::Resync(vec![instance.clone()])
411        );
412
413        let removed = timeout(Duration::from_secs(1), stream.next())
414            .await
415            .expect("an unregister after establishment must reach the stream")
416            .unwrap()
417            .unwrap();
418        assert_eq!(removed, DiscoveryEvent::Removed(instance.id()));
419    }
420
421    #[tokio::test]
422    async fn watch_reports_the_removal_of_an_instance_listed_after_establishment() {
423        let client = MockDiscovery::new(Some(1), SharedMockRegistry::new());
424        let mut stream = client
425            .list_and_watch(DiscoveryQuery::AllEndpoints, None)
426            .await
427            .unwrap();
428        contract::expect_empty_snapshot(&mut stream).await;
429
430        let instance = client
431            .register(DiscoverySpec::Endpoint {
432                namespace: "ns".to_string(),
433                component: "component".to_string(),
434                endpoint: "endpoint".to_string(),
435                transport: TransportType::Tcp("127.0.0.1:8000".to_string()),
436                device_type: None,
437                request_plane_codec: None,
438            })
439            .await
440            .unwrap();
441        let listed = client.list(DiscoveryQuery::AllEndpoints).await.unwrap();
442        assert_eq!(listed, vec![instance.clone()]);
443        client.unregister(instance.clone()).await.unwrap();
444
445        let added = timeout(Duration::from_secs(1), stream.next())
446            .await
447            .expect("a registration after establishment must reach the stream")
448            .unwrap()
449            .unwrap();
450        assert_eq!(added, DiscoveryEvent::Added(instance.clone()));
451
452        let removed = timeout(Duration::from_secs(1), stream.next())
453            .await
454            .expect("a caller that listed the instance must see its removal")
455            .unwrap()
456            .unwrap();
457        assert_eq!(removed, DiscoveryEvent::Removed(instance.id()));
458    }
459
460    fn model_spec(
461        namespace: &str,
462        component: &str,
463        endpoint: &str,
464        model_name: &str,
465    ) -> DiscoverySpec {
466        DiscoverySpec::Model {
467            namespace: namespace.to_string(),
468            component: component.to_string(),
469            endpoint: endpoint.to_string(),
470            card_json: serde_json::json!({
471                "display_name": model_name,
472            }),
473            model_suffix: None,
474        }
475    }
476
477    fn lora_model_spec(
478        namespace: &str,
479        component: &str,
480        endpoint: &str,
481        model_name: &str,
482        source_path: &str,
483        lora_name: &str,
484    ) -> DiscoverySpec {
485        DiscoverySpec::Model {
486            namespace: namespace.to_string(),
487            component: component.to_string(),
488            endpoint: endpoint.to_string(),
489            card_json: serde_json::json!({
490                "display_name": model_name,
491                "source_path": source_path,
492                "lora": {
493                    "name": lora_name,
494                },
495            }),
496            model_suffix: Some(lora_name.to_string()),
497        }
498    }
499
500    #[tokio::test]
501    async fn model_taint_updates_use_the_authoritative_registry() {
502        let client = MockDiscovery::new(Some(7), SharedMockRegistry::new());
503        let model = client
504            .register(DiscoverySpec::Model {
505                namespace: "ns".to_string(),
506                component: "worker".to_string(),
507                endpoint: "generate".to_string(),
508                card_json: serde_json::json!({
509                    "display_name": "model",
510                    "runtime_config": {"taints": ["a"]}
511                }),
512                model_suffix: None,
513            })
514            .await
515            .unwrap();
516        let DiscoveryInstanceId::Model(id) = model.id() else {
517            unreachable!()
518        };
519
520        client
521            .update_model_taints(id.clone(), HashSet::from(["b".to_string()]))
522            .await
523            .unwrap();
524        client
525            .update_model_taints(id.clone(), HashSet::from(["a".to_string()]))
526            .await
527            .unwrap();
528
529        let stored = client
530            .list(DiscoveryQuery::EndpointModels {
531                namespace: "ns".to_string(),
532                component: "worker".to_string(),
533                endpoint: "generate".to_string(),
534            })
535            .await
536            .unwrap()
537            .pop()
538            .unwrap();
539        let DiscoveryInstance::Model { card_json, .. } = stored else {
540            unreachable!()
541        };
542        assert_eq!(
543            card_json["runtime_config"]["taints"],
544            serde_json::json!(["a"])
545        );
546
547        client.unregister(model).await.unwrap();
548        assert!(
549            client
550                .update_model_taints(id, HashSet::new())
551                .await
552                .is_err()
553        );
554    }
555
556    #[tokio::test]
557    async fn same_id_model_registration_preserves_updated_taints_without_duplicates() {
558        let client = MockDiscovery::new(Some(7), SharedMockRegistry::new());
559        let spec = DiscoverySpec::Model {
560            namespace: "ns".to_string(),
561            component: "worker".to_string(),
562            endpoint: "generate".to_string(),
563            card_json: serde_json::json!({
564                "display_name": "model",
565                "runtime_config": {"taints": ["initial"]}
566            }),
567            model_suffix: None,
568        };
569        let original = client.register(spec.clone()).await.unwrap();
570        let DiscoveryInstanceId::Model(id) = original.id() else {
571            unreachable!()
572        };
573        client
574            .update_model_taints(id, HashSet::from(["updated".to_string()]))
575            .await
576            .unwrap();
577
578        let replayed = client.register(spec).await.unwrap();
579        let models = client
580            .list(DiscoveryQuery::EndpointModels {
581                namespace: "ns".to_string(),
582                component: "worker".to_string(),
583                endpoint: "generate".to_string(),
584            })
585            .await
586            .unwrap();
587
588        assert_eq!(models, vec![replayed.clone()]);
589        let DiscoveryInstance::Model { card_json, .. } = replayed else {
590            unreachable!()
591        };
592        assert_eq!(
593            card_json["runtime_config"]["taints"],
594            serde_json::json!(["updated"])
595        );
596    }
597
598    #[tokio::test]
599    async fn model_taint_update_rejects_foreign_worker_id() {
600        let client = MockDiscovery::new(Some(7), SharedMockRegistry::new());
601        let foreign_id = ModelCardInstanceId {
602            namespace: "ns".to_string(),
603            component: "worker".to_string(),
604            endpoint: "generate".to_string(),
605            instance_id: 8,
606            model_suffix: None,
607        };
608
609        let error = client
610            .update_model_taints(foreign_id, HashSet::new())
611            .await
612            .unwrap_err();
613
614        assert!(
615            error
616                .to_string()
617                .contains("this discovery client owns worker 7")
618        );
619    }
620
621    #[tokio::test]
622    async fn test_mock_discovery_add_and_remove() {
623        let registry = SharedMockRegistry::new();
624        let client1 = MockDiscovery::new(Some(1), registry.clone());
625        let client2 = MockDiscovery::new(Some(2), registry.clone());
626
627        let spec = DiscoverySpec::Endpoint {
628            namespace: "test-ns".to_string(),
629            component: "test-comp".to_string(),
630            endpoint: "test-ep".to_string(),
631            transport: crate::component::TransportType::Nats("test-subject".to_string()),
632            device_type: None,
633            request_plane_codec: None,
634        };
635
636        let query = DiscoveryQuery::Endpoint {
637            namespace: "test-ns".to_string(),
638            component: "test-comp".to_string(),
639            endpoint: "test-ep".to_string(),
640        };
641
642        // Start watching
643        let mut stream = client1.list_and_watch(query.clone(), None).await.unwrap();
644        contract::expect_empty_snapshot(&mut stream).await;
645
646        // Add first instance
647        let instance1 = client1.register(spec.clone()).await.unwrap();
648
649        let event = stream.next().await.unwrap().unwrap();
650        match event {
651            DiscoveryEvent::Added(DiscoveryInstance::Endpoint(inst)) => {
652                assert_eq!(inst.instance_id, 1);
653            }
654            _ => panic!("Expected Added event for instance-1"),
655        }
656
657        // Add second instance
658        client2.register(spec.clone()).await.unwrap();
659
660        let event = stream.next().await.unwrap().unwrap();
661        match event {
662            DiscoveryEvent::Added(DiscoveryInstance::Endpoint(inst)) => {
663                assert_eq!(inst.instance_id, 2);
664            }
665            _ => panic!("Expected Added event for instance-2"),
666        }
667
668        // Remove first instance
669        client1.unregister(instance1).await.unwrap();
670
671        let event = stream.next().await.unwrap().unwrap();
672        match event {
673            DiscoveryEvent::Removed(id) => {
674                let endpoint_id = id.extract_endpoint_id().expect("Expected endpoint removal");
675                assert_eq!(endpoint_id.instance_id, 1);
676            }
677            _ => panic!("Expected Removed event for instance-1"),
678        }
679    }
680
681    #[tokio::test]
682    async fn event_source_removal_is_publisher_specific() {
683        use crate::discovery::{EventScope, EventSourceQuery};
684
685        let client = MockDiscovery::new(Some(42), SharedMockRegistry::new());
686        let endpoint = crate::protocols::EndpointId {
687            namespace: "workers".to_string(),
688            component: "backend".to_string(),
689            name: "kv-state".to_string(),
690        };
691        let query = DiscoveryQuery::EventSources(EventSourceQuery::endpoint_topic(
692            endpoint.clone(),
693            "kv-events",
694        ));
695        let spec = |publisher_id, worker_id| DiscoverySpec::EventSource {
696            scope: EventScope::Endpoint {
697                endpoint: endpoint.clone(),
698            },
699            topic: "kv-events".to_string(),
700            publisher_id,
701            metadata: serde_json::json!({"worker_id": worker_id, "dp_rank": 0}),
702        };
703
704        let old = client.register(spec(100, 7)).await.unwrap();
705        assert_eq!(client.register(spec(100, 7)).await.unwrap(), old);
706        assert!(client.register(spec(100, 8)).await.is_err());
707        assert_eq!(client.list(query.clone()).await.unwrap(), vec![old.clone()]);
708
709        let current = client.register(spec(205, 7)).await.unwrap();
710        assert_eq!(client.list(query.clone()).await.unwrap().len(), 2);
711
712        client.unregister(old).await.unwrap();
713        assert_eq!(client.list(query).await.unwrap(), vec![current]);
714    }
715
716    #[tokio::test]
717    async fn register_allows_same_model_name_on_same_endpoint() {
718        let registry = SharedMockRegistry::new();
719        let discovery1 = MockDiscovery::new(Some(1), registry.clone());
720        let discovery2 = MockDiscovery::new(Some(2), registry);
721        let spec = model_spec("ns", "comp", "generate", "model-a");
722
723        discovery1.register(spec.clone()).await.unwrap();
724        discovery2.register(spec).await.unwrap();
725
726        let instances = discovery1
727            .list(DiscoveryQuery::EndpointModels {
728                namespace: "ns".to_string(),
729                component: "comp".to_string(),
730                endpoint: "generate".to_string(),
731            })
732            .await
733            .unwrap();
734        assert_eq!(instances.len(), 2);
735    }
736
737    #[tokio::test]
738    async fn register_non_lora_alias_compatibility() {
739        for (name, source_a, source_b, compatible) in [
740            ("alias-b", Some("org/base"), Some("org/base"), true),
741            ("alias-a", Some("/mount/a"), Some("/mount/b"), true),
742            ("alias-b", Some("org/base"), Some("org/other"), false),
743            ("alias-b", Some("org/base"), None, false),
744            ("alias-b", None, Some("org/base"), false),
745            ("alias-b", Some(""), Some(""), false),
746        ] {
747            let registry = SharedMockRegistry::new();
748            let discovery1 = MockDiscovery::new(Some(1), registry.clone());
749            let discovery2 = MockDiscovery::new(Some(2), registry);
750            let spec = |display_name: &str, source_path: Option<&str>| DiscoverySpec::Model {
751                namespace: "ns".to_string(),
752                component: "comp".to_string(),
753                endpoint: "generate".to_string(),
754                card_json: serde_json::json!({
755                    "display_name": display_name,
756                    "source_path": source_path,
757                }),
758                model_suffix: None,
759            };
760            discovery1
761                .register(spec("alias-a", source_a))
762                .await
763                .unwrap();
764            let result = discovery2.register(spec(name, source_b)).await;
765            assert_eq!(
766                result.is_ok(),
767                compatible,
768                "{name}: {source_a:?}, {source_b:?}: {result:?}"
769            );
770            if let Err(err) = result {
771                assert!(
772                    err.to_string()
773                        .contains("a different model 'alias-a' is already registered there")
774                );
775            }
776            let instances = discovery1
777                .list(DiscoveryQuery::EndpointModels {
778                    namespace: "ns".to_string(),
779                    component: "comp".to_string(),
780                    endpoint: "generate".to_string(),
781                })
782                .await
783                .unwrap();
784            assert_eq!(instances.len(), if compatible { 2 } else { 1 });
785        }
786    }
787
788    #[tokio::test]
789    async fn register_shared_source_requires_disjoint_served_names() {
790        for (name_b, aliases_a, aliases_b, compatible) in [
791            ("b", vec!["b"], vec![], false),
792            ("b", vec![], vec!["a"], false),
793            ("b", vec!["shared"], vec!["shared"], false),
794            ("b", vec!["a", "extra-a"], vec!["b", "extra-b"], true),
795            ("a", vec!["shared"], vec!["shared"], true),
796        ] {
797            let registry = SharedMockRegistry::new();
798            let first = MockDiscovery::new(Some(1), registry.clone());
799            let second = MockDiscovery::new(Some(2), registry);
800            let spec = |name: &str, aliases: Vec<&str>| DiscoverySpec::Model {
801                namespace: "ns".into(),
802                component: "comp".into(),
803                endpoint: "generate".into(),
804                card_json: serde_json::json!({
805                    "display_name": name,
806                    "aliases": aliases,
807                    "source_path": "org/base",
808                }),
809                model_suffix: None,
810            };
811            let incumbent = first.register(spec("a", aliases_a)).await.unwrap();
812            let result = second.register(spec(name_b, aliases_b)).await;
813            assert_eq!(result.is_ok(), compatible, "{result:?}");
814            let instances = first
815                .list(DiscoveryQuery::EndpointModels {
816                    namespace: "ns".into(),
817                    component: "comp".into(),
818                    endpoint: "generate".into(),
819                })
820                .await
821                .unwrap();
822            assert!(instances.contains(&incumbent));
823            assert_eq!(instances.len(), if compatible { 2 } else { 1 });
824        }
825    }
826
827    #[tokio::test]
828    async fn register_checks_every_existing_model() {
829        // B is compatible with A by name and C by source, but A and C conflict.
830        for order in [[0, 1, 2], [2, 1, 0]] {
831            let registry = SharedMockRegistry::new();
832            let cards = [("a", "/mount/a"), ("a", "/mount/b"), ("b", "/mount/b")];
833            let mut accepted = Vec::new();
834            for (position, index) in order.into_iter().enumerate() {
835                let discovery = MockDiscovery::new(Some(index as u64 + 1), registry.clone());
836                let (name, source) = cards[index];
837                let result = discovery
838                    .register(DiscoverySpec::Model {
839                        namespace: "ns".into(),
840                        component: "comp".into(),
841                        endpoint: "generate".into(),
842                        card_json: serde_json::json!({"display_name": name, "source_path": source}),
843                        model_suffix: None,
844                    })
845                    .await;
846                if position < 2 {
847                    accepted.push(result.unwrap());
848                } else {
849                    assert!(result.is_err());
850                    let instances = discovery
851                        .list(DiscoveryQuery::EndpointModels {
852                            namespace: "ns".into(),
853                            component: "comp".into(),
854                            endpoint: "generate".into(),
855                        })
856                        .await
857                        .unwrap();
858                    assert_eq!(instances.len(), accepted.len());
859                    assert!(accepted.iter().all(|instance| instances.contains(instance)));
860                }
861            }
862        }
863    }
864
865    #[tokio::test]
866    async fn register_rejects_different_model_name_on_same_endpoint() {
867        let registry = SharedMockRegistry::new();
868        let discovery1 = MockDiscovery::new(Some(1), registry.clone());
869        let discovery2 = MockDiscovery::new(Some(2), registry);
870
871        discovery1
872            .register(model_spec("ns", "comp", "generate", "model-a"))
873            .await
874            .unwrap();
875
876        let err = discovery2
877            .register(model_spec("ns", "comp", "generate", "model-b"))
878            .await
879            .unwrap_err();
880
881        assert!(err.to_string().contains(
882            "Cannot register model 'model-b' on endpoint 'ns/comp/generate': a different model 'model-a' is already registered there"
883        ));
884
885        let instances = discovery1
886            .list(DiscoveryQuery::EndpointModels {
887                namespace: "ns".to_string(),
888                component: "comp".to_string(),
889                endpoint: "generate".to_string(),
890            })
891            .await
892            .unwrap();
893        assert_eq!(instances.len(), 1);
894    }
895
896    #[tokio::test]
897    async fn register_allows_different_model_names_on_different_endpoints() {
898        let registry = SharedMockRegistry::new();
899        let discovery1 = MockDiscovery::new(Some(1), registry.clone());
900        let discovery2 = MockDiscovery::new(Some(2), registry);
901
902        discovery1
903            .register(model_spec("ns", "comp", "generate-a", "model-a"))
904            .await
905            .unwrap();
906        discovery2
907            .register(model_spec("ns", "comp", "generate-b", "model-b"))
908            .await
909            .unwrap();
910    }
911
912    #[tokio::test]
913    async fn register_allows_lora_adapter_on_same_endpoint() {
914        let registry = SharedMockRegistry::new();
915        let discovery1 = MockDiscovery::new(Some(1), registry.clone());
916        let discovery2 = MockDiscovery::new(Some(2), registry);
917
918        discovery1
919            .register(DiscoverySpec::Model {
920                namespace: "ns".to_string(),
921                component: "comp".to_string(),
922                endpoint: "generate".to_string(),
923                card_json: serde_json::json!({
924                    "display_name": "base-model",
925                    "source_path": "base-repo",
926                }),
927                model_suffix: None,
928            })
929            .await
930            .unwrap();
931
932        discovery2
933            .register(lora_model_spec(
934                "ns",
935                "comp",
936                "generate",
937                "adapter-a",
938                "base-repo",
939                "adapter-a",
940            ))
941            .await
942            .unwrap();
943    }
944
945    #[tokio::test]
946    async fn register_base_and_lora_require_distinct_served_names() {
947        for (adapter_name, compatible) in [("base", false), ("alias", false), ("adapter", true)] {
948            for adapter_first in [false, true] {
949                let registry = SharedMockRegistry::new();
950                let first = MockDiscovery::new(Some(1), registry.clone());
951                let second = MockDiscovery::new(Some(2), registry);
952                let base = DiscoverySpec::Model {
953                    namespace: "ns".into(),
954                    component: "comp".into(),
955                    endpoint: "generate".into(),
956                    card_json: serde_json::json!({
957                        "display_name": "base",
958                        "aliases": ["alias"],
959                        "source_path": "org/base",
960                    }),
961                    model_suffix: None,
962                };
963                let adapter = lora_model_spec(
964                    "ns",
965                    "comp",
966                    "generate",
967                    adapter_name,
968                    "org/base",
969                    adapter_name,
970                );
971                let (incumbent, newcomer) = if adapter_first {
972                    (adapter, base)
973                } else {
974                    (base, adapter)
975                };
976                let incumbent = first.register(incumbent).await.unwrap();
977                let result = second.register(newcomer).await;
978                assert_eq!(
979                    result.is_ok(),
980                    compatible,
981                    "{adapter_name}, adapter_first={adapter_first}: {result:?}"
982                );
983                let instances = first
984                    .list(DiscoveryQuery::EndpointModels {
985                        namespace: "ns".into(),
986                        component: "comp".into(),
987                        endpoint: "generate".into(),
988                    })
989                    .await
990                    .unwrap();
991                assert!(instances.contains(&incumbent));
992                assert_eq!(instances.len(), if compatible { 2 } else { 1 });
993            }
994        }
995    }
996
997    #[tokio::test]
998    async fn register_rejects_lora_adapter_for_different_base_model() {
999        let registry = SharedMockRegistry::new();
1000        let discovery1 = MockDiscovery::new(Some(1), registry.clone());
1001        let discovery2 = MockDiscovery::new(Some(2), registry);
1002
1003        discovery1
1004            .register(DiscoverySpec::Model {
1005                namespace: "ns".to_string(),
1006                component: "comp".to_string(),
1007                endpoint: "generate".to_string(),
1008                card_json: serde_json::json!({
1009                    "display_name": "base-model",
1010                    "source_path": "base-repo",
1011                }),
1012                model_suffix: None,
1013            })
1014            .await
1015            .unwrap();
1016
1017        let err = discovery2
1018            .register(lora_model_spec(
1019                "ns",
1020                "comp",
1021                "generate",
1022                "adapter-a",
1023                "other-base-repo",
1024                "adapter-a",
1025            ))
1026            .await
1027            .unwrap_err();
1028
1029        assert!(err.to_string().contains(
1030            "Cannot register model 'adapter-a' on endpoint 'ns/comp/generate': a different model 'base-model' is already registered there"
1031        ));
1032    }
1033}