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