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 {
36            discovery,
37            query,
38            topic,
39            cancel_token: CancellationToken::new(),
40        }
41    }
42
43    /// Start watching discovery and create a merged stream of events.
44    pub async fn start_zmq(self: Arc<Self>) -> Result<WireStream> {
45        // Bounded merged channel. Many peer publishers (e.g. every other
46        // frontend under replica-sync) feed this single-consumer channel; an
47        // unbounded channel grows RSS without limit when the consumer can't keep
48        // up (observed ~80 GiB/frontend at 168 frontends). Cap it and drop on
49        // overflow — the event plane is already best-effort/lossy (ZMQ RCVHWM),
50        // so a dropped event costs routing-estimate freshness, not correctness.
51        // Configurable via DYN_ZMQ_EVENT_SUBSCRIBER_CHANNEL_CAPACITY (default
52        // 100_000, matching ZMQ_RCVHWM).
53        let channel_cap = std::env::var(DYN_ZMQ_EVENT_SUBSCRIBER_CHANNEL_CAPACITY)
54            .ok()
55            .and_then(|v| v.parse::<usize>().ok())
56            .filter(|&n| n > 0)
57            .unwrap_or(100_000);
58        let (event_tx, event_rx) = mpsc::channel::<Bytes>(channel_cap);
59
60        // Track active endpoint connections with instance ID to endpoint mapping
61        let active_endpoints: Arc<
62            RwLock<HashMap<DiscoveryInstanceId, (String, CancellationToken)>>,
63        > = Arc::new(RwLock::new(HashMap::new()));
64
65        // Clone self for the spawned task
66        let subscriber_clone = Arc::clone(&self);
67
68        // Spawn background task to watch discovery
69        let discovery = Arc::clone(&self.discovery);
70        let query = self.query.clone();
71        // Use the actual topic for ZMQ native filtering (avoids decoding irrelevant messages)
72        let zmq_topic = self.topic.clone();
73        let cancel_token = self.cancel_token.clone();
74        let endpoints = Arc::clone(&active_endpoints);
75
76        tokio::spawn(async move {
77            tracing::debug!(
78                ?query,
79                cancel_token_cancelled = cancel_token.is_cancelled(),
80                "Attempting to start discovery watch"
81            );
82
83            // Don't pass the cancel token to list_and_watch - we'll handle cancellation ourselves
84            let mut watch_stream = match discovery.list_and_watch(query.clone(), None).await {
85                Ok(stream) => {
86                    tracing::debug!("Successfully obtained discovery watch stream");
87                    stream
88                }
89                Err(e) => {
90                    tracing::error!(error = %e, "Failed to start discovery watch");
91                    return;
92                }
93            };
94
95            tracing::info!(?query, "Started dynamic discovery watch for ZMQ publishers");
96
97            while let Some(event_result) = watch_stream.next().await {
98                tracing::debug!("Received discovery event: {:?}", event_result);
99                if cancel_token.is_cancelled() {
100                    tracing::info!("Dynamic subscriber cancelled, stopping watch");
101                    break;
102                }
103
104                match event_result {
105                    Ok(DiscoveryEvent::Added(instance)) => {
106                        tracing::info!(instance = ?instance, "Discovery Added event received");
107                        let instance_id = instance.id();
108
109                        // Extract ZMQ endpoint from the instance
110                        if let Some(endpoint) = Self::extract_zmq_endpoint(&instance, &zmq_topic) {
111                            let mut endpoints_guard = endpoints.write().await;
112
113                            // Skip if instance already tracked
114                            if endpoints_guard.contains_key(&instance_id) {
115                                tracing::debug!(endpoint = %endpoint, ?instance_id, "Already connected to ZMQ publisher");
116                                continue;
117                            }
118
119                            tracing::info!(endpoint = %endpoint, ?instance_id, "Connecting to new ZMQ publisher");
120
121                            // Create cancellation token for this endpoint's stream
122                            let endpoint_cancel = CancellationToken::new();
123                            endpoints_guard.insert(
124                                instance_id.clone(),
125                                (endpoint.clone(), endpoint_cancel.clone()),
126                            );
127                            drop(endpoints_guard);
128
129                            // Spawn task to handle this endpoint's stream
130                            let event_tx_clone = event_tx.clone();
131                            let zmq_topic_clone = zmq_topic.clone();
132                            let endpoint_clone = endpoint.clone();
133                            let endpoints_clone = Arc::clone(&endpoints);
134                            let instance_id_clone = instance_id.clone();
135
136                            tokio::spawn(async move {
137                                if let Err(e) = Self::consume_endpoint_stream(
138                                    &endpoint_clone,
139                                    &zmq_topic_clone,
140                                    event_tx_clone,
141                                    endpoint_cancel,
142                                )
143                                .await
144                                {
145                                    tracing::warn!(
146                                        endpoint = %endpoint_clone,
147                                        error = %e,
148                                        "Error consuming ZMQ endpoint stream"
149                                    );
150                                }
151                                // Clean up on stream termination
152                                endpoints_clone.write().await.remove(&instance_id_clone);
153                            });
154                        } else {
155                            tracing::debug!(
156                                instance = ?instance,
157                                expected_topic = %zmq_topic,
158                                "Discovery event is not a matching ZMQ publisher"
159                            );
160                        }
161                    }
162                    Ok(DiscoveryEvent::Removed(instance_id)) => {
163                        let is_expected_topic = matches!(
164                            &instance_id,
165                            DiscoveryInstanceId::EventChannel(channel_id)
166                                if channel_id.topic == zmq_topic
167                        );
168                        if !is_expected_topic {
169                            tracing::debug!(
170                                ?instance_id,
171                                expected_topic = %zmq_topic,
172                                "Ignoring removal for unrelated event channel"
173                            );
174                            continue;
175                        }
176
177                        tracing::info!(
178                            ?instance_id,
179                            "ZMQ publisher removed from discovery, cancelling endpoint stream"
180                        );
181
182                        // Cancel the endpoint's stream via its CancellationToken
183                        if let Some((_endpoint, cancel)) =
184                            endpoints.write().await.remove(&instance_id)
185                        {
186                            cancel.cancel();
187                            tracing::info!(?instance_id, "Cancelled endpoint stream");
188                        } else {
189                            tracing::debug!(
190                                ?instance_id,
191                                "No active endpoint found for removed stream instance"
192                            );
193                        }
194                    }
195                    Err(e) => {
196                        tracing::error!(error = %e, "Discovery watch error");
197                        break;
198                    }
199                }
200            }
201
202            // Cancel all active endpoints on shutdown
203            let endpoints_guard = endpoints.write().await;
204            for (_id, (_endpoint, cancel)) in endpoints_guard.iter() {
205                cancel.cancel();
206            }
207            tracing::info!("Discovery watch stream ended");
208        });
209
210        // Return a stream that reads from the merged channel
211        let stream = async_stream::stream! {
212            // Keep subscriber_clone alive by capturing it in the stream
213            let _subscriber = subscriber_clone;
214            let mut rx = event_rx;
215            while let Some(bytes) = rx.recv().await {
216                yield Ok(bytes);
217            }
218        };
219
220        Ok(Box::pin(stream))
221    }
222
223    /// Extract ZMQ endpoint from a discovery instance.
224    fn extract_zmq_endpoint(instance: &DiscoveryInstance, expected_topic: &str) -> Option<String> {
225        if let DiscoveryInstance::EventChannel {
226            topic, transport, ..
227        } = instance
228            && topic == expected_topic
229            && let EventTransport::Zmq { endpoint } = transport
230        {
231            return Some(endpoint.clone());
232        }
233        None
234    }
235
236    /// Consume events from a single endpoint and forward to the merged channel.
237    async fn consume_endpoint_stream(
238        endpoint: &str,
239        zmq_topic: &str,
240        event_tx: mpsc::Sender<Bytes>,
241        cancel_token: CancellationToken,
242    ) -> Result<()> {
243        // Connect to the endpoint
244        let sub_transport = ZmqSubTransport::connect(endpoint, zmq_topic).await?;
245        let mut stream = sub_transport.subscribe(zmq_topic).await?;
246
247        tracing::info!(endpoint = %endpoint, topic = %zmq_topic, "Started consuming ZMQ endpoint stream");
248
249        loop {
250            tokio::select! {
251                _ = cancel_token.cancelled() => {
252                    tracing::info!(endpoint = %endpoint, "Endpoint stream cancelled");
253                    break;
254                }
255
256                event = stream.next() => {
257                    match event {
258                        Some(Ok(bytes)) => {
259                            match event_tx.try_send(bytes) {
260                                Ok(()) => {}
261                                Err(mpsc::error::TrySendError::Full(_)) => {
262                                    // Consumer is behind; drop to bound memory.
263                                    // Best-effort plane — a stale estimate
264                                    // self-corrects on subsequent events.
265                                    tracing::trace!(endpoint = %endpoint, "Event subscriber channel full; dropping event");
266                                }
267                                Err(mpsc::error::TrySendError::Closed(_)) => {
268                                    tracing::warn!(endpoint = %endpoint, "Event channel closed, stopping endpoint stream");
269                                    break;
270                                }
271                            }
272                        }
273                        Some(Err(e)) => {
274                            tracing::error!(
275                                endpoint = %endpoint,
276                                error = %e,
277                                "Error receiving from ZMQ endpoint"
278                            );
279                            break;
280                        }
281                        None => {
282                            tracing::info!(endpoint = %endpoint, "ZMQ endpoint stream ended");
283                            break;
284                        }
285                    }
286                }
287            }
288        }
289
290        Ok(())
291    }
292
293    /// Stop watching and disconnect from all endpoints.
294    pub fn cancel(&self) {
295        self.cancel_token.cancel();
296    }
297}
298
299impl Drop for DynamicSubscriber {
300    fn drop(&mut self) {
301        self.cancel_token.cancel();
302    }
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308
309    fn event_channel(topic: &str, transport: EventTransport) -> DiscoveryInstance {
310        DiscoveryInstance::EventChannel {
311            namespace: "test-ns".to_string(),
312            component: "test-component".to_string(),
313            topic: topic.to_string(),
314            instance_id: 1,
315            transport,
316        }
317    }
318
319    #[test]
320    fn extracts_only_matching_zmq_topic() {
321        let matching = event_channel("kv-events", EventTransport::zmq("tcp://127.0.0.1:1"));
322        let wrong_topic = event_channel("kv-metrics", EventTransport::zmq("tcp://127.0.0.1:2"));
323        let wrong_transport = event_channel(
324            "kv-events",
325            EventTransport::nats("namespace.test-ns.component.test-component"),
326        );
327
328        assert_eq!(
329            DynamicSubscriber::extract_zmq_endpoint(&matching, "kv-events").as_deref(),
330            Some("tcp://127.0.0.1:1")
331        );
332        assert_eq!(
333            DynamicSubscriber::extract_zmq_endpoint(&wrong_topic, "kv-events"),
334            None
335        );
336        assert_eq!(
337            DynamicSubscriber::extract_zmq_endpoint(&wrong_transport, "kv-events"),
338            None
339        );
340    }
341}