Skip to main content

dynamo_runtime/transports/event_plane/
dynamic_subscriber.rs

1// SPDX-FileCopyrightText: Copyright (c) 2024-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Dynamic subscriber that watches discovery and manages connections to multiple publishers.
5//!
6//! This module enables automatic discovery and connection to new publishers as they come online,
7//! and cleanup of disconnected publishers.
8
9use anyhow::Result;
10use bytes::Bytes;
11use futures::stream::StreamExt;
12use std::collections::HashMap;
13use std::sync::Arc;
14use tokio::sync::{RwLock, mpsc};
15use tokio_util::sync::CancellationToken;
16
17use super::transport::{EventTransportRx, WireStream};
18use super::zmq_transport::ZmqSubTransport;
19use crate::config::environment_names::event_plane::DYN_ZMQ_EVENT_SUBSCRIBER_CHANNEL_CAPACITY;
20use crate::discovery::{
21    Discovery, DiscoveryEvent, DiscoveryInstance, DiscoveryInstanceId, DiscoveryQuery,
22    EventTransport,
23};
24
25/// Manages dynamic subscriptions to multiple publishers.
26pub struct DynamicSubscriber {
27    discovery: Arc<dyn Discovery>,
28    query: DiscoveryQuery,
29    topic: String,
30    cancel_token: CancellationToken,
31}
32
33impl DynamicSubscriber {
34    pub fn new(discovery: Arc<dyn Discovery>, query: DiscoveryQuery, topic: String) -> Self {
35        Self::with_cancel_token(discovery, query, topic, CancellationToken::new())
36    }
37
38    pub fn with_cancel_token(
39        discovery: Arc<dyn Discovery>,
40        query: DiscoveryQuery,
41        topic: String,
42        cancel_token: CancellationToken,
43    ) -> Self {
44        Self {
45            discovery,
46            query,
47            topic,
48            cancel_token,
49        }
50    }
51
52    /// Start watching discovery and create a merged stream of events.
53    pub async fn start_zmq(self: Arc<Self>) -> Result<WireStream> {
54        // Bounded merged channel. Many peer publishers (e.g. every other
55        // frontend under replica-sync) feed this single-consumer channel; an
56        // unbounded channel grows RSS without limit when the consumer can't keep
57        // up (observed ~80 GiB/frontend at 168 frontends). Cap it and drop on
58        // overflow — the event plane is already best-effort/lossy (ZMQ RCVHWM),
59        // so a dropped event costs routing-estimate freshness, not correctness.
60        // Configurable via DYN_ZMQ_EVENT_SUBSCRIBER_CHANNEL_CAPACITY (default
61        // 100_000, matching ZMQ_RCVHWM).
62        let channel_cap = std::env::var(DYN_ZMQ_EVENT_SUBSCRIBER_CHANNEL_CAPACITY)
63            .ok()
64            .and_then(|v| v.parse::<usize>().ok())
65            .filter(|&n| n > 0)
66            .unwrap_or(100_000);
67        let (event_tx, event_rx) = mpsc::channel::<Bytes>(channel_cap);
68
69        // Track active endpoint connections with instance ID to endpoint mapping
70        let active_endpoints: Arc<
71            RwLock<HashMap<DiscoveryInstanceId, (String, CancellationToken)>>,
72        > = Arc::new(RwLock::new(HashMap::new()));
73
74        // Clone self for the spawned task
75        let subscriber_clone = Arc::clone(&self);
76
77        // Spawn background task to watch discovery
78        let discovery = Arc::clone(&self.discovery);
79        let query = self.query.clone();
80        // Use the actual topic for ZMQ native filtering (avoids decoding irrelevant messages)
81        let zmq_topic = self.topic.clone();
82        let cancel_token = self.cancel_token.clone();
83        let endpoints = Arc::clone(&active_endpoints);
84
85        tokio::spawn(async move {
86            tracing::debug!(
87                ?query,
88                cancel_token_cancelled = cancel_token.is_cancelled(),
89                "Attempting to start discovery watch"
90            );
91
92            // Pass cancellation through so the discovery backend can stop any
93            // task that it owns in addition to the consumer loop below.
94            let mut watch_stream = match discovery
95                .list_and_watch(query.clone(), Some(cancel_token.clone()))
96                .await
97            {
98                Ok(stream) => {
99                    tracing::debug!("Successfully obtained discovery watch stream");
100                    stream
101                }
102                Err(e) => {
103                    tracing::error!(error = %e, "Failed to start discovery watch");
104                    return;
105                }
106            };
107
108            tracing::info!(?query, "Started dynamic discovery watch for ZMQ publishers");
109
110            loop {
111                let event_result = tokio::select! {
112                    biased;
113
114                    _ = cancel_token.cancelled() => {
115                        tracing::info!("Dynamic subscriber cancelled, stopping watch");
116                        break;
117                    }
118                    result = watch_stream.next() => match result {
119                        Some(result) => result,
120                        None => break,
121                    },
122                };
123
124                tracing::debug!("Received discovery event: {:?}", event_result);
125
126                match event_result {
127                    Ok(DiscoveryEvent::Added(instance)) => {
128                        tracing::info!(instance = ?instance, "Discovery Added event received");
129                        let instance_id = instance.id();
130
131                        // Extract ZMQ endpoint from the instance
132                        if let Some(endpoint) = Self::extract_zmq_endpoint(&instance, &zmq_topic) {
133                            let mut endpoints_guard = endpoints.write().await;
134
135                            // Skip if instance already tracked
136                            if endpoints_guard.contains_key(&instance_id) {
137                                tracing::debug!(endpoint = %endpoint, ?instance_id, "Already connected to ZMQ publisher");
138                                continue;
139                            }
140
141                            tracing::info!(endpoint = %endpoint, ?instance_id, "Connecting to new ZMQ publisher");
142
143                            // Create cancellation token for this endpoint's stream
144                            let endpoint_cancel = CancellationToken::new();
145                            endpoints_guard.insert(
146                                instance_id.clone(),
147                                (endpoint.clone(), endpoint_cancel.clone()),
148                            );
149                            drop(endpoints_guard);
150
151                            // Spawn task to handle this endpoint's stream
152                            let event_tx_clone = event_tx.clone();
153                            let zmq_topic_clone = zmq_topic.clone();
154                            let endpoint_clone = endpoint.clone();
155                            let endpoints_clone = Arc::clone(&endpoints);
156                            let instance_id_clone = instance_id.clone();
157
158                            tokio::spawn(async move {
159                                if let Err(e) = Self::consume_endpoint_stream(
160                                    &endpoint_clone,
161                                    &zmq_topic_clone,
162                                    event_tx_clone,
163                                    endpoint_cancel,
164                                )
165                                .await
166                                {
167                                    tracing::warn!(
168                                        endpoint = %endpoint_clone,
169                                        error = %e,
170                                        "Error consuming ZMQ endpoint stream"
171                                    );
172                                }
173                                // Clean up on stream termination
174                                endpoints_clone.write().await.remove(&instance_id_clone);
175                            });
176                        } else {
177                            tracing::debug!(
178                                instance = ?instance,
179                                expected_topic = %zmq_topic,
180                                "Discovery event is not a matching ZMQ publisher"
181                            );
182                        }
183                    }
184                    Ok(DiscoveryEvent::ModelTaintsUpdated(_)) => {}
185                    Ok(DiscoveryEvent::Removed(instance_id)) => {
186                        let is_expected_topic = matches!(
187                            &instance_id,
188                            DiscoveryInstanceId::EventChannel(channel_id)
189                                if channel_id.topic == zmq_topic
190                        );
191                        if !is_expected_topic {
192                            tracing::debug!(
193                                ?instance_id,
194                                expected_topic = %zmq_topic,
195                                "Ignoring removal for unrelated event channel"
196                            );
197                            continue;
198                        }
199
200                        tracing::info!(
201                            ?instance_id,
202                            "ZMQ publisher removed from discovery, cancelling endpoint stream"
203                        );
204
205                        // Cancel the endpoint's stream via its CancellationToken
206                        if let Some((_endpoint, cancel)) =
207                            endpoints.write().await.remove(&instance_id)
208                        {
209                            cancel.cancel();
210                            tracing::info!(?instance_id, "Cancelled endpoint stream");
211                        } else {
212                            tracing::debug!(
213                                ?instance_id,
214                                "No active endpoint found for removed stream instance"
215                            );
216                        }
217                    }
218                    Err(e) => {
219                        tracing::error!(error = %e, "Discovery watch error");
220                        break;
221                    }
222                }
223            }
224
225            // Cancel all active endpoints on shutdown
226            let endpoints_guard = endpoints.write().await;
227            for (_id, (_endpoint, cancel)) in endpoints_guard.iter() {
228                cancel.cancel();
229            }
230            tracing::info!("Discovery watch stream ended");
231        });
232
233        // Return a stream that reads from the merged channel
234        let stream = async_stream::stream! {
235            // Keep subscriber_clone alive by capturing it in the stream
236            let _subscriber = subscriber_clone;
237            let mut rx = event_rx;
238            while let Some(bytes) = rx.recv().await {
239                yield Ok(bytes);
240            }
241        };
242
243        Ok(Box::pin(stream))
244    }
245
246    /// Extract ZMQ endpoint from a discovery instance.
247    fn extract_zmq_endpoint(instance: &DiscoveryInstance, expected_topic: &str) -> Option<String> {
248        if let DiscoveryInstance::EventChannel {
249            topic, transport, ..
250        } = instance
251            && topic == expected_topic
252            && let EventTransport::Zmq { endpoint } = transport
253        {
254            return Some(endpoint.clone());
255        }
256        None
257    }
258
259    /// Consume events from a single endpoint and forward to the merged channel.
260    async fn consume_endpoint_stream(
261        endpoint: &str,
262        zmq_topic: &str,
263        event_tx: mpsc::Sender<Bytes>,
264        cancel_token: CancellationToken,
265    ) -> Result<()> {
266        // Connect to the endpoint
267        let sub_transport = ZmqSubTransport::connect(endpoint, zmq_topic).await?;
268        let mut stream = sub_transport.subscribe(zmq_topic).await?;
269
270        tracing::info!(endpoint = %endpoint, topic = %zmq_topic, "Started consuming ZMQ endpoint stream");
271
272        loop {
273            tokio::select! {
274                _ = cancel_token.cancelled() => {
275                    tracing::info!(endpoint = %endpoint, "Endpoint stream cancelled");
276                    break;
277                }
278
279                event = stream.next() => {
280                    match event {
281                        Some(Ok(bytes)) => {
282                            match event_tx.try_send(bytes) {
283                                Ok(()) => {}
284                                Err(mpsc::error::TrySendError::Full(_)) => {
285                                    // Consumer is behind; drop to bound memory.
286                                    // Best-effort plane — a stale estimate
287                                    // self-corrects on subsequent events.
288                                    tracing::trace!(endpoint = %endpoint, "Event subscriber channel full; dropping event");
289                                }
290                                Err(mpsc::error::TrySendError::Closed(_)) => {
291                                    tracing::warn!(endpoint = %endpoint, "Event channel closed, stopping endpoint stream");
292                                    break;
293                                }
294                            }
295                        }
296                        Some(Err(e)) => {
297                            tracing::error!(
298                                endpoint = %endpoint,
299                                error = %e,
300                                "Error receiving from ZMQ endpoint"
301                            );
302                            break;
303                        }
304                        None => {
305                            tracing::info!(endpoint = %endpoint, "ZMQ endpoint stream ended");
306                            break;
307                        }
308                    }
309                }
310            }
311        }
312
313        Ok(())
314    }
315
316    /// Stop watching and disconnect from all endpoints.
317    pub fn cancel(&self) {
318        self.cancel_token.cancel();
319    }
320}
321
322impl Drop for DynamicSubscriber {
323    fn drop(&mut self) {
324        self.cancel_token.cancel();
325    }
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331    use crate::discovery::{DiscoverySpec, DiscoveryStream, EventChannelQuery, EventScope};
332    use tokio::sync::Notify;
333    use tokio::time::{Duration, timeout};
334
335    struct CancellationAwareDiscovery {
336        backend_stopped: Arc<Notify>,
337    }
338
339    #[async_trait::async_trait]
340    impl Discovery for CancellationAwareDiscovery {
341        fn instance_id(&self) -> u64 {
342            1
343        }
344
345        async fn register_internal(&self, _spec: DiscoverySpec) -> Result<DiscoveryInstance> {
346            anyhow::bail!("register is not supported by this test discovery")
347        }
348
349        async fn unregister(&self, _instance: DiscoveryInstance) -> Result<()> {
350            anyhow::bail!("unregister is not supported by this test discovery")
351        }
352
353        async fn list(&self, _query: DiscoveryQuery) -> Result<Vec<DiscoveryInstance>> {
354            Ok(Vec::new())
355        }
356
357        async fn list_and_watch(
358            &self,
359            _query: DiscoveryQuery,
360            cancel_token: Option<CancellationToken>,
361        ) -> Result<DiscoveryStream> {
362            let cancel_token = cancel_token
363                .ok_or_else(|| anyhow::anyhow!("dynamic subscriber must pass cancellation"))?;
364            let backend_stopped = Arc::clone(&self.backend_stopped);
365            tokio::spawn(async move {
366                cancel_token.cancelled().await;
367                backend_stopped.notify_one();
368            });
369
370            Ok(Box::pin(futures::stream::pending()))
371        }
372    }
373
374    fn event_channel(topic: &str, transport: EventTransport) -> DiscoveryInstance {
375        DiscoveryInstance::EventChannel {
376            scope: EventScope::Component {
377                namespace: "test-ns".to_string(),
378                component: "test-component".to_string(),
379            },
380            topic: topic.to_string(),
381            instance_id: 1,
382            transport,
383        }
384    }
385
386    #[test]
387    fn extracts_only_matching_zmq_topic() {
388        let matching = event_channel("kv-events", EventTransport::zmq("tcp://127.0.0.1:1"));
389        let wrong_topic = event_channel("kv-metrics", EventTransport::zmq("tcp://127.0.0.1:2"));
390        let wrong_transport = event_channel(
391            "kv-events",
392            EventTransport::nats("namespace.test-ns.component.test-component"),
393        );
394
395        assert_eq!(
396            DynamicSubscriber::extract_zmq_endpoint(&matching, "kv-events").as_deref(),
397            Some("tcp://127.0.0.1:1")
398        );
399        assert_eq!(
400            DynamicSubscriber::extract_zmq_endpoint(&wrong_topic, "kv-events"),
401            None
402        );
403        assert_eq!(
404            DynamicSubscriber::extract_zmq_endpoint(&wrong_transport, "kv-events"),
405            None
406        );
407    }
408
409    #[tokio::test]
410    async fn cancellation_stops_idle_discovery_watch() {
411        let backend_stopped = Arc::new(Notify::new());
412        let discovery = Arc::new(CancellationAwareDiscovery {
413            backend_stopped: Arc::clone(&backend_stopped),
414        });
415        let query = DiscoveryQuery::EventChannels(EventChannelQuery::topic(
416            "test-ns",
417            "test-component",
418            "kv-events",
419        ));
420        let subscriber = Arc::new(DynamicSubscriber::new(
421            discovery,
422            query,
423            "kv-events".to_string(),
424        ));
425        let mut stream = Arc::clone(&subscriber).start_zmq().await.unwrap();
426
427        tokio::task::yield_now().await;
428        subscriber.cancel();
429
430        let next = timeout(Duration::from_secs(1), stream.next())
431            .await
432            .expect("subscriber stream should close promptly after cancellation");
433        assert!(next.is_none());
434
435        timeout(Duration::from_secs(1), backend_stopped.notified())
436            .await
437            .expect("discovery backend should receive cancellation");
438    }
439
440    #[tokio::test]
441    async fn dropping_returned_stream_cancels_idle_discovery_watch() {
442        let backend_stopped = Arc::new(Notify::new());
443        let discovery = Arc::new(CancellationAwareDiscovery {
444            backend_stopped: Arc::clone(&backend_stopped),
445        });
446        let query = DiscoveryQuery::EventChannels(EventChannelQuery::topic(
447            "test-ns",
448            "test-component",
449            "kv-events",
450        ));
451        let parent_token = CancellationToken::new();
452        let subscriber = Arc::new(DynamicSubscriber::with_cancel_token(
453            discovery,
454            query,
455            "kv-events".to_string(),
456            parent_token.child_token(),
457        ));
458        let weak_subscriber = Arc::downgrade(&subscriber);
459        let stream = subscriber.start_zmq().await.unwrap();
460
461        assert!(
462            weak_subscriber.upgrade().is_some(),
463            "returned stream should retain the dynamic subscriber"
464        );
465        drop(stream);
466        assert!(
467            weak_subscriber.upgrade().is_none(),
468            "dropping the returned stream should release the dynamic subscriber"
469        );
470        assert!(
471            !parent_token.is_cancelled(),
472            "dropping a subscriber must not cancel its parent token"
473        );
474
475        timeout(Duration::from_secs(1), backend_stopped.notified())
476            .await
477            .expect("dropping the stream should cancel the discovery backend");
478    }
479}