Skip to main content

dora_node_api/event_stream/
mod.rs

1use std::{
2    collections::{BTreeMap, HashMap, VecDeque},
3    path::PathBuf,
4    pin::pin,
5    sync::Arc,
6    time::{Duration, Instant},
7};
8
9use dora_message::{
10    DataflowId,
11    daemon_to_node::{DaemonCommunication, DaemonReply, DataMessage, NodeEvent},
12    id::DataId,
13    node_to_daemon::{DaemonRequest, Timestamped},
14};
15pub use event::{Event, StopCause};
16use futures::{
17    FutureExt, Stream,
18    future::{Either, select},
19};
20use futures_timer::Delay;
21use scheduler::{NON_INPUT_EVENT, Scheduler};
22
23use self::thread::{EventItem, EventStreamThreadHandle};
24use crate::{
25    DaemonCommunicationWrapper, PatternError,
26    daemon_connection::{DaemonChannel, node_integration_testing::convert_output_to_json},
27    event_stream::data_conversion::RawData,
28    node::{ZENOH_TEARDOWN_TIMEOUT, teardown_with_timeout},
29};
30use dora_arrow_convert::IntoArrow;
31use dora_core::{
32    config::{Input, NodeId},
33    uhlc,
34};
35use eyre::{Context, eyre};
36
37pub use scheduler::Scheduler as EventScheduler;
38
39mod data_conversion;
40mod event;
41pub mod input_tracker;
42pub mod merged;
43mod scheduler;
44mod thread;
45
46/// Asynchronous iterator over the incoming [`Event`]s destined for this node.
47///
48/// This struct [implements](#impl-Stream-for-EventStream) the [`Stream`] trait,
49/// so you can use methods of the [`StreamExt`](futures::StreamExt) trait
50/// on this struct. A common pattern is `while let Some(event) = event_stream.next().await`.
51///
52/// Nodes should iterate over this event stream and react to events that they are interested in.
53/// Typically, the most important event type is [`Event::Input`].
54/// You don't need to handle all events, it's fine to ignore events that are not relevant to your node.
55///
56/// The event stream will close itself after a [`Event::Stop`] was received.
57/// A manual `break` on [`Event::Stop`] is typically not needed.
58/// _(You probably do need to use a manual `break` on stop events when using the
59/// [`StreamExt::merge`][`futures_concurrency::stream::StreamExt::merge`] implementation on
60/// [`EventStream`] to combine the stream with an external one.)_
61///
62/// Once the event stream finished, nodes should exit.
63/// Note that Dora kills nodes that don't exit quickly after a [`Event::Stop`] of type
64/// [`StopCause::Manual`] was received.
65pub struct EventStream {
66    node_id: NodeId,
67    // Drop order: Rust drops fields in declaration order (top to bottom).
68    // receiver must drop FIRST so tx_clone.send() in subscriber threads
69    // returns Err, causing threads to exit before JoinHandles are dropped.
70    // Using `tokio::sync::mpsc::Receiver` — instead of `flume` — avoids the
71    // AB-BA deadlock between flume 0.10's spinlock and pyo3's GIL-acquiring
72    // waker when a Python coroutine polls this stream
73    // (upstream dora-rs/dora#1603).
74    receiver: tokio::sync::mpsc::Receiver<EventItem>,
75    _thread_handle: EventStreamThreadHandle,
76    /// Callback subscribers — kept alive for the lifetime of the
77    /// EventStream. Dropping a subscriber undeclares it and stops further
78    /// callbacks. The callbacks use `try_send` (never block), so undeclaring
79    /// does not depend on `receiver` being dropped first. `EventStream::drop`
80    /// explicitly tears these down under a deadline (see there); by the time
81    /// this field drops it is already empty.
82    _zenoh_subscribers: Vec<zenoh::pubsub::Subscriber<()>>,
83    /// Per-input `@schema` subscribers (zenoh-ext AdvancedSubscriber) that prime
84    /// the data subscribers' decoders. Kept alive like `_zenoh_subscribers`.
85    _zenoh_schema_subscribers: Vec<zenoh_ext::AdvancedSubscriber<()>>,
86    /// Per-input liveliness tokens announcing "my data subscriber is declared"
87    /// so producers can count subscribers and only switch an output to the direct
88    /// zenoh data plane once every subscriber is wired (startup no-loss barrier;
89    /// see [`dora_core::topics::zenoh_input_ready_liveliness_topic`]). Kept alive
90    /// for the EventStream's lifetime; dropping a token undeclares it.
91    _zenoh_liveliness_tokens: Vec<zenoh::liveliness::LivelinessToken>,
92    close_channel: DaemonChannel,
93    clock: Arc<uhlc::HLC>,
94    scheduler: Scheduler,
95    write_events_to: Option<WriteEventsTo>,
96    start_timestamp: uhlc::Timestamp,
97    use_scheduler: bool,
98    /// Expected input types from YAML descriptor (for first-message validation).
99    /// Each input is checked once; after the first message, the entry is removed.
100    input_type_checks: HashMap<DataId, arrow_schema::DataType>,
101    /// Events that were consumed by a pattern-aware helper
102    /// (`recv_service_response`, `recv_action_result`) while searching
103    /// for a correlation match. Drained first on the next `recv()` so
104    /// the caller's main event loop never loses intermediate events
105    /// (dora-rs/adora#148).
106    pending_passthrough: std::collections::VecDeque<Event>,
107    /// Set to true after an `Event::Stop` has been delivered. Zenoh
108    /// subscriber threads hold clones of the event channel sender, so
109    /// the daemon thread's sender drop alone is not enough to close
110    /// the receiver — subsequent `recv_async`/`poll_next` would hang.
111    /// Returning `None` here lets the caller exit and drops the
112    /// `EventStream`, which signals subscriber shutdown.
113    stop_received: bool,
114}
115
116impl EventStream {
117    #[allow(clippy::too_many_arguments)]
118    #[tracing::instrument(level = "trace", skip(clock, zenoh_session))]
119    pub(crate) fn init(
120        dataflow_id: DataflowId,
121        node_id: &NodeId,
122        daemon_communication: &DaemonCommunicationWrapper,
123        input_config: BTreeMap<DataId, Input>,
124        input_types: &BTreeMap<DataId, String>,
125        clock: Arc<uhlc::HLC>,
126        write_events_to: Option<PathBuf>,
127        zenoh_session: Option<&zenoh::Session>,
128        dynamic: bool,
129    ) -> eyre::Result<Self> {
130        let channel = match daemon_communication {
131            DaemonCommunicationWrapper::Standard(daemon_communication) => {
132                match daemon_communication {
133                    DaemonCommunication::Tcp { socket_addr } => {
134                        DaemonChannel::new_tcp(*socket_addr).wrap_err_with(|| {
135                            format!("failed to connect event stream for node `{node_id}`")
136                        })?
137                    }
138
139                    DaemonCommunication::Interactive => {
140                        DaemonChannel::Interactive(Default::default())
141                    }
142                }
143            }
144
145            DaemonCommunicationWrapper::Testing { channel } => {
146                DaemonChannel::IntegrationTestChannel(channel.clone())
147            }
148        };
149
150        let close_channel = match daemon_communication {
151            DaemonCommunicationWrapper::Standard(daemon_communication) => {
152                match daemon_communication {
153                    DaemonCommunication::Tcp { socket_addr } => {
154                        DaemonChannel::new_tcp(*socket_addr).wrap_err_with(|| {
155                            format!("failed to connect event close channel for node `{node_id}`")
156                        })?
157                    }
158                    DaemonCommunication::Interactive => {
159                        DaemonChannel::Interactive(Default::default())
160                    }
161                }
162            }
163            DaemonCommunicationWrapper::Testing { channel } => {
164                DaemonChannel::IntegrationTestChannel(channel.clone())
165            }
166        };
167
168        let mut queue_size_limit: HashMap<DataId, (usize, VecDeque<EventItem>)> = input_config
169            .iter()
170            .map(|(input, config)| {
171                (
172                    input.clone(),
173                    (
174                        config
175                            .queue_size
176                            .unwrap_or(dora_message::config::DEFAULT_QUEUE_SIZE),
177                        VecDeque::new(),
178                    ),
179                )
180            })
181            .collect();
182
183        queue_size_limit.insert(
184            DataId::from(NON_INPUT_EVENT.to_string()),
185            (1_000, VecDeque::new()),
186        );
187
188        let queue_policies: HashMap<DataId, dora_message::config::QueuePolicy> = input_config
189            .iter()
190            .filter_map(|(input, config)| config.queue_policy.map(|p| (input.clone(), p)))
191            .collect();
192
193        let scheduler = Scheduler::with_policies(queue_size_limit, queue_policies);
194
195        let total_queue_capacity: usize = input_config
196            .values()
197            .map(|c| {
198                c.queue_size
199                    .unwrap_or(dora_message::config::DEFAULT_QUEUE_SIZE)
200            })
201            .sum::<usize>()
202            .max(64);
203
204        let write_events_to = match write_events_to {
205            Some(path) => {
206                if let Some(parent) = path.parent() {
207                    std::fs::create_dir_all(parent).wrap_err_with(|| {
208                        format!(
209                            "failed to create parent directories for event output file `{}` for node `{}`",
210                            path.display(),
211                            node_id
212                        )
213                    })?;
214                }
215
216                let file = std::fs::File::create(&path).wrap_err_with(|| {
217                    format!(
218                        "failed to create event output file `{}` for node `{}`",
219                        path.display(),
220                        node_id
221                    )
222                })?;
223
224                Some(WriteEventsTo {
225                    node_id: node_id.clone(),
226                    file,
227                    events_buffer: Vec::new(),
228                    poisoned: None,
229                })
230            }
231            None => None,
232        };
233
234        // Resolve input type URNs to Arrow DataTypes for first-message validation.
235        let mut input_type_checks = HashMap::new();
236        {
237            let registry = dora_core::types::TypeRegistry::new();
238            for (input_id, type_urn) in input_types {
239                match registry.resolve_arrow_type(type_urn) {
240                    Some(dt) => {
241                        input_type_checks.insert(input_id.clone(), dt);
242                    }
243                    None => {
244                        // Complex or custom types not resolvable to a simple Arrow DataType
245                        if registry.resolve(type_urn).is_some() {
246                            tracing::debug!(
247                                input = %input_id,
248                                "skipping type check for complex type \"{type_urn}\""
249                            );
250                        } else {
251                            tracing::warn!(
252                                input = %input_id,
253                                "unknown input type URN \"{type_urn}\" — skipping type check"
254                            );
255                        }
256                    }
257                }
258            }
259        }
260
261        Self::init_on_channel(
262            dataflow_id,
263            node_id,
264            channel,
265            close_channel,
266            clock,
267            scheduler,
268            write_events_to,
269            input_type_checks,
270            total_queue_capacity,
271            zenoh_session,
272            &input_config,
273            dynamic,
274        )
275    }
276
277    #[allow(clippy::too_many_arguments)]
278    pub(crate) fn init_on_channel(
279        dataflow_id: DataflowId,
280        node_id: &NodeId,
281        mut channel: DaemonChannel,
282        mut close_channel: DaemonChannel,
283        clock: Arc<uhlc::HLC>,
284        scheduler: Scheduler,
285        write_events_to: Option<WriteEventsTo>,
286        input_type_checks: HashMap<DataId, arrow_schema::DataType>,
287        channel_capacity: usize,
288        zenoh_session: Option<&zenoh::Session>,
289        input_config: &BTreeMap<DataId, Input>,
290        dynamic: bool,
291    ) -> eyre::Result<Self> {
292        channel.register(dataflow_id, node_id.clone(), clock.new_timestamp())?;
293        let (tx, rx) = tokio::sync::mpsc::channel(channel_capacity);
294
295        let use_scheduler = match &channel {
296            DaemonChannel::IntegrationTestChannel(_) => {
297                // don't use the scheduler for integration tests because it leads to
298                // non-deterministic event ordering
299                false
300            }
301            _ => true,
302        };
303
304        // Declare zenoh subscribers for each input that has a source node.
305        // We use callback subscribers so the zenoh IO thread delivers the
306        // sample directly into the event channel without an intermediate
307        // dora-side thread + recv_async wakeup. The `Subscriber<()>` handle
308        // must be kept alive for the lifetime of the EventStream — we store
309        // them in `_zenoh_subscribers` and drop them after `receiver`.
310        let mut zenoh_subscribers = Vec::new();
311        let mut zenoh_schema_subscribers = Vec::new();
312        let mut zenoh_liveliness_tokens = Vec::new();
313        if let Some(session) = zenoh_session {
314            use zenoh::Wait;
315            for (input_id, input) in input_config {
316                let mapping = &input.mapping;
317                if let dora_message::config::InputMapping::User(user_mapping) = mapping {
318                    let source_node = &user_mapping.source;
319                    let source_output = &user_mapping.output;
320                    let topic = dora_core::topics::zenoh_output_publish_topic(
321                        dataflow_id,
322                        source_node,
323                        source_output,
324                    );
325                    let key_expr = match zenoh::key_expr::KeyExpr::new(topic.clone()) {
326                        Ok(k) => k.into_owned(),
327                        Err(e) => {
328                            tracing::warn!(input = %input_id, "invalid zenoh key ({e}), using daemon path");
329                            continue;
330                        }
331                    };
332                    // Per-input persistent decoder for the schema-once path,
333                    // shared between this data subscriber (which decodes batches
334                    // in zenoh receipt order) and the `@schema` subscriber (which
335                    // primes it from the cached/live schema). `Mutex` because the
336                    // zenoh callbacks are `Fn`; uncontended in practice (each
337                    // subscriber delivers its samples serially).
338                    let decoder = std::sync::Arc::new(std::sync::Mutex::new(
339                        crate::arrow_utils::ipc_encode::InputDecoder::new(),
340                    ));
341                    // Set if the `@schema` subscriber fails to declare: the
342                    // schema plane is then dead for this input and only the
343                    // producer's periodic in-band full-stream refresh can prime
344                    // the decoder. The data callback surfaces a `FatalError` if
345                    // the input stays undecodable past a grace window (see
346                    // below), distinguishing a genuinely dead input from the
347                    // transient "schema not arrived yet" drop.
348                    let schema_plane_failed =
349                        std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
350                    // Start of the current run of undecodable schema-once
351                    // batches while the schema plane is dead; cleared by any
352                    // successful decode. Only touched when `schema_plane_failed`
353                    // is set, so it costs nothing on the healthy path.
354                    let first_undecodable =
355                        std::sync::Arc::new(std::sync::Mutex::new(Option::<Instant>::None));
356                    // The `@schema` subscriber primes `decoder`; its history query
357                    // fetches the cached schema on join (late joiners), and
358                    // `detect_late_publishers` covers a producer that starts later.
359                    declare_schema_subscriber(
360                        session,
361                        dataflow_id,
362                        source_node,
363                        source_output,
364                        input_id,
365                        decoder.clone(),
366                        tx.clone(),
367                        schema_plane_failed.clone(),
368                        &mut zenoh_schema_subscribers,
369                    );
370
371                    let tx_cb = tx.clone();
372                    let input_id_cb = input_id.clone();
373                    let decoder = decoder.clone();
374                    let first_undecodable_cb = first_undecodable.clone();
375                    let subscriber = session
376                        .declare_subscriber(key_expr)
377                        .callback(move |sample| {
378                            // catch_unwind: a panic inside the callback would
379                            // otherwise unwind through zenoh's IO worker, which
380                            // is unsafe. Surface as FatalError so the node sees
381                            // it and exits cleanly.
382                            let result =
383                                std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
384                                    use dora_message::metadata::Metadata;
385                                    let metadata = match sample.attachment() {
386                                        Some(att) => {
387                                            match bincode::deserialize::<Metadata>(&att.to_bytes())
388                                            {
389                                                // A version mismatch that still happens
390                                                // to deserialize: reject with a clear
391                                                // message rather than acting on data from
392                                                // an incompatible wire format.
393                                                Ok(m)
394                                                    if m.metadata_version()
395                                                        != Metadata::CURRENT_VERSION =>
396                                                {
397                                                    tracing::warn!(
398                                                        "dropping zenoh sample: incompatible \
399                                                     metadata wire version {} (this node \
400                                                     speaks {})",
401                                                        m.metadata_version(),
402                                                        Metadata::CURRENT_VERSION
403                                                    );
404                                                    return;
405                                                }
406                                                Ok(m) => m,
407                                                Err(e) => {
408                                                    // A pre-1.0 peer (old ArrowTypeInfo
409                                                    // sidecar layout) misaligns here; name
410                                                    // the likely cause so the failure isn't
411                                                    // a bare bincode error.
412                                                    tracing::warn!(
413                                                        "zenoh metadata deserialization failed \
414                                                     (possibly a peer using an incompatible \
415                                                     wire format/version): {e}"
416                                                    );
417                                                    return;
418                                                }
419                                            }
420                                        }
421                                        None => {
422                                            tracing::warn!(
423                                                "zenoh sample missing metadata attachment"
424                                            );
425                                            return;
426                                        }
427                                    };
428                                    let payload = sample.payload().clone();
429                                    // Decode here (receipt order) so the per-input
430                                    // persistent decoder stays in sync. This runs on
431                                    // zenoh's IO worker: the aligned-SHM path is
432                                    // zero-copy, but an under-aligned heap payload
433                                    // copies its buffers on this thread (acceptable —
434                                    // only small messages take the heap path).
435                                    let mut decoder = decoder.lock().unwrap_or_else(|poison| {
436                                        // A prior callback panicked mid-decode
437                                        // (caught below), poisoning the lock and
438                                        // leaving the decoder in an undefined
439                                        // state. Reset it so the next batch is not
440                                        // fed into a corrupt decoder.
441                                        let mut guard = poison.into_inner();
442                                        guard.reset();
443                                        guard
444                                    });
445                                    let data =
446                                        match decode_zenoh_sample(&mut decoder, &metadata, payload)
447                                        {
448                                            Ok(Some(data)) => data,
449                                            // Schema-less batch we can't decode yet.
450                                            // Normally transient: the priming schema
451                                            // hasn't arrived (lossy data plane), so drop
452                                            // and wait — the window is bounded by the
453                                            // producer's periodic full-stream refresh,
454                                            // which re-primes in-band. But if the
455                                            // `@schema` subscriber failed to declare, the
456                                            // schema plane is dead (a degraded zenoh
457                                            // session) and only that refresh can save the
458                                            // input — give it a grace window, then surface
459                                            // a `FatalError` so the node exits loudly
460                                            // rather than drop messages forever. Only
461                                            // clear the flag once the error is actually
462                                            // queued, so a momentarily-full channel can't
463                                            // swallow the one-shot fatal signal forever; it
464                                            // retries on the next undecodable batch. This
465                                            // callback is the sole reader, so the
466                                            // load-then-store is race-free. `Relaxed` is
467                                            // sufficient: the flag is stored in
468                                            // `declare_schema_subscriber` before the data
469                                            // subscriber that drives this callback is even
470                                            // declared, so that write happens-before any
471                                            // read here.
472                                            Ok(None) => {
473                                                if schema_plane_failed
474                                                    .load(std::sync::atomic::Ordering::Relaxed)
475                                                {
476                                                    let mut first = first_undecodable_cb
477                                                        .lock()
478                                                        .unwrap_or_else(|p| p.into_inner());
479                                                    if first.is_none() {
480                                                        tracing::warn!(
481                                                            input = %input_id_cb,
482                                                            "schema-once batch arrived unprimed \
483                                                             while the `@schema` subscriber is \
484                                                             not declared; dropping, waiting up \
485                                                             to {}s for the producer's in-band \
486                                                             full-stream refresh",
487                                                            SCHEMA_PLANE_FATAL_GRACE.as_secs()
488                                                        );
489                                                    }
490                                                    if schema_plane_fatal_due(
491                                                        &mut first,
492                                                        Instant::now(),
493                                                    ) && tx_cb
494                                                        .try_send(EventItem::FatalError(eyre!(
495                                                            "input `{input_id_cb}`: the `@schema` \
496                                                             subscriber failed to declare (a \
497                                                             degraded zenoh session) and the \
498                                                             input stayed undecodable for {}s \
499                                                             despite the producer's periodic \
500                                                             full-stream refresh — messages on \
501                                                             this input are being dropped",
502                                                            SCHEMA_PLANE_FATAL_GRACE.as_secs()
503                                                        )))
504                                                        .is_ok()
505                                                    {
506                                                        schema_plane_failed.store(
507                                                            false,
508                                                            std::sync::atomic::Ordering::Relaxed,
509                                                        );
510                                                    }
511                                                }
512                                                return;
513                                            }
514                                            Err(e) => {
515                                                tracing::warn!(
516                                                    input = %input_id_cb,
517                                                    "zenoh payload decode failed: {e}"
518                                                );
519                                                return;
520                                            }
521                                        };
522                                    drop(decoder);
523                                    // A successful decode means the input is healthy
524                                    // (in-band priming worked); reset the fatal grace
525                                    // window. Only costs a lock when the schema plane
526                                    // is actually dead.
527                                    if schema_plane_failed
528                                        .load(std::sync::atomic::Ordering::Relaxed)
529                                    {
530                                        *first_undecodable_cb
531                                            .lock()
532                                            .unwrap_or_else(|p| p.into_inner()) = None;
533                                    }
534                                    // Callback runs on zenoh's tokio IO worker —
535                                    // `blocking_send` panics from a tokio context, so
536                                    // use `try_send`. If the channel is full the event
537                                    // is dropped (logged); receiver-dropped also
538                                    // surfaces here, in which case there's nothing to do.
539                                    if let Err(e) = tx_cb.try_send(EventItem::ZenohInput {
540                                        id: input_id_cb.clone(),
541                                        metadata: std::sync::Arc::new(metadata),
542                                        data,
543                                    }) {
544                                        use tokio::sync::mpsc::error::TrySendError;
545                                        match e {
546                                            TrySendError::Full(_) => {
547                                                tracing::warn!(
548                                                    "event channel full; dropping zenoh input"
549                                                );
550                                            }
551                                            TrySendError::Closed(_) => {
552                                                // normal shutdown
553                                            }
554                                        }
555                                    }
556                                }));
557                            if result.is_err() {
558                                tracing::error!(
559                                    input = %input_id_cb,
560                                    "zenoh subscriber callback panicked"
561                                );
562                                let _ = tx_cb.try_send(EventItem::FatalError(eyre!(
563                                    "zenoh subscriber callback for input `{input_id_cb}` panicked"
564                                )));
565                            }
566                        })
567                        .wait();
568                    match subscriber {
569                        Ok(s) => {
570                            tracing::debug!(input = %input_id, %topic, "zenoh subscriber declared (callback)");
571                            zenoh_subscribers.push(s);
572
573                            // Announce readiness for this link so the producer
574                            // counts us before switching the output to the direct
575                            // zenoh data plane. Declared only after a *successful*
576                            // data subscriber (so a counted token always implies
577                            // the subscription is in flight) and never for dynamic
578                            // nodes — they connect at arbitrary times and are not
579                            // part of the startup barrier (the producer reaches
580                            // them via normal matching once they join). A failure
581                            // here is non-fatal: the producer just keeps this
582                            // output on the reliable daemon path.
583                            if !dynamic {
584                                let ready_key =
585                                    dora_core::topics::zenoh_input_ready_liveliness_topic(
586                                        dataflow_id,
587                                        source_node,
588                                        source_output,
589                                        node_id,
590                                        input_id,
591                                    );
592                                match session.liveliness().declare_token(ready_key).wait() {
593                                    Ok(token) => zenoh_liveliness_tokens.push(token),
594                                    Err(e) => {
595                                        tracing::warn!(
596                                            input = %input_id,
597                                            "failed to declare zenoh readiness token ({e}); \
598                                             producer will keep this output on the daemon path"
599                                        );
600                                    }
601                                }
602                            }
603                        }
604                        Err(e) => {
605                            tracing::warn!(
606                                input = %input_id,
607                                "failed to declare zenoh subscriber ({e}), using daemon path"
608                            );
609                        }
610                    }
611                }
612            }
613        }
614
615        let reply = channel
616            .request(&Timestamped {
617                inner: DaemonRequest::Subscribe,
618                timestamp: clock.new_timestamp(),
619            })
620            .map_err(|e| eyre!(e))
621            .wrap_err("failed to create subscription with dora-daemon")?;
622
623        match reply {
624            DaemonReply::Result(Ok(())) => {}
625            DaemonReply::Result(Err(err)) => {
626                eyre::bail!("subscribe failed: {err}")
627            }
628            other => eyre::bail!("unexpected subscribe reply: {other:?}"),
629        }
630
631        close_channel.register(dataflow_id, node_id.clone(), clock.new_timestamp())?;
632
633        let thread_handle = thread::init(node_id.clone(), tx, channel, clock.clone())?;
634
635        Ok(EventStream {
636            node_id: node_id.clone(),
637            receiver: rx,
638            _thread_handle: thread_handle,
639            _zenoh_subscribers: zenoh_subscribers,
640            _zenoh_schema_subscribers: zenoh_schema_subscribers,
641            _zenoh_liveliness_tokens: zenoh_liveliness_tokens,
642            close_channel,
643            start_timestamp: clock.new_timestamp(),
644            clock,
645            scheduler,
646            write_events_to,
647            use_scheduler,
648            input_type_checks,
649            pending_passthrough: std::collections::VecDeque::new(),
650            stop_received: false,
651        })
652    }
653
654    /// Synchronously waits for the next event.
655    ///
656    /// Blocks the thread until the next event arrives.
657    /// Returns [`None`] once the event stream is closed.
658    ///
659    /// For an asynchronous variant of this method see [`recv_async`][Self::recv_async].
660    ///
661    /// ## Event Reordering
662    ///
663    /// This method uses an [`EventScheduler`] internally to **reorder events**. This means that the
664    /// events might be returned in a different order than they occurred. For details, check the
665    /// documentation of the [`EventScheduler`] struct.
666    ///
667    /// If you want to receive the events in their original chronological order, use the
668    /// asynchronous [`StreamExt::next`](futures::StreamExt::next) method instead ([`EventStream`] implements the
669    /// [`Stream`] trait).
670    pub fn recv(&mut self) -> Option<Event> {
671        futures::executor::block_on(self.recv_async())
672    }
673
674    /// Receives the next incoming [`Event`] synchronously with a timeout.
675    ///
676    /// Blocks the thread until the next event arrives or the timeout is reached.
677    /// Returns a [`Event::Error`] if no event was received within the given duration.
678    ///
679    /// Returns [`None`] once the event stream is closed.
680    ///
681    /// For an asynchronous variant of this method see [`recv_async_timeout`][Self::recv_async_timeout].
682    ///
683    /// ## Event Reordering
684    ///
685    /// This method uses an [`EventScheduler`] internally to **reorder events**. This means that the
686    /// events might be returned in a different order than they occurred. For details, check the
687    /// documentation of the [`EventScheduler`] struct.
688    ///
689    /// If you want to receive the events in their original chronological order, use the
690    /// asynchronous [`StreamExt::next`](futures::StreamExt::next) method instead ([`EventStream`] implements the
691    /// [`Stream`] trait).
692    pub fn recv_timeout(&mut self, dur: Duration) -> Option<Event> {
693        futures::executor::block_on(self.recv_async_timeout(dur))
694    }
695
696    /// Receives the next incoming [`Event`] asynchronously, using an [`EventScheduler`] for fairness.
697    ///
698    /// Returns [`None`] once the event stream is closed.
699    ///
700    /// ## Event Reordering
701    ///
702    /// This method uses an [`EventScheduler`] internally to **reorder events**. This means that the
703    /// events might be returned in a different order than they occurred. For details, check the
704    /// documentation of the [`EventScheduler`] struct.
705    ///
706    /// If you want to receive the events in their original chronological order, use the
707    /// [`StreamExt::next`](futures::StreamExt::next) method with a custom timeout future instead
708    /// ([`EventStream`] implements the [`Stream`] trait).
709    pub async fn recv_async(&mut self) -> Option<Event> {
710        // Drain any events that were stashed by pattern-aware helpers
711        // (`recv_service_response`, `recv_action_result`) while they
712        // were waiting for a specific correlation. These must be
713        // returned to the caller before we poll the underlying
714        // scheduler, so the caller's main event loop never loses
715        // events that arrived during a helper wait (dora-rs/adora#148).
716        if let Some(event) = self.pending_passthrough.pop_front() {
717            return Some(event);
718        }
719        // Close the stream after a Stop event: the daemon thread has
720        // already dropped its sender, but zenoh subscriber threads
721        // hold clones that would otherwise keep `receiver` open.
722        if self.stop_received {
723            // The scheduler gives `Stop` (a NON_INPUT_EVENT) strict priority
724            // over buffered inputs (`scheduler::next`), so inputs enqueued
725            // before Stop can still be queued when Stop is delivered. Drain
726            // those buffered *inputs* before closing instead of dropping them
727            // silently. Trailing non-input control events (a second Stop /
728            // AllInputsClosed / InputClosed / Reload) are discarded rather than
729            // re-delivered after Stop — the contract is "nothing after Stop
730            // except the inputs that were already queued". Do NOT pull new
731            // events from `receiver`; the dataflow is stopping, and on the
732            // non-scheduler path returning `None` closes the stream against
733            // zenoh-held senders.
734            if self.use_scheduler {
735                while let Some(item) = self.scheduler.next() {
736                    if matches!(
737                        &item,
738                        EventItem::NodeEvent {
739                            event: NodeEvent::Input { .. },
740                            ..
741                        } | EventItem::ZenohInput { .. }
742                    ) {
743                        return Some(Self::convert_event_item(item));
744                    }
745                }
746            }
747            return None;
748        }
749        let event = if !self.use_scheduler {
750            self.receiver.recv().await.map(Self::convert_event_item)
751        } else {
752            loop {
753                if self.scheduler.is_empty() {
754                    if let Some(event) = self.receiver.recv().await {
755                        self.add_event(event);
756                    } else {
757                        break;
758                    }
759                } else {
760                    match self.receiver.try_recv() {
761                        Ok(event) => self.add_event(event),
762                        Err(_) => break, // empty or disconnected
763                    };
764                }
765            }
766            self.scheduler.next().map(Self::convert_event_item)
767        };
768
769        // First-message type validation: check once per input, then remove.
770        // Zero cost after first message per input.
771        //
772        // Skip the check when the message carries pattern metadata
773        // (`request_id`, `goal_id`, or `goal_status`) — the input is
774        // polymorphic by pattern design and a single declared type
775        // cannot cover all variants. The check stays armed so a later
776        // non-pattern message can still validate (dora-rs/adora#150).
777        if let Some(Event::Input {
778            ref id,
779            ref metadata,
780            ref data,
781        }) = event
782            && let Some(expected) = self.input_type_checks.get(id).cloned()
783        {
784            let is_pattern_message = metadata
785                .parameters
786                .contains_key(dora_message::metadata::REQUEST_ID)
787                || metadata
788                    .parameters
789                    .contains_key(dora_message::metadata::GOAL_ID)
790                || metadata
791                    .parameters
792                    .contains_key(dora_message::metadata::GOAL_STATUS);
793            if !is_pattern_message {
794                // Consume the check and validate.
795                self.input_type_checks.remove(id);
796                let actual = data.data_type();
797                // Skip check for Null type (timer ticks, empty payloads)
798                // to avoid spurious warnings on annotated timer inputs.
799                if *actual != arrow_schema::DataType::Null && *actual != expected {
800                    tracing::warn!(
801                        input = %id,
802                        expected = ?expected,
803                        actual = ?actual,
804                        "input type mismatch on first message"
805                    );
806                }
807            }
808        }
809
810        if matches!(&event, Some(Event::Stop(_))) {
811            self.stop_received = true;
812        }
813        event
814    }
815
816    /// Check if there are any buffered events in the scheduler, the
817    /// receiver, or the passthrough buffer used by pattern-aware helpers.
818    pub fn is_empty(&self) -> bool {
819        self.pending_passthrough.is_empty() && self.scheduler.is_empty() && self.receiver.is_empty()
820    }
821
822    /// Returns and resets the accumulated drop counts per input ID.
823    ///
824    /// When inputs overflow their queue limits, the oldest messages are discarded.
825    /// For `drop_oldest` inputs this happens at `queue_size`. For `backpressure`
826    /// inputs this happens at a hard safety cap of 10x `queue_size`.
827    /// This method returns a map from input ID to the number of messages dropped
828    /// since the last call.
829    pub fn drain_drop_counts(&mut self) -> HashMap<DataId, u64> {
830        self.scheduler.drain_drop_counts()
831    }
832
833    fn add_event(&mut self, event: EventItem) {
834        // Event recording is observability-only (writes to the optional
835        // `write_events_to` log). A write failure must not panic the event
836        // loop — drop the log line and continue scheduling.
837        if let Err(err) = self.record_event(&event) {
838            tracing::warn!(
839                node = %self.node_id,
840                "failed to record event to write_events_to log: {err:?}"
841            );
842            // Mark the recording poisoned so consumers can detect events
843            // are missing from the final JSON. `write_out()` surfaces this
844            // as a top-level `recording_status` field (#1857).
845            if let Some(write_events_to) = self.write_events_to.as_mut() {
846                let time_offset_secs = self
847                    .clock
848                    .new_timestamp()
849                    .get_diff_duration(&self.start_timestamp)
850                    .as_secs_f64();
851                write_events_to.mark_poisoned(&err, time_offset_secs);
852            }
853        }
854        self.scheduler.add_event(event);
855    }
856
857    fn record_event(&mut self, event: &EventItem) -> eyre::Result<()> {
858        if let Some(write_events_to) = &mut self.write_events_to {
859            let event_json = match event {
860                EventItem::NodeEvent { event, .. } => match event {
861                    NodeEvent::Stop => {
862                        let time_offset = self
863                            .clock
864                            .new_timestamp()
865                            .get_diff_duration(&self.start_timestamp);
866                        let event_json = serde_json::json!({
867                            "type": "Stop",
868                            "time_offset_secs": time_offset.as_secs_f64(),
869                        });
870                        Some(event_json)
871                    }
872                    NodeEvent::Reload { .. } => None,
873                    NodeEvent::Input { id, metadata, data } => {
874                        let mut event_json = convert_output_to_json(
875                            id,
876                            metadata,
877                            data,
878                            self.start_timestamp,
879                            false,
880                        )?;
881                        event_json.insert("type".into(), "Input".into());
882                        Some(event_json.into())
883                    }
884                    NodeEvent::InputClosed { id } => {
885                        let time_offset = self
886                            .clock
887                            .new_timestamp()
888                            .get_diff_duration(&self.start_timestamp);
889                        let event_json = serde_json::json!({
890                            "type": "InputClosed",
891                            "id": id.to_string(),
892                            "time_offset_secs": time_offset.as_secs_f64(),
893                        });
894                        Some(event_json)
895                    }
896                    NodeEvent::InputRecovered { id } => {
897                        let time_offset = self
898                            .clock
899                            .new_timestamp()
900                            .get_diff_duration(&self.start_timestamp);
901                        let event_json = serde_json::json!({
902                            "type": "InputRecovered",
903                            "id": id.to_string(),
904                            "time_offset_secs": time_offset.as_secs_f64(),
905                        });
906                        Some(event_json)
907                    }
908                    NodeEvent::NodeRestarted { id } => {
909                        let time_offset = self
910                            .clock
911                            .new_timestamp()
912                            .get_diff_duration(&self.start_timestamp);
913                        let event_json = serde_json::json!({
914                            "type": "NodeRestarted",
915                            "id": id.to_string(),
916                            "time_offset_secs": time_offset.as_secs_f64(),
917                        });
918                        Some(event_json)
919                    }
920                    NodeEvent::AllInputsClosed => {
921                        let time_offset = self
922                            .clock
923                            .new_timestamp()
924                            .get_diff_duration(&self.start_timestamp);
925                        let event_json = serde_json::json!({
926                            "type": "AllInputsClosed",
927                            "time_offset_secs": time_offset.as_secs_f64(),
928                        });
929                        Some(event_json)
930                    }
931                    _ => None,
932                },
933                _ => None,
934            };
935            if let Some(event_json) = event_json {
936                write_events_to.events_buffer.push(event_json);
937            }
938        }
939        Ok(())
940    }
941
942    /// Receives the next buffered [`Event`] (if any) without blocking, using an
943    /// [`EventScheduler`] for fairness.
944    ///
945    /// Returns [`TryRecvError::Empty`] if no event is available right now.
946    /// Returns [`TryRecvError::Closed`] once the event stream is closed.
947    ///
948    /// This method never blocks and is safe to use in asynchronous contexts.
949    ///
950    /// ## Event Reordering
951    ///
952    /// This method uses an [`EventScheduler`] internally to **reorder events**. This means that the
953    /// events might be returned in a different order than they occurred. For details, check the
954    /// documentation of the [`EventScheduler`] struct.
955    ///
956    /// If you want to receive the events in their original chronological order, use the
957    /// [`StreamExt::next`](futures::StreamExt::next) method with a custom timeout future instead
958    /// ([`EventStream`] implements the [`Stream`] trait).
959    pub fn try_recv(&mut self) -> Result<Event, TryRecvError> {
960        match self.recv_async().now_or_never() {
961            Some(Some(event)) => Ok(event),
962            Some(None) => Err(TryRecvError::Closed),
963            None => Err(TryRecvError::Empty),
964        }
965    }
966
967    /// Receives all buffered [`Event`]s without blocking, using an [`EventScheduler`] for fairness.
968    ///
969    /// Return `Some(Vec::new())` if no events are ready.
970    /// Returns [`None`] once the event stream is closed and no events are buffered anymore.
971    ///
972    /// This method never blocks and is safe to use in asynchronous contexts.
973    ///
974    /// This method is equivalent to repeatedly calling [`try_recv`][Self::try_recv]. See its docs
975    /// for details on event reordering.
976    pub fn drain(&mut self) -> Option<Vec<Event>> {
977        let mut events = Vec::new();
978        loop {
979            match self.try_recv() {
980                Ok(event) => events.push(event),
981                Err(TryRecvError::Empty) => break,
982                Err(TryRecvError::Closed) => {
983                    if events.is_empty() {
984                        return None;
985                    } else {
986                        break;
987                    }
988                }
989            }
990        }
991        Some(events)
992    }
993
994    /// Receives the next incoming [`Event`] asynchronously with a timeout.
995    ///
996    /// Returns a [`Event::Error`] if no event was received within the given duration.
997    ///
998    /// Returns [`None`] once the event stream is closed.
999    ///
1000    /// ## Event Reordering
1001    ///
1002    /// This method uses an [`EventScheduler`] internally to **reorder events**. This means that the
1003    /// events might be returned in a different order than they occurred. For details, check the
1004    /// documentation of the [`EventScheduler`] struct.
1005    ///
1006    /// If you want to receive the events in their original chronological order, use the
1007    /// [`StreamExt::next`](futures::StreamExt::next) method with a custom timeout future instead
1008    /// ([`EventStream`] implements the [`Stream`] trait).
1009    pub async fn recv_async_timeout(&mut self, dur: Duration) -> Option<Event> {
1010        match select(Delay::new(dur), pin!(self.recv_async())).await {
1011            Either::Left((_elapsed, _)) => Some(Self::convert_event_item(EventItem::TimeoutError(
1012                eyre!("Receiver timed out"),
1013            ))),
1014            Either::Right((event, _)) => event,
1015        }
1016    }
1017
1018    /// Waits for a service response carrying `request_id` in its metadata.
1019    ///
1020    /// Drives the event loop internally and returns the matching
1021    /// [`Event::Input`] as soon as it arrives. Non-matching events are
1022    /// buffered and replayed on the next call to `recv()` / `recv_async()`,
1023    /// so your main event loop does not lose intermediate events.
1024    ///
1025    /// Terminal conditions return a [`PatternError`]:
1026    ///
1027    /// - `Timeout` — `timeout` elapsed before any matching response arrived.
1028    /// - `ServerRestarted(expected_server)` — the expected server node
1029    ///   restarted, which means its in-flight `request_id` correlation
1030    ///   was orphaned. The caller should retry against the new instance.
1031    /// - `StreamEnded` — the event stream closed (dataflow stopping)
1032    ///   before a response arrived. The terminal `Stop` event is still
1033    ///   returned to the caller's next `recv()`.
1034    /// - `StreamError` — an upstream error event surfaced during the wait.
1035    ///
1036    /// # Example
1037    ///
1038    /// ```ignore
1039    /// let request_id = node.send_service_request(...)?;
1040    /// match events
1041    ///     .recv_service_response(&request_id, &server_id, Duration::from_secs(5))
1042    ///     .await
1043    /// {
1044    ///     Ok(Event::Input { data, .. }) => handle_response(data),
1045    ///     Err(PatternError::Timeout) => fallback_path(),
1046    ///     Err(PatternError::ServerRestarted(_)) => retry_with_new_instance(),
1047    ///     Err(e) => return Err(e.into()),
1048    ///     _ => unreachable!(),
1049    /// }
1050    /// ```
1051    pub async fn recv_service_response(
1052        &mut self,
1053        request_id: &str,
1054        expected_server: &NodeId,
1055        timeout: Duration,
1056    ) -> Result<Event, PatternError> {
1057        self.wait_for_correlation(
1058            timeout,
1059            expected_server,
1060            |event, request_id| match event {
1061                Event::Input { metadata, .. } => {
1062                    dora_message::metadata::get_string_param(
1063                        &metadata.parameters,
1064                        dora_message::metadata::REQUEST_ID,
1065                    ) == Some(request_id)
1066                }
1067                _ => false,
1068            },
1069            request_id,
1070        )
1071        .await
1072    }
1073
1074    /// Waits for a terminal action result (`goal_status` ∈
1075    /// {`succeeded`, `aborted`, `canceled`}) with a matching `goal_id`.
1076    ///
1077    /// Semantics mirror [`recv_service_response`](Self::recv_service_response)
1078    /// but match on `goal_id` + a terminal `goal_status` instead of
1079    /// `request_id`. Intermediate feedback events (matching `goal_id`
1080    /// without a terminal `goal_status`) are stashed for the caller's
1081    /// main loop — use `recv()` separately if you need to observe them.
1082    pub async fn recv_action_result(
1083        &mut self,
1084        goal_id: &str,
1085        expected_server: &NodeId,
1086        timeout: Duration,
1087    ) -> Result<Event, PatternError> {
1088        self.wait_for_correlation(
1089            timeout,
1090            expected_server,
1091            |event, goal_id| match event {
1092                Event::Input { metadata, .. } => {
1093                    let matches_goal = dora_message::metadata::get_string_param(
1094                        &metadata.parameters,
1095                        dora_message::metadata::GOAL_ID,
1096                    ) == Some(goal_id);
1097                    if !matches_goal {
1098                        return false;
1099                    }
1100                    matches!(
1101                        dora_message::metadata::get_string_param(
1102                            &metadata.parameters,
1103                            dora_message::metadata::GOAL_STATUS,
1104                        ),
1105                        Some(dora_message::metadata::GOAL_STATUS_SUCCEEDED)
1106                            | Some(dora_message::metadata::GOAL_STATUS_ABORTED)
1107                            | Some(dora_message::metadata::GOAL_STATUS_CANCELED)
1108                    )
1109                }
1110                _ => false,
1111            },
1112            goal_id,
1113        )
1114        .await
1115    }
1116
1117    /// Core loop for the pattern-aware helpers. Waits up to `timeout`
1118    /// for an event that satisfies `is_match(event, needle)`. Buffers
1119    /// every non-matching event so the caller's main event loop can
1120    /// still see them via `recv()`.
1121    async fn wait_for_correlation<F>(
1122        &mut self,
1123        timeout: Duration,
1124        expected_server: &NodeId,
1125        is_match: F,
1126        needle: &str,
1127    ) -> Result<Event, PatternError>
1128    where
1129        F: Fn(&Event, &str) -> bool,
1130    {
1131        let deadline = std::time::Instant::now() + timeout;
1132        loop {
1133            let remaining = deadline.saturating_duration_since(std::time::Instant::now());
1134            if remaining.is_zero() {
1135                return Err(PatternError::Timeout);
1136            }
1137            let event = match select(Delay::new(remaining), pin!(self.recv_async())).await {
1138                Either::Left((_elapsed, _)) => return Err(PatternError::Timeout),
1139                Either::Right((None, _)) => return Err(PatternError::StreamEnded),
1140                Either::Right((Some(e), _)) => e,
1141            };
1142
1143            match classify_correlation_event(&event, expected_server, |e| is_match(e, needle)) {
1144                CorrelationOutcome::Match => return Ok(event),
1145                CorrelationOutcome::ServerRestarted => {
1146                    self.pending_passthrough.push_back(event);
1147                    return Err(PatternError::ServerRestarted(expected_server.to_string()));
1148                }
1149                CorrelationOutcome::StreamEnded => {
1150                    self.pending_passthrough.push_back(event);
1151                    return Err(PatternError::StreamEnded);
1152                }
1153                CorrelationOutcome::StreamError => {
1154                    if let Event::Error(err) = event {
1155                        return Err(PatternError::StreamError(err));
1156                    }
1157                    unreachable!("StreamError only returned for Event::Error");
1158                }
1159                CorrelationOutcome::Passthrough => {
1160                    self.pending_passthrough.push_back(event);
1161                }
1162            }
1163        }
1164    }
1165}
1166
1167/// Outcome of classifying a single event during a pattern-aware wait.
1168/// Separated from `wait_for_correlation` so the decision logic can be
1169/// unit-tested without a live `EventStream`.
1170#[derive(Debug, PartialEq, Eq)]
1171enum CorrelationOutcome {
1172    /// The event satisfies the caller's predicate — return it.
1173    Match,
1174    /// `Event::NodeRestarted { id }` where `id == expected_server`.
1175    ServerRestarted,
1176    /// `Event::Stop(_)` — the dataflow is shutting down.
1177    StreamEnded,
1178    /// `Event::Error(_)` — the stream surfaced an error.
1179    StreamError,
1180    /// Unrelated event — buffer it and keep waiting.
1181    Passthrough,
1182}
1183
1184fn classify_correlation_event<F>(
1185    event: &Event,
1186    expected_server: &NodeId,
1187    is_match: F,
1188) -> CorrelationOutcome
1189where
1190    F: Fn(&Event) -> bool,
1191{
1192    if is_match(event) {
1193        return CorrelationOutcome::Match;
1194    }
1195    match event {
1196        Event::NodeRestarted { id } if id == expected_server => CorrelationOutcome::ServerRestarted,
1197        Event::Stop(_) => CorrelationOutcome::StreamEnded,
1198        Event::Error(_) => CorrelationOutcome::StreamError,
1199        _ => CorrelationOutcome::Passthrough,
1200    }
1201}
1202
1203impl EventStream {
1204    fn convert_event_item(item: EventItem) -> Event {
1205        match item {
1206            EventItem::NodeEvent { event } => match event {
1207                NodeEvent::Stop => Event::Stop(event::StopCause::Manual),
1208                NodeEvent::Reload { operator_id } => Event::Reload { operator_id },
1209                NodeEvent::InputClosed { id } => Event::InputClosed { id },
1210                NodeEvent::InputRecovered { id } => Event::InputRecovered { id },
1211                NodeEvent::NodeRestarted { id } => Event::NodeRestarted { id },
1212                NodeEvent::Input { id, metadata, data } => {
1213                    let data_inner = data.map(Arc::unwrap_or_clone);
1214                    let result = data_to_arrow_array(data_inner);
1215                    match result {
1216                        Ok(data) => {
1217                            let mut metadata = Arc::unwrap_or_clone(metadata);
1218                            dora_message::metadata::strip_internal_parameters(
1219                                &mut metadata.parameters,
1220                            );
1221                            Event::Input {
1222                                id,
1223                                metadata,
1224                                data: data.into(),
1225                            }
1226                        }
1227                        Err(err) => Event::Error(format!("{err:?}")),
1228                    }
1229                }
1230                NodeEvent::AllInputsClosed => Event::Stop(event::StopCause::AllInputsClosed),
1231                NodeEvent::ParamUpdate { key, value_json } => {
1232                    match serde_json::from_slice(&value_json) {
1233                        Ok(value) => Event::ParamUpdate { key, value },
1234                        Err(err) => Event::Error(format!(
1235                            "failed to deserialize ParamUpdate value for `{key}`: {err}"
1236                        )),
1237                    }
1238                }
1239                NodeEvent::ParamDeleted { key } => Event::ParamDeleted { key },
1240                NodeEvent::NodeFailed {
1241                    affected_input_ids,
1242                    error,
1243                    source_node_id,
1244                } => Event::NodeFailed {
1245                    affected_input_ids,
1246                    error,
1247                    source_node_id,
1248                },
1249                other => {
1250                    tracing::warn!("ignoring unrecognized NodeEvent variant: {other:?}");
1251                    Event::Error(format!("unrecognized node event: {other:?}"))
1252                }
1253            },
1254
1255            EventItem::ZenohInput { id, metadata, data } => {
1256                let mut metadata = Arc::unwrap_or_clone(metadata);
1257                dora_message::metadata::strip_internal_parameters(&mut metadata.parameters);
1258                Event::Input {
1259                    id,
1260                    metadata,
1261                    // Already decoded in the subscriber callback (receipt order).
1262                    data: arrow::array::make_array(data).into(),
1263                }
1264            }
1265
1266            EventItem::FatalError(err) => {
1267                Event::Error(format!("fatal event stream error: {err:?}"))
1268            }
1269            EventItem::TimeoutError(err) => {
1270                Event::Error(format!("Timeout event stream error: {err:?}"))
1271            }
1272        }
1273    }
1274}
1275
1276/// No event is available right now or the event stream has been closed.
1277#[derive(Debug)]
1278pub enum TryRecvError {
1279    /// No new event is available right now.
1280    Empty,
1281    /// The event stream has been closed.
1282    Closed,
1283}
1284
1285/// Convert a zenoh `ZBytes` payload into an Arrow array without copying
1286/// for contiguous buffers (e.g. Zenoh SHM).
1287///
1288/// For `Cow::Borrowed` payloads (SHM), the Arrow `Buffer` is backed by
1289/// the original `ZBytes` allocation via `Buffer::from_custom_allocation`,
1290/// achieving true zero-copy. For `Cow::Owned` (normal network path),
1291/// copy into Dora's aligned buffer type before reconstructing Arrow arrays.
1292/// Newtype that owns a Zenoh [`ZBytes`](zenoh::bytes::ZBytes) payload so it can
1293/// back an Arrow `Buffer` via `Buffer::from_custom_allocation`. Keeping the
1294/// `ZBytes` alive keeps the underlying SHM mapping (or heap buffer) valid for
1295/// the lifetime of the zero-copy Arrow buffer.
1296#[allow(dead_code)] // field kept alive to own the zenoh buffer
1297struct ZBytesAllocation(zenoh::bytes::ZBytes);
1298// SAFETY: the wrapped `ZBytes` is only used to keep the backing allocation
1299// alive; the bytes are treated as immutable for the Buffer's lifetime.
1300unsafe impl Sync for ZBytesAllocation {}
1301unsafe impl Send for ZBytesAllocation {}
1302impl std::panic::RefUnwindSafe for ZBytesAllocation {}
1303
1304/// Convert a zenoh payload to an Arrow array (dora-rs/adora#132).
1305///
1306/// Every data-plane payload is a self-describing Arrow IPC stream, so the
1307/// decode needs no type sidecar. An empty payload is a metadata-only message
1308/// and maps to the unit array.
1309/// Wrap a zenoh payload as an Arrow `Buffer` — aliasing the zenoh SHM mapping
1310/// for borrowed payloads (zero-copy), owning the materialized `Vec` otherwise.
1311fn zenoh_payload_to_buffer(payload: zenoh::bytes::ZBytes) -> arrow::buffer::Buffer {
1312    use std::ptr::NonNull;
1313    match payload.to_bytes() {
1314        std::borrow::Cow::Borrowed(slice) => {
1315            let ptr =
1316                NonNull::new(slice.as_ptr() as *mut u8).expect("zenoh SHM payload ptr is null");
1317            let len = slice.len();
1318            // SAFETY: `ptr` points into the SHM region owned by `payload`;
1319            // moving `payload` into the Arc keeps the region mapped for the
1320            // lifetime of the Buffer.
1321            unsafe {
1322                arrow::buffer::Buffer::from_custom_allocation(
1323                    ptr,
1324                    len,
1325                    Arc::new(ZBytesAllocation(payload)),
1326                )
1327            }
1328        }
1329        std::borrow::Cow::Owned(vec) => arrow::buffer::Buffer::from_vec(vec),
1330    }
1331}
1332
1333/// Declare the `@schema` AdvancedSubscriber for an input: its callback primes
1334/// `decoder` from the schema published on the output's schema subtopic. The
1335/// history query fetches the cached schema on join; `detect_late_publishers`
1336/// re-queries a producer that appears after this subscriber.
1337///
1338/// On failure, `schema_plane_failed` is set so the data subscriber surfaces a
1339/// `FatalError` if the input stays undecodable past
1340/// [`SCHEMA_PLANE_FATAL_GRACE`]: a failed declare means a degraded zenoh
1341/// session, so exit loudly rather than limp along on the in-band full-stream
1342/// refresh alone — but give that refresh (which fully heals the input) its
1343/// chance first instead of killing a node that would recover within seconds.
1344/// It never blocks the data subscriber.
1345#[allow(clippy::too_many_arguments)]
1346fn declare_schema_subscriber(
1347    session: &zenoh::Session,
1348    dataflow_id: DataflowId,
1349    source_node: &NodeId,
1350    source_output: &DataId,
1351    input_id: &DataId,
1352    decoder: Arc<std::sync::Mutex<crate::arrow_utils::ipc_encode::InputDecoder>>,
1353    tx: tokio::sync::mpsc::Sender<EventItem>,
1354    schema_plane_failed: Arc<std::sync::atomic::AtomicBool>,
1355    out: &mut Vec<zenoh_ext::AdvancedSubscriber<()>>,
1356) {
1357    use zenoh::Wait;
1358    use zenoh_ext::{AdvancedSubscriberBuilderExt, HistoryConfig};
1359
1360    let topic =
1361        dora_core::topics::zenoh_output_schema_topic(dataflow_id, source_node, source_output);
1362    let key = match zenoh::key_expr::KeyExpr::new(topic) {
1363        Ok(k) => k.into_owned(),
1364        Err(e) => {
1365            tracing::warn!(input = %input_id, "invalid @schema zenoh key ({e}); schema-once disabled for this input");
1366            schema_plane_failed.store(true, std::sync::atomic::Ordering::Relaxed);
1367            return;
1368        }
1369    };
1370    let input_id_cb = input_id.clone();
1371    let sub = session
1372        .declare_subscriber(key)
1373        .history(HistoryConfig::default().detect_late_publishers())
1374        .callback(move |sample| {
1375            // catch_unwind: a panic must not unwind through zenoh's IO worker.
1376            let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1377                let buffer = zenoh_payload_to_buffer(sample.payload().clone());
1378                let hash = crate::node::fnv1a(buffer.as_slice());
1379                let mut decoder = decoder.lock().unwrap_or_else(|poison| {
1380                    let mut guard = poison.into_inner();
1381                    guard.reset();
1382                    guard
1383                });
1384                if let Err(e) = decoder.set_schema(hash, buffer) {
1385                    tracing::warn!(input = %input_id_cb, "failed to prime decoder from @schema sample: {e}");
1386                }
1387            }));
1388            if result.is_err() {
1389                tracing::error!(input = %input_id_cb, "zenoh @schema subscriber callback panicked");
1390                let _ = tx.try_send(EventItem::FatalError(eyre!(
1391                    "zenoh @schema subscriber for input `{input_id_cb}` panicked"
1392                )));
1393            }
1394        })
1395        .wait();
1396    match sub {
1397        Ok(s) => out.push(s),
1398        Err(e) => {
1399            tracing::warn!(input = %input_id, "failed to declare @schema subscriber ({e}); schema-once disabled for this input");
1400            schema_plane_failed.store(true, std::sync::atomic::Ordering::Relaxed);
1401        }
1402    }
1403}
1404
1405/// How long a schema-once input may stay continuously undecodable — with the
1406/// `@schema` plane dead — before the node exits with a `FatalError`. Three
1407/// producer full-stream refresh intervals: the refresh re-primes the input
1408/// in-band, so if it hasn't healed after three periods the input is genuinely
1409/// dead (producer gone or data plane dropping every refresh), not just waiting
1410/// out the documented recovery window.
1411const SCHEMA_PLANE_FATAL_GRACE: Duration =
1412    crate::node::SCHEMA_ONCE_REFRESH_INTERVAL.saturating_mul(3);
1413
1414/// Whether the undecodable-input condition has persisted past
1415/// [`SCHEMA_PLANE_FATAL_GRACE`]. Records the start of the window on first call;
1416/// the caller clears `first_undecodable` on any successful decode.
1417fn schema_plane_fatal_due(first_undecodable: &mut Option<Instant>, now: Instant) -> bool {
1418    let start = *first_undecodable.get_or_insert(now);
1419    now.duration_since(start) >= SCHEMA_PLANE_FATAL_GRACE
1420}
1421
1422/// Decode a zenoh data sample in receipt order. A schema-less batch (the
1423/// `SCHEMA_HASH` parameter is present) decodes against the per-input decoder
1424/// primed from the output's `@schema` subtopic or in-band from an earlier full
1425/// stream — returning `Ok(None)` if that schema hasn't been received yet, in
1426/// which case the caller drops it. Other messages (large/SHM full streams,
1427/// daemon-path payloads) decode standalone — and additionally prime the decoder
1428/// in-band (see [`prime_in_band`]).
1429fn decode_zenoh_sample(
1430    decoder: &mut crate::arrow_utils::ipc_encode::InputDecoder,
1431    metadata: &dora_message::metadata::Metadata,
1432    payload: zenoh::bytes::ZBytes,
1433) -> eyre::Result<Option<arrow::array::ArrayData>> {
1434    use crate::arrow_utils::decode_arrow_ipc_zero_copy;
1435    use dora_message::metadata::{SCHEMA_HASH, get_integer_param};
1436
1437    if payload.is_empty() {
1438        return Ok(Some(().into_arrow().into()));
1439    }
1440    let buffer = zenoh_payload_to_buffer(payload);
1441    match get_integer_param(&metadata.parameters, SCHEMA_HASH) {
1442        Some(hash) => {
1443            tracing::debug!("received schema-less batch with SCHEMA_HASH={}", hash);
1444            decoder.decode_batch(buffer, hash as u64)
1445        }
1446        None => {
1447            tracing::debug!("received full IPC stream (no SCHEMA_HASH)");
1448            // Service/action messages are excluded from schema-once (a server
1449            // multiplexes per-request schemas through one output); don't let
1450            // them churn the retained schema set.
1451            if !crate::node::carries_pattern_correlation(&metadata.parameters) {
1452                prime_in_band(decoder, &buffer);
1453            }
1454            decode_arrow_ipc_zero_copy(buffer).map(Some)
1455        }
1456    }
1457}
1458
1459/// Prime the per-input decoder from the schema block of a full self-describing
1460/// stream received on the data topic.
1461///
1462/// The producer sends a full stream for the first message of an output (and
1463/// after every schema change, failed `@schema` publish, or periodic refresh —
1464/// see `publish_schema_once`). Since all data-plane puts share one publisher,
1465/// zenoh delivers them in order, so this priming happens strictly before the
1466/// schema-less batches that reference the schema — closing the QoS race where
1467/// an express batch overtakes the `@schema` plane's non-express schema, and
1468/// re-priming (within the refresh interval) any consumer whose `@schema`
1469/// history query missed the single schema emission.
1470fn prime_in_band(
1471    decoder: &mut crate::arrow_utils::ipc_encode::InputDecoder,
1472    buffer: &arrow::buffer::Buffer,
1473) {
1474    let Some((hash, schema)) =
1475        crate::arrow_utils::ipc_encode::schema_block_and_hash(buffer.as_slice())
1476    else {
1477        return;
1478    };
1479    // A known schema (live or retained) needs no eager re-prime: the full
1480    // stream decodes standalone, and `decode_batch` re-primes lazily from the
1481    // retained set when a schema-less batch actually references it.
1482    if decoder.knows_schema(hash) {
1483        return;
1484    }
1485    // Copy the schema block out of the payload: retaining a slice of an
1486    // SHM-backed buffer would pin the whole segment for the decoder's lifetime.
1487    let schema = arrow::buffer::Buffer::from(schema);
1488    if let Err(e) = decoder.set_schema(hash, schema) {
1489        tracing::debug!("in-band schema priming failed: {e}");
1490    }
1491}
1492
1493pub fn data_to_arrow_array(
1494    data: Option<DataMessage>,
1495) -> eyre::Result<Arc<dyn arrow::array::Array>> {
1496    let data: eyre::Result<Option<RawData>> = match data {
1497        None => Ok(None),
1498        Some(DataMessage::Vec(v)) => Ok(Some(RawData::Vec(v))),
1499    };
1500
1501    data.and_then(|data| {
1502        let raw_data = data.unwrap_or(RawData::Empty);
1503        raw_data.into_arrow_array().map(arrow::array::make_array)
1504    })
1505}
1506
1507impl Stream for EventStream {
1508    type Item = Event;
1509
1510    fn poll_next(
1511        mut self: std::pin::Pin<&mut Self>,
1512        cx: &mut std::task::Context<'_>,
1513    ) -> std::task::Poll<Option<Self::Item>> {
1514        // Drain events that were buffered by pattern-aware helpers
1515        // (`recv_service_response`, `recv_action_result`) before
1516        // polling the underlying receiver. Mirrors the drain at the
1517        // top of `recv_async` so `StreamExt::next()` and `recv()`
1518        // return the same events in the same order
1519        // (dora-rs/adora#172).
1520        if let Some(event) = self.pending_passthrough.pop_front() {
1521            return std::task::Poll::Ready(Some(event));
1522        }
1523
1524        // Close the stream after a Stop event: zenoh subscriber threads
1525        // hold sender clones that would otherwise keep `receiver` open.
1526        if self.stop_received {
1527            return std::task::Poll::Ready(None);
1528        }
1529
1530        let poll = self
1531            .receiver
1532            .poll_recv(cx)
1533            .map(|item| item.map(Self::convert_event_item));
1534
1535        // Run first-message type check on the Stream path too.
1536        //
1537        // Mirror the recv_async() logic: skip the check (and keep it
1538        // armed) when the message carries pattern metadata, so a later
1539        // non-pattern message can still validate (dora-rs/adora#174).
1540        if let std::task::Poll::Ready(Some(Event::Input {
1541            ref id,
1542            ref metadata,
1543            ref data,
1544        })) = poll
1545            && let Some(expected) = self.input_type_checks.get(id).cloned()
1546        {
1547            let is_pattern_message = metadata
1548                .parameters
1549                .contains_key(dora_message::metadata::REQUEST_ID)
1550                || metadata
1551                    .parameters
1552                    .contains_key(dora_message::metadata::GOAL_ID)
1553                || metadata
1554                    .parameters
1555                    .contains_key(dora_message::metadata::GOAL_STATUS);
1556            if !is_pattern_message {
1557                self.input_type_checks.remove(id);
1558                let actual = data.data_type();
1559                if *actual != arrow_schema::DataType::Null && *actual != expected {
1560                    tracing::warn!(
1561                        input = %id,
1562                        expected = ?expected,
1563                        actual = ?actual,
1564                        "input type mismatch on first message (Stream path)"
1565                    );
1566                }
1567            }
1568        }
1569
1570        if matches!(&poll, std::task::Poll::Ready(Some(Event::Stop(_)))) {
1571            self.stop_received = true;
1572        }
1573        poll
1574    }
1575}
1576
1577impl Drop for EventStream {
1578    fn drop(&mut self) {
1579        // Tear down the per-input zenoh callback subscribers under a deadline.
1580        // `Subscriber::drop` undeclares the subscription on the shared zenoh
1581        // session, which blocks indefinitely when zenoh's net runtime is
1582        // wedged (e.g. stuck retrying an unreachable scouted peer). Left
1583        // unbounded, that hangs the whole node in `EventStream`'s field-drop
1584        // sequence — before `DoraNode::Drop` (which already bounds its own
1585        // session teardown) even runs — so the daemon never sees the node
1586        // finish and the dataflow stalls until an outer timeout (dora-rs/dora#2425).
1587        // The callbacks use `try_send`, so undeclaring before `receiver` drops
1588        // cannot deadlock on a blocked callback.
1589        //
1590        // The `@schema` `AdvancedSubscriber`s (added with the Arrow IPC data
1591        // plane in #2366) undeclare on the same shared session and can wedge
1592        // the same way, so they must be torn down under the same deadline —
1593        // mirroring `DoraNode::Drop`, which drops both publisher maps inside
1594        // one guard. Left out, they would otherwise drop unbounded in the
1595        // implicit field-drop phase after this `Drop` body returns (#2583).
1596        let subscribers = std::mem::take(&mut self._zenoh_subscribers);
1597        let schema_subscribers = std::mem::take(&mut self._zenoh_schema_subscribers);
1598        if !subscribers.is_empty() || !schema_subscribers.is_empty() {
1599            let completed =
1600                teardown_with_timeout("zenoh-subscribers", ZENOH_TEARDOWN_TIMEOUT, move || {
1601                    drop(subscribers);
1602                    drop(schema_subscribers);
1603                });
1604            if !completed {
1605                tracing::warn!(
1606                    "zenoh subscriber teardown timed out after {}s; continuing node shutdown",
1607                    ZENOH_TEARDOWN_TIMEOUT.as_secs()
1608                );
1609            }
1610        }
1611
1612        // Undeclare readiness tokens under the same deadline: like subscriber
1613        // teardown, `LivelinessToken::drop` undeclares on the shared session and
1614        // can block if zenoh's net runtime is wedged (dora-rs/dora#2425).
1615        let tokens = std::mem::take(&mut self._zenoh_liveliness_tokens);
1616        if !tokens.is_empty() {
1617            let completed = teardown_with_timeout(
1618                "zenoh-liveliness-tokens",
1619                ZENOH_TEARDOWN_TIMEOUT,
1620                move || {
1621                    drop(tokens);
1622                },
1623            );
1624            if !completed {
1625                tracing::warn!(
1626                    "zenoh readiness-token teardown timed out after {}s; continuing node shutdown",
1627                    ZENOH_TEARDOWN_TIMEOUT.as_secs()
1628                );
1629            }
1630        }
1631
1632        let request = Timestamped {
1633            inner: DaemonRequest::EventStreamDropped,
1634            timestamp: self.clock.new_timestamp(),
1635        };
1636        let result = self
1637            .close_channel
1638            .request(&request)
1639            .map_err(|e| eyre!(e))
1640            .wrap_err("failed to signal event stream closure to dora-daemon")
1641            .and_then(|r| match r {
1642                DaemonReply::Result(Ok(())) => Ok(()),
1643                DaemonReply::Result(Err(err)) => Err(eyre!("EventStreamClosed failed: {err}")),
1644                other => Err(eyre!("unexpected EventStreamClosed reply: {other:?}")),
1645            });
1646        if let Err(err) = result {
1647            tracing::warn!("{err:?}")
1648        }
1649
1650        if let Some(write_events_to) = self.write_events_to.take()
1651            && let Err(err) = write_events_to.write_out()
1652        {
1653            tracing::warn!(
1654                "failed to write out events for node {}: {err:?}",
1655                self.node_id
1656            );
1657        }
1658    }
1659}
1660
1661pub(crate) struct WriteEventsTo {
1662    node_id: NodeId,
1663    file: std::fs::File,
1664    events_buffer: Vec<serde_json::Value>,
1665    /// `None` while the recording is complete. Becomes `Some(...)` on
1666    /// the first `record_event` failure; subsequent failures bump the
1667    /// counter inside. Surfaced in `write_out()` as a top-level
1668    /// `recording_status` field so consumers (replay tools, audit
1669    /// pipelines) can detect partial recordings instead of silently
1670    /// treating a syntactically-valid file as complete (#1857).
1671    poisoned: Option<PoisonInfo>,
1672}
1673
1674#[derive(Debug)]
1675pub(crate) struct PoisonInfo {
1676    /// `events_buffer.len()` at the moment of the first failure — i.e.
1677    /// the number of events successfully recorded before the gap.
1678    first_failure_event_index: usize,
1679    /// Seconds since `EventStream::start_timestamp` at the first failure.
1680    first_failure_time_offset_secs: f64,
1681    /// `format!("{err:?}")` of the first `record_event()` error.
1682    first_failure_error: String,
1683    /// Count of subsequent failures after the first one.
1684    additional_failures: u64,
1685}
1686
1687impl WriteEventsTo {
1688    /// Mark the recording poisoned. First call captures the failure
1689    /// detail; later calls just bump `additional_failures`.
1690    fn mark_poisoned(&mut self, err: &eyre::Report, time_offset_secs: f64) {
1691        match &mut self.poisoned {
1692            None => {
1693                self.poisoned = Some(PoisonInfo {
1694                    first_failure_event_index: self.events_buffer.len(),
1695                    first_failure_time_offset_secs: time_offset_secs,
1696                    first_failure_error: format!("{err:?}"),
1697                    additional_failures: 0,
1698                });
1699            }
1700            Some(info) => {
1701                info.additional_failures += 1;
1702            }
1703        }
1704    }
1705
1706    fn write_out(self) -> eyre::Result<()> {
1707        use dora_message::integration_testing_format::RecordingStatus;
1708
1709        let Self {
1710            node_id,
1711            file,
1712            events_buffer,
1713            poisoned,
1714        } = self;
1715        let mut inputs_file = serde_json::Map::new();
1716        inputs_file.insert("id".into(), node_id.to_string().into());
1717        // Emit `recording_status` for clean recordings too, so consumers
1718        // can rely on its presence as a definitive signal rather than
1719        // having to treat "field absent" as ambiguous between "clean"
1720        // and "older format" (#1857). Serialized via the canonical
1721        // `RecordingStatus` enum in `dora-message` so the wire format
1722        // stays in lockstep with the consumer-side type. The wire
1723        // shape is unaffected by `IntegrationTestInput`'s
1724        // `Option<Box<RecordingStatus>>` storage choice — serde
1725        // transparently serializes through the `Box`.
1726        let recording_status = match poisoned {
1727            None => RecordingStatus::Clean,
1728            Some(info) => RecordingStatus::Poisoned {
1729                first_failure_event_index: info.first_failure_event_index,
1730                first_failure_time_offset_secs: info.first_failure_time_offset_secs,
1731                first_failure_error: info.first_failure_error,
1732                additional_failures: info.additional_failures,
1733            },
1734        };
1735        inputs_file.insert(
1736            "recording_status".into(),
1737            serde_json::to_value(&recording_status)
1738                .context("failed to serialize recording_status")?,
1739        );
1740        inputs_file.insert("events".into(), events_buffer.into());
1741
1742        serde_json::to_writer_pretty(file, &inputs_file)
1743            .context("failed to write events to file")?;
1744        Ok(())
1745    }
1746}
1747
1748#[cfg(test)]
1749impl EventStream {
1750    /// Test-only: inject an event into the passthrough buffer so we can
1751    /// verify that `is_empty`, `recv_async`, and `Stream::poll_next` all
1752    /// drain it correctly (dora-rs/adora#172).
1753    fn push_passthrough_for_testing(&mut self, event: Event) {
1754        self.pending_passthrough.push_back(event);
1755    }
1756
1757    /// Test-only: buffer an empty input directly in the scheduler and force
1758    /// scheduler mode, simulating an input the scheduler held back while
1759    /// prioritizing `Stop`. Used to verify `recv_async` drains buffered inputs
1760    /// after `Stop` instead of dropping them (dora-rs/dora#2027).
1761    fn push_scheduler_input_for_testing(&mut self, id: &str) {
1762        use crate::event_stream::thread::EventItem;
1763        use dora_message::{daemon_to_node::NodeEvent, metadata::Metadata};
1764        self.use_scheduler = true;
1765        let meta = Metadata::new(dora_core::uhlc::HLC::default().new_timestamp());
1766        self.scheduler.add_event(EventItem::NodeEvent {
1767            event: NodeEvent::Input {
1768                id: id.into(),
1769                metadata: std::sync::Arc::new(meta),
1770                data: None,
1771            },
1772        });
1773    }
1774
1775    /// Test-only: buffer a `Stop` directly in the scheduler (a NON_INPUT_EVENT)
1776    /// and force scheduler mode, to verify the post-Stop drain discards trailing
1777    /// control events instead of re-delivering a second `Stop` (dora-rs/dora#2027).
1778    fn push_scheduler_stop_for_testing(&mut self) {
1779        use crate::event_stream::thread::EventItem;
1780        use dora_message::daemon_to_node::NodeEvent;
1781        self.use_scheduler = true;
1782        self.scheduler.add_event(EventItem::NodeEvent {
1783            event: NodeEvent::Stop,
1784        });
1785    }
1786}
1787
1788#[cfg(test)]
1789mod tests {
1790    use super::*;
1791
1792    #[test]
1793    fn convert_param_update() {
1794        let item = EventItem::NodeEvent {
1795            event: NodeEvent::ParamUpdate {
1796                key: "fps".into(),
1797                value_json: serde_json::to_vec(&serde_json::json!(60)).unwrap(),
1798            },
1799        };
1800        let event = EventStream::convert_event_item(item);
1801        match event {
1802            Event::ParamUpdate { key, value } => {
1803                assert_eq!(key, "fps");
1804                assert_eq!(value, serde_json::json!(60));
1805            }
1806            other => panic!("expected ParamUpdate, got {other:?}"),
1807        }
1808    }
1809
1810    /// Regression test for the daemon↔node wire protocol: `NodeEvent`
1811    /// is sent over TCP with bincode, so any field type that uses
1812    /// `Deserializer::deserialize_any` (like `serde_json::Value`)
1813    /// breaks the channel and kills the node at the next receive.
1814    /// `NodeEvent::ParamUpdate` carries its value as JSON-encoded
1815    /// bytes for that reason. This test pins the invariant so we
1816    /// don't regress back to a `deserialize_any` field.
1817    #[test]
1818    fn node_event_param_update_round_trips_through_bincode() {
1819        let cases = [
1820            serde_json::json!(42),
1821            serde_json::json!(1.5),
1822            serde_json::json!("hello"),
1823            serde_json::json!(null),
1824            serde_json::json!([1, 2, 3]),
1825            serde_json::json!({"nested": {"array": [true, false]}}),
1826        ];
1827        for value in cases {
1828            let event = NodeEvent::ParamUpdate {
1829                key: "rate".into(),
1830                value_json: serde_json::to_vec(&value).unwrap(),
1831            };
1832            let bytes = bincode::serialize(&event).expect("bincode serialize");
1833            let back: NodeEvent = bincode::deserialize(&bytes).expect("bincode deserialize");
1834            match back {
1835                NodeEvent::ParamUpdate { key, value_json } => {
1836                    assert_eq!(key, "rate");
1837                    let decoded: serde_json::Value =
1838                        serde_json::from_slice(&value_json).expect("value_json is JSON");
1839                    assert_eq!(decoded, value);
1840                }
1841                other => panic!("expected ParamUpdate, got {other:?}"),
1842            }
1843        }
1844    }
1845
1846    // -- WriteEventsTo poisoned-state tests (#1857) ------------------------
1847    //
1848    // Build a `WriteEventsTo` against a tempfile, exercise the public
1849    // surface (push events / mark_poisoned / write_out), then parse the
1850    // resulting JSON and assert on the `recording_status` field shape.
1851    // No new dev-deps — uses std::env::temp_dir() + uuid (already a dep).
1852
1853    fn write_events_to_with_tempfile() -> (WriteEventsTo, std::path::PathBuf) {
1854        let path = std::env::temp_dir().join(format!(
1855            "dora-write-events-test-{}.json",
1856            uuid::Uuid::new_v4()
1857        ));
1858        let file = std::fs::File::create(&path).expect("create tempfile");
1859        let w = WriteEventsTo {
1860            node_id: "test-node".parse().unwrap(),
1861            file,
1862            events_buffer: Vec::new(),
1863            poisoned: None,
1864        };
1865        (w, path)
1866    }
1867
1868    fn read_back(path: &std::path::Path) -> serde_json::Value {
1869        let s = std::fs::read_to_string(path).expect("read back tempfile");
1870        std::fs::remove_file(path).ok();
1871        serde_json::from_str(&s).expect("output is valid JSON")
1872    }
1873
1874    #[test]
1875    fn write_events_clean_recording_emits_state_clean() {
1876        let (mut w, path) = write_events_to_with_tempfile();
1877        w.events_buffer.push(serde_json::json!({"type": "Stop"}));
1878        w.write_out().expect("write_out clean recording");
1879
1880        let v = read_back(&path);
1881        assert_eq!(v["recording_status"]["state"], "clean");
1882        assert_eq!(v["events"].as_array().unwrap().len(), 1);
1883        assert_eq!(v["id"], "test-node");
1884    }
1885
1886    #[test]
1887    fn write_events_poisoned_recording_emits_state_poisoned_with_first_failure() {
1888        let (mut w, path) = write_events_to_with_tempfile();
1889        // 2 events recorded cleanly, then a failure, then 1 more event
1890        w.events_buffer.push(serde_json::json!({"type": "Input"}));
1891        w.events_buffer.push(serde_json::json!({"type": "Input"}));
1892        w.mark_poisoned(&eyre!("arrow conversion failed: bad type"), 1.5);
1893        w.events_buffer.push(serde_json::json!({"type": "Stop"}));
1894        w.write_out().expect("write_out poisoned recording");
1895
1896        let v = read_back(&path);
1897        let status = &v["recording_status"];
1898        assert_eq!(status["state"], "poisoned");
1899        assert_eq!(status["first_failure_event_index"], 2);
1900        assert_eq!(status["first_failure_time_offset_secs"], 1.5);
1901        assert!(
1902            status["first_failure_error"]
1903                .as_str()
1904                .unwrap()
1905                .contains("arrow conversion failed: bad type")
1906        );
1907        assert_eq!(status["additional_failures"], 0);
1908        // `events` keeps the 2 clean + 1 post-failure-but-successful events.
1909        assert_eq!(v["events"].as_array().unwrap().len(), 3);
1910    }
1911
1912    #[test]
1913    fn write_events_multiple_failures_keep_first_and_count_rest() {
1914        let (mut w, path) = write_events_to_with_tempfile();
1915        w.mark_poisoned(&eyre!("first error"), 0.5);
1916        w.mark_poisoned(&eyre!("second error"), 1.0);
1917        w.mark_poisoned(&eyre!("third error"), 1.5);
1918        w.write_out().expect("write_out with multiple failures");
1919
1920        let v = read_back(&path);
1921        let status = &v["recording_status"];
1922        assert_eq!(status["state"], "poisoned");
1923        // First failure detail is preserved, NOT overwritten by later ones.
1924        assert_eq!(status["first_failure_event_index"], 0);
1925        assert_eq!(status["first_failure_time_offset_secs"], 0.5);
1926        assert!(
1927            status["first_failure_error"]
1928                .as_str()
1929                .unwrap()
1930                .contains("first error")
1931        );
1932        // Two additional failures after the first.
1933        assert_eq!(status["additional_failures"], 2);
1934    }
1935
1936    #[test]
1937    fn convert_param_deleted() {
1938        let item = EventItem::NodeEvent {
1939            event: NodeEvent::ParamDeleted { key: "fps".into() },
1940        };
1941        let event = EventStream::convert_event_item(item);
1942        match event {
1943            Event::ParamDeleted { key } => {
1944                assert_eq!(key, "fps");
1945            }
1946            other => panic!("expected ParamDeleted, got {other:?}"),
1947        }
1948    }
1949
1950    #[test]
1951    fn convert_stop_event() {
1952        let item = EventItem::NodeEvent {
1953            event: NodeEvent::Stop,
1954        };
1955        let event = EventStream::convert_event_item(item);
1956        assert!(matches!(event, Event::Stop(StopCause::Manual)));
1957    }
1958
1959    #[test]
1960    fn convert_all_inputs_closed() {
1961        let item = EventItem::NodeEvent {
1962            event: NodeEvent::AllInputsClosed,
1963        };
1964        let event = EventStream::convert_event_item(item);
1965        assert!(matches!(event, Event::Stop(StopCause::AllInputsClosed)));
1966    }
1967
1968    #[test]
1969    fn convert_input_closed() {
1970        let item = EventItem::NodeEvent {
1971            event: NodeEvent::InputClosed {
1972                id: "input_1".to_string().into(),
1973            },
1974        };
1975        let event = EventStream::convert_event_item(item);
1976        match event {
1977            Event::InputClosed { id } => assert_eq!(AsRef::<str>::as_ref(&id), "input_1"),
1978            other => panic!("expected InputClosed, got {other:?}"),
1979        }
1980    }
1981
1982    #[test]
1983    fn convert_node_restarted() {
1984        let item = EventItem::NodeEvent {
1985            event: NodeEvent::NodeRestarted {
1986                id: "upstream".to_string().into(),
1987            },
1988        };
1989        let event = EventStream::convert_event_item(item);
1990        match event {
1991            Event::NodeRestarted { id } => assert_eq!(AsRef::<str>::as_ref(&id), "upstream"),
1992            other => panic!("expected NodeRestarted, got {other:?}"),
1993        }
1994    }
1995
1996    // ---- dora-rs/adora#148: pattern-aware correlation classification ----
1997
1998    use arrow::array::new_empty_array;
1999    use arrow::datatypes::DataType as ArrowDataType;
2000    use dora_arrow_convert::ArrowData;
2001    use dora_message::metadata::{
2002        GOAL_ID, GOAL_STATUS, GOAL_STATUS_ABORTED, GOAL_STATUS_SUCCEEDED, Metadata,
2003        MetadataParameters, Parameter, REQUEST_ID,
2004    };
2005
2006    fn make_metadata(params: MetadataParameters) -> Metadata {
2007        Metadata::from_parameters(dora_core::uhlc::HLC::default().new_timestamp(), params)
2008    }
2009
2010    fn make_input_event(id: &str, params: MetadataParameters) -> Event {
2011        Event::Input {
2012            id: id.into(),
2013            metadata: make_metadata(params),
2014            data: ArrowData(new_empty_array(&ArrowDataType::Null)),
2015        }
2016    }
2017
2018    fn request_id_params(id: &str) -> MetadataParameters {
2019        let mut p = MetadataParameters::new();
2020        p.insert(REQUEST_ID.into(), Parameter::String(id.to_string()));
2021        p
2022    }
2023
2024    fn goal_params(goal_id: &str, status: Option<&str>) -> MetadataParameters {
2025        let mut p = MetadataParameters::new();
2026        p.insert(GOAL_ID.into(), Parameter::String(goal_id.to_string()));
2027        if let Some(s) = status {
2028            p.insert(GOAL_STATUS.into(), Parameter::String(s.to_string()));
2029        }
2030        p
2031    }
2032
2033    fn is_request_match(needle: &str) -> impl Fn(&Event) -> bool + '_ {
2034        move |event: &Event| match event {
2035            Event::Input { metadata, .. } => {
2036                dora_message::metadata::get_string_param(&metadata.parameters, REQUEST_ID)
2037                    == Some(needle)
2038            }
2039            _ => false,
2040        }
2041    }
2042
2043    fn is_action_result_match(needle: &str) -> impl Fn(&Event) -> bool + '_ {
2044        move |event: &Event| match event {
2045            Event::Input { metadata, .. } => {
2046                let p = &metadata.parameters;
2047                dora_message::metadata::get_string_param(p, GOAL_ID) == Some(needle)
2048                    && matches!(
2049                        dora_message::metadata::get_string_param(p, GOAL_STATUS),
2050                        Some(GOAL_STATUS_SUCCEEDED)
2051                            | Some(GOAL_STATUS_ABORTED)
2052                            | Some(dora_message::metadata::GOAL_STATUS_CANCELED)
2053                    )
2054            }
2055            _ => false,
2056        }
2057    }
2058
2059    #[test]
2060    fn classify_matching_request_id_returns_match() {
2061        let server = NodeId::from("calc".to_string());
2062        let event = make_input_event("response", request_id_params("req-42"));
2063        assert_eq!(
2064            classify_correlation_event(&event, &server, is_request_match("req-42")),
2065            CorrelationOutcome::Match
2066        );
2067    }
2068
2069    #[test]
2070    fn classify_different_request_id_is_passthrough() {
2071        let server = NodeId::from("calc".to_string());
2072        let event = make_input_event("response", request_id_params("req-99"));
2073        assert_eq!(
2074            classify_correlation_event(&event, &server, is_request_match("req-42")),
2075            CorrelationOutcome::Passthrough
2076        );
2077    }
2078
2079    #[test]
2080    fn classify_expected_server_restart_returns_server_restarted() {
2081        let server = NodeId::from("calc".to_string());
2082        let event = Event::NodeRestarted { id: server.clone() };
2083        assert_eq!(
2084            classify_correlation_event(&event, &server, is_request_match("req-42")),
2085            CorrelationOutcome::ServerRestarted
2086        );
2087    }
2088
2089    #[test]
2090    fn classify_unrelated_node_restart_is_passthrough() {
2091        let server = NodeId::from("calc".to_string());
2092        let event = Event::NodeRestarted {
2093            id: NodeId::from("other".to_string()),
2094        };
2095        assert_eq!(
2096            classify_correlation_event(&event, &server, is_request_match("req-42")),
2097            CorrelationOutcome::Passthrough
2098        );
2099    }
2100
2101    #[test]
2102    fn classify_stop_returns_stream_ended() {
2103        let server = NodeId::from("calc".to_string());
2104        let event = Event::Stop(StopCause::Manual);
2105        assert_eq!(
2106            classify_correlation_event(&event, &server, is_request_match("req-42")),
2107            CorrelationOutcome::StreamEnded
2108        );
2109    }
2110
2111    #[test]
2112    fn classify_error_returns_stream_error() {
2113        let server = NodeId::from("calc".to_string());
2114        let event = Event::Error("boom".to_string());
2115        assert_eq!(
2116            classify_correlation_event(&event, &server, is_request_match("req-42")),
2117            CorrelationOutcome::StreamError
2118        );
2119    }
2120
2121    #[test]
2122    fn classify_unrelated_input_is_passthrough() {
2123        let server = NodeId::from("calc".to_string());
2124        let event = make_input_event("sensor", MetadataParameters::new());
2125        assert_eq!(
2126            classify_correlation_event(&event, &server, is_request_match("req-42")),
2127            CorrelationOutcome::Passthrough
2128        );
2129    }
2130
2131    #[test]
2132    fn classify_param_update_is_passthrough() {
2133        // Runtime parameter updates must survive a helper wait.
2134        let server = NodeId::from("calc".to_string());
2135        let event = Event::ParamUpdate {
2136            key: "threshold".to_string(),
2137            value: serde_json::json!(0.85),
2138        };
2139        assert_eq!(
2140            classify_correlation_event(&event, &server, is_request_match("req-42")),
2141            CorrelationOutcome::Passthrough
2142        );
2143    }
2144
2145    #[test]
2146    fn classify_action_result_terminal_succeeded_matches() {
2147        let server = NodeId::from("nav".to_string());
2148        let event = make_input_event("result", goal_params("goal-1", Some(GOAL_STATUS_SUCCEEDED)));
2149        assert_eq!(
2150            classify_correlation_event(&event, &server, is_action_result_match("goal-1")),
2151            CorrelationOutcome::Match
2152        );
2153    }
2154
2155    #[test]
2156    fn classify_action_result_terminal_aborted_matches() {
2157        let server = NodeId::from("nav".to_string());
2158        let event = make_input_event("result", goal_params("goal-1", Some(GOAL_STATUS_ABORTED)));
2159        assert_eq!(
2160            classify_correlation_event(&event, &server, is_action_result_match("goal-1")),
2161            CorrelationOutcome::Match
2162        );
2163    }
2164
2165    #[test]
2166    fn classify_action_feedback_without_terminal_status_is_passthrough() {
2167        // Intermediate feedback (no terminal goal_status) should pass
2168        // through so the caller's main loop can observe it.
2169        let server = NodeId::from("nav".to_string());
2170        let event = make_input_event("feedback", goal_params("goal-1", None));
2171        assert_eq!(
2172            classify_correlation_event(&event, &server, is_action_result_match("goal-1")),
2173            CorrelationOutcome::Passthrough
2174        );
2175    }
2176
2177    #[test]
2178    fn classify_action_result_for_different_goal_is_passthrough() {
2179        let server = NodeId::from("nav".to_string());
2180        let event = make_input_event("result", goal_params("goal-2", Some(GOAL_STATUS_SUCCEEDED)));
2181        assert_eq!(
2182            classify_correlation_event(&event, &server, is_action_result_match("goal-1")),
2183            CorrelationOutcome::Passthrough
2184        );
2185    }
2186
2187    // ---- dora-rs/adora#172: pending_passthrough integration ----
2188
2189    use crate::integration_testing::{
2190        IntegrationTestInput, TestingInput, TestingOptions, TestingOutput,
2191        integration_testing_format::{IncomingEvent, TimedIncomingEvent},
2192    };
2193
2194    /// Create a minimal EventStream via the testing path.
2195    fn test_event_stream() -> (crate::DoraNode, EventStream) {
2196        let events = vec![TimedIncomingEvent {
2197            time_offset_secs: 0.0,
2198            event: IncomingEvent::Stop,
2199        }];
2200        let inputs = TestingInput::Input(IntegrationTestInput::new(
2201            "test-node".parse().unwrap(),
2202            events,
2203        ));
2204        let (tx, _rx) = flume::unbounded();
2205        let outputs = TestingOutput::ToChannel(tx);
2206        let options = TestingOptions {
2207            skip_output_time_offsets: true,
2208        };
2209        crate::DoraNode::init_testing(inputs, outputs, options).unwrap()
2210    }
2211
2212    #[test]
2213    fn is_empty_reflects_pending_passthrough() {
2214        let (_node, mut events) = test_event_stream();
2215        // Drain the initial Stop event so the stream is empty.
2216        let _ = events.recv();
2217        assert!(events.is_empty(), "should be empty after draining");
2218
2219        // Inject a passthrough event — is_empty must now return false.
2220        events.push_passthrough_for_testing(Event::ParamDeleted {
2221            key: "k".to_string(),
2222        });
2223        assert!(
2224            !events.is_empty(),
2225            "should not be empty with pending passthrough"
2226        );
2227    }
2228
2229    #[test]
2230    fn stream_poll_next_drains_pending_passthrough() {
2231        use futures::StreamExt;
2232        let (_node, mut events) = test_event_stream();
2233        // Drain the initial Stop event.
2234        let _ = events.recv();
2235
2236        // Inject a passthrough event.
2237        events.push_passthrough_for_testing(Event::ParamUpdate {
2238            key: "threshold".to_string(),
2239            value: serde_json::json!(42),
2240        });
2241
2242        // StreamExt::next() should return the passthrough event, not
2243        // block waiting on the underlying receiver.
2244        let next = futures::executor::block_on(events.next());
2245        match next {
2246            Some(Event::ParamUpdate { key, value }) => {
2247                assert_eq!(key, "threshold");
2248                assert_eq!(value, serde_json::json!(42));
2249            }
2250            other => panic!("expected ParamUpdate from passthrough, got {other:?}"),
2251        }
2252    }
2253
2254    #[test]
2255    fn recv_async_drains_pending_passthrough_before_receiver() {
2256        let (_node, mut events) = test_event_stream();
2257
2258        // Inject a passthrough event BEFORE the Stop in the receiver.
2259        events.push_passthrough_for_testing(Event::ParamDeleted {
2260            key: "x".to_string(),
2261        });
2262
2263        // First recv should return the passthrough event.
2264        let first = events.recv();
2265        assert!(
2266            matches!(first, Some(Event::ParamDeleted { .. })),
2267            "expected passthrough ParamDeleted first, got {first:?}"
2268        );
2269
2270        // Second recv should return the Stop from the receiver.
2271        let second = events.recv();
2272        assert!(
2273            matches!(second, Some(Event::Stop(_))),
2274            "expected Stop second, got {second:?}"
2275        );
2276    }
2277
2278    /// After a `Stop` event is delivered, subsequent `recv` calls must
2279    /// return `None` so the node can exit cleanly even when zenoh
2280    /// subscriber threads still hold clones of the event channel
2281    /// sender (which would otherwise keep the receiver open).
2282    #[test]
2283    fn recv_returns_none_after_stop() {
2284        let (_node, mut events) = test_event_stream();
2285
2286        // First recv delivers the seeded Stop.
2287        let first = events.recv();
2288        assert!(matches!(first, Some(Event::Stop(_))));
2289
2290        // Second recv must return None even though the underlying
2291        // receiver may still have live senders.
2292        let second = events.recv();
2293        assert!(
2294            second.is_none(),
2295            "recv must return None after Stop, got {second:?}"
2296        );
2297    }
2298
2299    /// #2027: the scheduler gives `Stop` (a NON_INPUT_EVENT) strict priority
2300    /// over buffered inputs, so an input enqueued before `Stop` is still in the
2301    /// scheduler when `Stop` is delivered. `recv` must drain that input before
2302    /// closing rather than dropping it silently (the previous `return None`
2303    /// after `stop_received` lost it).
2304    #[test]
2305    fn recv_drains_buffered_scheduler_inputs_after_stop() {
2306        let (_node, mut events) = test_event_stream();
2307
2308        // Deliver the seeded Stop (sets `stop_received`).
2309        assert!(matches!(events.recv(), Some(Event::Stop(_))));
2310
2311        // Simulate the input the scheduler held back behind the prioritized Stop.
2312        events.push_scheduler_input_for_testing("cam");
2313
2314        let drained = events.recv();
2315        assert!(
2316            matches!(&drained, Some(Event::Input { id, .. }) if id.as_str() == "cam"),
2317            "buffered input must be drained after Stop, got {drained:?}"
2318        );
2319
2320        // Once the scheduler is empty the stream closes.
2321        assert!(
2322            events.recv().is_none(),
2323            "stream must close after draining buffered inputs"
2324        );
2325    }
2326
2327    /// #2027 review (P2): the post-Stop drain must deliver buffered *inputs*
2328    /// only. A non-input control event buffered behind Stop (e.g. a second
2329    /// `Stop`) must NOT be re-delivered to a loop-until-`None` caller.
2330    #[test]
2331    fn recv_after_stop_skips_trailing_control_events() {
2332        let (_node, mut events) = test_event_stream();
2333
2334        // Deliver the seeded Stop (sets `stop_received`).
2335        assert!(matches!(events.recv(), Some(Event::Stop(_))));
2336
2337        // Buffer a trailing Stop AND a real input behind it. The scheduler
2338        // prioritizes the Stop (NON_INPUT), so the drain meets it first.
2339        events.push_scheduler_stop_for_testing();
2340        events.push_scheduler_input_for_testing("cam");
2341
2342        // The drain must skip the trailing Stop and return only the input...
2343        let drained = events.recv();
2344        assert!(
2345            matches!(&drained, Some(Event::Input { id, .. }) if id.as_str() == "cam"),
2346            "expected the buffered input, not a re-delivered Stop, got {drained:?}"
2347        );
2348        // ...then close (no second Stop ever surfaces).
2349        assert!(events.recv().is_none(), "stream must close after the input");
2350    }
2351
2352    /// The zenoh receive path is Arrow-IPC-only. An empty payload is a
2353    /// metadata-only message and maps to the unit array; a non-empty payload is
2354    /// a self-describing IPC stream and round-trips to its original array (with
2355    /// no type sidecar involved).
2356    #[test]
2357    fn zenoh_payload_ipc_roundtrip_and_empty_is_unit() {
2358        use crate::arrow_utils::ipc_encode::{InputDecoder, encode_ipc_into, ipc_fast_path_len};
2359        use arrow::array::{Array, Int32Array};
2360
2361        // A standalone full stream (no SCHEMA_HASH parameter) decodes directly.
2362        let metadata =
2363            dora_message::metadata::Metadata::new(dora_core::uhlc::HLC::default().new_timestamp());
2364        let mut decoder = InputDecoder::new();
2365
2366        // Empty payload -> unit array (metadata-only message).
2367        let unit = decode_zenoh_sample(&mut decoder, &metadata, zenoh::bytes::ZBytes::new())
2368            .unwrap()
2369            .unwrap();
2370        assert_eq!(
2371            unit.data_type(),
2372            &arrow_schema::DataType::Null,
2373            "empty payload maps to the unit array"
2374        );
2375
2376        // Non-empty IPC payload round-trips to the original array.
2377        let data = Int32Array::from(vec![10, 20, 30]).into_data();
2378        let len = ipc_fast_path_len(&data).expect("primitive is fast-path eligible");
2379        let mut buf = vec![0u8; len];
2380        encode_ipc_into(&data, &mut buf).unwrap();
2381
2382        let decoded = decode_zenoh_sample(&mut decoder, &metadata, zenoh::bytes::ZBytes::from(buf))
2383            .unwrap()
2384            .unwrap();
2385        assert_eq!(decoded.data_type(), &arrow_schema::DataType::Int32);
2386        assert_eq!(&decoded, &data);
2387    }
2388
2389    /// A full self-describing stream on the data topic must prime the per-input
2390    /// decoder in-band: the schema-less batches that follow decode against it
2391    /// without any `@schema`-plane delivery. This is what makes the producer's
2392    /// "full stream until the schema is confirmed published" strategy race-free
2393    /// — data-plane puts share one publisher, so zenoh preserves their order,
2394    /// while the separate `@schema` plane can lose the race with an express
2395    /// batch (dora-rs/dora#2366 review: first-message QoS race).
2396    #[test]
2397    fn full_stream_primes_decoder_in_band_for_schema_less_batches() {
2398        use crate::arrow_utils::ipc_encode::{
2399            InputDecoder, batch_fast_path_len, encode_batch_into, encode_ipc_into,
2400            ipc_fast_path_len, schema_block_len,
2401        };
2402        use arrow::array::{Array, Int32Array};
2403        use dora_message::metadata::{Metadata, Parameter, SCHEMA_HASH};
2404
2405        let hlc = dora_core::uhlc::HLC::default();
2406        let mut decoder = InputDecoder::new();
2407
2408        // Message 1: full stream (no SCHEMA_HASH), as the producer sends while
2409        // the schema is not yet confirmed published on the `@schema` plane.
2410        let first = Int32Array::from(vec![1, 2]).into_data();
2411        let mut full = vec![0u8; ipc_fast_path_len(&first).unwrap()];
2412        encode_ipc_into(&first, &mut full).unwrap();
2413        let block = schema_block_len(&full).unwrap();
2414        let hash = dora_message::metadata::fnv1a(&full[..block]);
2415
2416        let plain = Metadata::new(hlc.new_timestamp());
2417        let got = decode_zenoh_sample(&mut decoder, &plain, zenoh::bytes::ZBytes::from(full))
2418            .unwrap()
2419            .unwrap();
2420        assert_eq!(&got, &first);
2421
2422        // Message 2: schema-less batch tagged with the schema hash. No
2423        // `set_schema` call happened — decoding must succeed purely from the
2424        // in-band priming above.
2425        let second = Int32Array::from(vec![3]).into_data();
2426        let mut batch = vec![0u8; batch_fast_path_len(&second).unwrap()];
2427        encode_batch_into(&second, &mut batch).unwrap();
2428        let mut tagged = Metadata::new(hlc.new_timestamp());
2429        tagged
2430            .parameters
2431            .insert(SCHEMA_HASH.to_string(), Parameter::Integer(hash as i64));
2432
2433        let got = decode_zenoh_sample(&mut decoder, &tagged, zenoh::bytes::ZBytes::from(batch))
2434            .unwrap()
2435            .expect("schema-less batch must decode against the in-band-primed decoder");
2436        assert_eq!(&got, &second);
2437    }
2438
2439    /// Service/action full streams (pattern-correlated) are excluded from
2440    /// schema-once and must NOT prime the decoder in-band: a server multiplexes
2441    /// per-request schemas through one output, and letting those prime the
2442    /// decoder would churn the retained schema set for no benefit.
2443    #[test]
2444    fn pattern_correlated_full_stream_does_not_prime_in_band() {
2445        use crate::arrow_utils::ipc_encode::{
2446            InputDecoder, batch_fast_path_len, encode_batch_into, encode_ipc_into,
2447            ipc_fast_path_len, schema_block_len,
2448        };
2449        use arrow::array::{Array, Int32Array};
2450        use dora_message::metadata::{Metadata, Parameter, REQUEST_ID, SCHEMA_HASH};
2451
2452        let hlc = dora_core::uhlc::HLC::default();
2453        let mut decoder = InputDecoder::new();
2454
2455        let reply = Int32Array::from(vec![7]).into_data();
2456        let mut full = vec![0u8; ipc_fast_path_len(&reply).unwrap()];
2457        encode_ipc_into(&reply, &mut full).unwrap();
2458        let block = schema_block_len(&full).unwrap();
2459        let hash = dora_message::metadata::fnv1a(&full[..block]);
2460
2461        // A service reply (request_id) decodes standalone…
2462        let mut service = Metadata::new(hlc.new_timestamp());
2463        service
2464            .parameters
2465            .insert(REQUEST_ID.to_string(), Parameter::String("req-1".into()));
2466        let got = decode_zenoh_sample(&mut decoder, &service, zenoh::bytes::ZBytes::from(full))
2467            .unwrap()
2468            .unwrap();
2469        assert_eq!(&got, &reply);
2470
2471        // …but must not have primed the decoder for its schema hash.
2472        let batch_array = Int32Array::from(vec![8]).into_data();
2473        let mut batch = vec![0u8; batch_fast_path_len(&batch_array).unwrap()];
2474        encode_batch_into(&batch_array, &mut batch).unwrap();
2475        let mut tagged = Metadata::new(hlc.new_timestamp());
2476        tagged
2477            .parameters
2478            .insert(SCHEMA_HASH.to_string(), Parameter::Integer(hash as i64));
2479        assert!(
2480            decode_zenoh_sample(&mut decoder, &tagged, zenoh::bytes::ZBytes::from(batch))
2481                .unwrap()
2482                .is_none(),
2483            "a pattern-correlated stream must not prime the schema-once decoder"
2484        );
2485    }
2486
2487    /// Internal wire-protocol keys (`_schema_hash`, `_framing`) must be
2488    /// stripped from the metadata handed to user code — on both the zenoh and
2489    /// the daemon receive paths. A forwarded `_schema_hash` would otherwise
2490    /// ride onto a large/service output (which does not overwrite it) and make
2491    /// receivers hash-mismatch and silently drop the message
2492    /// (dora-rs/dora#2366 review).
2493    #[test]
2494    fn internal_wire_keys_are_stripped_from_user_visible_metadata() {
2495        use dora_message::metadata::{
2496            FRAMING, FRAMING_ARROW_IPC, Metadata, Parameter, SCHEMA_HASH,
2497        };
2498
2499        let hlc = dora_core::uhlc::HLC::default();
2500        let mut metadata = Metadata::new(hlc.new_timestamp());
2501        metadata
2502            .parameters
2503            .insert(SCHEMA_HASH.to_string(), Parameter::Integer(42));
2504        metadata.parameters.insert(
2505            FRAMING.to_string(),
2506            Parameter::String(FRAMING_ARROW_IPC.to_string()),
2507        );
2508        metadata
2509            .parameters
2510            .insert("user_key".to_string(), Parameter::Integer(7));
2511
2512        // Zenoh receive path.
2513        let zenoh_item = EventItem::ZenohInput {
2514            id: DataId::from("in".to_string()),
2515            metadata: Arc::new(metadata.clone()),
2516            data: {
2517                use arrow::array::Array;
2518                arrow::array::Int32Array::from(vec![1]).into_data()
2519            },
2520        };
2521        let Event::Input {
2522            metadata: user_metadata,
2523            ..
2524        } = EventStream::convert_event_item(zenoh_item)
2525        else {
2526            panic!("expected an input event");
2527        };
2528        assert!(!user_metadata.parameters.contains_key(SCHEMA_HASH));
2529        assert!(!user_metadata.parameters.contains_key(FRAMING));
2530        assert_eq!(
2531            user_metadata.parameters.get("user_key"),
2532            Some(&Parameter::Integer(7)),
2533            "user-provided keys must survive the strip"
2534        );
2535
2536        // Daemon receive path.
2537        let daemon_item = EventItem::NodeEvent {
2538            event: dora_message::daemon_to_node::NodeEvent::Input {
2539                id: DataId::from("in".to_string()),
2540                metadata: Arc::new(metadata),
2541                data: None,
2542            },
2543        };
2544        let Event::Input {
2545            metadata: user_metadata,
2546            ..
2547        } = EventStream::convert_event_item(daemon_item)
2548        else {
2549            panic!("expected an input event");
2550        };
2551        assert!(!user_metadata.parameters.contains_key(SCHEMA_HASH));
2552        assert!(!user_metadata.parameters.contains_key(FRAMING));
2553    }
2554
2555    /// The schema-plane FatalError only fires after the grace window: the
2556    /// producer's periodic full-stream refresh heals an unprimed input in-band,
2557    /// so a node with a dead `@schema` plane must not be killed on the first
2558    /// dropped batch when it would recover within seconds.
2559    #[test]
2560    fn schema_plane_fatal_waits_out_the_grace_window() {
2561        let start = Instant::now();
2562        let mut first = None;
2563
2564        // First undecodable batch starts the window — not fatal yet.
2565        assert!(!schema_plane_fatal_due(&mut first, start));
2566        assert_eq!(first, Some(start));
2567        // Still inside the grace window — not fatal.
2568        assert!(!schema_plane_fatal_due(
2569            &mut first,
2570            start + SCHEMA_PLANE_FATAL_GRACE / 2
2571        ));
2572        // Past the window — fatal.
2573        assert!(schema_plane_fatal_due(
2574            &mut first,
2575            start + SCHEMA_PLANE_FATAL_GRACE
2576        ));
2577
2578        // A successful decode clears the window (caller side); the next drop
2579        // starts a fresh one.
2580        let mut first = None;
2581        let later = start + SCHEMA_PLANE_FATAL_GRACE * 2;
2582        assert!(!schema_plane_fatal_due(&mut first, later));
2583        assert_eq!(first, Some(later));
2584    }
2585
2586    /// Same invariant as `recv_returns_none_after_stop`, verified via
2587    /// the `Stream` impl (`StreamExt::next`).
2588    #[test]
2589    fn stream_returns_none_after_stop() {
2590        use futures::StreamExt;
2591        let (_node, mut events) = test_event_stream();
2592
2593        let first = futures::executor::block_on(events.next());
2594        assert!(matches!(first, Some(Event::Stop(_))));
2595
2596        let second = futures::executor::block_on(events.next());
2597        assert!(
2598            second.is_none(),
2599            "Stream::next must yield None after Stop, got {second:?}"
2600        );
2601    }
2602}