dynamo_runtime/transports/event_plane/
dynamic_subscriber.rs1use 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
25pub 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 pub async fn start_zmq(self: Arc<Self>) -> Result<WireStream> {
54 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 let active_endpoints: Arc<
71 RwLock<HashMap<DiscoveryInstanceId, (String, CancellationToken)>>,
72 > = Arc::new(RwLock::new(HashMap::new()));
73
74 let subscriber_clone = Arc::clone(&self);
76
77 let discovery = Arc::clone(&self.discovery);
79 let query = self.query.clone();
80 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 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 if let Some(endpoint) = Self::extract_zmq_endpoint(&instance, &zmq_topic) {
133 let mut endpoints_guard = endpoints.write().await;
134
135 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 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 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 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 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 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 let stream = async_stream::stream! {
234 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 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 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 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 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 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}