1use 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#[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 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
48pub 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
82fn matches_query(instance: &DiscoveryInstance, query: &DiscoveryQuery) -> bool {
84 match (instance, query) {
85 (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 (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 (
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 (
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 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 let mut stream = client1.list_and_watch(query.clone(), None).await.unwrap();
644 contract::expect_empty_snapshot(&mut stream).await;
645
646 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 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 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 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}