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::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 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 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 let stream = async_stream::stream! {
236 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 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 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 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 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 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}