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::Removed(id)) => {
128 let had_entry = state.contains_key(&id);
129
130 tracing::debug!(
131 instance_id = id.instance_id(),
132 ?id,
133 had_entry,
134 state_len = state.len(),
135 "watch_and_extract_field: removing instance"
136 );
137
138 state.remove(&id);
139
140 let collapsed = collapse_by_instance_id(&state);
144 if *tx.borrow() != collapsed && tx.send(collapsed).is_err() {
145 tracing::debug!("watch_and_extract_field receiver dropped, stopping");
146 break;
147 }
148 }
149 Err(e) => {
150 tracing::error!(error = %e, "Discovery event stream error in watch_and_extract_field");
151 }
153 }
154 }
155
156 tracing::debug!("watch_and_extract_field task stopped");
157 });
158
159 rx
160}
161
162#[cfg(test)]
163mod tests {
164 use super::*;
165 use crate::discovery::mock::{MockDiscovery, SharedMockRegistry};
166 use crate::discovery::{Discovery, DiscoveryQuery, DiscoverySpec};
167
168 #[derive(serde::Deserialize, Clone, Debug)]
170 struct FakeCard {
171 display_name: String,
172 }
173
174 fn model_spec(name: &str) -> DiscoverySpec {
175 DiscoverySpec::Model {
176 namespace: "ns".to_string(),
177 component: "comp".to_string(),
178 endpoint: "generate".to_string(),
179 card_json: serde_json::json!({ "display_name": name }),
180 model_suffix: None,
181 }
182 }
183
184 async fn poll_until(
186 rx: &tokio::sync::watch::Receiver<std::collections::HashMap<u64, String>>,
187 pred: impl Fn(&std::collections::HashMap<u64, String>) -> bool,
188 msg: &str,
189 ) {
190 for _ in 0..100 {
191 if pred(&rx.borrow()) {
192 return;
193 }
194 tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
195 }
196 panic!("{}: state={:?}", msg, *rx.borrow());
197 }
198
199 fn lora_spec(lora_name: &str) -> DiscoverySpec {
200 DiscoverySpec::Model {
201 namespace: "ns".to_string(),
202 component: "comp".to_string(),
203 endpoint: "generate".to_string(),
204 card_json: serde_json::json!({
205 "display_name": lora_name,
206 "source_path": "base-model",
207 "lora": { "name": lora_name },
208 }),
209 model_suffix: Some(lora_name.to_string()),
210 }
211 }
212
213 #[tokio::test]
217 async fn test_lora_unregister_preserves_worker_runtime_config() {
218 let discovery = MockDiscovery::new(Some(42), SharedMockRegistry::new());
220
221 let query = DiscoveryQuery::EndpointModels {
222 namespace: "ns".to_string(),
223 component: "comp".to_string(),
224 endpoint: "generate".to_string(),
225 };
226
227 let stream = discovery.list_and_watch(query, None).await.unwrap();
228
229 let rx = watch_and_extract_field(stream, |card: FakeCard| card.display_name);
231
232 let base = discovery.register(model_spec("base-model")).await.unwrap();
234 let lora_a = discovery.register(lora_spec("lora-a")).await.unwrap();
235 discovery.register(lora_spec("lora-b")).await.unwrap();
236
237 poll_until(
238 &rx,
239 |s| s.contains_key(&42),
240 "Worker 42 should be present after registrations",
241 )
242 .await;
243
244 discovery.unregister(lora_a).await.unwrap();
246
247 poll_until(
249 &rx,
250 |s| s.get(&42).map(|v| v.as_str()) == Some("base-model"),
251 "Worker 42 should have base-model after removing lora-a",
252 )
253 .await;
254
255 {
256 let state = rx.borrow();
257 assert_eq!(state.get(&42).map(|s| s.as_str()), Some("base-model"));
258 }
259
260 discovery.unregister(base).await.unwrap();
262
263 poll_until(
264 &rx,
265 |s| s.get(&42).map(|v| v.as_str()) == Some("lora-b"),
266 "Worker 42 should fall back to lora-b after removing base model",
267 )
268 .await;
269
270 {
271 let state = rx.borrow();
272 assert_eq!(state.get(&42).map(|s| s.as_str()), Some("lora-b"));
273 }
274 }
275
276 #[tokio::test]
280 async fn test_all_models_cross_endpoint_no_alias() {
281 let registry = SharedMockRegistry::new();
282 let discovery = MockDiscovery::new(Some(7), registry.clone());
284
285 let stream = discovery
286 .list_and_watch(DiscoveryQuery::AllModels, None)
287 .await
288 .unwrap();
289 let rx = watch_and_extract_field(stream, |card: FakeCard| card.display_name);
290
291 let ep_a = discovery
293 .register(DiscoverySpec::Model {
294 namespace: "ns".to_string(),
295 component: "comp".to_string(),
296 endpoint: "ep-a".to_string(),
297 card_json: serde_json::json!({ "display_name": "model-on-ep-a" }),
298 model_suffix: None,
299 })
300 .await
301 .unwrap();
302
303 discovery
305 .register(DiscoverySpec::Model {
306 namespace: "ns".to_string(),
307 component: "comp".to_string(),
308 endpoint: "ep-b".to_string(),
309 card_json: serde_json::json!({ "display_name": "model-on-ep-b" }),
310 model_suffix: None,
311 })
312 .await
313 .unwrap();
314
315 poll_until(
316 &rx,
317 |s| s.contains_key(&7),
318 "Worker 7 should appear after registrations",
319 )
320 .await;
321
322 discovery.unregister(ep_a).await.unwrap();
324
325 poll_until(
326 &rx,
327 |s| s.get(&7).map(|v| v.as_str()) == Some("model-on-ep-b"),
328 "Worker 7 should still be present via ep-b after removing ep-a",
329 )
330 .await;
331 }
332}