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            // Block for the first event while the scheduler is empty, then drain
753            // the rest non-blocking. The old code re-checked `is_empty()` on
754            // every iteration; `Scheduler::is_empty()` scans every input queue
755            // (O(#inputs)), so draining K events cost O(K·#inputs).
756            //
757            // `add_event` usually pushes, but it can also *drop* the event
758            // without retaining it (e.g. `queue_size: 0` -> `DropIncoming`), so
759            // we must keep blocking while the scheduler is still empty rather
760            // than assume a single `recv` made it non-empty — otherwise a
761            // dropped-only event would fall through to `scheduler.next() ==
762            // None` and be misread as a closed stream. This preserves the
763            // previous "block until a retained event arrives" behavior while
764            // checking `is_empty()` only twice in the common case instead of
765            // once per drained event.
766            while self.scheduler.is_empty() {
767                match self.receiver.recv().await {
768                    Some(event) => self.add_event(event),
769                    None => break,
770                }
771            }
772            while let Ok(event) = self.receiver.try_recv() {
773                self.add_event(event);
774            }
775            self.scheduler.next().map(Self::convert_event_item)
776        };
777
778        // First-message type validation: check once per input, then remove.
779        // Zero cost after first message per input.
780        //
781        // Skip the check when the message carries pattern metadata
782        // (`request_id`, `goal_id`, or `goal_status`) — the input is
783        // polymorphic by pattern design and a single declared type
784        // cannot cover all variants. The check stays armed so a later
785        // non-pattern message can still validate (dora-rs/adora#150).
786        if let Some(Event::Input {
787            ref id,
788            ref metadata,
789            ref data,
790        }) = event
791            && let Some(expected) = self.input_type_checks.get(id).cloned()
792        {
793            let is_pattern_message = metadata
794                .parameters
795                .contains_key(dora_message::metadata::REQUEST_ID)
796                || metadata
797                    .parameters
798                    .contains_key(dora_message::metadata::GOAL_ID)
799                || metadata
800                    .parameters
801                    .contains_key(dora_message::metadata::GOAL_STATUS);
802            if !is_pattern_message {
803                // Consume the check and validate.
804                self.input_type_checks.remove(id);
805                let actual = data.data_type();
806                // Skip check for Null type (timer ticks, empty payloads)
807                // to avoid spurious warnings on annotated timer inputs.
808                if *actual != arrow_schema::DataType::Null && *actual != expected {
809                    tracing::warn!(
810                        input = %id,
811                        expected = ?expected,
812                        actual = ?actual,
813                        "input type mismatch on first message"
814                    );
815                }
816            }
817        }
818
819        if matches!(&event, Some(Event::Stop(_))) {
820            self.stop_received = true;
821        }
822        event
823    }
824
825    /// Check if there are any buffered events in the scheduler, the
826    /// receiver, or the passthrough buffer used by pattern-aware helpers.
827    pub fn is_empty(&self) -> bool {
828        self.pending_passthrough.is_empty() && self.scheduler.is_empty() && self.receiver.is_empty()
829    }
830
831    /// Returns and resets the accumulated drop counts per input ID.
832    ///
833    /// When inputs overflow their queue limits, the oldest messages are discarded.
834    /// For `drop_oldest` inputs this happens at `queue_size`. For `backpressure`
835    /// inputs this happens at a hard safety cap of 10x `queue_size`.
836    /// This method returns a map from input ID to the number of messages dropped
837    /// since the last call.
838    pub fn drain_drop_counts(&mut self) -> HashMap<DataId, u64> {
839        self.scheduler.drain_drop_counts()
840    }
841
842    fn add_event(&mut self, event: EventItem) {
843        // Event recording is observability-only (writes to the optional
844        // `write_events_to` log). A write failure must not panic the event
845        // loop — drop the log line and continue scheduling.
846        if let Err(err) = self.record_event(&event) {
847            tracing::warn!(
848                node = %self.node_id,
849                "failed to record event to write_events_to log: {err:?}"
850            );
851            // Mark the recording poisoned so consumers can detect events
852            // are missing from the final JSON. `write_out()` surfaces this
853            // as a top-level `recording_status` field (#1857).
854            if let Some(write_events_to) = self.write_events_to.as_mut() {
855                let time_offset_secs = self
856                    .clock
857                    .new_timestamp()
858                    .get_diff_duration(&self.start_timestamp)
859                    .as_secs_f64();
860                write_events_to.mark_poisoned(&err, time_offset_secs);
861            }
862        }
863        self.scheduler.add_event(event);
864    }
865
866    fn record_event(&mut self, event: &EventItem) -> eyre::Result<()> {
867        if let Some(write_events_to) = &mut self.write_events_to {
868            let event_json = match event {
869                EventItem::NodeEvent { event, .. } => match event {
870                    NodeEvent::Stop => {
871                        let time_offset = self
872                            .clock
873                            .new_timestamp()
874                            .get_diff_duration(&self.start_timestamp);
875                        let event_json = serde_json::json!({
876                            "type": "Stop",
877                            "time_offset_secs": time_offset.as_secs_f64(),
878                        });
879                        Some(event_json)
880                    }
881                    NodeEvent::Reload { .. } => None,
882                    NodeEvent::Input { id, metadata, data } => {
883                        let mut event_json = convert_output_to_json(
884                            id,
885                            metadata,
886                            data,
887                            self.start_timestamp,
888                            false,
889                        )?;
890                        event_json.insert("type".into(), "Input".into());
891                        Some(event_json.into())
892                    }
893                    NodeEvent::InputClosed { id } => {
894                        let time_offset = self
895                            .clock
896                            .new_timestamp()
897                            .get_diff_duration(&self.start_timestamp);
898                        let event_json = serde_json::json!({
899                            "type": "InputClosed",
900                            "id": id.to_string(),
901                            "time_offset_secs": time_offset.as_secs_f64(),
902                        });
903                        Some(event_json)
904                    }
905                    NodeEvent::InputRecovered { id } => {
906                        let time_offset = self
907                            .clock
908                            .new_timestamp()
909                            .get_diff_duration(&self.start_timestamp);
910                        let event_json = serde_json::json!({
911                            "type": "InputRecovered",
912                            "id": id.to_string(),
913                            "time_offset_secs": time_offset.as_secs_f64(),
914                        });
915                        Some(event_json)
916                    }
917                    NodeEvent::NodeRestarted { id } => {
918                        let time_offset = self
919                            .clock
920                            .new_timestamp()
921                            .get_diff_duration(&self.start_timestamp);
922                        let event_json = serde_json::json!({
923                            "type": "NodeRestarted",
924                            "id": id.to_string(),
925                            "time_offset_secs": time_offset.as_secs_f64(),
926                        });
927                        Some(event_json)
928                    }
929                    NodeEvent::AllInputsClosed => {
930                        let time_offset = self
931                            .clock
932                            .new_timestamp()
933                            .get_diff_duration(&self.start_timestamp);
934                        let event_json = serde_json::json!({
935                            "type": "AllInputsClosed",
936                            "time_offset_secs": time_offset.as_secs_f64(),
937                        });
938                        Some(event_json)
939                    }
940                    _ => None,
941                },
942                _ => None,
943            };
944            if let Some(event_json) = event_json {
945                write_events_to.events_buffer.push(event_json);
946            }
947        }
948        Ok(())
949    }
950
951    /// Receives the next buffered [`Event`] (if any) without blocking, using an
952    /// [`EventScheduler`] for fairness.
953    ///
954    /// Returns [`TryRecvError::Empty`] if no event is available right now.
955    /// Returns [`TryRecvError::Closed`] once the event stream is closed.
956    ///
957    /// This method never blocks and is safe to use in asynchronous contexts.
958    ///
959    /// ## Event Reordering
960    ///
961    /// This method uses an [`EventScheduler`] internally to **reorder events**. This means that the
962    /// events might be returned in a different order than they occurred. For details, check the
963    /// documentation of the [`EventScheduler`] struct.
964    ///
965    /// If you want to receive the events in their original chronological order, use the
966    /// [`StreamExt::next`](futures::StreamExt::next) method with a custom timeout future instead
967    /// ([`EventStream`] implements the [`Stream`] trait).
968    pub fn try_recv(&mut self) -> Result<Event, TryRecvError> {
969        match self.recv_async().now_or_never() {
970            Some(Some(event)) => Ok(event),
971            Some(None) => Err(TryRecvError::Closed),
972            None => Err(TryRecvError::Empty),
973        }
974    }
975
976    /// Receives all buffered [`Event`]s without blocking, using an [`EventScheduler`] for fairness.
977    ///
978    /// Return `Some(Vec::new())` if no events are ready.
979    /// Returns [`None`] once the event stream is closed and no events are buffered anymore.
980    ///
981    /// This method never blocks and is safe to use in asynchronous contexts.
982    ///
983    /// This method is equivalent to repeatedly calling [`try_recv`][Self::try_recv]. See its docs
984    /// for details on event reordering.
985    pub fn drain(&mut self) -> Option<Vec<Event>> {
986        let mut events = Vec::new();
987        loop {
988            match self.try_recv() {
989                Ok(event) => events.push(event),
990                Err(TryRecvError::Empty) => break,
991                Err(TryRecvError::Closed) => {
992                    if events.is_empty() {
993                        return None;
994                    } else {
995                        break;
996                    }
997                }
998            }
999        }
1000        Some(events)
1001    }
1002
1003    /// Receives the next incoming [`Event`] asynchronously with a timeout.
1004    ///
1005    /// Returns a [`Event::Error`] if no event was received within the given duration.
1006    ///
1007    /// Returns [`None`] once the event stream is closed.
1008    ///
1009    /// ## Event Reordering
1010    ///
1011    /// This method uses an [`EventScheduler`] internally to **reorder events**. This means that the
1012    /// events might be returned in a different order than they occurred. For details, check the
1013    /// documentation of the [`EventScheduler`] struct.
1014    ///
1015    /// If you want to receive the events in their original chronological order, use the
1016    /// [`StreamExt::next`](futures::StreamExt::next) method with a custom timeout future instead
1017    /// ([`EventStream`] implements the [`Stream`] trait).
1018    pub async fn recv_async_timeout(&mut self, dur: Duration) -> Option<Event> {
1019        match select(Delay::new(dur), pin!(self.recv_async())).await {
1020            Either::Left((_elapsed, _)) => Some(Self::convert_event_item(EventItem::TimeoutError(
1021                eyre!("Receiver timed out"),
1022            ))),
1023            Either::Right((event, _)) => event,
1024        }
1025    }
1026
1027    /// Waits for a service response carrying `request_id` in its metadata.
1028    ///
1029    /// Drives the event loop internally and returns the matching
1030    /// [`Event::Input`] as soon as it arrives. Non-matching events are
1031    /// buffered and replayed on the next call to `recv()` / `recv_async()`,
1032    /// so your main event loop does not lose intermediate events.
1033    ///
1034    /// Terminal conditions return a [`PatternError`]:
1035    ///
1036    /// - `Timeout` — `timeout` elapsed before any matching response arrived.
1037    /// - `ServerRestarted(expected_server)` — the expected server node
1038    ///   restarted, which means its in-flight `request_id` correlation
1039    ///   was orphaned. The caller should retry against the new instance.
1040    /// - `StreamEnded` — the event stream closed (dataflow stopping)
1041    ///   before a response arrived. The terminal `Stop` event is still
1042    ///   returned to the caller's next `recv()`.
1043    /// - `StreamError` — an upstream error event surfaced during the wait.
1044    ///
1045    /// # Example
1046    ///
1047    /// ```ignore
1048    /// let request_id = node.send_service_request(...)?;
1049    /// match events
1050    ///     .recv_service_response(&request_id, &server_id, Duration::from_secs(5))
1051    ///     .await
1052    /// {
1053    ///     Ok(Event::Input { data, .. }) => handle_response(data),
1054    ///     Err(PatternError::Timeout) => fallback_path(),
1055    ///     Err(PatternError::ServerRestarted(_)) => retry_with_new_instance(),
1056    ///     Err(e) => return Err(e.into()),
1057    ///     _ => unreachable!(),
1058    /// }
1059    /// ```
1060    pub async fn recv_service_response(
1061        &mut self,
1062        request_id: &str,
1063        expected_server: &NodeId,
1064        timeout: Duration,
1065    ) -> Result<Event, PatternError> {
1066        self.wait_for_correlation(
1067            timeout,
1068            expected_server,
1069            |event, request_id| match event {
1070                Event::Input { metadata, .. } => {
1071                    dora_message::metadata::get_string_param(
1072                        &metadata.parameters,
1073                        dora_message::metadata::REQUEST_ID,
1074                    ) == Some(request_id)
1075                }
1076                _ => false,
1077            },
1078            request_id,
1079        )
1080        .await
1081    }
1082
1083    /// Waits for a terminal action result (`goal_status` ∈
1084    /// {`succeeded`, `aborted`, `canceled`}) with a matching `goal_id`.
1085    ///
1086    /// Semantics mirror [`recv_service_response`](Self::recv_service_response)
1087    /// but match on `goal_id` + a terminal `goal_status` instead of
1088    /// `request_id`. Intermediate feedback events (matching `goal_id`
1089    /// without a terminal `goal_status`) are stashed for the caller's
1090    /// main loop — use `recv()` separately if you need to observe them.
1091    pub async fn recv_action_result(
1092        &mut self,
1093        goal_id: &str,
1094        expected_server: &NodeId,
1095        timeout: Duration,
1096    ) -> Result<Event, PatternError> {
1097        self.wait_for_correlation(
1098            timeout,
1099            expected_server,
1100            |event, goal_id| match event {
1101                Event::Input { metadata, .. } => {
1102                    let matches_goal = dora_message::metadata::get_string_param(
1103                        &metadata.parameters,
1104                        dora_message::metadata::GOAL_ID,
1105                    ) == Some(goal_id);
1106                    if !matches_goal {
1107                        return false;
1108                    }
1109                    matches!(
1110                        dora_message::metadata::get_string_param(
1111                            &metadata.parameters,
1112                            dora_message::metadata::GOAL_STATUS,
1113                        ),
1114                        Some(dora_message::metadata::GOAL_STATUS_SUCCEEDED)
1115                            | Some(dora_message::metadata::GOAL_STATUS_ABORTED)
1116                            | Some(dora_message::metadata::GOAL_STATUS_CANCELED)
1117                    )
1118                }
1119                _ => false,
1120            },
1121            goal_id,
1122        )
1123        .await
1124    }
1125
1126    /// Core loop for the pattern-aware helpers. Waits up to `timeout`
1127    /// for an event that satisfies `is_match(event, needle)`. Buffers
1128    /// every non-matching event so the caller's main event loop can
1129    /// still see them via `recv()`.
1130    async fn wait_for_correlation<F>(
1131        &mut self,
1132        timeout: Duration,
1133        expected_server: &NodeId,
1134        is_match: F,
1135        needle: &str,
1136    ) -> Result<Event, PatternError>
1137    where
1138        F: Fn(&Event, &str) -> bool,
1139    {
1140        let deadline = std::time::Instant::now() + timeout;
1141        loop {
1142            let remaining = deadline.saturating_duration_since(std::time::Instant::now());
1143            if remaining.is_zero() {
1144                return Err(PatternError::Timeout);
1145            }
1146            let event = match select(Delay::new(remaining), pin!(self.recv_async())).await {
1147                Either::Left((_elapsed, _)) => return Err(PatternError::Timeout),
1148                Either::Right((None, _)) => return Err(PatternError::StreamEnded),
1149                Either::Right((Some(e), _)) => e,
1150            };
1151
1152            match classify_correlation_event(&event, expected_server, |e| is_match(e, needle)) {
1153                CorrelationOutcome::Match => return Ok(event),
1154                CorrelationOutcome::ServerRestarted => {
1155                    self.pending_passthrough.push_back(event);
1156                    return Err(PatternError::ServerRestarted(expected_server.to_string()));
1157                }
1158                CorrelationOutcome::StreamEnded => {
1159                    self.pending_passthrough.push_back(event);
1160                    return Err(PatternError::StreamEnded);
1161                }
1162                CorrelationOutcome::StreamError => {
1163                    if let Event::Error(err) = event {
1164                        return Err(PatternError::StreamError(err));
1165                    }
1166                    unreachable!("StreamError only returned for Event::Error");
1167                }
1168                CorrelationOutcome::Passthrough => {
1169                    self.pending_passthrough.push_back(event);
1170                }
1171            }
1172        }
1173    }
1174}
1175
1176/// Outcome of classifying a single event during a pattern-aware wait.
1177/// Separated from `wait_for_correlation` so the decision logic can be
1178/// unit-tested without a live `EventStream`.
1179#[derive(Debug, PartialEq, Eq)]
1180enum CorrelationOutcome {
1181    /// The event satisfies the caller's predicate — return it.
1182    Match,
1183    /// `Event::NodeRestarted { id }` where `id == expected_server`.
1184    ServerRestarted,
1185    /// `Event::Stop(_)` — the dataflow is shutting down.
1186    StreamEnded,
1187    /// `Event::Error(_)` — the stream surfaced an error.
1188    StreamError,
1189    /// Unrelated event — buffer it and keep waiting.
1190    Passthrough,
1191}
1192
1193fn classify_correlation_event<F>(
1194    event: &Event,
1195    expected_server: &NodeId,
1196    is_match: F,
1197) -> CorrelationOutcome
1198where
1199    F: Fn(&Event) -> bool,
1200{
1201    if is_match(event) {
1202        return CorrelationOutcome::Match;
1203    }
1204    match event {
1205        Event::NodeRestarted { id } if id == expected_server => CorrelationOutcome::ServerRestarted,
1206        Event::Stop(_) => CorrelationOutcome::StreamEnded,
1207        Event::Error(_) => CorrelationOutcome::StreamError,
1208        _ => CorrelationOutcome::Passthrough,
1209    }
1210}
1211
1212impl EventStream {
1213    fn convert_event_item(item: EventItem) -> Event {
1214        match item {
1215            EventItem::NodeEvent { event } => match event {
1216                NodeEvent::Stop => Event::Stop(event::StopCause::Manual),
1217                NodeEvent::Reload { operator_id } => Event::Reload { operator_id },
1218                NodeEvent::InputClosed { id } => Event::InputClosed { id },
1219                NodeEvent::InputRecovered { id } => Event::InputRecovered { id },
1220                NodeEvent::NodeRestarted { id } => Event::NodeRestarted { id },
1221                NodeEvent::Input { id, metadata, data } => {
1222                    let data_inner = data.map(Arc::unwrap_or_clone);
1223                    let result = data_to_arrow_array(data_inner);
1224                    match result {
1225                        Ok(data) => {
1226                            let mut metadata = Arc::unwrap_or_clone(metadata);
1227                            dora_message::metadata::strip_internal_parameters(
1228                                &mut metadata.parameters,
1229                            );
1230                            Event::Input {
1231                                id,
1232                                metadata,
1233                                data: data.into(),
1234                            }
1235                        }
1236                        Err(err) => Event::Error(format!("{err:?}")),
1237                    }
1238                }
1239                NodeEvent::AllInputsClosed => Event::Stop(event::StopCause::AllInputsClosed),
1240                NodeEvent::ParamUpdate { key, value_json } => {
1241                    match serde_json::from_slice(&value_json) {
1242                        Ok(value) => Event::ParamUpdate { key, value },
1243                        Err(err) => Event::Error(format!(
1244                            "failed to deserialize ParamUpdate value for `{key}`: {err}"
1245                        )),
1246                    }
1247                }
1248                NodeEvent::ParamDeleted { key } => Event::ParamDeleted { key },
1249                NodeEvent::NodeFailed {
1250                    affected_input_ids,
1251                    error,
1252                    source_node_id,
1253                } => Event::NodeFailed {
1254                    affected_input_ids,
1255                    error,
1256                    source_node_id,
1257                },
1258                other => {
1259                    tracing::warn!("ignoring unrecognized NodeEvent variant: {other:?}");
1260                    Event::Error(format!("unrecognized node event: {other:?}"))
1261                }
1262            },
1263
1264            EventItem::ZenohInput { id, metadata, data } => {
1265                let mut metadata = Arc::unwrap_or_clone(metadata);
1266                dora_message::metadata::strip_internal_parameters(&mut metadata.parameters);
1267                Event::Input {
1268                    id,
1269                    metadata,
1270                    // Already decoded in the subscriber callback (receipt order).
1271                    data: arrow::array::make_array(data).into(),
1272                }
1273            }
1274
1275            EventItem::FatalError(err) => {
1276                Event::Error(format!("fatal event stream error: {err:?}"))
1277            }
1278            EventItem::TimeoutError(err) => {
1279                Event::Error(format!("Timeout event stream error: {err:?}"))
1280            }
1281        }
1282    }
1283}
1284
1285/// No event is available right now or the event stream has been closed.
1286#[derive(Debug)]
1287pub enum TryRecvError {
1288    /// No new event is available right now.
1289    Empty,
1290    /// The event stream has been closed.
1291    Closed,
1292}
1293
1294/// Convert a zenoh `ZBytes` payload into an Arrow array without copying
1295/// for contiguous buffers (e.g. Zenoh SHM).
1296///
1297/// For `Cow::Borrowed` payloads (SHM), the Arrow `Buffer` is backed by
1298/// the original `ZBytes` allocation via `Buffer::from_custom_allocation`,
1299/// achieving true zero-copy. For `Cow::Owned` (normal network path),
1300/// copy into Dora's aligned buffer type before reconstructing Arrow arrays.
1301/// Newtype that owns a Zenoh [`ZBytes`](zenoh::bytes::ZBytes) payload so it can
1302/// back an Arrow `Buffer` via `Buffer::from_custom_allocation`. Keeping the
1303/// `ZBytes` alive keeps the underlying SHM mapping (or heap buffer) valid for
1304/// the lifetime of the zero-copy Arrow buffer.
1305#[allow(dead_code)] // field kept alive to own the zenoh buffer
1306struct ZBytesAllocation(zenoh::bytes::ZBytes);
1307// SAFETY: the wrapped `ZBytes` is only used to keep the backing allocation
1308// alive; the bytes are treated as immutable for the Buffer's lifetime.
1309unsafe impl Sync for ZBytesAllocation {}
1310unsafe impl Send for ZBytesAllocation {}
1311impl std::panic::RefUnwindSafe for ZBytesAllocation {}
1312
1313/// Convert a zenoh payload to an Arrow array (dora-rs/adora#132).
1314///
1315/// Every data-plane payload is a self-describing Arrow IPC stream, so the
1316/// decode needs no type sidecar. An empty payload is a metadata-only message
1317/// and maps to the unit array.
1318/// Wrap a zenoh payload as an Arrow `Buffer` — aliasing the zenoh SHM mapping
1319/// for borrowed payloads (zero-copy), owning the materialized `Vec` otherwise.
1320fn zenoh_payload_to_buffer(payload: zenoh::bytes::ZBytes) -> arrow::buffer::Buffer {
1321    use std::ptr::NonNull;
1322    match payload.to_bytes() {
1323        std::borrow::Cow::Borrowed(slice) => {
1324            let ptr =
1325                NonNull::new(slice.as_ptr() as *mut u8).expect("zenoh SHM payload ptr is null");
1326            let len = slice.len();
1327            // SAFETY: `ptr` points into the SHM region owned by `payload`;
1328            // moving `payload` into the Arc keeps the region mapped for the
1329            // lifetime of the Buffer.
1330            unsafe {
1331                arrow::buffer::Buffer::from_custom_allocation(
1332                    ptr,
1333                    len,
1334                    Arc::new(ZBytesAllocation(payload)),
1335                )
1336            }
1337        }
1338        std::borrow::Cow::Owned(vec) => arrow::buffer::Buffer::from_vec(vec),
1339    }
1340}
1341
1342/// Declare the `@schema` AdvancedSubscriber for an input: its callback primes
1343/// `decoder` from the schema published on the output's schema subtopic. The
1344/// history query fetches the cached schema on join; `detect_late_publishers`
1345/// re-queries a producer that appears after this subscriber.
1346///
1347/// On failure, `schema_plane_failed` is set so the data subscriber surfaces a
1348/// `FatalError` if the input stays undecodable past
1349/// [`SCHEMA_PLANE_FATAL_GRACE`]: a failed declare means a degraded zenoh
1350/// session, so exit loudly rather than limp along on the in-band full-stream
1351/// refresh alone — but give that refresh (which fully heals the input) its
1352/// chance first instead of killing a node that would recover within seconds.
1353/// It never blocks the data subscriber.
1354#[allow(clippy::too_many_arguments)]
1355fn declare_schema_subscriber(
1356    session: &zenoh::Session,
1357    dataflow_id: DataflowId,
1358    source_node: &NodeId,
1359    source_output: &DataId,
1360    input_id: &DataId,
1361    decoder: Arc<std::sync::Mutex<crate::arrow_utils::ipc_encode::InputDecoder>>,
1362    tx: tokio::sync::mpsc::Sender<EventItem>,
1363    schema_plane_failed: Arc<std::sync::atomic::AtomicBool>,
1364    out: &mut Vec<zenoh_ext::AdvancedSubscriber<()>>,
1365) {
1366    use zenoh::Wait;
1367    use zenoh_ext::{AdvancedSubscriberBuilderExt, HistoryConfig};
1368
1369    let topic =
1370        dora_core::topics::zenoh_output_schema_topic(dataflow_id, source_node, source_output);
1371    let key = match zenoh::key_expr::KeyExpr::new(topic) {
1372        Ok(k) => k.into_owned(),
1373        Err(e) => {
1374            tracing::warn!(input = %input_id, "invalid @schema zenoh key ({e}); schema-once disabled for this input");
1375            schema_plane_failed.store(true, std::sync::atomic::Ordering::Relaxed);
1376            return;
1377        }
1378    };
1379    let input_id_cb = input_id.clone();
1380    let sub = session
1381        .declare_subscriber(key)
1382        .history(HistoryConfig::default().detect_late_publishers())
1383        .callback(move |sample| {
1384            // catch_unwind: a panic must not unwind through zenoh's IO worker.
1385            let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1386                let buffer = zenoh_payload_to_buffer(sample.payload().clone());
1387                let hash = crate::node::fnv1a(buffer.as_slice());
1388                let mut decoder = decoder.lock().unwrap_or_else(|poison| {
1389                    let mut guard = poison.into_inner();
1390                    guard.reset();
1391                    guard
1392                });
1393                if let Err(e) = decoder.set_schema(hash, buffer) {
1394                    tracing::warn!(input = %input_id_cb, "failed to prime decoder from @schema sample: {e}");
1395                }
1396            }));
1397            if result.is_err() {
1398                tracing::error!(input = %input_id_cb, "zenoh @schema subscriber callback panicked");
1399                let _ = tx.try_send(EventItem::FatalError(eyre!(
1400                    "zenoh @schema subscriber for input `{input_id_cb}` panicked"
1401                )));
1402            }
1403        })
1404        .wait();
1405    match sub {
1406        Ok(s) => out.push(s),
1407        Err(e) => {
1408            tracing::warn!(input = %input_id, "failed to declare @schema subscriber ({e}); schema-once disabled for this input");
1409            schema_plane_failed.store(true, std::sync::atomic::Ordering::Relaxed);
1410        }
1411    }
1412}
1413
1414/// How long a schema-once input may stay continuously undecodable — with the
1415/// `@schema` plane dead — before the node exits with a `FatalError`. Three
1416/// producer full-stream refresh intervals: the refresh re-primes the input
1417/// in-band, so if it hasn't healed after three periods the input is genuinely
1418/// dead (producer gone or data plane dropping every refresh), not just waiting
1419/// out the documented recovery window.
1420const SCHEMA_PLANE_FATAL_GRACE: Duration =
1421    crate::node::SCHEMA_ONCE_REFRESH_INTERVAL.saturating_mul(3);
1422
1423/// Whether the undecodable-input condition has persisted past
1424/// [`SCHEMA_PLANE_FATAL_GRACE`]. Records the start of the window on first call;
1425/// the caller clears `first_undecodable` on any successful decode.
1426fn schema_plane_fatal_due(first_undecodable: &mut Option<Instant>, now: Instant) -> bool {
1427    let start = *first_undecodable.get_or_insert(now);
1428    now.duration_since(start) >= SCHEMA_PLANE_FATAL_GRACE
1429}
1430
1431/// Decode a zenoh data sample in receipt order. A schema-less batch (the
1432/// `SCHEMA_HASH` parameter is present) decodes against the per-input decoder
1433/// primed from the output's `@schema` subtopic or in-band from an earlier full
1434/// stream — returning `Ok(None)` if that schema hasn't been received yet, in
1435/// which case the caller drops it. Other messages (large/SHM full streams,
1436/// daemon-path payloads) decode standalone — and additionally prime the decoder
1437/// in-band (see [`prime_in_band`]).
1438fn decode_zenoh_sample(
1439    decoder: &mut crate::arrow_utils::ipc_encode::InputDecoder,
1440    metadata: &dora_message::metadata::Metadata,
1441    payload: zenoh::bytes::ZBytes,
1442) -> eyre::Result<Option<arrow::array::ArrayData>> {
1443    use crate::arrow_utils::decode_arrow_ipc_zero_copy;
1444    use dora_message::metadata::{SCHEMA_HASH, get_integer_param};
1445
1446    if payload.is_empty() {
1447        return Ok(Some(().into_arrow().into()));
1448    }
1449    let buffer = zenoh_payload_to_buffer(payload);
1450    match get_integer_param(&metadata.parameters, SCHEMA_HASH) {
1451        Some(hash) => {
1452            tracing::debug!("received schema-less batch with SCHEMA_HASH={}", hash);
1453            decoder.decode_batch(buffer, hash as u64)
1454        }
1455        None => {
1456            tracing::debug!("received full IPC stream (no SCHEMA_HASH)");
1457            // Service/action messages are excluded from schema-once (a server
1458            // multiplexes per-request schemas through one output); don't let
1459            // them churn the retained schema set.
1460            if !crate::node::carries_pattern_correlation(&metadata.parameters) {
1461                prime_in_band(decoder, &buffer);
1462            }
1463            decode_arrow_ipc_zero_copy(buffer).map(Some)
1464        }
1465    }
1466}
1467
1468/// Prime the per-input decoder from the schema block of a full self-describing
1469/// stream received on the data topic.
1470///
1471/// The producer sends a full stream for the first message of an output (and
1472/// after every schema change, failed `@schema` publish, or periodic refresh —
1473/// see `publish_schema_once`). Since all data-plane puts share one publisher,
1474/// zenoh delivers them in order, so this priming happens strictly before the
1475/// schema-less batches that reference the schema — closing the QoS race where
1476/// an express batch overtakes the `@schema` plane's non-express schema, and
1477/// re-priming (within the refresh interval) any consumer whose `@schema`
1478/// history query missed the single schema emission.
1479fn prime_in_band(
1480    decoder: &mut crate::arrow_utils::ipc_encode::InputDecoder,
1481    buffer: &arrow::buffer::Buffer,
1482) {
1483    let Some((hash, schema)) =
1484        crate::arrow_utils::ipc_encode::schema_block_and_hash(buffer.as_slice())
1485    else {
1486        return;
1487    };
1488    // A known schema (live or retained) needs no eager re-prime: the full
1489    // stream decodes standalone, and `decode_batch` re-primes lazily from the
1490    // retained set when a schema-less batch actually references it.
1491    if decoder.knows_schema(hash) {
1492        return;
1493    }
1494    // Copy the schema block out of the payload: retaining a slice of an
1495    // SHM-backed buffer would pin the whole segment for the decoder's lifetime.
1496    let schema = arrow::buffer::Buffer::from(schema);
1497    if let Err(e) = decoder.set_schema(hash, schema) {
1498        tracing::debug!("in-band schema priming failed: {e}");
1499    }
1500}
1501
1502pub fn data_to_arrow_array(
1503    data: Option<DataMessage>,
1504) -> eyre::Result<Arc<dyn arrow::array::Array>> {
1505    let data: eyre::Result<Option<RawData>> = match data {
1506        None => Ok(None),
1507        Some(DataMessage::Vec(v)) => Ok(Some(RawData::Vec(v))),
1508    };
1509
1510    data.and_then(|data| {
1511        let raw_data = data.unwrap_or(RawData::Empty);
1512        raw_data.into_arrow_array().map(arrow::array::make_array)
1513    })
1514}
1515
1516impl Stream for EventStream {
1517    type Item = Event;
1518
1519    fn poll_next(
1520        mut self: std::pin::Pin<&mut Self>,
1521        cx: &mut std::task::Context<'_>,
1522    ) -> std::task::Poll<Option<Self::Item>> {
1523        // Drain events that were buffered by pattern-aware helpers
1524        // (`recv_service_response`, `recv_action_result`) before
1525        // polling the underlying receiver. Mirrors the drain at the
1526        // top of `recv_async` so `StreamExt::next()` and `recv()`
1527        // return the same events in the same order
1528        // (dora-rs/adora#172).
1529        if let Some(event) = self.pending_passthrough.pop_front() {
1530            return std::task::Poll::Ready(Some(event));
1531        }
1532
1533        // Close the stream after a Stop event: zenoh subscriber threads
1534        // hold sender clones that would otherwise keep `receiver` open.
1535        if self.stop_received {
1536            return std::task::Poll::Ready(None);
1537        }
1538
1539        let poll = self
1540            .receiver
1541            .poll_recv(cx)
1542            .map(|item| item.map(Self::convert_event_item));
1543
1544        // Run first-message type check on the Stream path too.
1545        //
1546        // Mirror the recv_async() logic: skip the check (and keep it
1547        // armed) when the message carries pattern metadata, so a later
1548        // non-pattern message can still validate (dora-rs/adora#174).
1549        if let std::task::Poll::Ready(Some(Event::Input {
1550            ref id,
1551            ref metadata,
1552            ref data,
1553        })) = poll
1554            && let Some(expected) = self.input_type_checks.get(id).cloned()
1555        {
1556            let is_pattern_message = metadata
1557                .parameters
1558                .contains_key(dora_message::metadata::REQUEST_ID)
1559                || metadata
1560                    .parameters
1561                    .contains_key(dora_message::metadata::GOAL_ID)
1562                || metadata
1563                    .parameters
1564                    .contains_key(dora_message::metadata::GOAL_STATUS);
1565            if !is_pattern_message {
1566                self.input_type_checks.remove(id);
1567                let actual = data.data_type();
1568                if *actual != arrow_schema::DataType::Null && *actual != expected {
1569                    tracing::warn!(
1570                        input = %id,
1571                        expected = ?expected,
1572                        actual = ?actual,
1573                        "input type mismatch on first message (Stream path)"
1574                    );
1575                }
1576            }
1577        }
1578
1579        if matches!(&poll, std::task::Poll::Ready(Some(Event::Stop(_)))) {
1580            self.stop_received = true;
1581        }
1582        poll
1583    }
1584}
1585
1586impl Drop for EventStream {
1587    fn drop(&mut self) {
1588        // Tear down the per-input zenoh callback subscribers under a deadline.
1589        // `Subscriber::drop` undeclares the subscription on the shared zenoh
1590        // session, which blocks indefinitely when zenoh's net runtime is
1591        // wedged (e.g. stuck retrying an unreachable scouted peer). Left
1592        // unbounded, that hangs the whole node in `EventStream`'s field-drop
1593        // sequence — before `DoraNode::Drop` (which already bounds its own
1594        // session teardown) even runs — so the daemon never sees the node
1595        // finish and the dataflow stalls until an outer timeout (dora-rs/dora#2425).
1596        // The callbacks use `try_send`, so undeclaring before `receiver` drops
1597        // cannot deadlock on a blocked callback.
1598        //
1599        // The `@schema` `AdvancedSubscriber`s (added with the Arrow IPC data
1600        // plane in #2366) undeclare on the same shared session and can wedge
1601        // the same way, so they must be torn down under the same deadline —
1602        // mirroring `DoraNode::Drop`, which drops both publisher maps inside
1603        // one guard. Left out, they would otherwise drop unbounded in the
1604        // implicit field-drop phase after this `Drop` body returns (#2583).
1605        let subscribers = std::mem::take(&mut self._zenoh_subscribers);
1606        let schema_subscribers = std::mem::take(&mut self._zenoh_schema_subscribers);
1607        if !subscribers.is_empty() || !schema_subscribers.is_empty() {
1608            let completed =
1609                teardown_with_timeout("zenoh-subscribers", ZENOH_TEARDOWN_TIMEOUT, move || {
1610                    drop(subscribers);
1611                    drop(schema_subscribers);
1612                });
1613            if !completed {
1614                tracing::warn!(
1615                    "zenoh subscriber teardown timed out after {}s; continuing node shutdown",
1616                    ZENOH_TEARDOWN_TIMEOUT.as_secs()
1617                );
1618            }
1619        }
1620
1621        // Undeclare readiness tokens under the same deadline: like subscriber
1622        // teardown, `LivelinessToken::drop` undeclares on the shared session and
1623        // can block if zenoh's net runtime is wedged (dora-rs/dora#2425).
1624        let tokens = std::mem::take(&mut self._zenoh_liveliness_tokens);
1625        if !tokens.is_empty() {
1626            let completed = teardown_with_timeout(
1627                "zenoh-liveliness-tokens",
1628                ZENOH_TEARDOWN_TIMEOUT,
1629                move || {
1630                    drop(tokens);
1631                },
1632            );
1633            if !completed {
1634                tracing::warn!(
1635                    "zenoh readiness-token teardown timed out after {}s; continuing node shutdown",
1636                    ZENOH_TEARDOWN_TIMEOUT.as_secs()
1637                );
1638            }
1639        }
1640
1641        let request = Timestamped {
1642            inner: DaemonRequest::EventStreamDropped,
1643            timestamp: self.clock.new_timestamp(),
1644        };
1645        let result = self
1646            .close_channel
1647            .request(&request)
1648            .map_err(|e| eyre!(e))
1649            .wrap_err("failed to signal event stream closure to dora-daemon")
1650            .and_then(|r| match r {
1651                DaemonReply::Result(Ok(())) => Ok(()),
1652                DaemonReply::Result(Err(err)) => Err(eyre!("EventStreamClosed failed: {err}")),
1653                other => Err(eyre!("unexpected EventStreamClosed reply: {other:?}")),
1654            });
1655        if let Err(err) = result {
1656            tracing::warn!("{err:?}")
1657        }
1658
1659        if let Some(write_events_to) = self.write_events_to.take()
1660            && let Err(err) = write_events_to.write_out()
1661        {
1662            tracing::warn!(
1663                "failed to write out events for node {}: {err:?}",
1664                self.node_id
1665            );
1666        }
1667    }
1668}
1669
1670pub(crate) struct WriteEventsTo {
1671    node_id: NodeId,
1672    file: std::fs::File,
1673    events_buffer: Vec<serde_json::Value>,
1674    /// `None` while the recording is complete. Becomes `Some(...)` on
1675    /// the first `record_event` failure; subsequent failures bump the
1676    /// counter inside. Surfaced in `write_out()` as a top-level
1677    /// `recording_status` field so consumers (replay tools, audit
1678    /// pipelines) can detect partial recordings instead of silently
1679    /// treating a syntactically-valid file as complete (#1857).
1680    poisoned: Option<PoisonInfo>,
1681}
1682
1683#[derive(Debug)]
1684pub(crate) struct PoisonInfo {
1685    /// `events_buffer.len()` at the moment of the first failure — i.e.
1686    /// the number of events successfully recorded before the gap.
1687    first_failure_event_index: usize,
1688    /// Seconds since `EventStream::start_timestamp` at the first failure.
1689    first_failure_time_offset_secs: f64,
1690    /// `format!("{err:?}")` of the first `record_event()` error.
1691    first_failure_error: String,
1692    /// Count of subsequent failures after the first one.
1693    additional_failures: u64,
1694}
1695
1696impl WriteEventsTo {
1697    /// Mark the recording poisoned. First call captures the failure
1698    /// detail; later calls just bump `additional_failures`.
1699    fn mark_poisoned(&mut self, err: &eyre::Report, time_offset_secs: f64) {
1700        match &mut self.poisoned {
1701            None => {
1702                self.poisoned = Some(PoisonInfo {
1703                    first_failure_event_index: self.events_buffer.len(),
1704                    first_failure_time_offset_secs: time_offset_secs,
1705                    first_failure_error: format!("{err:?}"),
1706                    additional_failures: 0,
1707                });
1708            }
1709            Some(info) => {
1710                info.additional_failures += 1;
1711            }
1712        }
1713    }
1714
1715    fn write_out(self) -> eyre::Result<()> {
1716        use dora_message::integration_testing_format::RecordingStatus;
1717
1718        let Self {
1719            node_id,
1720            file,
1721            events_buffer,
1722            poisoned,
1723        } = self;
1724        let mut inputs_file = serde_json::Map::new();
1725        inputs_file.insert("id".into(), node_id.to_string().into());
1726        // Emit `recording_status` for clean recordings too, so consumers
1727        // can rely on its presence as a definitive signal rather than
1728        // having to treat "field absent" as ambiguous between "clean"
1729        // and "older format" (#1857). Serialized via the canonical
1730        // `RecordingStatus` enum in `dora-message` so the wire format
1731        // stays in lockstep with the consumer-side type. The wire
1732        // shape is unaffected by `IntegrationTestInput`'s
1733        // `Option<Box<RecordingStatus>>` storage choice — serde
1734        // transparently serializes through the `Box`.
1735        let recording_status = match poisoned {
1736            None => RecordingStatus::Clean,
1737            Some(info) => RecordingStatus::Poisoned {
1738                first_failure_event_index: info.first_failure_event_index,
1739                first_failure_time_offset_secs: info.first_failure_time_offset_secs,
1740                first_failure_error: info.first_failure_error,
1741                additional_failures: info.additional_failures,
1742            },
1743        };
1744        inputs_file.insert(
1745            "recording_status".into(),
1746            serde_json::to_value(&recording_status)
1747                .context("failed to serialize recording_status")?,
1748        );
1749        inputs_file.insert("events".into(), events_buffer.into());
1750
1751        serde_json::to_writer_pretty(file, &inputs_file)
1752            .context("failed to write events to file")?;
1753        Ok(())
1754    }
1755}
1756
1757#[cfg(test)]
1758impl EventStream {
1759    /// Test-only: inject an event into the passthrough buffer so we can
1760    /// verify that `is_empty`, `recv_async`, and `Stream::poll_next` all
1761    /// drain it correctly (dora-rs/adora#172).
1762    fn push_passthrough_for_testing(&mut self, event: Event) {
1763        self.pending_passthrough.push_back(event);
1764    }
1765
1766    /// Test-only: buffer an empty input directly in the scheduler and force
1767    /// scheduler mode, simulating an input the scheduler held back while
1768    /// prioritizing `Stop`. Used to verify `recv_async` drains buffered inputs
1769    /// after `Stop` instead of dropping them (dora-rs/dora#2027).
1770    fn push_scheduler_input_for_testing(&mut self, id: &str) {
1771        use crate::event_stream::thread::EventItem;
1772        use dora_message::{daemon_to_node::NodeEvent, metadata::Metadata};
1773        self.use_scheduler = true;
1774        let meta = Metadata::new(dora_core::uhlc::HLC::default().new_timestamp());
1775        self.scheduler.add_event(EventItem::NodeEvent {
1776            event: NodeEvent::Input {
1777                id: id.into(),
1778                metadata: std::sync::Arc::new(meta),
1779                data: None,
1780            },
1781        });
1782    }
1783
1784    /// Test-only: buffer a `Stop` directly in the scheduler (a NON_INPUT_EVENT)
1785    /// and force scheduler mode, to verify the post-Stop drain discards trailing
1786    /// control events instead of re-delivering a second `Stop` (dora-rs/dora#2027).
1787    fn push_scheduler_stop_for_testing(&mut self) {
1788        use crate::event_stream::thread::EventItem;
1789        use dora_message::daemon_to_node::NodeEvent;
1790        self.use_scheduler = true;
1791        self.scheduler.add_event(EventItem::NodeEvent {
1792            event: NodeEvent::Stop,
1793        });
1794    }
1795}
1796
1797#[cfg(test)]
1798mod tests {
1799    use super::*;
1800
1801    #[test]
1802    fn convert_param_update() {
1803        let item = EventItem::NodeEvent {
1804            event: NodeEvent::ParamUpdate {
1805                key: "fps".into(),
1806                value_json: serde_json::to_vec(&serde_json::json!(60)).unwrap(),
1807            },
1808        };
1809        let event = EventStream::convert_event_item(item);
1810        match event {
1811            Event::ParamUpdate { key, value } => {
1812                assert_eq!(key, "fps");
1813                assert_eq!(value, serde_json::json!(60));
1814            }
1815            other => panic!("expected ParamUpdate, got {other:?}"),
1816        }
1817    }
1818
1819    /// Regression test for the daemon↔node wire protocol: `NodeEvent`
1820    /// is sent over TCP with bincode, so any field type that uses
1821    /// `Deserializer::deserialize_any` (like `serde_json::Value`)
1822    /// breaks the channel and kills the node at the next receive.
1823    /// `NodeEvent::ParamUpdate` carries its value as JSON-encoded
1824    /// bytes for that reason. This test pins the invariant so we
1825    /// don't regress back to a `deserialize_any` field.
1826    #[test]
1827    fn node_event_param_update_round_trips_through_bincode() {
1828        let cases = [
1829            serde_json::json!(42),
1830            serde_json::json!(1.5),
1831            serde_json::json!("hello"),
1832            serde_json::json!(null),
1833            serde_json::json!([1, 2, 3]),
1834            serde_json::json!({"nested": {"array": [true, false]}}),
1835        ];
1836        for value in cases {
1837            let event = NodeEvent::ParamUpdate {
1838                key: "rate".into(),
1839                value_json: serde_json::to_vec(&value).unwrap(),
1840            };
1841            let bytes = bincode::serialize(&event).expect("bincode serialize");
1842            let back: NodeEvent = bincode::deserialize(&bytes).expect("bincode deserialize");
1843            match back {
1844                NodeEvent::ParamUpdate { key, value_json } => {
1845                    assert_eq!(key, "rate");
1846                    let decoded: serde_json::Value =
1847                        serde_json::from_slice(&value_json).expect("value_json is JSON");
1848                    assert_eq!(decoded, value);
1849                }
1850                other => panic!("expected ParamUpdate, got {other:?}"),
1851            }
1852        }
1853    }
1854
1855    // -- WriteEventsTo poisoned-state tests (#1857) ------------------------
1856    //
1857    // Build a `WriteEventsTo` against a tempfile, exercise the public
1858    // surface (push events / mark_poisoned / write_out), then parse the
1859    // resulting JSON and assert on the `recording_status` field shape.
1860    // No new dev-deps — uses std::env::temp_dir() + uuid (already a dep).
1861
1862    fn write_events_to_with_tempfile() -> (WriteEventsTo, std::path::PathBuf) {
1863        let path = std::env::temp_dir().join(format!(
1864            "dora-write-events-test-{}.json",
1865            uuid::Uuid::new_v4()
1866        ));
1867        let file = std::fs::File::create(&path).expect("create tempfile");
1868        let w = WriteEventsTo {
1869            node_id: "test-node".parse().unwrap(),
1870            file,
1871            events_buffer: Vec::new(),
1872            poisoned: None,
1873        };
1874        (w, path)
1875    }
1876
1877    fn read_back(path: &std::path::Path) -> serde_json::Value {
1878        let s = std::fs::read_to_string(path).expect("read back tempfile");
1879        std::fs::remove_file(path).ok();
1880        serde_json::from_str(&s).expect("output is valid JSON")
1881    }
1882
1883    #[test]
1884    fn write_events_clean_recording_emits_state_clean() {
1885        let (mut w, path) = write_events_to_with_tempfile();
1886        w.events_buffer.push(serde_json::json!({"type": "Stop"}));
1887        w.write_out().expect("write_out clean recording");
1888
1889        let v = read_back(&path);
1890        assert_eq!(v["recording_status"]["state"], "clean");
1891        assert_eq!(v["events"].as_array().unwrap().len(), 1);
1892        assert_eq!(v["id"], "test-node");
1893    }
1894
1895    #[test]
1896    fn write_events_poisoned_recording_emits_state_poisoned_with_first_failure() {
1897        let (mut w, path) = write_events_to_with_tempfile();
1898        // 2 events recorded cleanly, then a failure, then 1 more event
1899        w.events_buffer.push(serde_json::json!({"type": "Input"}));
1900        w.events_buffer.push(serde_json::json!({"type": "Input"}));
1901        w.mark_poisoned(&eyre!("arrow conversion failed: bad type"), 1.5);
1902        w.events_buffer.push(serde_json::json!({"type": "Stop"}));
1903        w.write_out().expect("write_out poisoned recording");
1904
1905        let v = read_back(&path);
1906        let status = &v["recording_status"];
1907        assert_eq!(status["state"], "poisoned");
1908        assert_eq!(status["first_failure_event_index"], 2);
1909        assert_eq!(status["first_failure_time_offset_secs"], 1.5);
1910        assert!(
1911            status["first_failure_error"]
1912                .as_str()
1913                .unwrap()
1914                .contains("arrow conversion failed: bad type")
1915        );
1916        assert_eq!(status["additional_failures"], 0);
1917        // `events` keeps the 2 clean + 1 post-failure-but-successful events.
1918        assert_eq!(v["events"].as_array().unwrap().len(), 3);
1919    }
1920
1921    #[test]
1922    fn write_events_multiple_failures_keep_first_and_count_rest() {
1923        let (mut w, path) = write_events_to_with_tempfile();
1924        w.mark_poisoned(&eyre!("first error"), 0.5);
1925        w.mark_poisoned(&eyre!("second error"), 1.0);
1926        w.mark_poisoned(&eyre!("third error"), 1.5);
1927        w.write_out().expect("write_out with multiple failures");
1928
1929        let v = read_back(&path);
1930        let status = &v["recording_status"];
1931        assert_eq!(status["state"], "poisoned");
1932        // First failure detail is preserved, NOT overwritten by later ones.
1933        assert_eq!(status["first_failure_event_index"], 0);
1934        assert_eq!(status["first_failure_time_offset_secs"], 0.5);
1935        assert!(
1936            status["first_failure_error"]
1937                .as_str()
1938                .unwrap()
1939                .contains("first error")
1940        );
1941        // Two additional failures after the first.
1942        assert_eq!(status["additional_failures"], 2);
1943    }
1944
1945    #[test]
1946    fn convert_param_deleted() {
1947        let item = EventItem::NodeEvent {
1948            event: NodeEvent::ParamDeleted { key: "fps".into() },
1949        };
1950        let event = EventStream::convert_event_item(item);
1951        match event {
1952            Event::ParamDeleted { key } => {
1953                assert_eq!(key, "fps");
1954            }
1955            other => panic!("expected ParamDeleted, got {other:?}"),
1956        }
1957    }
1958
1959    #[test]
1960    fn convert_stop_event() {
1961        let item = EventItem::NodeEvent {
1962            event: NodeEvent::Stop,
1963        };
1964        let event = EventStream::convert_event_item(item);
1965        assert!(matches!(event, Event::Stop(StopCause::Manual)));
1966    }
1967
1968    #[test]
1969    fn convert_all_inputs_closed() {
1970        let item = EventItem::NodeEvent {
1971            event: NodeEvent::AllInputsClosed,
1972        };
1973        let event = EventStream::convert_event_item(item);
1974        assert!(matches!(event, Event::Stop(StopCause::AllInputsClosed)));
1975    }
1976
1977    #[test]
1978    fn convert_input_closed() {
1979        let item = EventItem::NodeEvent {
1980            event: NodeEvent::InputClosed {
1981                id: "input_1".to_string().into(),
1982            },
1983        };
1984        let event = EventStream::convert_event_item(item);
1985        match event {
1986            Event::InputClosed { id } => assert_eq!(AsRef::<str>::as_ref(&id), "input_1"),
1987            other => panic!("expected InputClosed, got {other:?}"),
1988        }
1989    }
1990
1991    #[test]
1992    fn convert_node_restarted() {
1993        let item = EventItem::NodeEvent {
1994            event: NodeEvent::NodeRestarted {
1995                id: "upstream".to_string().into(),
1996            },
1997        };
1998        let event = EventStream::convert_event_item(item);
1999        match event {
2000            Event::NodeRestarted { id } => assert_eq!(AsRef::<str>::as_ref(&id), "upstream"),
2001            other => panic!("expected NodeRestarted, got {other:?}"),
2002        }
2003    }
2004
2005    // ---- dora-rs/adora#148: pattern-aware correlation classification ----
2006
2007    use arrow::array::new_empty_array;
2008    use arrow::datatypes::DataType as ArrowDataType;
2009    use dora_arrow_convert::ArrowData;
2010    use dora_message::metadata::{
2011        GOAL_ID, GOAL_STATUS, GOAL_STATUS_ABORTED, GOAL_STATUS_SUCCEEDED, Metadata,
2012        MetadataParameters, Parameter, REQUEST_ID,
2013    };
2014
2015    fn make_metadata(params: MetadataParameters) -> Metadata {
2016        Metadata::from_parameters(dora_core::uhlc::HLC::default().new_timestamp(), params)
2017    }
2018
2019    fn make_input_event(id: &str, params: MetadataParameters) -> Event {
2020        Event::Input {
2021            id: id.into(),
2022            metadata: make_metadata(params),
2023            data: ArrowData(new_empty_array(&ArrowDataType::Null)),
2024        }
2025    }
2026
2027    fn request_id_params(id: &str) -> MetadataParameters {
2028        let mut p = MetadataParameters::new();
2029        p.insert(REQUEST_ID.into(), Parameter::String(id.to_string()));
2030        p
2031    }
2032
2033    fn goal_params(goal_id: &str, status: Option<&str>) -> MetadataParameters {
2034        let mut p = MetadataParameters::new();
2035        p.insert(GOAL_ID.into(), Parameter::String(goal_id.to_string()));
2036        if let Some(s) = status {
2037            p.insert(GOAL_STATUS.into(), Parameter::String(s.to_string()));
2038        }
2039        p
2040    }
2041
2042    fn is_request_match(needle: &str) -> impl Fn(&Event) -> bool + '_ {
2043        move |event: &Event| match event {
2044            Event::Input { metadata, .. } => {
2045                dora_message::metadata::get_string_param(&metadata.parameters, REQUEST_ID)
2046                    == Some(needle)
2047            }
2048            _ => false,
2049        }
2050    }
2051
2052    fn is_action_result_match(needle: &str) -> impl Fn(&Event) -> bool + '_ {
2053        move |event: &Event| match event {
2054            Event::Input { metadata, .. } => {
2055                let p = &metadata.parameters;
2056                dora_message::metadata::get_string_param(p, GOAL_ID) == Some(needle)
2057                    && matches!(
2058                        dora_message::metadata::get_string_param(p, GOAL_STATUS),
2059                        Some(GOAL_STATUS_SUCCEEDED)
2060                            | Some(GOAL_STATUS_ABORTED)
2061                            | Some(dora_message::metadata::GOAL_STATUS_CANCELED)
2062                    )
2063            }
2064            _ => false,
2065        }
2066    }
2067
2068    #[test]
2069    fn classify_matching_request_id_returns_match() {
2070        let server = NodeId::from("calc".to_string());
2071        let event = make_input_event("response", request_id_params("req-42"));
2072        assert_eq!(
2073            classify_correlation_event(&event, &server, is_request_match("req-42")),
2074            CorrelationOutcome::Match
2075        );
2076    }
2077
2078    #[test]
2079    fn classify_different_request_id_is_passthrough() {
2080        let server = NodeId::from("calc".to_string());
2081        let event = make_input_event("response", request_id_params("req-99"));
2082        assert_eq!(
2083            classify_correlation_event(&event, &server, is_request_match("req-42")),
2084            CorrelationOutcome::Passthrough
2085        );
2086    }
2087
2088    #[test]
2089    fn classify_expected_server_restart_returns_server_restarted() {
2090        let server = NodeId::from("calc".to_string());
2091        let event = Event::NodeRestarted { id: server.clone() };
2092        assert_eq!(
2093            classify_correlation_event(&event, &server, is_request_match("req-42")),
2094            CorrelationOutcome::ServerRestarted
2095        );
2096    }
2097
2098    #[test]
2099    fn classify_unrelated_node_restart_is_passthrough() {
2100        let server = NodeId::from("calc".to_string());
2101        let event = Event::NodeRestarted {
2102            id: NodeId::from("other".to_string()),
2103        };
2104        assert_eq!(
2105            classify_correlation_event(&event, &server, is_request_match("req-42")),
2106            CorrelationOutcome::Passthrough
2107        );
2108    }
2109
2110    #[test]
2111    fn classify_stop_returns_stream_ended() {
2112        let server = NodeId::from("calc".to_string());
2113        let event = Event::Stop(StopCause::Manual);
2114        assert_eq!(
2115            classify_correlation_event(&event, &server, is_request_match("req-42")),
2116            CorrelationOutcome::StreamEnded
2117        );
2118    }
2119
2120    #[test]
2121    fn classify_error_returns_stream_error() {
2122        let server = NodeId::from("calc".to_string());
2123        let event = Event::Error("boom".to_string());
2124        assert_eq!(
2125            classify_correlation_event(&event, &server, is_request_match("req-42")),
2126            CorrelationOutcome::StreamError
2127        );
2128    }
2129
2130    #[test]
2131    fn classify_unrelated_input_is_passthrough() {
2132        let server = NodeId::from("calc".to_string());
2133        let event = make_input_event("sensor", MetadataParameters::new());
2134        assert_eq!(
2135            classify_correlation_event(&event, &server, is_request_match("req-42")),
2136            CorrelationOutcome::Passthrough
2137        );
2138    }
2139
2140    #[test]
2141    fn classify_param_update_is_passthrough() {
2142        // Runtime parameter updates must survive a helper wait.
2143        let server = NodeId::from("calc".to_string());
2144        let event = Event::ParamUpdate {
2145            key: "threshold".to_string(),
2146            value: serde_json::json!(0.85),
2147        };
2148        assert_eq!(
2149            classify_correlation_event(&event, &server, is_request_match("req-42")),
2150            CorrelationOutcome::Passthrough
2151        );
2152    }
2153
2154    #[test]
2155    fn classify_action_result_terminal_succeeded_matches() {
2156        let server = NodeId::from("nav".to_string());
2157        let event = make_input_event("result", goal_params("goal-1", Some(GOAL_STATUS_SUCCEEDED)));
2158        assert_eq!(
2159            classify_correlation_event(&event, &server, is_action_result_match("goal-1")),
2160            CorrelationOutcome::Match
2161        );
2162    }
2163
2164    #[test]
2165    fn classify_action_result_terminal_aborted_matches() {
2166        let server = NodeId::from("nav".to_string());
2167        let event = make_input_event("result", goal_params("goal-1", Some(GOAL_STATUS_ABORTED)));
2168        assert_eq!(
2169            classify_correlation_event(&event, &server, is_action_result_match("goal-1")),
2170            CorrelationOutcome::Match
2171        );
2172    }
2173
2174    #[test]
2175    fn classify_action_feedback_without_terminal_status_is_passthrough() {
2176        // Intermediate feedback (no terminal goal_status) should pass
2177        // through so the caller's main loop can observe it.
2178        let server = NodeId::from("nav".to_string());
2179        let event = make_input_event("feedback", goal_params("goal-1", None));
2180        assert_eq!(
2181            classify_correlation_event(&event, &server, is_action_result_match("goal-1")),
2182            CorrelationOutcome::Passthrough
2183        );
2184    }
2185
2186    #[test]
2187    fn classify_action_result_for_different_goal_is_passthrough() {
2188        let server = NodeId::from("nav".to_string());
2189        let event = make_input_event("result", goal_params("goal-2", Some(GOAL_STATUS_SUCCEEDED)));
2190        assert_eq!(
2191            classify_correlation_event(&event, &server, is_action_result_match("goal-1")),
2192            CorrelationOutcome::Passthrough
2193        );
2194    }
2195
2196    // ---- dora-rs/adora#172: pending_passthrough integration ----
2197
2198    use crate::integration_testing::{
2199        IntegrationTestInput, TestingInput, TestingOptions, TestingOutput,
2200        integration_testing_format::{IncomingEvent, TimedIncomingEvent},
2201    };
2202
2203    /// Create a minimal EventStream via the testing path.
2204    fn test_event_stream() -> (crate::DoraNode, EventStream) {
2205        let events = vec![TimedIncomingEvent {
2206            time_offset_secs: 0.0,
2207            event: IncomingEvent::Stop,
2208        }];
2209        let inputs = TestingInput::Input(IntegrationTestInput::new(
2210            "test-node".parse().unwrap(),
2211            events,
2212        ));
2213        let (tx, _rx) = flume::unbounded();
2214        let outputs = TestingOutput::ToChannel(tx);
2215        let options = TestingOptions {
2216            skip_output_time_offsets: true,
2217        };
2218        crate::DoraNode::init_testing(inputs, outputs, options).unwrap()
2219    }
2220
2221    #[test]
2222    fn is_empty_reflects_pending_passthrough() {
2223        let (_node, mut events) = test_event_stream();
2224        // Drain the initial Stop event so the stream is empty.
2225        let _ = events.recv();
2226        assert!(events.is_empty(), "should be empty after draining");
2227
2228        // Inject a passthrough event — is_empty must now return false.
2229        events.push_passthrough_for_testing(Event::ParamDeleted {
2230            key: "k".to_string(),
2231        });
2232        assert!(
2233            !events.is_empty(),
2234            "should not be empty with pending passthrough"
2235        );
2236    }
2237
2238    #[test]
2239    fn stream_poll_next_drains_pending_passthrough() {
2240        use futures::StreamExt;
2241        let (_node, mut events) = test_event_stream();
2242        // Drain the initial Stop event.
2243        let _ = events.recv();
2244
2245        // Inject a passthrough event.
2246        events.push_passthrough_for_testing(Event::ParamUpdate {
2247            key: "threshold".to_string(),
2248            value: serde_json::json!(42),
2249        });
2250
2251        // StreamExt::next() should return the passthrough event, not
2252        // block waiting on the underlying receiver.
2253        let next = futures::executor::block_on(events.next());
2254        match next {
2255            Some(Event::ParamUpdate { key, value }) => {
2256                assert_eq!(key, "threshold");
2257                assert_eq!(value, serde_json::json!(42));
2258            }
2259            other => panic!("expected ParamUpdate from passthrough, got {other:?}"),
2260        }
2261    }
2262
2263    #[test]
2264    fn recv_async_drains_pending_passthrough_before_receiver() {
2265        let (_node, mut events) = test_event_stream();
2266
2267        // Inject a passthrough event BEFORE the Stop in the receiver.
2268        events.push_passthrough_for_testing(Event::ParamDeleted {
2269            key: "x".to_string(),
2270        });
2271
2272        // First recv should return the passthrough event.
2273        let first = events.recv();
2274        assert!(
2275            matches!(first, Some(Event::ParamDeleted { .. })),
2276            "expected passthrough ParamDeleted first, got {first:?}"
2277        );
2278
2279        // Second recv should return the Stop from the receiver.
2280        let second = events.recv();
2281        assert!(
2282            matches!(second, Some(Event::Stop(_))),
2283            "expected Stop second, got {second:?}"
2284        );
2285    }
2286
2287    /// After a `Stop` event is delivered, subsequent `recv` calls must
2288    /// return `None` so the node can exit cleanly even when zenoh
2289    /// subscriber threads still hold clones of the event channel
2290    /// sender (which would otherwise keep the receiver open).
2291    #[test]
2292    fn recv_returns_none_after_stop() {
2293        let (_node, mut events) = test_event_stream();
2294
2295        // First recv delivers the seeded Stop.
2296        let first = events.recv();
2297        assert!(matches!(first, Some(Event::Stop(_))));
2298
2299        // Second recv must return None even though the underlying
2300        // receiver may still have live senders.
2301        let second = events.recv();
2302        assert!(
2303            second.is_none(),
2304            "recv must return None after Stop, got {second:?}"
2305        );
2306    }
2307
2308    /// #2027: the scheduler gives `Stop` (a NON_INPUT_EVENT) strict priority
2309    /// over buffered inputs, so an input enqueued before `Stop` is still in the
2310    /// scheduler when `Stop` is delivered. `recv` must drain that input before
2311    /// closing rather than dropping it silently (the previous `return None`
2312    /// after `stop_received` lost it).
2313    #[test]
2314    fn recv_drains_buffered_scheduler_inputs_after_stop() {
2315        let (_node, mut events) = test_event_stream();
2316
2317        // Deliver the seeded Stop (sets `stop_received`).
2318        assert!(matches!(events.recv(), Some(Event::Stop(_))));
2319
2320        // Simulate the input the scheduler held back behind the prioritized Stop.
2321        events.push_scheduler_input_for_testing("cam");
2322
2323        let drained = events.recv();
2324        assert!(
2325            matches!(&drained, Some(Event::Input { id, .. }) if id.as_str() == "cam"),
2326            "buffered input must be drained after Stop, got {drained:?}"
2327        );
2328
2329        // Once the scheduler is empty the stream closes.
2330        assert!(
2331            events.recv().is_none(),
2332            "stream must close after draining buffered inputs"
2333        );
2334    }
2335
2336    /// #2027 review (P2): the post-Stop drain must deliver buffered *inputs*
2337    /// only. A non-input control event buffered behind Stop (e.g. a second
2338    /// `Stop`) must NOT be re-delivered to a loop-until-`None` caller.
2339    #[test]
2340    fn recv_after_stop_skips_trailing_control_events() {
2341        let (_node, mut events) = test_event_stream();
2342
2343        // Deliver the seeded Stop (sets `stop_received`).
2344        assert!(matches!(events.recv(), Some(Event::Stop(_))));
2345
2346        // Buffer a trailing Stop AND a real input behind it. The scheduler
2347        // prioritizes the Stop (NON_INPUT), so the drain meets it first.
2348        events.push_scheduler_stop_for_testing();
2349        events.push_scheduler_input_for_testing("cam");
2350
2351        // The drain must skip the trailing Stop and return only the input...
2352        let drained = events.recv();
2353        assert!(
2354            matches!(&drained, Some(Event::Input { id, .. }) if id.as_str() == "cam"),
2355            "expected the buffered input, not a re-delivered Stop, got {drained:?}"
2356        );
2357        // ...then close (no second Stop ever surfaces).
2358        assert!(events.recv().is_none(), "stream must close after the input");
2359    }
2360
2361    /// The zenoh receive path is Arrow-IPC-only. An empty payload is a
2362    /// metadata-only message and maps to the unit array; a non-empty payload is
2363    /// a self-describing IPC stream and round-trips to its original array (with
2364    /// no type sidecar involved).
2365    #[test]
2366    fn zenoh_payload_ipc_roundtrip_and_empty_is_unit() {
2367        use crate::arrow_utils::ipc_encode::{InputDecoder, encode_ipc_into, ipc_fast_path_len};
2368        use arrow::array::{Array, Int32Array};
2369
2370        // A standalone full stream (no SCHEMA_HASH parameter) decodes directly.
2371        let metadata =
2372            dora_message::metadata::Metadata::new(dora_core::uhlc::HLC::default().new_timestamp());
2373        let mut decoder = InputDecoder::new();
2374
2375        // Empty payload -> unit array (metadata-only message).
2376        let unit = decode_zenoh_sample(&mut decoder, &metadata, zenoh::bytes::ZBytes::new())
2377            .unwrap()
2378            .unwrap();
2379        assert_eq!(
2380            unit.data_type(),
2381            &arrow_schema::DataType::Null,
2382            "empty payload maps to the unit array"
2383        );
2384
2385        // Non-empty IPC payload round-trips to the original array.
2386        let data = Int32Array::from(vec![10, 20, 30]).into_data();
2387        let len = ipc_fast_path_len(&data).expect("primitive is fast-path eligible");
2388        let mut buf = vec![0u8; len];
2389        encode_ipc_into(&data, &mut buf).unwrap();
2390
2391        let decoded = decode_zenoh_sample(&mut decoder, &metadata, zenoh::bytes::ZBytes::from(buf))
2392            .unwrap()
2393            .unwrap();
2394        assert_eq!(decoded.data_type(), &arrow_schema::DataType::Int32);
2395        assert_eq!(&decoded, &data);
2396    }
2397
2398    /// A full self-describing stream on the data topic must prime the per-input
2399    /// decoder in-band: the schema-less batches that follow decode against it
2400    /// without any `@schema`-plane delivery. This is what makes the producer's
2401    /// "full stream until the schema is confirmed published" strategy race-free
2402    /// — data-plane puts share one publisher, so zenoh preserves their order,
2403    /// while the separate `@schema` plane can lose the race with an express
2404    /// batch (dora-rs/dora#2366 review: first-message QoS race).
2405    #[test]
2406    fn full_stream_primes_decoder_in_band_for_schema_less_batches() {
2407        use crate::arrow_utils::ipc_encode::{
2408            InputDecoder, batch_fast_path_len, encode_batch_into, encode_ipc_into,
2409            ipc_fast_path_len, schema_block_len,
2410        };
2411        use arrow::array::{Array, Int32Array};
2412        use dora_message::metadata::{Metadata, Parameter, SCHEMA_HASH};
2413
2414        let hlc = dora_core::uhlc::HLC::default();
2415        let mut decoder = InputDecoder::new();
2416
2417        // Message 1: full stream (no SCHEMA_HASH), as the producer sends while
2418        // the schema is not yet confirmed published on the `@schema` plane.
2419        let first = Int32Array::from(vec![1, 2]).into_data();
2420        let mut full = vec![0u8; ipc_fast_path_len(&first).unwrap()];
2421        encode_ipc_into(&first, &mut full).unwrap();
2422        let block = schema_block_len(&full).unwrap();
2423        let hash = dora_message::metadata::fnv1a(&full[..block]);
2424
2425        let plain = Metadata::new(hlc.new_timestamp());
2426        let got = decode_zenoh_sample(&mut decoder, &plain, zenoh::bytes::ZBytes::from(full))
2427            .unwrap()
2428            .unwrap();
2429        assert_eq!(&got, &first);
2430
2431        // Message 2: schema-less batch tagged with the schema hash. No
2432        // `set_schema` call happened — decoding must succeed purely from the
2433        // in-band priming above.
2434        let second = Int32Array::from(vec![3]).into_data();
2435        let mut batch = vec![0u8; batch_fast_path_len(&second).unwrap()];
2436        encode_batch_into(&second, &mut batch).unwrap();
2437        let mut tagged = Metadata::new(hlc.new_timestamp());
2438        tagged
2439            .parameters
2440            .insert(SCHEMA_HASH.to_string(), Parameter::Integer(hash as i64));
2441
2442        let got = decode_zenoh_sample(&mut decoder, &tagged, zenoh::bytes::ZBytes::from(batch))
2443            .unwrap()
2444            .expect("schema-less batch must decode against the in-band-primed decoder");
2445        assert_eq!(&got, &second);
2446    }
2447
2448    /// Service/action full streams (pattern-correlated) are excluded from
2449    /// schema-once and must NOT prime the decoder in-band: a server multiplexes
2450    /// per-request schemas through one output, and letting those prime the
2451    /// decoder would churn the retained schema set for no benefit.
2452    #[test]
2453    fn pattern_correlated_full_stream_does_not_prime_in_band() {
2454        use crate::arrow_utils::ipc_encode::{
2455            InputDecoder, batch_fast_path_len, encode_batch_into, encode_ipc_into,
2456            ipc_fast_path_len, schema_block_len,
2457        };
2458        use arrow::array::{Array, Int32Array};
2459        use dora_message::metadata::{Metadata, Parameter, REQUEST_ID, SCHEMA_HASH};
2460
2461        let hlc = dora_core::uhlc::HLC::default();
2462        let mut decoder = InputDecoder::new();
2463
2464        let reply = Int32Array::from(vec![7]).into_data();
2465        let mut full = vec![0u8; ipc_fast_path_len(&reply).unwrap()];
2466        encode_ipc_into(&reply, &mut full).unwrap();
2467        let block = schema_block_len(&full).unwrap();
2468        let hash = dora_message::metadata::fnv1a(&full[..block]);
2469
2470        // A service reply (request_id) decodes standalone…
2471        let mut service = Metadata::new(hlc.new_timestamp());
2472        service
2473            .parameters
2474            .insert(REQUEST_ID.to_string(), Parameter::String("req-1".into()));
2475        let got = decode_zenoh_sample(&mut decoder, &service, zenoh::bytes::ZBytes::from(full))
2476            .unwrap()
2477            .unwrap();
2478        assert_eq!(&got, &reply);
2479
2480        // …but must not have primed the decoder for its schema hash.
2481        let batch_array = Int32Array::from(vec![8]).into_data();
2482        let mut batch = vec![0u8; batch_fast_path_len(&batch_array).unwrap()];
2483        encode_batch_into(&batch_array, &mut batch).unwrap();
2484        let mut tagged = Metadata::new(hlc.new_timestamp());
2485        tagged
2486            .parameters
2487            .insert(SCHEMA_HASH.to_string(), Parameter::Integer(hash as i64));
2488        assert!(
2489            decode_zenoh_sample(&mut decoder, &tagged, zenoh::bytes::ZBytes::from(batch))
2490                .unwrap()
2491                .is_none(),
2492            "a pattern-correlated stream must not prime the schema-once decoder"
2493        );
2494    }
2495
2496    /// Internal wire-protocol keys (`_schema_hash`, `_framing`) must be
2497    /// stripped from the metadata handed to user code — on both the zenoh and
2498    /// the daemon receive paths. A forwarded `_schema_hash` would otherwise
2499    /// ride onto a large/service output (which does not overwrite it) and make
2500    /// receivers hash-mismatch and silently drop the message
2501    /// (dora-rs/dora#2366 review).
2502    #[test]
2503    fn internal_wire_keys_are_stripped_from_user_visible_metadata() {
2504        use dora_message::metadata::{
2505            FRAMING, FRAMING_ARROW_IPC, Metadata, Parameter, SCHEMA_HASH,
2506        };
2507
2508        let hlc = dora_core::uhlc::HLC::default();
2509        let mut metadata = Metadata::new(hlc.new_timestamp());
2510        metadata
2511            .parameters
2512            .insert(SCHEMA_HASH.to_string(), Parameter::Integer(42));
2513        metadata.parameters.insert(
2514            FRAMING.to_string(),
2515            Parameter::String(FRAMING_ARROW_IPC.to_string()),
2516        );
2517        metadata
2518            .parameters
2519            .insert("user_key".to_string(), Parameter::Integer(7));
2520
2521        // Zenoh receive path.
2522        let zenoh_item = EventItem::ZenohInput {
2523            id: DataId::from("in".to_string()),
2524            metadata: Arc::new(metadata.clone()),
2525            data: {
2526                use arrow::array::Array;
2527                arrow::array::Int32Array::from(vec![1]).into_data()
2528            },
2529        };
2530        let Event::Input {
2531            metadata: user_metadata,
2532            ..
2533        } = EventStream::convert_event_item(zenoh_item)
2534        else {
2535            panic!("expected an input event");
2536        };
2537        assert!(!user_metadata.parameters.contains_key(SCHEMA_HASH));
2538        assert!(!user_metadata.parameters.contains_key(FRAMING));
2539        assert_eq!(
2540            user_metadata.parameters.get("user_key"),
2541            Some(&Parameter::Integer(7)),
2542            "user-provided keys must survive the strip"
2543        );
2544
2545        // Daemon receive path.
2546        let daemon_item = EventItem::NodeEvent {
2547            event: dora_message::daemon_to_node::NodeEvent::Input {
2548                id: DataId::from("in".to_string()),
2549                metadata: Arc::new(metadata),
2550                data: None,
2551            },
2552        };
2553        let Event::Input {
2554            metadata: user_metadata,
2555            ..
2556        } = EventStream::convert_event_item(daemon_item)
2557        else {
2558            panic!("expected an input event");
2559        };
2560        assert!(!user_metadata.parameters.contains_key(SCHEMA_HASH));
2561        assert!(!user_metadata.parameters.contains_key(FRAMING));
2562    }
2563
2564    /// The schema-plane FatalError only fires after the grace window: the
2565    /// producer's periodic full-stream refresh heals an unprimed input in-band,
2566    /// so a node with a dead `@schema` plane must not be killed on the first
2567    /// dropped batch when it would recover within seconds.
2568    #[test]
2569    fn schema_plane_fatal_waits_out_the_grace_window() {
2570        let start = Instant::now();
2571        let mut first = None;
2572
2573        // First undecodable batch starts the window — not fatal yet.
2574        assert!(!schema_plane_fatal_due(&mut first, start));
2575        assert_eq!(first, Some(start));
2576        // Still inside the grace window — not fatal.
2577        assert!(!schema_plane_fatal_due(
2578            &mut first,
2579            start + SCHEMA_PLANE_FATAL_GRACE / 2
2580        ));
2581        // Past the window — fatal.
2582        assert!(schema_plane_fatal_due(
2583            &mut first,
2584            start + SCHEMA_PLANE_FATAL_GRACE
2585        ));
2586
2587        // A successful decode clears the window (caller side); the next drop
2588        // starts a fresh one.
2589        let mut first = None;
2590        let later = start + SCHEMA_PLANE_FATAL_GRACE * 2;
2591        assert!(!schema_plane_fatal_due(&mut first, later));
2592        assert_eq!(first, Some(later));
2593    }
2594
2595    /// Same invariant as `recv_returns_none_after_stop`, verified via
2596    /// the `Stream` impl (`StreamExt::next`).
2597    #[test]
2598    fn stream_returns_none_after_stop() {
2599        use futures::StreamExt;
2600        let (_node, mut events) = test_event_stream();
2601
2602        let first = futures::executor::block_on(events.next());
2603        assert!(matches!(first, Some(Event::Stop(_))));
2604
2605        let second = futures::executor::block_on(events.next());
2606        assert!(
2607            second.is_none(),
2608            "Stream::next must yield None after Stop, got {second:?}"
2609        );
2610    }
2611}