dynamo_runtime/discovery/
utils.rs1use serde::Deserialize;
7
8use super::{DiscoveryEvent, DiscoveryInstance, DiscoveryInstanceId, DiscoveryStream};
9
10fn collapse_by_instance_id<V: Clone>(
16 state: &std::collections::HashMap<DiscoveryInstanceId, V>,
17) -> std::collections::HashMap<u64, V> {
18 let mut result = std::collections::HashMap::new();
19 for (id, val) in state {
20 let instance_id = id.instance_id();
21 let model_suffix = match id {
22 DiscoveryInstanceId::Model(mid) => mid.model_suffix.as_ref(),
23 _ => None,
24 };
25 if model_suffix.is_none() || !result.contains_key(&instance_id) {
26 result.insert(instance_id, val.clone());
27 }
28 }
29 result
30}
31
32pub fn watch_and_extract_field<T, V, F>(
64 stream: DiscoveryStream,
65 extractor: F,
66) -> tokio::sync::watch::Receiver<std::collections::HashMap<u64, V>>
67where
68 T: for<'de> Deserialize<'de> + 'static,
69 V: Clone + PartialEq + Send + Sync + 'static,
70 F: Fn(T) -> V + Send + 'static,
71{
72 use futures::StreamExt;
73 use std::collections::HashMap;
74
75 let (tx, rx) = tokio::sync::watch::channel(HashMap::new());
76
77 tokio::spawn(async move {
78 let mut state: HashMap<DiscoveryInstanceId, V> = HashMap::new();
84 let mut stream = stream;
85
86 while let Some(result) = stream.next().await {
87 match result {
88 Ok(DiscoveryEvent::Added(instance)) => {
89 let instance_id = instance.instance_id();
90 let key = instance.id();
91
92 let deserialized: T = match instance.deserialize_model() {
94 Ok(d) => d,
95 Err(e) => {
96 tracing::warn!(
97 instance_id,
98 error = %e,
99 "Failed to deserialize discovery instance, skipping"
100 );
101 continue;
102 }
103 };
104
105 let value = extractor(deserialized);
107
108 tracing::debug!(
109 instance_id,
110 ?key,
111 state_len = state.len(),
112 "watch_and_extract_field: inserting instance"
113 );
114
115 state.insert(key, value);
116
117 let collapsed = collapse_by_instance_id(&state);
121 if *tx.borrow() != collapsed && tx.send(collapsed).is_err() {
122 tracing::debug!("watch_and_extract_field receiver dropped, stopping");
123 break;
124 }
125 }
126 Ok(DiscoveryEvent::ModelTaintsUpdated(_)) => {}
127 Ok(DiscoveryEvent::Resync(_)) => {}
128 Ok(DiscoveryEvent::Removed(id)) => {
129 let had_entry = state.contains_key(&id);
130
131 tracing::debug!(
132 instance_id = id.instance_id(),
133 ?id,
134 had_entry,
135 state_len = state.len(),
136 "watch_and_extract_field: removing instance"
137 );
138
139 state.remove(&id);
140
141 let collapsed = collapse_by_instance_id(&state);
145 if *tx.borrow() != collapsed && tx.send(collapsed).is_err() {
146 tracing::debug!("watch_and_extract_field receiver dropped, stopping");
147 break;
148 }
149 }
150 Err(e) => {
151 tracing::error!(error = %e, "Discovery event stream error in watch_and_extract_field");
152 }
154 }
155 }
156
157 tracing::debug!("watch_and_extract_field task stopped");
158 });
159
160 rx
161}
162
163#[cfg(test)]
164mod tests {
165 use super::*;
166 use crate::discovery::mock::{MockDiscovery, SharedMockRegistry};
167 use crate::discovery::{Discovery, DiscoveryQuery, DiscoverySpec};
168
169 #[derive(serde::Deserialize, Clone, Debug)]
171 struct FakeCard {
172 display_name: String,
173 }
174
175 fn model_spec(name: &str) -> DiscoverySpec {
176 DiscoverySpec::Model {
177 namespace: "ns".to_string(),
178 component: "comp".to_string(),
179 endpoint: "generate".to_string(),
180 card_json: serde_json::json!({ "display_name": name }),
181 model_suffix: None,
182 }
183 }
184
185 async fn poll_until(
187 rx: &tokio::sync::watch::Receiver<std::collections::HashMap<u64, String>>,
188 pred: impl Fn(&std::collections::HashMap<u64, String>) -> bool,
189 msg: &str,
190 ) {
191 for _ in 0..100 {
192 if pred(&rx.borrow()) {
193 return;
194 }
195 tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
196 }
197 panic!("{}: state={:?}", msg, *rx.borrow());
198 }
199
200 fn lora_spec(lora_name: &str) -> DiscoverySpec {
201 DiscoverySpec::Model {
202 namespace: "ns".to_string(),
203 component: "comp".to_string(),
204 endpoint: "generate".to_string(),
205 card_json: serde_json::json!({
206 "display_name": lora_name,
207 "source_path": "base-model",
208 "lora": { "name": lora_name },
209 }),
210 model_suffix: Some(lora_name.to_string()),
211 }
212 }
213
214 #[tokio::test]
218 async fn test_lora_unregister_preserves_worker_runtime_config() {
219 let discovery = MockDiscovery::new(Some(42), SharedMockRegistry::new());
221
222 let query = DiscoveryQuery::EndpointModels {
223 namespace: "ns".to_string(),
224 component: "comp".to_string(),
225 endpoint: "generate".to_string(),
226 };
227
228 let stream = discovery.list_and_watch(query, None).await.unwrap();
229
230 let rx = watch_and_extract_field(stream, |card: FakeCard| card.display_name);
232
233 let base = discovery.register(model_spec("base-model")).await.unwrap();
235 let lora_a = discovery.register(lora_spec("lora-a")).await.unwrap();
236 discovery.register(lora_spec("lora-b")).await.unwrap();
237
238 poll_until(
239 &rx,
240 |s| s.contains_key(&42),
241 "Worker 42 should be present after registrations",
242 )
243 .await;
244
245 discovery.unregister(lora_a).await.unwrap();
247
248 poll_until(
250 &rx,
251 |s| s.get(&42).map(|v| v.as_str()) == Some("base-model"),
252 "Worker 42 should have base-model after removing lora-a",
253 )
254 .await;
255
256 {
257 let state = rx.borrow();
258 assert_eq!(state.get(&42).map(|s| s.as_str()), Some("base-model"));
259 }
260
261 discovery.unregister(base).await.unwrap();
263
264 poll_until(
265 &rx,
266 |s| s.get(&42).map(|v| v.as_str()) == Some("lora-b"),
267 "Worker 42 should fall back to lora-b after removing base model",
268 )
269 .await;
270
271 {
272 let state = rx.borrow();
273 assert_eq!(state.get(&42).map(|s| s.as_str()), Some("lora-b"));
274 }
275 }
276
277 #[tokio::test]
281 async fn test_all_models_cross_endpoint_no_alias() {
282 let registry = SharedMockRegistry::new();
283 let discovery = MockDiscovery::new(Some(7), registry.clone());
285
286 let stream = discovery
287 .list_and_watch(DiscoveryQuery::AllModels, None)
288 .await
289 .unwrap();
290 let rx = watch_and_extract_field(stream, |card: FakeCard| card.display_name);
291
292 let ep_a = discovery
294 .register(DiscoverySpec::Model {
295 namespace: "ns".to_string(),
296 component: "comp".to_string(),
297 endpoint: "ep-a".to_string(),
298 card_json: serde_json::json!({ "display_name": "model-on-ep-a" }),
299 model_suffix: None,
300 })
301 .await
302 .unwrap();
303
304 discovery
306 .register(DiscoverySpec::Model {
307 namespace: "ns".to_string(),
308 component: "comp".to_string(),
309 endpoint: "ep-b".to_string(),
310 card_json: serde_json::json!({ "display_name": "model-on-ep-b" }),
311 model_suffix: None,
312 })
313 .await
314 .unwrap();
315
316 poll_until(
317 &rx,
318 |s| s.contains_key(&7),
319 "Worker 7 should appear after registrations",
320 )
321 .await;
322
323 discovery.unregister(ep_a).await.unwrap();
325
326 poll_until(
327 &rx,
328 |s| s.get(&7).map(|v| v.as_str()) == Some("model-on-ep-b"),
329 "Worker 7 should still be present via ep-b after removing ep-a",
330 )
331 .await;
332 }
333}