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