Skip to main content

dynamo_runtime/transports/event_plane/
mod.rs

1// SPDX-FileCopyrightText: Copyright (c) 2024-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Generic Event Plane for transport-agnostic pub/sub communication.
5
6mod codec;
7mod dynamic_subscriber;
8mod frame;
9mod nats_transport;
10mod traits;
11mod transport;
12pub mod zmq_transport;
13
14pub use codec::{Codec, MsgpackCodec};
15pub use dynamic_subscriber::DynamicSubscriber;
16pub use frame::{FRAME_HEADER_SIZE, FRAME_VERSION, Frame, FrameError, FrameHeader};
17pub use traits::{EventEnvelope, EventStream, TypedEventStream};
18pub use transport::{EventTransportRx, EventTransportTx, WireStream};
19pub use zmq_transport::{
20    DynamicZmqSubSocket, ValidatedEnvelope, ValidatedZmqSource, ValidatedZmqSourceError,
21    ZmqPubTransport, ZmqSubTransport, ZmqWireMessage,
22};
23
24// Re-export transport kind from discovery for convenience
25pub use crate::discovery::{EventScope, EventTransportKind};
26
27use std::net::{IpAddr, SocketAddr};
28use std::num::NonZeroUsize;
29use std::sync::Arc;
30use std::sync::atomic::{AtomicU64, Ordering};
31use std::time::{SystemTime, UNIX_EPOCH};
32
33use anyhow::{Context as _, Result};
34use bytes::Bytes;
35use futures::{Stream, StreamExt};
36use lru::LruCache;
37use rand::TryRngCore;
38use serde::Serialize;
39use serde::de::DeserializeOwned;
40use std::pin::Pin;
41use std::task::{Context, Poll};
42
43use crate::DistributedRuntime;
44use crate::component::{Component, Endpoint, Namespace};
45use crate::config::environment_names::event_plane::DYN_EVENT_PLANE_HOST;
46use crate::discovery::{
47    Discovery, DiscoveryInstance, DiscoveryQuery, DiscoverySpec, EventChannelQuery, EventTransport,
48    MAX_JSON_SAFE_PUBLISHER_ID,
49};
50use crate::protocols::EndpointId;
51use crate::traits::DistributedRuntimeProvider;
52use crate::utils::ip_resolver::{
53    DefaultIpResolver, IpResolver, host_override_from_env, resolve_host_or_interface,
54    resolve_local_host,
55};
56
57fn event_plane_host_from_env() -> Result<IpAddr> {
58    event_plane_host_from_env_with_resolver(&DefaultIpResolver)
59}
60
61fn event_plane_host_from_env_with_resolver<R: IpResolver>(resolver: &R) -> Result<IpAddr> {
62    let Some(host) = host_override_from_env(DYN_EVENT_PLANE_HOST)? else {
63        let resolved = resolve_local_host(resolver)?;
64        if resolved.used_loopback_fallback() {
65            tracing::warn!(
66                address = %resolved.advertise_ip(),
67                "No usable non-loopback IP address found; set DYN_EVENT_PLANE_HOST to an address reachable by subscribers"
68            );
69        }
70        return Ok(resolved.advertise_ip());
71    };
72
73    resolve_host_or_interface(&host, resolver)
74        .map_err(|error| anyhow::anyhow!("Invalid {DYN_EVENT_PLANE_HOST} value '{host}': {error}"))
75        .and_then(|resolved| {
76            if resolved.bind_ip().to_canonical().is_unspecified() {
77                anyhow::bail!("Invalid {DYN_EVENT_PLANE_HOST} value '{host}': unspecified IP addresses cannot be advertised");
78            }
79            Ok(resolved.advertise_ip())
80        })
81}
82
83fn direct_zmq_public_endpoint(advertised_ip: IpAddr, actual_bind_endpoint: &str) -> Result<String> {
84    let bind_address = actual_bind_endpoint
85        .strip_prefix("tcp://")
86        .ok_or_else(|| anyhow::anyhow!("invalid ZMQ TCP bind endpoint: {actual_bind_endpoint}"))?
87        .parse::<SocketAddr>()
88        .map_err(|error| {
89            anyhow::anyhow!("invalid ZMQ TCP bind endpoint '{actual_bind_endpoint}': {error}")
90        })?;
91    Ok(format!(
92        "tcp://{}",
93        SocketAddr::new(advertised_ip, bind_address.port())
94    ))
95}
96
97// ============================================================================
98// Broker Resolution Logic
99// ============================================================================
100
101/// Broker endpoints for ZMQ broker mode
102#[derive(Debug, Clone)]
103struct BrokerEndpoints {
104    xsub_endpoints: Vec<String>,
105    xpub_endpoints: Vec<String>,
106}
107
108/// Whether the configured event plane selects direct per-publisher ZMQ sockets.
109///
110/// This intentionally answers only the topology question needed by specialized
111/// consumers. Broker discovery and connection errors remain owned by the normal
112/// [`EventSubscriber`] construction path.
113pub fn uses_direct_zmq(transport_kind: EventTransportKind) -> bool {
114    uses_direct_zmq_from_lookup(transport_kind, |key| std::env::var_os(key))
115}
116
117fn uses_direct_zmq_from_lookup(
118    transport_kind: EventTransportKind,
119    mut get_env: impl FnMut(&str) -> Option<std::ffi::OsString>,
120) -> bool {
121    if transport_kind != EventTransportKind::Zmq {
122        return false;
123    }
124
125    if get_env(crate::config::environment_names::zmq_broker::DYN_ZMQ_BROKER_URL).is_some() {
126        return false;
127    }
128
129    !get_env(crate::config::environment_names::zmq_broker::DYN_ZMQ_BROKER_ENABLED)
130        .is_some_and(|value| crate::config::is_truthy(&value.to_string_lossy()))
131}
132
133/// Resolve ZMQ broker endpoints from environment or discovery
134/// Returns None if broker mode is not configured (direct mode)
135async fn resolve_zmq_broker(
136    drt: &DistributedRuntime,
137    scope: &EventScope,
138) -> Result<Option<BrokerEndpoints>> {
139    // Priority 1: Explicit URL from DYN_ZMQ_BROKER_URL
140    if let Ok(broker_url) =
141        std::env::var(crate::config::environment_names::zmq_broker::DYN_ZMQ_BROKER_URL)
142    {
143        let (xsub_endpoints, xpub_endpoints) = parse_broker_url(&broker_url)?;
144        tracing::info!(
145            num_xsub = xsub_endpoints.len(),
146            num_xpub = xpub_endpoints.len(),
147            "Using explicit ZMQ broker URL"
148        );
149        return Ok(Some(BrokerEndpoints {
150            xsub_endpoints,
151            xpub_endpoints,
152        }));
153    }
154
155    // Priority 2: Discovery-based lookup if DYN_ZMQ_BROKER_ENABLED is truthy
156    if crate::config::env_is_truthy(
157        crate::config::environment_names::zmq_broker::DYN_ZMQ_BROKER_ENABLED,
158    ) {
159        let query = DiscoveryQuery::EventChannels(EventChannelQuery::component(
160            scope.namespace().to_string(),
161            "zmq_broker".to_string(),
162        ));
163
164        let instances = drt.discovery().list(query).await?;
165
166        // Collect all broker instances (multiple brokers for HA)
167        let mut xsub_endpoints = Vec::new();
168        let mut xpub_endpoints = Vec::new();
169
170        for instance in instances {
171            if let DiscoveryInstance::EventChannel { transport, .. } = instance
172                && let EventTransport::ZmqBroker {
173                    xsub_endpoints: xsubs,
174                    xpub_endpoints: xpubs,
175                } = transport
176            {
177                xsub_endpoints.extend(xsubs);
178                xpub_endpoints.extend(xpubs);
179            }
180        }
181
182        if xsub_endpoints.is_empty() {
183            anyhow::bail!(
184                "DYN_ZMQ_BROKER_ENABLED is set but no broker found in discovery for namespace '{}'",
185                scope.namespace()
186            );
187        }
188
189        tracing::info!(
190            num_brokers = xsub_endpoints.len(),
191            "Discovered ZMQ brokers from discovery plane"
192        );
193
194        return Ok(Some(BrokerEndpoints {
195            xsub_endpoints,
196            xpub_endpoints,
197        }));
198    }
199
200    // No broker configured - use direct mode
201    Ok(None)
202}
203
204/// Parse broker URL format: "xsub=tcp://host1:5555;tcp://host2:5555 , xpub=tcp://host1:5556;tcp://host2:5556"
205fn parse_broker_url(url: &str) -> Result<(Vec<String>, Vec<String>)> {
206    let parts: Vec<&str> = url.split(',').map(|s| s.trim()).collect();
207    if parts.len() != 2 {
208        anyhow::bail!(
209            "Invalid broker URL format. Expected 'xsub=<urls> , xpub=<urls>', got: {}",
210            url
211        );
212    }
213
214    let mut xsub_endpoints = Vec::new();
215    let mut xpub_endpoints = Vec::new();
216
217    for part in parts {
218        if let Some(urls_str) = part.strip_prefix("xsub=") {
219            xsub_endpoints = urls_str
220                .split(';')
221                .map(|s| s.trim().to_string())
222                .filter(|s| !s.is_empty())
223                .collect();
224        } else if let Some(urls_str) = part.strip_prefix("xpub=") {
225            xpub_endpoints = urls_str
226                .split(';')
227                .map(|s| s.trim().to_string())
228                .filter(|s| !s.is_empty())
229                .collect();
230        } else {
231            anyhow::bail!(
232                "Invalid broker URL part. Expected 'xsub=' or 'xpub=' prefix, got: {}",
233                part
234            );
235        }
236    }
237
238    if xsub_endpoints.is_empty() || xpub_endpoints.is_empty() {
239        anyhow::bail!(
240            "Broker URL must contain at least one xsub and one xpub endpoint. Got xsub={:?}, xpub={:?}",
241            xsub_endpoints,
242            xpub_endpoints
243        );
244    }
245
246    Ok((xsub_endpoints, xpub_endpoints))
247}
248
249/// Deduplicates events based on (publisher_id, sequence) tuple
250/// Required when connecting to multiple brokers in HA mode
251struct DeduplicatingStream {
252    inner: WireStream,
253    codec: Arc<Codec>,
254    seen_events: LruCache<(u64, u64), ()>, // (publisher_id, sequence) -> ()
255}
256
257impl DeduplicatingStream {
258    fn new(inner: WireStream, codec: Arc<Codec>, cache_size: usize) -> Self {
259        Self {
260            inner,
261            codec,
262            seen_events: LruCache::new(
263                NonZeroUsize::new(cache_size).expect("cache_size must be non-zero"),
264            ),
265        }
266    }
267}
268
269impl Stream for DeduplicatingStream {
270    type Item = Result<Bytes>;
271
272    fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
273        loop {
274            match Pin::new(&mut self.inner).poll_next(cx) {
275                Poll::Ready(Some(Ok(bytes))) => {
276                    // Decode envelope to extract publisher_id and sequence
277                    match self.codec.decode_envelope_identity(&bytes) {
278                        Ok(key) => {
279                            // Check if we've seen this event before
280                            if self.seen_events.contains(&key) {
281                                // Duplicate - skip and continue loop
282                                tracing::debug!(
283                                    publisher_id = key.0,
284                                    sequence = key.1,
285                                    "Filtered duplicate event from multi-broker setup"
286                                );
287                                continue;
288                            }
289
290                            // New event - record and return
291                            self.seen_events.put(key, ());
292                            return Poll::Ready(Some(Ok(bytes)));
293                        }
294                        Err(e) => {
295                            tracing::warn!(error = %e, "Failed to decode envelope for deduplication");
296                            return Poll::Ready(Some(Err(e)));
297                        }
298                    }
299                }
300                Poll::Ready(Some(Err(e))) => return Poll::Ready(Some(Err(e))),
301                Poll::Ready(None) => return Poll::Ready(None),
302                Poll::Pending => return Poll::Pending,
303            }
304        }
305    }
306}
307
308/// Keep publisher IDs exactly representable in float64-backed JSON metadata.
309fn discovery_safe_publisher_id(random_id: u64) -> u64 {
310    random_id & MAX_JSON_SAFE_PUBLISHER_ID
311}
312
313/// Event publisher for a specific topic.
314pub struct EventPublisher {
315    transport_kind: EventTransportKind,
316    topic: String,
317    subject: String,
318    publisher_id: u64,
319    sequence: AtomicU64,
320    tx: Arc<dyn EventTransportTx>,
321    codec: Arc<Codec>,
322    runtime_handle: tokio::runtime::Handle,
323    // Keeps unregister work in graceful-shutdown Phase 2 when dropped before shutdown.
324    graceful_shutdown_tracker: Arc<crate::utils::GracefulShutdownTracker>,
325    /// Discovery client and registered instance for unregistration on drop
326    discovery_client: Option<Arc<dyn Discovery>>,
327    discovery_instance: Option<crate::discovery::DiscoveryInstance>,
328}
329
330impl EventPublisher {
331    /// Create a publisher for an endpoint-scoped topic.
332    pub async fn for_endpoint(endpoint: &Endpoint, topic: impl Into<String>) -> Result<Self> {
333        let transport_kind = endpoint.drt().default_event_transport_kind();
334        Self::for_endpoint_with_transport(endpoint, topic, transport_kind).await
335    }
336
337    /// Create an endpoint-scoped publisher with explicit transport.
338    pub async fn for_endpoint_with_transport(
339        endpoint: &Endpoint,
340        topic: impl Into<String>,
341        transport_kind: EventTransportKind,
342    ) -> Result<Self> {
343        Self::new_internal(
344            endpoint.drt(),
345            EventScope::Endpoint {
346                endpoint: endpoint.id(),
347            },
348            topic.into(),
349            transport_kind,
350        )
351        .await
352    }
353
354    /// Create a publisher for an endpoint identity without constructing a local endpoint handle.
355    pub async fn for_endpoint_id(
356        drt: &DistributedRuntime,
357        endpoint: &EndpointId,
358        topic: impl Into<String>,
359    ) -> Result<Self> {
360        let transport_kind = drt.default_event_transport_kind();
361        Self::for_endpoint_id_with_transport(drt, endpoint, topic, transport_kind).await
362    }
363
364    /// Create an endpoint-identity publisher with an explicit transport.
365    pub async fn for_endpoint_id_with_transport(
366        drt: &DistributedRuntime,
367        endpoint: &EndpointId,
368        topic: impl Into<String>,
369        transport_kind: EventTransportKind,
370    ) -> Result<Self> {
371        Self::new_internal(
372            drt,
373            EventScope::Endpoint {
374                endpoint: endpoint.clone(),
375            },
376            topic.into(),
377            transport_kind,
378        )
379        .await
380    }
381
382    /// Create a publisher for a component-scoped topic.
383    ///
384    /// The event transport is chosen automatically: if `DYN_EVENT_PLANE` is set that
385    /// value is used; otherwise the runtime's default is used (ZMQ for local backends
386    /// such as `file`/`mem`, NATS for distributed backends such as `etcd`/`kubernetes`).
387    /// Use [`for_component_with_transport`](Self::for_component_with_transport) to
388    /// override explicitly.
389    pub async fn for_component(comp: &Component, topic: impl Into<String>) -> Result<Self> {
390        let transport_kind = comp.drt().default_event_transport_kind();
391        Self::for_component_with_transport(comp, topic, transport_kind).await
392    }
393
394    /// Create a publisher with explicit transport.
395    pub async fn for_component_with_transport(
396        comp: &Component,
397        topic: impl Into<String>,
398        transport_kind: EventTransportKind,
399    ) -> Result<Self> {
400        let drt = comp.drt();
401        let scope = EventScope::Component {
402            namespace: comp.namespace().name(),
403            component: comp.name().to_string(),
404        };
405        Self::new_internal(drt, scope, topic.into(), transport_kind).await
406    }
407
408    /// Create a publisher for a namespace-scoped topic.
409    ///
410    /// The event transport is chosen automatically: if `DYN_EVENT_PLANE` is set that
411    /// value is used; otherwise the runtime's default is used (ZMQ for local backends
412    /// such as `file`/`mem`, NATS for distributed backends such as `etcd`/`kubernetes`).
413    /// Use [`for_namespace_with_transport`](Self::for_namespace_with_transport) to
414    /// override explicitly.
415    pub async fn for_namespace(ns: &Namespace, topic: impl Into<String>) -> Result<Self> {
416        let transport_kind = ns.drt().default_event_transport_kind();
417        Self::for_namespace_with_transport(ns, topic, transport_kind).await
418    }
419
420    /// Create a namespace publisher with explicit transport.
421    pub async fn for_namespace_with_transport(
422        ns: &Namespace,
423        topic: impl Into<String>,
424        transport_kind: EventTransportKind,
425    ) -> Result<Self> {
426        let drt = ns.drt();
427        let scope = EventScope::Namespace { name: ns.name() };
428        Self::new_internal(drt, scope, topic.into(), transport_kind).await
429    }
430
431    async fn new_internal(
432        drt: &DistributedRuntime,
433        scope: EventScope,
434        topic: String,
435        transport_kind: EventTransportKind,
436    ) -> Result<Self> {
437        // Publishers are discovery objects in their own right. A single process
438        // can host multiple publishers for the same scope/topic, each with its
439        // own ZMQ endpoint and sequence space, so the process ID is not unique
440        // enough here.
441        let publisher_id = discovery_safe_publisher_id(
442            rand::rngs::OsRng
443                .try_next_u64()
444                .map_err(|error| anyhow::anyhow!("failed to generate publisher ID: {error}"))?,
445        );
446        let discovery = Some(drt.discovery());
447        let runtime_handle = drt.runtime().secondary();
448        let subject = scope.subject(&topic);
449        let graceful_shutdown_tracker = drt.graceful_shutdown_tracker();
450
451        // Use Msgpack codec for all transports
452        enum TransportSetup {
453            Nats(Arc<dyn EventTransportTx>, Arc<Codec>),
454            ZmqDirect(Arc<dyn EventTransportTx>, Arc<Codec>, String), // includes public endpoint
455            ZmqBroker(Arc<dyn EventTransportTx>, Arc<Codec>),
456        }
457
458        let transport_setup = match transport_kind {
459            EventTransportKind::Nats => {
460                let transport = Arc::new(nats_transport::NatsTransport::new_publisher(
461                    drt.clone(),
462                    subject.clone(),
463                ));
464                let codec = Arc::new(Codec::Msgpack(MsgpackCodec));
465                TransportSetup::Nats(transport as Arc<dyn EventTransportTx>, codec)
466            }
467            EventTransportKind::Zmq => {
468                // Check for broker mode
469                if let Some(broker) = resolve_zmq_broker(drt, &scope).await? {
470                    // BROKER MODE: Connect to broker (single or multiple endpoints)
471                    let pub_transport = if broker.xsub_endpoints.len() == 1 {
472                        zmq_transport::ZmqPubTransport::connect(&broker.xsub_endpoints[0], &subject)
473                            .await?
474                    } else {
475                        zmq_transport::ZmqPubTransport::connect_multiple(
476                            &broker.xsub_endpoints,
477                            &subject,
478                        )
479                        .await?
480                    };
481
482                    let codec = Arc::new(Codec::Msgpack(MsgpackCodec));
483                    TransportSetup::ZmqBroker(
484                        Arc::new(pub_transport) as Arc<dyn EventTransportTx>,
485                        codec,
486                    )
487                } else {
488                    // DIRECT MODE: Bind PUB socket
489                    let advertised_host = event_plane_host_from_env()?;
490                    let bind_endpoint = if advertised_host.is_ipv4() {
491                        "tcp://0.0.0.0:0"
492                    } else {
493                        "tcp://[::]:0"
494                    };
495                    let (pub_transport, actual_bind_endpoint) = std::thread::spawn({
496                        let topic = topic.clone();
497                        move || -> Result<(ZmqPubTransport, String)> {
498                            let rt = tokio::runtime::Builder::new_current_thread()
499                                .enable_all()
500                                .build()
501                                .context("Failed to create Tokio runtime for ZMQ")?;
502
503                            rt.block_on(ZmqPubTransport::bind(bind_endpoint, &topic))
504                                .with_context(|| {
505                                    format!(
506                                        "Failed to bind direct ZMQ publisher at {bind_endpoint} for advertised address {advertised_host}; check DYN_EVENT_PLANE_HOST and host IP support"
507                                    )
508                                })
509                        }
510                    })
511                    .join()
512                    .map_err(|_| anyhow::anyhow!("ZMQ initialization thread panicked"))??;
513
514                    let public_endpoint =
515                        direct_zmq_public_endpoint(advertised_host, &actual_bind_endpoint)?;
516                    tracing::info!(%public_endpoint, "direct ZMQ publisher advertised endpoint");
517
518                    let codec = Arc::new(Codec::Msgpack(MsgpackCodec));
519                    TransportSetup::ZmqDirect(
520                        Arc::new(pub_transport) as Arc<dyn EventTransportTx>,
521                        codec,
522                        public_endpoint,
523                    )
524                }
525            }
526        };
527
528        // Extract transport and codec, and register if needed
529        let (tx, codec, discovery_instance) = match transport_setup {
530            TransportSetup::Nats(tx, codec) => {
531                let transport_config = EventTransport::nats(scope.subject_prefix());
532                let spec = DiscoverySpec::EventChannel {
533                    scope: scope.clone(),
534                    topic: topic.clone(),
535                    publisher_id,
536                    transport: transport_config,
537                };
538
539                let discovery_instance = drt.discovery().register(spec).await?;
540                tracing::info!(
541                    topic = %topic,
542                    transport = ?transport_kind,
543                    publisher_id = %publisher_id,
544                    "EventPublisher registered with discovery"
545                );
546                (tx, codec, Some(discovery_instance))
547            }
548            TransportSetup::ZmqDirect(tx, codec, public_endpoint) => {
549                let transport_config = EventTransport::zmq(public_endpoint);
550                let spec = DiscoverySpec::EventChannel {
551                    scope: scope.clone(),
552                    topic: topic.clone(),
553                    publisher_id,
554                    transport: transport_config,
555                };
556
557                let discovery_instance = drt.discovery().register(spec).await?;
558                tracing::info!(
559                    topic = %topic,
560                    transport = ?transport_kind,
561                    publisher_id = %publisher_id,
562                    "EventPublisher registered with discovery (direct mode)"
563                );
564                (tx, codec, Some(discovery_instance))
565            }
566            TransportSetup::ZmqBroker(tx, codec) => {
567                tracing::info!(
568                    topic = %topic,
569                    transport = ?transport_kind,
570                    "EventPublisher in broker mode - skipping discovery registration"
571                );
572                (tx, codec, None)
573            }
574        };
575
576        Ok(Self {
577            transport_kind,
578            topic,
579            subject,
580            publisher_id,
581            sequence: AtomicU64::new(0),
582            tx,
583            codec,
584            runtime_handle,
585            graceful_shutdown_tracker,
586            discovery_client: discovery,
587            discovery_instance,
588        })
589    }
590
591    /// Publish a serializable event.
592    pub async fn publish<T: Serialize + Send + Sync>(&self, event: &T) -> Result<()> {
593        let payload = self.codec.encode_payload(event)?;
594        self.publish_bytes_ref(payload.as_ref()).await
595    }
596
597    /// Publish raw bytes.
598    pub async fn publish_bytes(&self, bytes: Vec<u8>) -> Result<()> {
599        self.publish_bytes_ref(&bytes).await
600    }
601
602    /// Publish raw bytes without taking ownership of the payload buffer.
603    pub async fn publish_bytes_ref(&self, bytes: &[u8]) -> Result<()> {
604        let envelope_bytes = self.codec.encode_envelope_parts(
605            self.publisher_id,
606            self.sequence.fetch_add(1, Ordering::SeqCst),
607            current_timestamp_ms(),
608            &self.topic,
609            bytes,
610        )?;
611
612        self.tx.publish(&self.subject, envelope_bytes).await
613    }
614
615    /// Get the publisher ID.
616    pub fn publisher_id(&self) -> u64 {
617        self.publisher_id
618    }
619
620    /// Get the topic.
621    pub fn topic(&self) -> &str {
622        &self.topic
623    }
624
625    /// Get the transport kind.
626    pub fn transport_kind(&self) -> EventTransportKind {
627        self.transport_kind
628    }
629}
630
631impl Drop for EventPublisher {
632    fn drop(&mut self) {
633        // Unregister from discovery on drop
634        if let (Some(discovery), Some(instance)) =
635            (self.discovery_client.take(), self.discovery_instance.take())
636        {
637            let topic = self.topic.clone();
638            let publisher_id = instance.instance_id();
639            let runtime_handle = self.runtime_handle.clone();
640            let shutdown_guard = self.graceful_shutdown_tracker.register_task();
641
642            // Drop can run outside any Tokio context (notably via PyO3 finalizers), so use
643            // the runtime that created the publisher rather than the ambient thread state.
644            let spawn_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || {
645                runtime_handle.spawn(async move {
646                    let _shutdown_guard = shutdown_guard;
647                    match discovery.unregister(instance).await {
648                        Ok(()) => {
649                            tracing::info!(
650                                topic = %topic,
651                                publisher_id = %publisher_id,
652                                "EventPublisher unregistered from discovery"
653                            );
654                        }
655                        Err(e) => {
656                            tracing::warn!(
657                                topic = %topic,
658                                publisher_id = %publisher_id,
659                                error = %e,
660                                "Failed to unregister EventPublisher from discovery"
661                            );
662                        }
663                    }
664                });
665            }));
666
667            if spawn_result.is_err() {
668                tracing::warn!(
669                    topic = %self.topic,
670                    publisher_id = %publisher_id,
671                    "Skipping EventPublisher unregister during drop because the runtime is unavailable"
672                );
673            }
674        }
675    }
676}
677
678/// Event subscriber for a specific topic.
679pub struct EventSubscriber {
680    stream: EventStream,
681    #[allow(dead_code)]
682    scope: EventScope,
683    #[allow(dead_code)]
684    topic: String,
685    codec: Arc<Codec>,
686}
687
688impl EventSubscriber {
689    /// Create a subscriber for an endpoint-scoped topic.
690    pub async fn for_endpoint(endpoint: &Endpoint, topic: impl Into<String>) -> Result<Self> {
691        let transport_kind = endpoint.drt().default_event_transport_kind();
692        Self::for_endpoint_with_transport(endpoint, topic, transport_kind).await
693    }
694
695    /// Create an endpoint-scoped subscriber with explicit transport.
696    pub async fn for_endpoint_with_transport(
697        endpoint: &Endpoint,
698        topic: impl Into<String>,
699        transport_kind: EventTransportKind,
700    ) -> Result<Self> {
701        Self::for_endpoint_id_with_transport(endpoint.drt(), &endpoint.id(), topic, transport_kind)
702            .await
703    }
704
705    /// Create a subscriber for an endpoint identity without constructing a
706    /// local [`Endpoint`] handle.
707    pub async fn for_endpoint_id(
708        drt: &DistributedRuntime,
709        endpoint: &EndpointId,
710        topic: impl Into<String>,
711    ) -> Result<Self> {
712        let transport_kind = drt.default_event_transport_kind();
713        Self::for_endpoint_id_with_transport(drt, endpoint, topic, transport_kind).await
714    }
715
716    /// Create an endpoint-identity subscriber with explicit transport.
717    pub async fn for_endpoint_id_with_transport(
718        drt: &DistributedRuntime,
719        endpoint: &EndpointId,
720        topic: impl Into<String>,
721        transport_kind: EventTransportKind,
722    ) -> Result<Self> {
723        Self::new_internal(
724            drt,
725            EventScope::Endpoint {
726                endpoint: endpoint.clone(),
727            },
728            topic.into(),
729            transport_kind,
730        )
731        .await
732    }
733
734    /// Create a subscriber for a component-scoped topic.
735    ///
736    /// The event transport is chosen automatically: if `DYN_EVENT_PLANE` is set that
737    /// value is used; otherwise the runtime's default is used (ZMQ for local backends
738    /// such as `file`/`mem`, NATS for distributed backends such as `etcd`/`kubernetes`).
739    /// Use [`for_component_with_transport`](Self::for_component_with_transport) to
740    /// override explicitly.
741    pub async fn for_component(comp: &Component, topic: impl Into<String>) -> Result<Self> {
742        let transport_kind = comp.drt().default_event_transport_kind();
743        Self::for_component_with_transport(comp, topic, transport_kind).await
744    }
745
746    /// Create a subscriber with explicit transport.
747    pub async fn for_component_with_transport(
748        comp: &Component,
749        topic: impl Into<String>,
750        transport_kind: EventTransportKind,
751    ) -> Result<Self> {
752        let drt = comp.drt();
753        let scope = EventScope::Component {
754            namespace: comp.namespace().name(),
755            component: comp.name().to_string(),
756        };
757        Self::new_internal(drt, scope, topic.into(), transport_kind).await
758    }
759
760    /// Create a subscriber for a namespace-scoped topic.
761    ///
762    /// The event transport is chosen automatically: if `DYN_EVENT_PLANE` is set that
763    /// value is used; otherwise the runtime's default is used (ZMQ for local backends
764    /// such as `file`/`mem`, NATS for distributed backends such as `etcd`/`kubernetes`).
765    /// Use [`for_namespace_with_transport`](Self::for_namespace_with_transport) to
766    /// override explicitly.
767    pub async fn for_namespace(ns: &Namespace, topic: impl Into<String>) -> Result<Self> {
768        let transport_kind = ns.drt().default_event_transport_kind();
769        Self::for_namespace_with_transport(ns, topic, transport_kind).await
770    }
771
772    /// Create a namespace subscriber with explicit transport.
773    pub async fn for_namespace_with_transport(
774        ns: &Namespace,
775        topic: impl Into<String>,
776        transport_kind: EventTransportKind,
777    ) -> Result<Self> {
778        let drt = ns.drt();
779        let scope = EventScope::Namespace { name: ns.name() };
780        Self::new_internal(drt, scope, topic.into(), transport_kind).await
781    }
782
783    async fn new_internal(
784        drt: &DistributedRuntime,
785        scope: EventScope,
786        topic: String,
787        transport_kind: EventTransportKind,
788    ) -> Result<Self> {
789        let discovery = drt.discovery();
790        let routing_key = scope.subject(&topic);
791
792        // Use Msgpack codec for all transports
793        let (wire_stream, codec): (WireStream, Arc<Codec>) = match transport_kind {
794            EventTransportKind::Nats => {
795                let transport = nats_transport::NatsTransport::new(drt.clone());
796                let stream = transport.subscribe(&routing_key).await?;
797                let codec = Arc::new(Codec::Msgpack(MsgpackCodec));
798                (stream, codec)
799            }
800            EventTransportKind::Zmq => {
801                // Check for broker mode
802                if let Some(broker) = resolve_zmq_broker(drt, &scope).await? {
803                    // BROKER MODE: Connect to broker's XPUB (single or multiple endpoints)
804                    let codec = Arc::new(Codec::Msgpack(MsgpackCodec));
805
806                    let stream: WireStream = if broker.xpub_endpoints.len() == 1 {
807                        // One EventSubscriber has one consumer. Poll the ZMQ socket
808                        // directly instead of forwarding through a lossy broadcast channel.
809                        let stream = zmq_transport::ZmqSubTransport::connect_single_consumer(
810                            &broker.xpub_endpoints[0],
811                            &routing_key,
812                        )
813                        .await?;
814                        Box::pin(stream.map(|result| result.map(|message| message.payload)))
815                    } else {
816                        // Multiple brokers - need deduplication
817                        let inner_stream =
818                            zmq_transport::ZmqSubTransport::connect_single_consumer_multiple(
819                                &broker.xpub_endpoints,
820                                &routing_key,
821                            )
822                            .await?;
823                        let inner_stream: WireStream = Box::pin(
824                            inner_stream.map(|result| result.map(|message| message.payload)),
825                        );
826
827                        // Wrap with deduplication (default cache size: 100,000 entries)
828                        Box::pin(DeduplicatingStream::new(
829                            inner_stream,
830                            codec.clone(),
831                            100_000,
832                        ))
833                    };
834
835                    (stream, codec)
836                } else {
837                    // DIRECT MODE: Use dynamic subscriber to discover and connect to publishers
838                    let query = match &scope {
839                        EventScope::Namespace { name } => {
840                            crate::discovery::DiscoveryQuery::EventChannels(
841                                crate::discovery::EventChannelQuery::namespace_topic(
842                                    name.clone(),
843                                    topic.clone(),
844                                ),
845                            )
846                        }
847                        EventScope::Component {
848                            namespace,
849                            component,
850                        } => crate::discovery::DiscoveryQuery::EventChannels(
851                            crate::discovery::EventChannelQuery::topic(
852                                namespace.clone(),
853                                component.clone(),
854                                topic.clone(),
855                            ),
856                        ),
857                        EventScope::Endpoint { endpoint } => {
858                            crate::discovery::DiscoveryQuery::EventChannels(
859                                crate::discovery::EventChannelQuery::endpoint_topic(
860                                    endpoint.clone(),
861                                    topic.clone(),
862                                ),
863                            )
864                        }
865                    };
866
867                    let subscriber = Arc::new(DynamicSubscriber::with_cancel_token(
868                        discovery,
869                        query,
870                        topic.clone(),
871                        drt.primary_token().child_token(),
872                    ));
873
874                    let stream = subscriber.start_zmq().await?;
875                    let codec = Arc::new(Codec::Msgpack(MsgpackCodec));
876                    (stream, codec)
877                }
878            }
879        };
880
881        // Filter by topic and decode envelopes
882        let topic_filter = topic.clone();
883        let codec_for_stream = codec.clone();
884        let stream = wire_stream.filter_map(move |result| {
885            let codec = codec_for_stream.clone();
886            let topic_filter = topic_filter.clone();
887            async move {
888                match result {
889                    Ok(bytes) => match codec.decode_envelope(&bytes) {
890                        Ok(envelope) => {
891                            // Filter by topic for transports that don't support native filtering
892                            if envelope.topic == topic_filter {
893                                Some(Ok(envelope))
894                            } else {
895                                None
896                            }
897                        }
898                        Err(e) => Some(Err(e)),
899                    },
900                    Err(e) => Some(Err(e)),
901                }
902            }
903        });
904
905        tracing::info!(
906            topic = %topic,
907            transport = ?transport_kind,
908            "EventSubscriber created"
909        );
910
911        Ok(Self {
912            stream: Box::pin(stream),
913            scope,
914            topic,
915            codec,
916        })
917    }
918
919    /// Get the next event envelope.
920    pub async fn next(&mut self) -> Option<Result<EventEnvelope>> {
921        self.stream.next().await
922    }
923
924    /// Subscribe with automatic deserialization.
925    pub fn typed<T: DeserializeOwned + Send + 'static>(self) -> TypedEventSubscriber<T> {
926        TypedEventSubscriber {
927            stream: self.stream,
928            codec: self.codec,
929            _marker: std::marker::PhantomData,
930        }
931    }
932}
933
934/// Typed event subscriber that deserializes payloads.
935pub struct TypedEventSubscriber<T> {
936    stream: EventStream,
937    codec: Arc<Codec>,
938    _marker: std::marker::PhantomData<T>,
939}
940
941impl<T: DeserializeOwned + Send + 'static> TypedEventSubscriber<T> {
942    /// Get the next typed event with its envelope.
943    pub async fn next(&mut self) -> Option<Result<(EventEnvelope, T)>> {
944        std::future::poll_fn(|cx| self.poll_next(cx)).await
945    }
946
947    /// Poll for the next typed event.
948    pub fn poll_next(&mut self, cx: &mut Context<'_>) -> Poll<Option<Result<(EventEnvelope, T)>>> {
949        match self.stream.as_mut().poll_next(cx) {
950            Poll::Ready(Some(envelope)) => Poll::Ready(Some(match envelope {
951                Ok(env) => match self.codec.decode_payload(&env.payload) {
952                    Ok(typed) => Ok((env, typed)),
953                    Err(e) => Err(e),
954                },
955                Err(e) => Err(e),
956            })),
957            Poll::Ready(None) => Poll::Ready(None),
958            Poll::Pending => Poll::Pending,
959        }
960    }
961}
962
963/// Get current timestamp in milliseconds since Unix epoch.
964fn current_timestamp_ms() -> u64 {
965    SystemTime::now()
966        .duration_since(UNIX_EPOCH)
967        .map(|d| d.as_millis() as u64)
968        .unwrap_or(0)
969}
970
971#[cfg(test)]
972mod tests {
973    use super::*;
974    use crate::config::environment_names::zmq_broker as broker_env;
975    use crate::utils::ip_resolver::test_support::StubResolver;
976
977    #[test]
978    fn direct_zmq_automatic_host_selection() {
979        let mut resolver = StubResolver::not_found();
980        resolver.interfaces = vec![
981            ("lo", "127.0.0.1".parse().unwrap()),
982            ("lo", "::1".parse().unwrap()),
983            ("eth0", "2001:db8::20".parse().unwrap()),
984        ];
985
986        assert_eq!(
987            temp_env::with_vars([(DYN_EVENT_PLANE_HOST, None::<&str>)], || {
988                event_plane_host_from_env_with_resolver(&resolver)
989            })
990            .unwrap(),
991            "2001:db8::20".parse::<IpAddr>().unwrap()
992        );
993        resolver
994            .interfaces
995            .push(("eth0", "192.0.2.20".parse().unwrap()));
996        assert_eq!(
997            temp_env::with_vars([(DYN_EVENT_PLANE_HOST, None::<&str>)], || {
998                event_plane_host_from_env_with_resolver(&resolver)
999            })
1000            .unwrap(),
1001            "192.0.2.20".parse::<IpAddr>().unwrap()
1002        );
1003    }
1004
1005    #[test]
1006    fn direct_zmq_advertise_host_from_env_resolves_ips_and_interfaces() {
1007        let mut resolver = StubResolver::not_found();
1008        resolver.interfaces = vec![
1009            ("ib0", "192.0.2.20".parse().unwrap()),
1010            ("ib6", "2001:db8::20".parse().unwrap()),
1011        ];
1012
1013        assert_eq!(
1014            temp_env::with_vars([(DYN_EVENT_PLANE_HOST, Some(" 192.0.2.10 "))], || {
1015                event_plane_host_from_env_with_resolver(&resolver)
1016            })
1017            .unwrap(),
1018            "192.0.2.10".parse::<IpAddr>().unwrap()
1019        );
1020        assert_eq!(
1021            temp_env::with_vars([(DYN_EVENT_PLANE_HOST, Some("ib0"))], || {
1022                event_plane_host_from_env_with_resolver(&resolver)
1023            })
1024            .unwrap(),
1025            "192.0.2.20".parse::<IpAddr>().unwrap()
1026        );
1027        assert_eq!(
1028            temp_env::with_vars([(DYN_EVENT_PLANE_HOST, Some("ib6"))], || {
1029                event_plane_host_from_env_with_resolver(&resolver)
1030            })
1031            .unwrap(),
1032            "2001:db8::20".parse::<IpAddr>().unwrap()
1033        );
1034        for host in ["2001:db8::10", "[2001:db8::10]"] {
1035            assert_eq!(
1036                temp_env::with_vars([(DYN_EVENT_PLANE_HOST, Some(host))], || {
1037                    event_plane_host_from_env_with_resolver(&resolver)
1038                })
1039                .unwrap(),
1040                "2001:db8::10".parse::<IpAddr>().unwrap()
1041            );
1042        }
1043    }
1044
1045    #[test]
1046    fn direct_zmq_advertise_host_falls_back_to_ipv6_and_rejects_wildcards() {
1047        let mut resolver = StubResolver::not_found();
1048        resolver.interfaces = vec![("eth0", "2001:db8::1".parse().unwrap())];
1049        assert_eq!(
1050            temp_env::with_vars([(DYN_EVENT_PLANE_HOST, None::<&str>)], || {
1051                event_plane_host_from_env_with_resolver(&resolver)
1052            })
1053            .unwrap(),
1054            "2001:db8::1".parse::<IpAddr>().unwrap()
1055        );
1056        assert_eq!(
1057            temp_env::with_vars([(DYN_EVENT_PLANE_HOST, Some(" \t"))], || {
1058                event_plane_host_from_env_with_resolver(&resolver)
1059            })
1060            .unwrap(),
1061            "2001:db8::1".parse::<IpAddr>().unwrap()
1062        );
1063
1064        for host in [
1065            "0.0.0.0",
1066            "::",
1067            "[::]",
1068            "::ffff:0.0.0.0",
1069            "[::ffff:0.0.0.0]",
1070        ] {
1071            let error = temp_env::with_vars([(DYN_EVENT_PLANE_HOST, Some(host))], || {
1072                event_plane_host_from_env_with_resolver(&resolver)
1073            })
1074            .unwrap_err();
1075            assert_eq!(
1076                error.to_string(),
1077                format!(
1078                    "Invalid DYN_EVENT_PLANE_HOST value '{host}': unspecified IP addresses cannot be advertised"
1079                )
1080            );
1081        }
1082    }
1083
1084    #[test]
1085    fn direct_zmq_public_endpoint_formats_ipv4_and_ipv6() {
1086        assert_eq!(
1087            direct_zmq_public_endpoint("192.0.2.10".parse().unwrap(), "tcp://0.0.0.0:4321")
1088                .unwrap(),
1089            "tcp://192.0.2.10:4321"
1090        );
1091        assert_eq!(
1092            direct_zmq_public_endpoint("2001:db8::10".parse().unwrap(), "tcp://[::]:4321").unwrap(),
1093            "tcp://[2001:db8::10]:4321"
1094        );
1095    }
1096
1097    #[rstest::rstest]
1098    #[case("127.0.0.1")]
1099    #[case("[::1]")]
1100    #[tokio::test]
1101    async fn direct_zmq_publisher_serves_advertised_endpoint(#[case] host: &str) {
1102        if host == "[::1]"
1103            && let Err(error) = std::net::TcpListener::bind("[::1]:0")
1104        {
1105            eprintln!("Skipping IPv6 publisher test: {error}");
1106            return;
1107        }
1108        temp_env::async_with_vars(
1109            [
1110                (DYN_EVENT_PLANE_HOST, Some(host)),
1111                (broker_env::DYN_ZMQ_BROKER_URL, None::<&str>),
1112                (broker_env::DYN_ZMQ_BROKER_ENABLED, None::<&str>),
1113            ],
1114            async {
1115                let runtime = crate::Runtime::from_current().expect("create runtime handle");
1116                let drt = DistributedRuntime::new(
1117                    runtime,
1118                    crate::distributed::DistributedConfig::process_local(),
1119                )
1120                .await
1121                .expect("create distributed runtime");
1122                let component = drt
1123                    .namespace("event-publisher-host-test")
1124                    .expect("create namespace")
1125                    .component("worker")
1126                    .expect("create component");
1127
1128                let publisher = EventPublisher::for_component_with_transport(
1129                    &component,
1130                    "events",
1131                    EventTransportKind::Zmq,
1132                )
1133                .await
1134                .expect("create publisher");
1135
1136                let instances = drt
1137                    .discovery()
1138                    .list(DiscoveryQuery::EventChannels(EventChannelQuery::topic(
1139                        "event-publisher-host-test",
1140                        "worker",
1141                        "events",
1142                    )))
1143                    .await
1144                    .expect("list event publishers");
1145                assert_eq!(instances.len(), 1);
1146
1147                let endpoint = match &instances[0] {
1148                    DiscoveryInstance::EventChannel {
1149                        transport: EventTransport::Zmq { endpoint },
1150                        ..
1151                    } => endpoint,
1152                    instance => panic!("expected direct ZMQ event channel, got {instance:?}"),
1153                };
1154                let address = endpoint
1155                    .strip_prefix("tcp://")
1156                    .unwrap()
1157                    .parse::<SocketAddr>()
1158                    .expect("advertised endpoint should include an IP address and port");
1159                assert_eq!(
1160                    address.ip(),
1161                    host.trim_matches(['[', ']']).parse::<IpAddr>().unwrap()
1162                );
1163                assert_ne!(address.port(), 0);
1164
1165                let mut subscriber = EventSubscriber::for_component_with_transport(
1166                    &component,
1167                    "events",
1168                    EventTransportKind::Zmq,
1169                )
1170                .await
1171                .expect("subscribe through discovery");
1172
1173                let receive = async {
1174                    loop {
1175                        publisher
1176                            .publish_bytes(vec![0x42])
1177                            .await
1178                            .expect("publish event");
1179                        // Retry while the ZMQ subscription reaches the publisher.
1180                        if let Ok(event) = tokio::time::timeout(
1181                            std::time::Duration::from_millis(100),
1182                            subscriber.next(),
1183                        )
1184                        .await
1185                        {
1186                            let event =
1187                                event.expect("event stream is open").expect("receive event");
1188                            assert_eq!(event.publisher_id, publisher.publisher_id());
1189                            assert_eq!(event.payload.as_ref(), &[0x42]);
1190                            break;
1191                        }
1192                    }
1193                };
1194                tokio::time::timeout(std::time::Duration::from_secs(5), receive)
1195                    .await
1196                    .expect("subscriber should receive through the advertised address and port");
1197
1198                drop(publisher);
1199            },
1200        )
1201        .await;
1202    }
1203
1204    #[tokio::test]
1205    async fn direct_zmq_publisher_rejects_invalid_configured_host() {
1206        temp_env::async_with_vars(
1207            [
1208                (DYN_EVENT_PLANE_HOST, Some("::")),
1209                (broker_env::DYN_ZMQ_BROKER_URL, None::<&str>),
1210                (broker_env::DYN_ZMQ_BROKER_ENABLED, None::<&str>),
1211            ],
1212            async {
1213                let runtime = crate::Runtime::from_current().expect("create runtime handle");
1214                let drt = DistributedRuntime::new(
1215                    runtime,
1216                    crate::distributed::DistributedConfig::process_local(),
1217                )
1218                .await
1219                .expect("create distributed runtime");
1220                let component = drt
1221                    .namespace("event-publisher-invalid-host-test")
1222                    .expect("create namespace")
1223                    .component("worker")
1224                    .expect("create component");
1225
1226                match EventPublisher::for_component_with_transport(
1227                    &component,
1228                    "events",
1229                    EventTransportKind::Zmq,
1230                )
1231                .await
1232                {
1233                    Ok(_) => panic!("invalid host should reject publisher creation"),
1234                    Err(error) => assert_eq!(
1235                        error.to_string(),
1236                        "Invalid DYN_EVENT_PLANE_HOST value '::': unspecified IP addresses cannot be advertised"
1237                    ),
1238                }
1239            },
1240        )
1241        .await;
1242    }
1243
1244    #[tokio::test]
1245    async fn multi_broker_stream_deduplicates_by_publisher_and_sequence() {
1246        let codec = Arc::new(Codec::default());
1247        let first = codec
1248            .encode_envelope_parts(7, 11, 1, "events", b"first")
1249            .unwrap();
1250        let second = codec
1251            .encode_envelope_parts(7, 12, 2, "events", b"second")
1252            .unwrap();
1253        let other_publisher = codec
1254            .encode_envelope_parts(8, 11, 3, "events", b"other")
1255            .unwrap();
1256        let expected_first = first.clone();
1257        let expected_second = second.clone();
1258        let expected_other = other_publisher.clone();
1259        let inner: WireStream = Box::pin(futures::stream::iter(vec![
1260            Ok(first.clone()),
1261            Ok(first),
1262            Ok(second),
1263            Ok(other_publisher),
1264        ]));
1265        let mut stream = DeduplicatingStream::new(inner, codec, 16);
1266
1267        assert_eq!(stream.next().await.unwrap().unwrap(), expected_first);
1268        assert_eq!(stream.next().await.unwrap().unwrap(), expected_second);
1269        assert_eq!(stream.next().await.unwrap().unwrap(), expected_other);
1270        assert!(stream.next().await.is_none());
1271    }
1272
1273    #[test]
1274    fn publisher_ids_survive_a_json_number_round_trip() {
1275        // This historical full-range ID is not JSON-safe. It was observed
1276        // rounding to 13584172880116488000 after a discovery round trip.
1277        let unsafe_id: u64 = 13_584_172_880_116_487_724;
1278        assert!(unsafe_id > MAX_JSON_SAFE_PUBLISHER_ID);
1279        assert_ne!(unsafe_id as f64 as u64, unsafe_id);
1280
1281        for random_id in [0, 1, u64::MAX, unsafe_id, 6_633_287_539_119_378] {
1282            let publisher_id = discovery_safe_publisher_id(random_id);
1283            assert!(
1284                publisher_id <= MAX_JSON_SAFE_PUBLISHER_ID,
1285                "publisher ID {publisher_id} exceeds the JSON-safe integer range"
1286            );
1287            assert_eq!(
1288                publisher_id as f64 as u64, publisher_id,
1289                "publisher ID {publisher_id} must survive an f64 round trip"
1290            );
1291        }
1292    }
1293
1294    #[test]
1295    fn direct_zmq_topology_selection_is_narrow() {
1296        let lookup = |url: Option<&str>, enabled: Option<&str>| {
1297            let url = url.map(std::ffi::OsString::from);
1298            let enabled = enabled.map(std::ffi::OsString::from);
1299            move |key: &str| match key {
1300                broker_env::DYN_ZMQ_BROKER_URL => url.clone(),
1301                broker_env::DYN_ZMQ_BROKER_ENABLED => enabled.clone(),
1302                _ => None,
1303            }
1304        };
1305
1306        assert!(uses_direct_zmq_from_lookup(
1307            EventTransportKind::Zmq,
1308            lookup(None, None)
1309        ));
1310        assert!(!uses_direct_zmq_from_lookup(
1311            EventTransportKind::Zmq,
1312            lookup(Some("xsub=tcp://broker:5555,xpub=tcp://broker:5556"), None)
1313        ));
1314        assert!(!uses_direct_zmq_from_lookup(
1315            EventTransportKind::Zmq,
1316            lookup(None, Some("true"))
1317        ));
1318        assert!(uses_direct_zmq_from_lookup(
1319            EventTransportKind::Zmq,
1320            lookup(None, Some("false"))
1321        ));
1322        assert!(!uses_direct_zmq_from_lookup(
1323            EventTransportKind::Nats,
1324            lookup(None, None)
1325        ));
1326    }
1327
1328    #[tokio::test]
1329    async fn direct_zmq_endpoint_scopes_are_isolated() {
1330        temp_env::async_with_vars(
1331            [
1332                (DYN_EVENT_PLANE_HOST, None::<&str>),
1333                (broker_env::DYN_ZMQ_BROKER_URL, None::<&str>),
1334                (broker_env::DYN_ZMQ_BROKER_ENABLED, None::<&str>),
1335            ],
1336            async {
1337                let runtime = crate::Runtime::from_current().expect("create runtime handle");
1338                let drt = DistributedRuntime::new(
1339                    runtime,
1340                    crate::distributed::DistributedConfig::process_local(),
1341                )
1342                .await
1343                .expect("create distributed runtime");
1344                let component = drt
1345                    .namespace("endpoint-event-isolation-test")
1346                    .expect("create namespace")
1347                    .component("worker")
1348                    .expect("create component");
1349                let endpoint_a = component.endpoint("a");
1350                let endpoint_b = component.endpoint("b");
1351
1352                let publisher_a = EventPublisher::for_endpoint_with_transport(
1353                    &endpoint_a,
1354                    "events",
1355                    EventTransportKind::Zmq,
1356                )
1357                .await
1358                .expect("create endpoint A publisher");
1359                let publisher_b = EventPublisher::for_endpoint_with_transport(
1360                    &endpoint_b,
1361                    "events",
1362                    EventTransportKind::Zmq,
1363                )
1364                .await
1365                .expect("create endpoint B publisher");
1366                let mut subscriber_a = EventSubscriber::for_endpoint_with_transport(
1367                    &endpoint_a,
1368                    "events",
1369                    EventTransportKind::Zmq,
1370                )
1371                .await
1372                .expect("create endpoint A subscriber");
1373                let mut subscriber_b = EventSubscriber::for_endpoint_with_transport(
1374                    &endpoint_b,
1375                    "events",
1376                    EventTransportKind::Zmq,
1377                )
1378                .await
1379                .expect("create endpoint B subscriber");
1380
1381                let receive = async {
1382                    loop {
1383                        publisher_a
1384                            .publish_bytes(vec![0xa1])
1385                            .await
1386                            .expect("publish endpoint A event");
1387                        publisher_b
1388                            .publish_bytes(vec![0xb2])
1389                            .await
1390                            .expect("publish endpoint B event");
1391
1392                        let a = tokio::time::timeout(
1393                            std::time::Duration::from_millis(100),
1394                            subscriber_a.next(),
1395                        )
1396                        .await;
1397                        let b = tokio::time::timeout(
1398                            std::time::Duration::from_millis(100),
1399                            subscriber_b.next(),
1400                        )
1401                        .await;
1402                        if let (Ok(Some(Ok(a))), Ok(Some(Ok(b)))) = (a, b) {
1403                            assert_eq!(a.publisher_id, publisher_a.publisher_id());
1404                            assert_eq!(a.payload.as_ref(), &[0xa1]);
1405                            assert_eq!(b.publisher_id, publisher_b.publisher_id());
1406                            assert_eq!(b.payload.as_ref(), &[0xb2]);
1407                            break;
1408                        }
1409
1410                        tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1411                    }
1412                };
1413
1414                tokio::time::timeout(std::time::Duration::from_secs(5), receive)
1415                    .await
1416                    .expect("each endpoint subscriber should receive only its own publisher");
1417            },
1418        )
1419        .await;
1420    }
1421
1422    #[tokio::test]
1423    async fn direct_zmq_publishers_in_one_endpoint_fan_into_one_subscriber() {
1424        temp_env::async_with_vars(
1425            [
1426                (DYN_EVENT_PLANE_HOST, None::<&str>),
1427                (broker_env::DYN_ZMQ_BROKER_URL, None::<&str>),
1428                (broker_env::DYN_ZMQ_BROKER_ENABLED, None::<&str>),
1429            ],
1430            async {
1431                let runtime = crate::Runtime::from_current().expect("create runtime handle");
1432                let drt = DistributedRuntime::new(
1433                    runtime,
1434                    crate::distributed::DistributedConfig::process_local(),
1435                )
1436                .await
1437                .expect("create distributed runtime");
1438                let endpoint = drt
1439                    .namespace("endpoint-event-fan-in-test")
1440                    .expect("create namespace")
1441                    .component("worker")
1442                    .expect("create component")
1443                    .endpoint("generate");
1444                let publisher_a = EventPublisher::for_endpoint_with_transport(
1445                    &endpoint,
1446                    "events",
1447                    EventTransportKind::Zmq,
1448                )
1449                .await
1450                .expect("create first publisher");
1451                let publisher_b = EventPublisher::for_endpoint_with_transport(
1452                    &endpoint,
1453                    "events",
1454                    EventTransportKind::Zmq,
1455                )
1456                .await
1457                .expect("create second publisher");
1458                let mut subscriber = EventSubscriber::for_endpoint_with_transport(
1459                    &endpoint,
1460                    "events",
1461                    EventTransportKind::Zmq,
1462                )
1463                .await
1464                .expect("create subscriber");
1465
1466                let receive = async {
1467                    let mut publisher_ids = std::collections::HashSet::new();
1468                    while publisher_ids.len() < 2 {
1469                        publisher_a.publish_bytes(vec![0xa1]).await.unwrap();
1470                        publisher_b.publish_bytes(vec![0xb2]).await.unwrap();
1471                        if let Ok(Some(Ok(envelope))) = tokio::time::timeout(
1472                            std::time::Duration::from_millis(100),
1473                            subscriber.next(),
1474                        )
1475                        .await
1476                        {
1477                            publisher_ids.insert(envelope.publisher_id);
1478                        }
1479                    }
1480                    assert_eq!(
1481                        publisher_ids,
1482                        std::collections::HashSet::from([
1483                            publisher_a.publisher_id(),
1484                            publisher_b.publisher_id(),
1485                        ])
1486                    );
1487                    for publisher_id in [publisher_a.publisher_id(), publisher_b.publisher_id()] {
1488                        assert!(
1489                            publisher_id <= MAX_JSON_SAFE_PUBLISHER_ID,
1490                            "publisher ID {publisher_id} exceeds the JSON-safe integer range"
1491                        );
1492                    }
1493                };
1494                tokio::time::timeout(std::time::Duration::from_secs(5), receive)
1495                    .await
1496                    .expect("subscriber should receive both endpoint publishers");
1497            },
1498        )
1499        .await;
1500    }
1501
1502    #[tokio::test]
1503    async fn same_topic_publishers_are_independent_across_recreation() {
1504        temp_env::async_with_vars(
1505            [
1506                (DYN_EVENT_PLANE_HOST, None::<&str>),
1507                (broker_env::DYN_ZMQ_BROKER_URL, None::<&str>),
1508                (broker_env::DYN_ZMQ_BROKER_ENABLED, None::<&str>),
1509            ],
1510            async {
1511                let runtime = crate::Runtime::from_current().expect("create runtime handle");
1512                let drt = DistributedRuntime::new(
1513                    runtime,
1514                    crate::distributed::DistributedConfig::process_local(),
1515                )
1516                .await
1517                .expect("create distributed runtime");
1518                let component = drt
1519                    .namespace("event-publisher-test")
1520                    .expect("create namespace")
1521                    .component("worker")
1522                    .expect("create component");
1523
1524                let publisher_a = EventPublisher::for_component_with_transport(
1525                    &component,
1526                    "events",
1527                    EventTransportKind::Zmq,
1528                )
1529                .await
1530                .expect("create first publisher");
1531                let publisher_b = EventPublisher::for_component_with_transport(
1532                    &component,
1533                    "events",
1534                    EventTransportKind::Zmq,
1535                )
1536                .await
1537                .expect("create second publisher");
1538                let publisher_a_id = publisher_a.publisher_id();
1539                let publisher_b_id = publisher_b.publisher_id();
1540
1541                assert_ne!(publisher_a_id, publisher_b_id);
1542
1543                let query = DiscoveryQuery::EventChannels(EventChannelQuery::topic(
1544                    "event-publisher-test",
1545                    "worker",
1546                    "events",
1547                ));
1548                let instances = drt
1549                    .discovery()
1550                    .list(query.clone())
1551                    .await
1552                    .expect("list event publishers");
1553                assert_eq!(instances.len(), 2);
1554                assert!(
1555                    instances
1556                        .iter()
1557                        .any(|instance| instance.instance_id() == publisher_a_id)
1558                );
1559                assert!(
1560                    instances
1561                        .iter()
1562                        .any(|instance| instance.instance_id() == publisher_b_id)
1563                );
1564
1565                let mut subscriber = EventSubscriber::for_component_with_transport(
1566                    &component,
1567                    "events",
1568                    EventTransportKind::Zmq,
1569                )
1570                .await
1571                .expect("create subscriber");
1572                let mut received_a = false;
1573                let mut received_b = false;
1574
1575                tokio::time::timeout(std::time::Duration::from_secs(5), async {
1576                    while !received_a || !received_b {
1577                        publisher_a
1578                            .publish_bytes(vec![0xa1])
1579                            .await
1580                            .expect("publish from first publisher");
1581                        publisher_b
1582                            .publish_bytes(vec![0xb2])
1583                            .await
1584                            .expect("publish from second publisher");
1585
1586                        if let Ok(Some(envelope)) = tokio::time::timeout(
1587                            std::time::Duration::from_millis(100),
1588                            subscriber.next(),
1589                        )
1590                        .await
1591                        {
1592                            let envelope = envelope.expect("receive event envelope");
1593                            if envelope.publisher_id == publisher_a_id {
1594                                assert_eq!(envelope.payload.as_ref(), &[0xa1]);
1595                                received_a = true;
1596                            } else if envelope.publisher_id == publisher_b_id {
1597                                assert_eq!(envelope.payload.as_ref(), &[0xb2]);
1598                                received_b = true;
1599                            } else {
1600                                panic!("event from unexpected publisher");
1601                            }
1602                        }
1603
1604                        tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1605                    }
1606                })
1607                .await
1608                .expect("subscriber should receive events from both publishers");
1609
1610                drop(publisher_a);
1611                let publisher_a_recreated = EventPublisher::for_component_with_transport(
1612                    &component,
1613                    "events",
1614                    EventTransportKind::Zmq,
1615                )
1616                .await
1617                .expect("recreate first publisher");
1618                let publisher_a_recreated_id = publisher_a_recreated.publisher_id();
1619
1620                assert_ne!(publisher_a_recreated_id, publisher_a_id);
1621                assert_ne!(publisher_a_recreated_id, publisher_b_id);
1622                assert_eq!(
1623                    publisher_a_recreated.sequence.load(Ordering::SeqCst),
1624                    0,
1625                    "a recreated publisher starts a new sequence space"
1626                );
1627
1628                tokio::time::timeout(std::time::Duration::from_secs(1), async {
1629                    loop {
1630                        let instances = drt
1631                            .discovery()
1632                            .list(query.clone())
1633                            .await
1634                            .expect("list event publishers after recreation");
1635                        if instances.len() == 2
1636                            && instances
1637                                .iter()
1638                                .any(|instance| instance.instance_id() == publisher_b_id)
1639                            && instances
1640                                .iter()
1641                                .any(|instance| instance.instance_id() == publisher_a_recreated_id)
1642                        {
1643                            break;
1644                        }
1645                        tokio::task::yield_now().await;
1646                    }
1647                })
1648                .await
1649                .expect("old publisher should unregister without removing current publishers");
1650
1651                let mut received_b_after_recreation = false;
1652                let mut received_recreated_a = false;
1653
1654                tokio::time::timeout(std::time::Duration::from_secs(5), async {
1655                    while !received_b_after_recreation || !received_recreated_a {
1656                        publisher_b
1657                            .publish_bytes(vec![0xb3])
1658                            .await
1659                            .expect("publish from second publisher after recreation");
1660                        publisher_a_recreated
1661                            .publish_bytes(vec![0xa2])
1662                            .await
1663                            .expect("publish from recreated publisher");
1664
1665                        if let Ok(Some(envelope)) = tokio::time::timeout(
1666                            std::time::Duration::from_millis(100),
1667                            subscriber.next(),
1668                        )
1669                        .await
1670                        {
1671                            let envelope = envelope.expect("receive event envelope after drop");
1672                            if envelope.publisher_id == publisher_b_id
1673                                && envelope.payload.as_ref() == [0xb3]
1674                            {
1675                                received_b_after_recreation = true;
1676                            } else if envelope.publisher_id == publisher_a_recreated_id
1677                                && envelope.payload.as_ref() == [0xa2]
1678                            {
1679                                received_recreated_a = true;
1680                            }
1681                        }
1682
1683                        tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1684                    }
1685                })
1686                .await
1687                .expect("subscriber should receive from surviving and recreated publishers");
1688            },
1689        )
1690        .await;
1691    }
1692
1693    #[tokio::test]
1694    async fn dropped_publisher_unregister_completes_within_graceful_shutdown() {
1695        temp_env::async_with_vars(
1696            [
1697                (DYN_EVENT_PLANE_HOST, None::<&str>),
1698                (broker_env::DYN_ZMQ_BROKER_URL, None::<&str>),
1699                (broker_env::DYN_ZMQ_BROKER_ENABLED, None::<&str>),
1700            ],
1701            async {
1702                let runtime = crate::Runtime::from_current().expect("create runtime handle");
1703                let drt = DistributedRuntime::new(
1704                    runtime,
1705                    crate::distributed::DistributedConfig::process_local(),
1706                )
1707                .await
1708                .expect("create distributed runtime");
1709                let component = drt
1710                    .namespace("event-publisher-shutdown-test")
1711                    .expect("create namespace")
1712                    .component("worker")
1713                    .expect("create component");
1714
1715                let publisher = EventPublisher::for_component_with_transport(
1716                    &component,
1717                    "events",
1718                    EventTransportKind::Zmq,
1719                )
1720                .await
1721                .expect("create publisher");
1722                let publisher_id = publisher.publisher_id();
1723
1724                let query = DiscoveryQuery::EventChannels(EventChannelQuery::topic(
1725                    "event-publisher-shutdown-test",
1726                    "worker",
1727                    "events",
1728                ));
1729                let instances = drt
1730                    .discovery()
1731                    .list(query.clone())
1732                    .await
1733                    .expect("list event publishers");
1734                assert_eq!(instances.len(), 1);
1735                assert_eq!(instances[0].instance_id(), publisher_id);
1736
1737                let tracker = drt.graceful_shutdown_tracker();
1738                assert_eq!(tracker.get_count(), 0);
1739
1740                let main_token = drt.runtime().primary_token();
1741                let endpoint_token = drt.runtime().child_token();
1742
1743                // Dropping the publisher schedules the async discovery unregister.
1744                // The drop path must synchronously take a graceful-shutdown guard so
1745                // that `Runtime::shutdown` Phase 2 waits for the unregister task.
1746                drop(publisher);
1747                assert_eq!(
1748                    tracker.get_count(),
1749                    1,
1750                    "dropping a publisher must register its unregister work with the graceful-shutdown tracker"
1751                );
1752
1753                drt.runtime().shutdown();
1754
1755                tokio::time::timeout(std::time::Duration::from_secs(5), main_token.cancelled())
1756                    .await
1757                    .expect("graceful shutdown should complete once the unregister task finishes");
1758
1759                assert!(endpoint_token.is_cancelled());
1760                assert_eq!(
1761                    tracker.get_count(),
1762                    0,
1763                    "the unregister task must release its graceful-shutdown guard"
1764                );
1765
1766                // Phase 3 (main token cancellation) only runs after Phase 2 drained
1767                // the tracker, so the dropped publisher must already be unregistered.
1768                let instances = drt
1769                    .discovery()
1770                    .list(query)
1771                    .await
1772                    .expect("list event publishers after shutdown");
1773                assert!(
1774                    instances.is_empty(),
1775                    "unregister must complete within the graceful-shutdown window"
1776                );
1777            },
1778        )
1779        .await;
1780    }
1781
1782    #[tokio::test]
1783    async fn runtime_cancellation_stops_retained_direct_zmq_subscriber() {
1784        temp_env::async_with_vars(
1785            [
1786                (broker_env::DYN_ZMQ_BROKER_URL, None::<&str>),
1787                (broker_env::DYN_ZMQ_BROKER_ENABLED, None::<&str>),
1788            ],
1789            async {
1790                let runtime = crate::Runtime::from_current().expect("create runtime handle");
1791                let drt = DistributedRuntime::new(
1792                    runtime,
1793                    crate::distributed::DistributedConfig::process_local(),
1794                )
1795                .await
1796                .expect("create distributed runtime");
1797                let component = drt
1798                    .namespace("event-subscriber-shutdown-test")
1799                    .expect("create namespace")
1800                    .component("worker")
1801                    .expect("create component");
1802
1803                let mut subscriber = EventSubscriber::for_component_with_transport(
1804                    &component,
1805                    "events",
1806                    EventTransportKind::Zmq,
1807                )
1808                .await
1809                .expect("create subscriber");
1810
1811                drt.primary_token().cancel();
1812
1813                let next =
1814                    tokio::time::timeout(std::time::Duration::from_secs(1), subscriber.next())
1815                        .await
1816                        .expect("runtime cancellation should stop the subscriber stream");
1817                assert!(next.is_none());
1818            },
1819        )
1820        .await;
1821    }
1822
1823    #[test]
1824    fn test_event_scope_subject_prefix() {
1825        let scopes = [
1826            (
1827                EventScope::Namespace {
1828                    name: "ns.one".to_string(),
1829                },
1830                "namespace.ns%2Eone",
1831            ),
1832            (
1833                EventScope::Component {
1834                    namespace: "ns.one".to_string(),
1835                    component: "worker/*".to_string(),
1836                },
1837                "namespace.ns%2Eone.component.worker%2F%2A",
1838            ),
1839            (
1840                EventScope::Endpoint {
1841                    endpoint: EndpointId {
1842                        namespace: "ns.one".to_string(),
1843                        component: "worker/*".to_string(),
1844                        name: "generate.>".to_string(),
1845                    },
1846                },
1847                "namespace.ns%2Eone.component.worker%2F%2A.endpoint.generate%2E%3E",
1848            ),
1849        ];
1850
1851        for (scope, expected_prefix) in scopes {
1852            assert_eq!(scope.subject_prefix(), expected_prefix);
1853            assert_eq!(
1854                scope.subject("kv.events/*"),
1855                format!("{expected_prefix}.kv%2Eevents%2F%2A")
1856            );
1857        }
1858    }
1859
1860    #[test]
1861    fn test_event_scope_accessors() {
1862        let ns_scope = EventScope::Namespace {
1863            name: "my-ns".to_string(),
1864        };
1865        assert_eq!(ns_scope.namespace(), "my-ns");
1866        assert_eq!(ns_scope.component(), None);
1867
1868        let comp_scope = EventScope::Component {
1869            namespace: "my-ns".to_string(),
1870            component: "my-comp".to_string(),
1871        };
1872        assert_eq!(comp_scope.namespace(), "my-ns");
1873        assert_eq!(comp_scope.component(), Some("my-comp"));
1874    }
1875
1876    #[test]
1877    fn test_event_envelope_serde() {
1878        let envelope = EventEnvelope {
1879            publisher_id: 42,
1880            sequence: 10,
1881            published_at: 1700000000000,
1882            topic: "test-topic".to_string(),
1883            payload: Bytes::from("test data"),
1884        };
1885
1886        let json = serde_json::to_string(&envelope).expect("serialize");
1887        let deserialized: EventEnvelope = serde_json::from_str(&json).expect("deserialize");
1888
1889        assert_eq!(deserialized.publisher_id, 42);
1890        assert_eq!(deserialized.sequence, 10);
1891        assert_eq!(deserialized.published_at, 1700000000000);
1892        assert_eq!(deserialized.topic, "test-topic");
1893        assert_eq!(deserialized.payload, Bytes::from("test data"));
1894    }
1895}