Skip to main content

dynamo_runtime/discovery/
utils.rs

1// SPDX-FileCopyrightText: Copyright (c) 2024-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Utility functions for working with discovery streams
5
6use serde::Deserialize;
7
8use super::{DiscoveryEvent, DiscoveryInstance, DiscoveryInstanceId, DiscoveryStream};
9
10/// Collapse state keyed by full `DiscoveryInstanceId` into a flat HashMap<u64, V>.
11/// When multiple entries share the same instance_id (e.g., base model +
12/// LoRA adapters on the same worker, or the same worker on different endpoints),
13/// the base model (suffix=None) is preferred. If no base model exists, an
14/// arbitrary LoRA entry is used.
15fn 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
32/// Helper to watch a discovery stream and extract a specific field into a HashMap
33///
34/// This helper spawns a background task that:
35/// - Deserializes ModelCards from discovery events
36/// - Extracts a specific field using the provided extractor function
37/// - Maintains a HashMap<instance_id, Field> that auto-updates on Add/Remove events
38/// - Returns a watch::Receiver that consumers can use to read the current state
39///
40/// # Type Parameters
41/// - `T`: The type to deserialize from DiscoveryInstance (e.g., ModelDeploymentCard)
42/// - `V`: The extracted field type (e.g., ModelRuntimeConfig)
43/// - `F`: The extractor function type
44///
45/// # Arguments
46/// - `stream`: The discovery event stream to watch
47/// - `extractor`: Function that extracts the desired field from the deserialized type
48///
49/// # Example
50/// ```ignore
51/// let stream = discovery.list_and_watch(DiscoveryQuery::ComponentModels { ... }, None).await?;
52/// let runtime_configs_rx = watch_and_extract_field(
53///     stream,
54///     |card: ModelDeploymentCard| card.runtime_config,
55/// );
56///
57/// // Use it:
58/// let configs = runtime_configs_rx.borrow();
59/// if let Some(config) = configs.get(&worker_id) {
60///     // Use config...
61/// }
62/// ```
63pub 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        // Internal state keyed by full DiscoveryInstanceId to correctly
79        // distinguish entries across namespaces, components, endpoints, and
80        // model suffixes — even when they share the same raw instance_id.
81        // Collapsed to HashMap<u64, V> for consumers, preferring suffix=None
82        // (base model) when multiple entries exist for the same instance_id.
83        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                    // Deserialize the full instance into type T
93                    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                    // Extract the field we care about
106                    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                    // Only publish if the collapsed worker view actually changed,
118                    // to avoid waking downstream watchers on no-op events
119                    // (e.g., adding a LoRA when base model already represents the worker).
120                    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                    // Only publish if the collapsed worker view actually changed,
142                    // to avoid waking downstream watchers on no-op events
143                    // (e.g., adding a LoRA when base model already represents the worker).
144                    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                    // Continue processing other events
153                }
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    /// Minimal struct that mirrors the fields watch_and_extract_field deserializes.
170    #[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    /// Poll a watch receiver until the predicate is satisfied, or timeout after 1s.
186    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    /// Unregistering a single LoRA adapter must not remove the worker's
215    /// runtime config. Base model and other LoRA adapters on the same worker
216    /// share the same instance_id; removing one must leave the others intact.
217    #[tokio::test]
218    async fn test_lora_unregister_preserves_worker_runtime_config() {
219        // All registrations use the same instance_id (same worker)
220        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        // Watch the stream, extracting display_name as a stand-in for runtime_config
231        let rx = watch_and_extract_field(stream, |card: FakeCard| card.display_name);
232
233        // Register base model + LoRA-A + LoRA-B on the same worker (instance_id=42)
234        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        // Unregister LoRA-A only — base model and LoRA-B remain.
246        discovery.unregister(lora_a).await.unwrap();
247
248        // Base model is preferred in the collapsed view.
249        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        // Unregister the base model — lora-b should be the fallback.
262        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    /// Same worker (instance_id) registered on two different endpoints must not
278    /// alias when watched via AllModels. Removing the registration from one
279    /// endpoint must leave the other intact in the collapsed view.
280    #[tokio::test]
281    async fn test_all_models_cross_endpoint_no_alias() {
282        let registry = SharedMockRegistry::new();
283        // Same instance_id for both — simulates a single worker serving two endpoints
284        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        // Register on endpoint "ep-a"
293        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        // Register on endpoint "ep-b"
305        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        // Remove the ep-a registration — ep-b should keep worker 7 alive.
324        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}