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::Resync(_)) => {}
186                    Ok(DiscoveryEvent::Removed(instance_id)) => {
187                        let is_expected_topic = matches!(
188                            &instance_id,
189                            DiscoveryInstanceId::EventChannel(channel_id)
190                                if channel_id.topic == zmq_topic
191                        );
192                        if !is_expected_topic {
193                            tracing::debug!(
194                                ?instance_id,
195                                expected_topic = %zmq_topic,
196                                "Ignoring removal for unrelated event channel"
197                            );
198                            continue;
199                        }
200
201                        tracing::info!(
202                            ?instance_id,
203                            "ZMQ publisher removed from discovery, cancelling endpoint stream"
204                        );
205
206                        // Cancel the endpoint's stream via its CancellationToken
207                        if let Some((_endpoint, cancel)) =
208                            endpoints.write().await.remove(&instance_id)
209                        {
210                            cancel.cancel();
211                            tracing::info!(?instance_id, "Cancelled endpoint stream");
212                        } else {
213                            tracing::debug!(
214                                ?instance_id,
215                                "No active endpoint found for removed stream instance"
216                            );
217                        }
218                    }
219                    Err(e) => {
220                        tracing::error!(error = %e, "Discovery watch error");
221                        break;
222                    }
223                }
224            }
225
226            // Cancel all active endpoints on shutdown
227            let endpoints_guard = endpoints.write().await;
228            for (_id, (_endpoint, cancel)) in endpoints_guard.iter() {
229                cancel.cancel();
230            }
231            tracing::info!("Discovery watch stream ended");
232        });
233
234        // Return a stream that reads from the merged channel
235        let stream = async_stream::stream! {
236            // Keep subscriber_clone alive by capturing it in the stream
237            let _subscriber = subscriber_clone;
238            let mut rx = event_rx;
239            while let Some(bytes) = rx.recv().await {
240                yield Ok(bytes);
241            }
242        };
243
244        Ok(Box::pin(stream))
245    }
246
247    /// Extract ZMQ endpoint from a discovery instance.
248    fn extract_zmq_endpoint(instance: &DiscoveryInstance, expected_topic: &str) -> Option<String> {
249        if let DiscoveryInstance::EventChannel {
250            topic, transport, ..
251        } = instance
252            && topic == expected_topic
253            && let EventTransport::Zmq { endpoint } = transport
254        {
255            return Some(endpoint.clone());
256        }
257        None
258    }
259
260    /// Consume events from a single endpoint and forward to the merged channel.
261    async fn consume_endpoint_stream(
262        endpoint: &str,
263        zmq_topic: &str,
264        event_tx: mpsc::Sender<Bytes>,
265        cancel_token: CancellationToken,
266    ) -> Result<()> {
267        // Connect to the endpoint
268        let sub_transport = ZmqSubTransport::connect(endpoint, zmq_topic).await?;
269        let mut stream = sub_transport.subscribe(zmq_topic).await?;
270
271        tracing::info!(endpoint = %endpoint, topic = %zmq_topic, "Started consuming ZMQ endpoint stream");
272
273        loop {
274            tokio::select! {
275                _ = cancel_token.cancelled() => {
276                    tracing::info!(endpoint = %endpoint, "Endpoint stream cancelled");
277                    break;
278                }
279
280                event = stream.next() => {
281                    match event {
282                        Some(Ok(bytes)) => {
283                            match event_tx.try_send(bytes) {
284                                Ok(()) => {}
285                                Err(mpsc::error::TrySendError::Full(_)) => {
286                                    // Consumer is behind; drop to bound memory.
287                                    // Best-effort plane — a stale estimate
288                                    // self-corrects on subsequent events.
289                                    tracing::trace!(endpoint = %endpoint, "Event subscriber channel full; dropping event");
290                                }
291                                Err(mpsc::error::TrySendError::Closed(_)) => {
292                                    tracing::warn!(endpoint = %endpoint, "Event channel closed, stopping endpoint stream");
293                                    break;
294                                }
295                            }
296                        }
297                        Some(Err(e)) => {
298                            tracing::error!(
299                                endpoint = %endpoint,
300                                error = %e,
301                                "Error receiving from ZMQ endpoint"
302                            );
303                            break;
304                        }
305                        None => {
306                            tracing::info!(endpoint = %endpoint, "ZMQ endpoint stream ended");
307                            break;
308                        }
309                    }
310                }
311            }
312        }
313
314        Ok(())
315    }
316
317    /// Stop watching and disconnect from all endpoints.
318    pub fn cancel(&self) {
319        self.cancel_token.cancel();
320    }
321}
322
323impl Drop for DynamicSubscriber {
324    fn drop(&mut self) {
325        self.cancel_token.cancel();
326    }
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332    use crate::discovery::{DiscoverySpec, DiscoveryStream, EventChannelQuery, EventScope};
333    use tokio::sync::Notify;
334    use tokio::time::{Duration, timeout};
335
336    struct CancellationAwareDiscovery {
337        backend_stopped: Arc<Notify>,
338    }
339
340    #[async_trait::async_trait]
341    impl Discovery for CancellationAwareDiscovery {
342        fn instance_id(&self) -> u64 {
343            1
344        }
345
346        async fn register_internal(&self, _spec: DiscoverySpec) -> Result<DiscoveryInstance> {
347            anyhow::bail!("register is not supported by this test discovery")
348        }
349
350        async fn unregister(&self, _instance: DiscoveryInstance) -> Result<()> {
351            anyhow::bail!("unregister is not supported by this test discovery")
352        }
353
354        async fn list(&self, _query: DiscoveryQuery) -> Result<Vec<DiscoveryInstance>> {
355            Ok(Vec::new())
356        }
357
358        async fn list_and_watch(
359            &self,
360            _query: DiscoveryQuery,
361            cancel_token: Option<CancellationToken>,
362        ) -> Result<DiscoveryStream> {
363            let cancel_token = cancel_token
364                .ok_or_else(|| anyhow::anyhow!("dynamic subscriber must pass cancellation"))?;
365            let backend_stopped = Arc::clone(&self.backend_stopped);
366            tokio::spawn(async move {
367                cancel_token.cancelled().await;
368                backend_stopped.notify_one();
369            });
370
371            Ok(Box::pin(futures::stream::pending()))
372        }
373    }
374
375    fn event_channel(topic: &str, transport: EventTransport) -> DiscoveryInstance {
376        DiscoveryInstance::EventChannel {
377            scope: EventScope::Component {
378                namespace: "test-ns".to_string(),
379                component: "test-component".to_string(),
380            },
381            topic: topic.to_string(),
382            instance_id: 1,
383            transport,
384        }
385    }
386
387    #[test]
388    fn extracts_only_matching_zmq_topic() {
389        let matching = event_channel("kv-events", EventTransport::zmq("tcp://127.0.0.1:1"));
390        let wrong_topic = event_channel("kv-metrics", EventTransport::zmq("tcp://127.0.0.1:2"));
391        let wrong_transport = event_channel(
392            "kv-events",
393            EventTransport::nats("namespace.test-ns.component.test-component"),
394        );
395
396        assert_eq!(
397            DynamicSubscriber::extract_zmq_endpoint(&matching, "kv-events").as_deref(),
398            Some("tcp://127.0.0.1:1")
399        );
400        assert_eq!(
401            DynamicSubscriber::extract_zmq_endpoint(&wrong_topic, "kv-events"),
402            None
403        );
404        assert_eq!(
405            DynamicSubscriber::extract_zmq_endpoint(&wrong_transport, "kv-events"),
406            None
407        );
408    }
409
410    #[tokio::test]
411    async fn cancellation_stops_idle_discovery_watch() {
412        let backend_stopped = Arc::new(Notify::new());
413        let discovery = Arc::new(CancellationAwareDiscovery {
414            backend_stopped: Arc::clone(&backend_stopped),
415        });
416        let query = DiscoveryQuery::EventChannels(EventChannelQuery::topic(
417            "test-ns",
418            "test-component",
419            "kv-events",
420        ));
421        let subscriber = Arc::new(DynamicSubscriber::new(
422            discovery,
423            query,
424            "kv-events".to_string(),
425        ));
426        let mut stream = Arc::clone(&subscriber).start_zmq().await.unwrap();
427
428        tokio::task::yield_now().await;
429        subscriber.cancel();
430
431        let next = timeout(Duration::from_secs(1), stream.next())
432            .await
433            .expect("subscriber stream should close promptly after cancellation");
434        assert!(next.is_none());
435
436        timeout(Duration::from_secs(1), backend_stopped.notified())
437            .await
438            .expect("discovery backend should receive cancellation");
439    }
440
441    #[tokio::test]
442    async fn dropping_returned_stream_cancels_idle_discovery_watch() {
443        let backend_stopped = Arc::new(Notify::new());
444        let discovery = Arc::new(CancellationAwareDiscovery {
445            backend_stopped: Arc::clone(&backend_stopped),
446        });
447        let query = DiscoveryQuery::EventChannels(EventChannelQuery::topic(
448            "test-ns",
449            "test-component",
450            "kv-events",
451        ));
452        let parent_token = CancellationToken::new();
453        let subscriber = Arc::new(DynamicSubscriber::with_cancel_token(
454            discovery,
455            query,
456            "kv-events".to_string(),
457            parent_token.child_token(),
458        ));
459        let weak_subscriber = Arc::downgrade(&subscriber);
460        let stream = subscriber.start_zmq().await.unwrap();
461
462        assert!(
463            weak_subscriber.upgrade().is_some(),
464            "returned stream should retain the dynamic subscriber"
465        );
466        drop(stream);
467        assert!(
468            weak_subscriber.upgrade().is_none(),
469            "dropping the returned stream should release the dynamic subscriber"
470        );
471        assert!(
472            !parent_token.is_cancelled(),
473            "dropping a subscriber must not cancel its parent token"
474        );
475
476        timeout(Duration::from_secs(1), backend_stopped.notified())
477            .await
478            .expect("dropping the stream should cancel the discovery backend");
479    }
480}