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::{
6        Arc,
7        atomic::{AtomicBool, AtomicU64, Ordering},
8    },
9    time::{Duration, Instant},
10};
11
12use dora_message::{
13    DataflowId,
14    daemon_to_node::{DaemonCommunication, DaemonReply, DataMessage, NodeEvent},
15    id::DataId,
16    node_to_daemon::{DaemonRequest, Timestamped},
17};
18pub use event::{Event, StopCause};
19use futures::{
20    FutureExt, Stream,
21    future::{Either, select},
22};
23use futures_timer::Delay;
24use scheduler::{NON_INPUT_EVENT, NON_INPUT_EVENT_QUEUE_SIZE, Scheduler};
25
26use self::thread::{EventItem, EventStreamThreadHandle};
27use crate::{
28    DaemonCommunicationWrapper, PatternError,
29    daemon_connection::{
30        DaemonChannel,
31        node_integration_testing::{convert_arrow_input_to_json, convert_output_to_json},
32    },
33    event_stream::data_conversion::RawData,
34    node::{ZENOH_TEARDOWN_TIMEOUT, teardown_with_timeout},
35};
36use dora_arrow_convert::IntoArrow;
37use dora_core::{
38    config::{Input, NodeId},
39    uhlc,
40};
41use eyre::{Context, eyre};
42
43pub use scheduler::Scheduler as EventScheduler;
44
45mod data_conversion;
46mod event;
47/// Drop notifications for the daemon's opaque extension table.
48pub mod extensions;
49/// Tracks input health (timeouts, liveness) for circuit-breaker recovery.
50pub mod input_tracker;
51/// Merged event streams combining internal and external event sources.
52pub mod merged;
53mod scheduler;
54mod thread;
55
56/// Hand one zenoh-delivered event to the shared ingress channel, counting it as
57/// a drop if the channel is full.
58///
59/// The callback runs on zenoh's tokio IO worker, where `blocking_send` would
60/// panic, so the send is a `try_send` and a full channel means the payload is
61/// gone. That loss used to be log-only, which made `drain_drop_counts()` read
62/// zero while a node was losing every zero-copy message it could not keep up
63/// with (#3282).
64///
65/// A *closed* channel is not counted: it means the receiver is gone, i.e.
66/// ordinary shutdown, and counting it would have every node report phantom
67/// drops as it tears down.
68///
69/// Split out of the callback so the accounting is unit-testable without a live
70/// zenoh session.
71fn send_or_count_ingress_drop(
72    tx: &tokio::sync::mpsc::Sender<EventItem>,
73    item: EventItem,
74    input_id: &DataId,
75    dropped: &AtomicU64,
76) {
77    use tokio::sync::mpsc::error::TrySendError;
78    match tx.try_send(item) {
79        Ok(()) => {}
80        Err(TrySendError::Full(_)) => {
81            // Rate-limit the log, but not on `drain_drop_counts()`: a formatted
82            // `warn!` with a subscriber attached measures ~1.6 us against ~1.7 ns
83            // for the counter, and this runs on zenoh's IO worker — the thread
84            // delivering every input of this node — so logging per drop would
85            // amplify the very backlog it reports. Earlier this fired only on the
86            // first drop since a `drain_drop_counts()` swap reset the counter, but
87            // the only production caller that drains is the record node (#3555):
88            // every other node warned once at the first drop and then went silent
89            // forever, however long it kept losing input.
90            //
91            // `fetch_add` returns the previous value; warn whenever the running
92            // count is a power of two (1, 2, 4, 8, …). That is drain-independent,
93            // so the message recurs while loss continues for a node that never
94            // drains, yet only O(log N) times for N drops so the log cost stays
95            // bounded. A node that *does* drain resets the counter each window,
96            // restoring the once-per-drain-window cadence. The count itself is
97            // never rate-limited — that is what the counter is for.
98            let count = dropped.fetch_add(1, Ordering::Relaxed) + 1;
99            if should_warn_ingress_drop(count) {
100                tracing::warn!(
101                    input = %input_id,
102                    drops = count,
103                    "event channel full; dropping zenoh input. Raise this input's \
104                     queue_size — the ingress channel is sized from the sum of the \
105                     node's input queue_sizes."
106                );
107            }
108        }
109        Err(TrySendError::Closed(_)) => {
110            // Normal shutdown: the receiver is gone, nothing to report.
111        }
112    }
113}
114
115/// Whether a zenoh ingress drop that brings the running drop count to `count`
116/// should be logged.
117///
118/// Power-of-two cadence (1, 2, 4, 8, …): drain-independent so it recurs while a
119/// node keeps losing input, but only O(log N) times for N drops so the log cost
120/// on the zenoh IO worker stays bounded. See `send_or_count_ingress_drop`.
121fn should_warn_ingress_drop(count: u64) -> bool {
122    count.is_power_of_two()
123}
124
125/// Asynchronous iterator over the incoming [`Event`]s destined for this node.
126///
127/// This struct [implements](#impl-Stream-for-EventStream) the [`Stream`] trait,
128/// so you can use methods of the [`StreamExt`](futures::StreamExt) trait
129/// on this struct. A common pattern is `while let Some(event) = event_stream.next().await`.
130///
131/// Nodes should iterate over this event stream and react to events that they are interested in.
132/// Typically, the most important event type is [`Event::Input`].
133/// You don't need to handle all events, it's fine to ignore events that are not relevant to your node.
134///
135/// The event stream will close itself after a [`Event::Stop`] was received.
136/// A manual `break` on [`Event::Stop`] is typically not needed.
137/// _(You probably do need to use a manual `break` on stop events when using the
138/// [`StreamExt::merge`][`futures_concurrency::stream::StreamExt::merge`] implementation on
139/// [`EventStream`] to combine the stream with an external one.)_
140///
141/// Once the event stream finished, nodes should exit.
142/// Note that Dora kills nodes that don't exit quickly after a [`Event::Stop`] of type
143/// [`StopCause::Manual`] was received.
144pub struct EventStream {
145    node_id: NodeId,
146    // Drop order: Rust drops fields in declaration order (top to bottom).
147    // receiver must drop FIRST so tx_clone.send() in subscriber threads
148    // returns Err, causing threads to exit before JoinHandles are dropped.
149    // Using `tokio::sync::mpsc::Receiver` — instead of `flume` — avoids the
150    // AB-BA deadlock between flume 0.10's spinlock and pyo3's GIL-acquiring
151    // waker when a Python coroutine polls this stream
152    // (upstream dora-rs/dora#1603).
153    receiver: tokio::sync::mpsc::Receiver<EventItem>,
154    _thread_handle: EventStreamThreadHandle,
155    /// Callback subscribers — kept alive for the lifetime of the
156    /// EventStream. Dropping a subscriber undeclares it and stops further
157    /// callbacks. The callbacks use `try_send` (never block), so undeclaring
158    /// does not depend on `receiver` being dropped first. `EventStream::drop`
159    /// explicitly tears these down under a deadline (see there); by the time
160    /// this field drops it is already empty.
161    _zenoh_subscribers: Vec<zenoh::pubsub::Subscriber<()>>,
162    /// Per-input `@schema` subscribers (zenoh-ext AdvancedSubscriber) that prime
163    /// the data subscribers' decoders. Kept alive like `_zenoh_subscribers`.
164    _zenoh_schema_subscribers: Vec<zenoh_ext::AdvancedSubscriber<()>>,
165    /// The `dora-startup-acker` thread (see [`spawn_startup_acker`]). Exits
166    /// once the data subscribers — the only senders into its queue — are
167    /// dropped; joined in `EventStream::drop` inside the bounded zenoh
168    /// teardown, right after those subscribers are dropped.
169    startup_acker: Option<std::thread::JoinHandle<()>>,
170    close_channel: DaemonChannel,
171    clock: Arc<uhlc::HLC>,
172    scheduler: Scheduler,
173    /// Per-input counters for events dropped at the shared zenoh ingress
174    /// channel, before the scheduler ever sees them.
175    ///
176    /// The scheduler's own counters cover only what reached its per-input
177    /// queues. A zero-copy payload arriving on the direct zenoh path is
178    /// `try_send`-ed into one shared channel by a zenoh IO worker, and a full
179    /// channel drops it there — a site the scheduler cannot observe and, being
180    /// on another thread, cannot be given `&mut Scheduler` to report through.
181    /// One `AtomicU64` per input keeps the callback lock-free; `drain_drop_counts`
182    /// folds them in so callers see one number per input regardless of which
183    /// transport the payload took (#3282).
184    ingress_drops: HashMap<DataId, Arc<AtomicU64>>,
185    write_events_to: Option<WriteEventsTo>,
186    start_timestamp: uhlc::Timestamp,
187    use_scheduler: bool,
188    /// Set whenever an event is added to `scheduler`; cleared by `poll_next`
189    /// once the scheduler has run dry. Lets the Stream path skip
190    /// `Scheduler::next()` (O(#inputs) when empty) on every poll when nothing
191    /// was ever buffered there.
192    scheduler_maybe_nonempty: bool,
193    /// Expected input types from YAML descriptor (for first-message validation).
194    /// Each input is checked once; after the first message, the entry is removed.
195    input_type_checks: HashMap<DataId, arrow_schema::DataType>,
196    /// Events that were consumed by a pattern-aware helper
197    /// (`recv_service_response`, `recv_action_result`) while searching
198    /// for a correlation match. Drained first on the next `recv()` so
199    /// the caller's main event loop never loses intermediate events
200    /// (dora-rs/adora#148).
201    ///
202    /// Bounded via [`buffer_passthrough`](Self::buffer_passthrough): an event a
203    /// correlated wait pulls out of the scheduler and stashes here still counts
204    /// against a bound — `Event::Input`s against their input's `queue_size` /
205    /// `queue_policy`, control events against a shared non-input cap — so the
206    /// retention cannot grow without limit for a node that never reads its
207    /// events, and per-input queue policy means the same thing whether or not a
208    /// correlated receive touched the event (dora-rs/dora#3197).
209    pending_passthrough: std::collections::VecDeque<Event>,
210    /// Set to true after an `Event::Stop` has been delivered. Zenoh
211    /// subscriber threads hold clones of the event channel sender, so
212    /// the daemon thread's sender drop alone is not enough to close
213    /// the receiver — subsequent `recv_async`/`poll_next` would hang.
214    /// Returning `None` here lets the caller exit and drops the
215    /// `EventStream`, which signals subscriber shutdown.
216    stop_received: bool,
217    /// Testing-mode shutdown flag shared with the in-process daemon thread.
218    /// Set in [`Drop`] before `EventStreamDropped` so a scheduled `next_event`
219    /// sleep cannot deadlock the close handshake (dora-rs/dora#2855).
220    testing_shutdown: Option<Arc<AtomicBool>>,
221}
222
223/// Spawn the consumer half of the startup handshake: a thread that answers
224/// every startup marker with an ack on the marked output's `@ack` topic.
225///
226/// The data callbacks `try_send` the input id of each received marker into
227/// `ack_rx` (a zenoh callback must never `put` itself — it runs on zenoh's IO
228/// worker); this thread publishes the corresponding ack, identifying this
229/// (node, input) in the attachment. The producer switches the output from the
230/// lossless daemon path to direct zenoh once all its required consumers acked.
231///
232/// The thread acks *every* marker for the stream's whole lifetime, so late
233/// producers (dynamic nodes, restarts) get their acks whenever they run their
234/// handshake. Note that the producer's markers are **not** unbounded: it stops
235/// marking an output at its startup grace boundary and pins any still-un-acked
236/// output to the daemon path for the rest of its run (dora-rs/dora#2891). So
237/// acking promptly is what preserves the fast path — an ack-route race that
238/// outlasts the producer's grace does not self-heal, and a dropped ack (the
239/// bounded-queue `try_send` fallback below) costs that output direct zenoh for
240/// the producer's whole run. The thread exits when the data subscribers (the
241/// only senders) are dropped.
242fn spawn_startup_acker(
243    node_id: NodeId,
244    ack_publishers: HashMap<DataId, zenoh::pubsub::Publisher<'static>>,
245    mut ack_rx: tokio::sync::mpsc::Receiver<DataId>,
246    clock: Arc<uhlc::HLC>,
247) -> Option<std::thread::JoinHandle<()>> {
248    use dora_message::metadata::Metadata;
249    use zenoh::Wait;
250
251    if ack_publishers.is_empty() {
252        return None;
253    }
254    let handle = std::thread::Builder::new()
255        .name("dora-startup-acker".into())
256        .spawn(move || {
257            while let Some(input_id) = ack_rx.blocking_recv() {
258                let Some(publisher) = ack_publishers.get(&input_id) else {
259                    continue;
260                };
261                let metadata = Metadata::startup_ack(
262                    clock.new_timestamp(),
263                    node_id.as_ref(),
264                    input_id.as_ref(),
265                );
266                let attachment = match dora_message::encode(&metadata) {
267                    Ok(bytes) => bytes,
268                    Err(e) => {
269                        tracing::debug!(input = %input_id, "failed to serialize startup ack ({e})");
270                        continue;
271                    }
272                };
273                if let Err(e) = publisher.put(&[][..]).attachment(&attachment[..]).wait() {
274                    // Expected while the ack route is still coming up; the
275                    // producer's next marker triggers a retry.
276                    tracing::trace!(input = %input_id, "startup ack put failed ({e})");
277                }
278            }
279        });
280    match handle {
281        Ok(handle) => Some(handle),
282        Err(e) => {
283            // Without acks the producers keep this node's inputs on the
284            // reliable daemon path — correct, just without the fast path.
285            tracing::warn!(
286                "failed to spawn startup-acker thread ({e}); \
287                 producers keep this node's inputs on the daemon path"
288            );
289            None
290        }
291    }
292}
293
294/// Open a fresh [`DaemonChannel`] to the daemon this node talks to.
295///
296/// `purpose` names the channel in the connection-error context (e.g. `"event
297/// stream"`), so a failure points at which of the node's channels could not be
298/// established.
299fn connect_daemon_channel(
300    daemon_communication: &DaemonCommunicationWrapper,
301    node_id: &NodeId,
302    purpose: &str,
303) -> eyre::Result<DaemonChannel> {
304    let channel = match daemon_communication {
305        DaemonCommunicationWrapper::Standard(daemon_communication) => match daemon_communication {
306            DaemonCommunication::Tcp { socket_addr } => DaemonChannel::new_tcp(*socket_addr)
307                .wrap_err_with(|| format!("failed to connect {purpose} for node `{node_id}`"))?,
308            DaemonCommunication::Interactive => DaemonChannel::Interactive(Default::default()),
309        },
310        DaemonCommunicationWrapper::Testing { channel, .. } => {
311            DaemonChannel::IntegrationTestChannel(channel.clone())
312        }
313    };
314    Ok(channel)
315}
316
317/// Capacity of a node's ingress event channel: the sum of its inputs'
318/// `queue_size`s, at least 64.
319///
320/// `queue_size` is unvalidated, so the sum saturates and is clamped to the
321/// `usize::MAX >> 3` limit above which tokio's `mpsc::channel` panics. The
322/// channel allocates lazily, so a huge capacity costs nothing up front.
323fn ingress_channel_capacity<'a>(inputs: impl Iterator<Item = &'a Input>) -> usize {
324    const MAX_CHANNEL_CAPACITY: usize = usize::MAX >> 3;
325    inputs
326        .map(|c| {
327            c.queue_size
328                .unwrap_or(dora_message::config::DEFAULT_QUEUE_SIZE)
329        })
330        .fold(0, usize::saturating_add)
331        .clamp(64, MAX_CHANNEL_CAPACITY)
332}
333
334impl EventStream {
335    #[allow(clippy::too_many_arguments)]
336    #[tracing::instrument(level = "trace", skip(clock, zenoh_session))]
337    pub(crate) fn init(
338        dataflow_id: DataflowId,
339        node_id: &NodeId,
340        daemon_communication: &DaemonCommunicationWrapper,
341        input_config: BTreeMap<DataId, Input>,
342        input_types: &BTreeMap<DataId, String>,
343        clock: Arc<uhlc::HLC>,
344        write_events_to: Option<PathBuf>,
345        zenoh_session: Option<&zenoh::Session>,
346    ) -> eyre::Result<Self> {
347        let channel = connect_daemon_channel(daemon_communication, node_id, "event stream")?;
348
349        let testing_shutdown = match daemon_communication {
350            DaemonCommunicationWrapper::Testing { shutdown, .. } => Some(shutdown.clone()),
351            _ => None,
352        };
353
354        let close_channel =
355            connect_daemon_channel(daemon_communication, node_id, "event close channel")?;
356
357        let mut queue_size_limit: HashMap<DataId, (usize, VecDeque<EventItem>)> = input_config
358            .iter()
359            .map(|(input, config)| {
360                (
361                    input.clone(),
362                    (
363                        config
364                            .queue_size
365                            .unwrap_or(dora_message::config::DEFAULT_QUEUE_SIZE),
366                        VecDeque::new(),
367                    ),
368                )
369            })
370            .collect();
371
372        queue_size_limit.insert(
373            DataId::from(NON_INPUT_EVENT.to_string()),
374            (NON_INPUT_EVENT_QUEUE_SIZE, VecDeque::new()),
375        );
376
377        let queue_policies: HashMap<DataId, dora_message::config::QueuePolicy> = input_config
378            .iter()
379            .filter_map(|(input, config)| config.queue_policy.map(|p| (input.clone(), p)))
380            .collect();
381
382        let scheduler = Scheduler::with_policies(queue_size_limit, queue_policies);
383
384        let total_queue_capacity = ingress_channel_capacity(input_config.values());
385
386        let write_events_to = match write_events_to {
387            Some(path) => {
388                if let Some(parent) = path.parent() {
389                    std::fs::create_dir_all(parent).wrap_err_with(|| {
390                        format!(
391                            "failed to create parent directories for event output file `{}` for node `{}`",
392                            path.display(),
393                            node_id
394                        )
395                    })?;
396                }
397
398                let file = std::fs::File::create(&path).wrap_err_with(|| {
399                    format!(
400                        "failed to create event output file `{}` for node `{}`",
401                        path.display(),
402                        node_id
403                    )
404                })?;
405
406                Some(WriteEventsTo {
407                    node_id: node_id.clone(),
408                    file,
409                    events_buffer: Vec::new(),
410                    poisoned: None,
411                })
412            }
413            None => None,
414        };
415
416        // Resolve input type URNs to Arrow DataTypes for first-message validation.
417        let mut input_type_checks = HashMap::new();
418        {
419            let registry = dora_core::types::TypeRegistry::new();
420            for (input_id, type_urn) in input_types {
421                match registry.resolve_arrow_type(type_urn) {
422                    Some(dt) => {
423                        input_type_checks.insert(input_id.clone(), dt);
424                    }
425                    None => {
426                        // Complex or custom types not resolvable to a simple Arrow DataType
427                        if registry.resolve(type_urn).is_some() {
428                            tracing::debug!(
429                                input = %input_id,
430                                "skipping type check for complex type \"{type_urn}\""
431                            );
432                        } else {
433                            tracing::warn!(
434                                input = %input_id,
435                                "unknown input type URN \"{type_urn}\" — skipping type check"
436                            );
437                        }
438                    }
439                }
440            }
441        }
442
443        Self::init_on_channel(
444            dataflow_id,
445            node_id,
446            channel,
447            close_channel,
448            clock,
449            scheduler,
450            write_events_to,
451            input_type_checks,
452            total_queue_capacity,
453            zenoh_session,
454            &input_config,
455            testing_shutdown,
456        )
457    }
458
459    #[allow(clippy::too_many_arguments)]
460    pub(crate) fn init_on_channel(
461        dataflow_id: DataflowId,
462        node_id: &NodeId,
463        mut channel: DaemonChannel,
464        mut close_channel: DaemonChannel,
465        clock: Arc<uhlc::HLC>,
466        scheduler: Scheduler,
467        write_events_to: Option<WriteEventsTo>,
468        input_type_checks: HashMap<DataId, arrow_schema::DataType>,
469        channel_capacity: usize,
470        zenoh_session: Option<&zenoh::Session>,
471        input_config: &BTreeMap<DataId, Input>,
472        testing_shutdown: Option<Arc<AtomicBool>>,
473    ) -> eyre::Result<Self> {
474        channel.register(dataflow_id, node_id.clone(), clock.new_timestamp())?;
475        let (tx, rx) = tokio::sync::mpsc::channel(channel_capacity);
476
477        let use_scheduler = match &channel {
478            DaemonChannel::IntegrationTestChannel(_) => {
479                // don't use the scheduler for integration tests because it leads to
480                // non-deterministic event ordering
481                false
482            }
483            _ => true,
484        };
485
486        // Declare zenoh subscribers for each input that has a source node.
487        // We use callback subscribers so the zenoh IO thread delivers the
488        // sample directly into the event channel without an intermediate
489        // dora-side thread + recv_async wakeup. The `Subscriber<()>` handle
490        // must be kept alive for the lifetime of the EventStream — we store
491        // them in `_zenoh_subscribers` and drop them after `receiver`.
492        let mut zenoh_subscribers = Vec::new();
493        let mut zenoh_schema_subscribers = Vec::new();
494        // Consumer half of the startup handshake: the data callbacks enqueue
495        // the input id of every received startup marker here, and the
496        // `dora-startup-acker` thread answers each with an ack on the output's
497        // `@ack` topic (see `spawn_startup_acker`). Bounded and `try_send`-fed:
498        // a full queue just delays the ack until the producer's next marker.
499        let (ack_tx, ack_rx) = tokio::sync::mpsc::channel::<DataId>(256);
500        let mut ack_publishers: HashMap<DataId, zenoh::pubsub::Publisher<'static>> = HashMap::new();
501        // Populated per subscribed input below and moved into `EventStream`, so
502        // `drain_drop_counts` can read what the zenoh IO workers recorded.
503        let mut ingress_drops: HashMap<DataId, Arc<AtomicU64>> = HashMap::new();
504        if let Some(session) = zenoh_session {
505            use zenoh::Wait;
506            use zenoh::qos::CongestionControl;
507            for (input_id, input) in input_config {
508                let mapping = &input.mapping;
509                if let dora_message::config::InputMapping::User(user_mapping) = mapping {
510                    let source_node = &user_mapping.source;
511                    let source_output = &user_mapping.output;
512                    let topic = dora_core::topics::zenoh_output_publish_topic(
513                        dataflow_id,
514                        source_node,
515                        source_output,
516                    );
517                    let key_expr = match zenoh::key_expr::KeyExpr::new(topic.clone()) {
518                        Ok(k) => k.into_owned(),
519                        Err(e) => {
520                            tracing::warn!(input = %input_id, "invalid zenoh key ({e}), using daemon path");
521                            continue;
522                        }
523                    };
524                    // Ack publisher for this input, declared eagerly so its
525                    // route wires while the node is parked in the barrier.
526                    // `express` + `Drop` QoS: a lost ack is retried on the
527                    // producer's next marker. On failure the producer's ack
528                    // deadline keeps the output on the (correct) daemon path.
529                    let ack_topic = dora_core::topics::zenoh_output_ack_topic(
530                        dataflow_id,
531                        source_node,
532                        source_output,
533                    );
534                    match session
535                        .declare_publisher(ack_topic)
536                        .congestion_control(CongestionControl::Drop)
537                        .express(true)
538                        .wait()
539                    {
540                        Ok(publisher) => {
541                            ack_publishers.insert(input_id.clone(), publisher);
542                        }
543                        Err(e) => {
544                            tracing::warn!(
545                                input = %input_id,
546                                "failed to declare startup-ack publisher ({e}); \
547                                 the producer keeps this input on the daemon path"
548                            );
549                        }
550                    }
551                    // Per-input persistent decoder for the schema-once path,
552                    // shared between this data subscriber (which decodes batches
553                    // in zenoh receipt order) and the `@schema` subscriber (which
554                    // primes it from the cached/live schema). `Mutex` because the
555                    // zenoh callbacks are `Fn`; uncontended in practice (each
556                    // subscriber delivers its samples serially).
557                    let decoder = std::sync::Arc::new(std::sync::Mutex::new(
558                        crate::arrow_utils::ipc_encode::InputDecoder::new(),
559                    ));
560                    // Set if the `@schema` subscriber fails to declare: the
561                    // schema plane is then dead for this input and only the
562                    // producer's periodic in-band full-stream refresh can prime
563                    // the decoder. The data callback surfaces a `FatalError` if
564                    // the input stays undecodable past a grace window (see
565                    // below), distinguishing a genuinely dead input from the
566                    // transient "schema not arrived yet" drop.
567                    let schema_plane_failed =
568                        std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
569                    // Start of the current run of undecodable schema-once
570                    // batches while the schema plane is dead; cleared by any
571                    // successful decode. Only touched when `schema_plane_failed`
572                    // is set, so it costs nothing on the healthy path.
573                    let first_undecodable =
574                        std::sync::Arc::new(std::sync::Mutex::new(Option::<Instant>::None));
575                    // The `@schema` subscriber primes `decoder`; its history query
576                    // fetches the cached schema on join (late joiners), and
577                    // `detect_late_publishers` covers a producer that starts later.
578                    declare_schema_subscriber(
579                        session,
580                        dataflow_id,
581                        source_node,
582                        source_output,
583                        input_id,
584                        decoder.clone(),
585                        tx.clone(),
586                        schema_plane_failed.clone(),
587                        &mut zenoh_schema_subscribers,
588                    );
589
590                    let ack_tx_cb = ack_tx.clone();
591                    let tx_cb = tx.clone();
592                    let input_id_cb = input_id.clone();
593                    let ingress_drops_cb =
594                        ingress_drops.entry(input_id.clone()).or_default().clone();
595                    let decoder = decoder.clone();
596                    let first_undecodable_cb = first_undecodable.clone();
597                    let subscriber = session
598                        .declare_subscriber(key_expr)
599                        .callback(move |sample| {
600                            // catch_unwind: a panic inside the callback would
601                            // otherwise unwind through zenoh's IO worker, which
602                            // is unsafe. Surface as FatalError so the node sees
603                            // it and exits cleanly.
604                            let result =
605                                std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
606                                    use dora_message::metadata::Metadata;
607                                    let metadata = match sample.attachment() {
608                                        Some(att) => {
609                                            match dora_message::decode::<Metadata>(&att.to_bytes())
610                                            {
611                                                // A version mismatch that still happens
612                                                // to deserialize: reject with a clear
613                                                // message rather than acting on data from
614                                                // an incompatible wire format.
615                                                Ok(m)
616                                                    if m.metadata_version()
617                                                        != Metadata::CURRENT_VERSION =>
618                                                {
619                                                    tracing::warn!(
620                                                        "dropping zenoh sample: incompatible \
621                                                     metadata wire version {} (this node \
622                                                     speaks {})",
623                                                        m.metadata_version(),
624                                                        Metadata::CURRENT_VERSION
625                                                    );
626                                                    return;
627                                                }
628                                                Ok(m) => m,
629                                                Err(e) => {
630                                                    // A pre-1.0 peer (old ArrowTypeInfo
631                                                    // sidecar layout) misaligns here; name
632                                                    // the likely cause so the failure isn't
633                                                    // a bare deserialization error.
634                                                    tracing::warn!(
635                                                        "zenoh metadata deserialization failed \
636                                                     (possibly a peer using an incompatible \
637                                                     wire format/version): {e}"
638                                                    );
639                                                    return;
640                                                }
641                                            }
642                                        }
643                                        None => {
644                                            tracing::warn!(
645                                                "zenoh sample missing metadata attachment"
646                                            );
647                                            return;
648                                        }
649                                    };
650                                    // Startup marker: its arrival proves this input's
651                                    // zenoh route carries data end-to-end. Answer with
652                                    // an ack (via the acker thread — a zenoh callback
653                                    // must not `put` itself) so the producer can switch
654                                    // the output to the direct zenoh path, and stop — a
655                                    // marker has no payload and must never reach the
656                                    // (stateful) decoder or user code. A full ack queue
657                                    // is fine: the producer's next marker retries.
658                                    if metadata.is_startup_marker() {
659                                        let _ = ack_tx_cb.try_send(input_id_cb.clone());
660                                        return;
661                                    }
662                                    let payload = sample.payload().clone();
663                                    // Decode here (receipt order) so the per-input
664                                    // persistent decoder stays in sync. This runs on
665                                    // zenoh's IO worker: the aligned-SHM path is
666                                    // zero-copy, but an under-aligned heap payload
667                                    // copies its buffers on this thread (acceptable —
668                                    // only small messages take the heap path).
669                                    let mut decoder = decoder.lock().unwrap_or_else(|poison| {
670                                        // A prior callback panicked mid-decode
671                                        // (caught below), poisoning the lock and
672                                        // leaving the decoder in an undefined
673                                        // state. Reset it so the next batch is not
674                                        // fed into a corrupt decoder.
675                                        let mut guard = poison.into_inner();
676                                        guard.reset();
677                                        guard
678                                    });
679                                    let data =
680                                        match decode_zenoh_sample(&mut decoder, &metadata, payload)
681                                        {
682                                            Ok(Some(data)) => data,
683                                            // Schema-less batch we can't decode yet.
684                                            // Normally transient: the priming schema
685                                            // hasn't arrived (lossy data plane), so drop
686                                            // and wait — the window is bounded by the
687                                            // producer's periodic full-stream refresh,
688                                            // which re-primes in-band. But if the
689                                            // `@schema` subscriber failed to declare, the
690                                            // schema plane is dead (a degraded zenoh
691                                            // session) and only that refresh can save the
692                                            // input — give it a grace window, then surface
693                                            // a `FatalError` so the node exits loudly
694                                            // rather than drop messages forever. Only
695                                            // clear the flag once the error is actually
696                                            // queued, so a momentarily-full channel can't
697                                            // swallow the one-shot fatal signal forever; it
698                                            // retries on the next undecodable batch. This
699                                            // callback is the sole reader, so the
700                                            // load-then-store is race-free. `Relaxed` is
701                                            // sufficient: the flag is stored in
702                                            // `declare_schema_subscriber` before the data
703                                            // subscriber that drives this callback is even
704                                            // declared, so that write happens-before any
705                                            // read here.
706                                            Ok(None) => {
707                                                if schema_plane_failed
708                                                    .load(std::sync::atomic::Ordering::Relaxed)
709                                                {
710                                                    let mut first = first_undecodable_cb
711                                                        .lock()
712                                                        .unwrap_or_else(|p| p.into_inner());
713                                                    if first.is_none() {
714                                                        tracing::warn!(
715                                                            input = %input_id_cb,
716                                                            "schema-once batch arrived unprimed \
717                                                             while the `@schema` subscriber is \
718                                                             not declared; dropping, waiting up \
719                                                             to {}s for the producer's in-band \
720                                                             full-stream refresh",
721                                                            SCHEMA_PLANE_FATAL_GRACE.as_secs()
722                                                        );
723                                                    }
724                                                    if schema_plane_fatal_due(
725                                                        &mut first,
726                                                        Instant::now(),
727                                                    ) && tx_cb
728                                                        .try_send(EventItem::FatalError(eyre!(
729                                                            "input `{input_id_cb}`: the `@schema` \
730                                                             subscriber failed to declare (a \
731                                                             degraded zenoh session) and the \
732                                                             input stayed undecodable for {}s \
733                                                             despite the producer's periodic \
734                                                             full-stream refresh — messages on \
735                                                             this input are being dropped",
736                                                            SCHEMA_PLANE_FATAL_GRACE.as_secs()
737                                                        )))
738                                                        .is_ok()
739                                                    {
740                                                        schema_plane_failed.store(
741                                                            false,
742                                                            std::sync::atomic::Ordering::Relaxed,
743                                                        );
744                                                    }
745                                                }
746                                                return;
747                                            }
748                                            Err(e) => {
749                                                tracing::warn!(
750                                                    input = %input_id_cb,
751                                                    "zenoh payload decode failed: {e}"
752                                                );
753                                                return;
754                                            }
755                                        };
756                                    drop(decoder);
757                                    // A successful decode means the input is healthy
758                                    // (in-band priming worked); reset the fatal grace
759                                    // window. Only costs a lock when the schema plane
760                                    // is actually dead.
761                                    if schema_plane_failed
762                                        .load(std::sync::atomic::Ordering::Relaxed)
763                                    {
764                                        *first_undecodable_cb
765                                            .lock()
766                                            .unwrap_or_else(|p| p.into_inner()) = None;
767                                    }
768                                    send_or_count_ingress_drop(
769                                        &tx_cb,
770                                        EventItem::ZenohInput {
771                                            id: input_id_cb.clone(),
772                                            metadata: std::sync::Arc::new(metadata),
773                                            data,
774                                        },
775                                        &input_id_cb,
776                                        &ingress_drops_cb,
777                                    );
778                                }));
779                            if result.is_err() {
780                                tracing::error!(
781                                    input = %input_id_cb,
782                                    "zenoh subscriber callback panicked"
783                                );
784                                let _ = tx_cb.try_send(EventItem::FatalError(eyre!(
785                                    "zenoh subscriber callback for input `{input_id_cb}` panicked"
786                                )));
787                            }
788                        })
789                        .wait();
790                    match subscriber {
791                        Ok(s) => {
792                            tracing::debug!(input = %input_id, %topic, "zenoh subscriber declared (callback)");
793                            zenoh_subscribers.push(s);
794                        }
795                        Err(e) => {
796                            // No zenoh subscriber for this input: no marker can
797                            // ever arrive, so this node never acks it and the
798                            // producer keeps the output on the reliable daemon
799                            // path — the input is served via daemon events.
800                            tracing::warn!(
801                                input = %input_id,
802                                "failed to declare zenoh subscriber ({e}), using daemon path"
803                            );
804                        }
805                    }
806                }
807            }
808        }
809
810        // Consumer half of the startup handshake: from here on, every startup
811        // marker received by a data callback above is answered with an ack.
812        // Nothing blocks and nothing can fail init: a route that never
813        // delivers a marker simply never gets acked, and the producer keeps
814        // that output on the lossless daemon path (see `StartupHandshake` on
815        // the producer side). The acker runs for the stream's whole lifetime
816        // so late producers (dynamic nodes, restarts) get acks too.
817        drop(ack_tx); // the callbacks hold the only remaining senders
818        let startup_acker =
819            spawn_startup_acker(node_id.clone(), ack_publishers, ack_rx, clock.clone());
820
821        let reply = channel
822            .request(&Timestamped {
823                inner: DaemonRequest::Subscribe,
824                timestamp: clock.new_timestamp(),
825            })
826            .map_err(|e| eyre!(e))
827            .wrap_err("failed to create subscription with dora-daemon")?;
828
829        match reply {
830            DaemonReply::Result(Ok(())) => {}
831            DaemonReply::Result(Err(err)) => {
832                eyre::bail!("subscribe failed: {err}")
833            }
834            other => eyre::bail!("unexpected subscribe reply: {other:?}"),
835        }
836
837        close_channel.register(dataflow_id, node_id.clone(), clock.new_timestamp())?;
838
839        let thread_handle = thread::init(node_id.clone(), tx, channel, clock.clone())?;
840
841        Ok(EventStream {
842            node_id: node_id.clone(),
843            receiver: rx,
844            _thread_handle: thread_handle,
845            _zenoh_subscribers: zenoh_subscribers,
846            _zenoh_schema_subscribers: zenoh_schema_subscribers,
847            startup_acker,
848            close_channel,
849            start_timestamp: clock.new_timestamp(),
850            clock,
851            scheduler,
852            ingress_drops,
853            write_events_to,
854            use_scheduler,
855            scheduler_maybe_nonempty: false,
856            input_type_checks,
857            pending_passthrough: std::collections::VecDeque::new(),
858            stop_received: false,
859            testing_shutdown,
860        })
861    }
862
863    /// Synchronously waits for the next event.
864    ///
865    /// Blocks the thread until the next event arrives.
866    /// Returns [`None`] once the event stream is closed.
867    ///
868    /// For an asynchronous variant of this method see [`recv_async`][Self::recv_async].
869    ///
870    /// ## Event Reordering
871    ///
872    /// This method uses an [`EventScheduler`] internally to **reorder events**. This means that the
873    /// events might be returned in a different order than they occurred. For details, check the
874    /// documentation of the [`EventScheduler`] struct.
875    ///
876    /// If you want to receive the events in their original chronological order, use the
877    /// asynchronous [`StreamExt::next`](futures::StreamExt::next) method instead ([`EventStream`] implements the
878    /// [`Stream`] trait).
879    ///
880    /// The canonical node loop drains this stream until it closes, reacting to
881    /// the events the node cares about (typically [`Event::Input`]) and ignoring
882    /// the rest:
883    ///
884    /// ```no_run
885    /// use dora_node_api::{DoraNode, Event};
886    ///
887    /// let (_node, mut events) = DoraNode::init_from_env()?;
888    ///
889    /// while let Some(event) = events.recv() {
890    ///     match event {
891    ///         Event::Input { id, metadata: _, data } => {
892    ///             // react to the input `id`, reading the Arrow `data`
893    ///             println!("received input `{id}` with {} element(s)", data.len());
894    ///         }
895    ///         Event::Stop(_) => break,
896    ///         _ => {}
897    ///     }
898    /// }
899    /// # Ok::<(), eyre::Report>(())
900    /// ```
901    pub fn recv(&mut self) -> Option<Event> {
902        futures::executor::block_on(self.recv_async())
903    }
904
905    /// Receives the next incoming [`Event`] synchronously with a timeout.
906    ///
907    /// Blocks the thread until the next event arrives or the timeout is reached.
908    /// Returns a [`Event::Error`] if no event was received within the given duration.
909    ///
910    /// Returns [`None`] once the event stream is closed.
911    ///
912    /// For an asynchronous variant of this method see [`recv_async_timeout`][Self::recv_async_timeout].
913    ///
914    /// ## Event Reordering
915    ///
916    /// This method uses an [`EventScheduler`] internally to **reorder events**. This means that the
917    /// events might be returned in a different order than they occurred. For details, check the
918    /// documentation of the [`EventScheduler`] struct.
919    ///
920    /// If you want to receive the events in their original chronological order, use the
921    /// asynchronous [`StreamExt::next`](futures::StreamExt::next) method instead ([`EventStream`] implements the
922    /// [`Stream`] trait).
923    pub fn recv_timeout(&mut self, dur: Duration) -> Option<Event> {
924        futures::executor::block_on(self.recv_async_timeout(dur))
925    }
926
927    /// Receives the next incoming [`Event`] asynchronously, using an [`EventScheduler`] for fairness.
928    ///
929    /// Returns [`None`] once the event stream is closed.
930    ///
931    /// ## Event Reordering
932    ///
933    /// This method uses an [`EventScheduler`] internally to **reorder events**. This means that the
934    /// events might be returned in a different order than they occurred. For details, check the
935    /// documentation of the [`EventScheduler`] struct.
936    ///
937    /// If you want to receive the events in their original chronological order, use the
938    /// [`StreamExt::next`](futures::StreamExt::next) method with a custom timeout future instead
939    /// ([`EventStream`] implements the [`Stream`] trait).
940    pub async fn recv_async(&mut self) -> Option<Event> {
941        // Drain any events that were stashed by pattern-aware helpers
942        // (`recv_service_response`, `recv_action_result`) while they
943        // were waiting for a specific correlation. These must be
944        // returned to the caller before we poll the underlying
945        // scheduler, so the caller's main event loop never loses
946        // events that arrived during a helper wait (dora-rs/adora#148).
947        if let Some(event) = self.pending_passthrough.pop_front() {
948            return Some(event);
949        }
950        self.recv_from_stream().await
951    }
952
953    /// Receive the next event straight from the scheduler/receiver,
954    /// **without** draining `pending_passthrough` first.
955    ///
956    /// The pattern-aware wait loop ([`wait_for_correlation`](Self::wait_for_correlation))
957    /// buffers every non-matching event into `pending_passthrough` itself. If
958    /// it pumped the stream through [`recv_async`](Self::recv_async), that
959    /// drain would immediately hand the just-buffered event straight back, the
960    /// classifier would re-buffer it, and the loop would spin on the same event
961    /// forever — never reading the awaited response off the receiver — until it
962    /// hit its deadline and wrongly reported a timeout (while pinning a CPU
963    /// core). Reading through this bypass keeps the buffered events reserved for
964    /// the caller's own `recv`/`recv_async` while the wait loop makes real
965    /// progress.
966    async fn recv_from_stream(&mut self) -> Option<Event> {
967        // Close the stream after a Stop event: the daemon thread has
968        // already dropped its sender, but zenoh subscriber threads
969        // hold clones that would otherwise keep `receiver` open.
970        if self.stop_received {
971            // The scheduler gives `Stop` (a NON_INPUT_EVENT) strict priority
972            // over buffered inputs (`scheduler::next`), so inputs enqueued
973            // before Stop can still be queued when Stop is delivered. Drain
974            // those buffered *inputs* before closing instead of dropping them
975            // silently. Trailing non-input control events (a second Stop /
976            // AllInputsClosed / InputClosed / Reload) are discarded rather than
977            // re-delivered after Stop — the contract is "nothing after Stop
978            // except the inputs that were already queued". Do NOT pull new
979            // events from `receiver`; the dataflow is stopping, and on the
980            // non-scheduler path returning `None` closes the stream against
981            // zenoh-held senders.
982            return self.pop_scheduled(true);
983        }
984        let event = if !self.use_scheduler {
985            self.receiver.recv().await.map(Self::convert_event_item)
986        } else {
987            // Block for the first event while the scheduler is empty, then drain
988            // the rest non-blocking. The old code re-checked `is_empty()` on
989            // every iteration; `Scheduler::is_empty()` scans every input queue
990            // (O(#inputs)), so draining K events cost O(K·#inputs).
991            //
992            // `add_event` usually pushes, but it can also *drop* the event
993            // without retaining it (e.g. `queue_size: 0` -> `DropIncoming`), so
994            // we must keep blocking while the scheduler is still empty rather
995            // than assume a single `recv` made it non-empty — otherwise a
996            // dropped-only event would fall through to `scheduler.next() ==
997            // None` and be misread as a closed stream. This preserves the
998            // previous "block until a retained event arrives" behavior while
999            // checking `is_empty()` only twice in the common case instead of
1000            // once per drained event.
1001            while self.scheduler.is_empty() {
1002                match self.receiver.recv().await {
1003                    Some(event) => self.add_event(event),
1004                    None => break,
1005                }
1006            }
1007            while let Ok(event) = self.receiver.try_recv() {
1008                self.add_event(event);
1009            }
1010            self.scheduler.next().map(Self::convert_event_item)
1011        };
1012
1013        if let Some(ref event) = event {
1014            self.note_produced_event(event);
1015        }
1016        event
1017    }
1018
1019    /// Pop the next event buffered in the scheduler, converted and
1020    /// post-processed. With `inputs_only` (the post-`Stop` drain, see
1021    /// `recv_from_stream`), control events are discarded instead of returned.
1022    fn pop_scheduled(&mut self, inputs_only: bool) -> Option<Event> {
1023        if !self.use_scheduler {
1024            return None;
1025        }
1026        while let Some(item) = self.scheduler.next() {
1027            if inputs_only
1028                && !matches!(
1029                    &item,
1030                    EventItem::NodeEvent {
1031                        event: NodeEvent::Input { .. },
1032                        ..
1033                    } | EventItem::ZenohInput { .. }
1034                )
1035            {
1036                continue;
1037            }
1038            // Route through the shared post-process helper, exactly like the
1039            // other receive paths, so the one-shot first-message type check
1040            // still runs. The paths must stay in lockstep
1041            // (dora-rs/adora#172, #174).
1042            let event = Self::convert_event_item(item);
1043            self.note_produced_event(&event);
1044            return Some(event);
1045        }
1046        None
1047    }
1048
1049    /// Post-process an event just produced by `recv_async` / `poll_next`: run
1050    /// the one-shot, first-message input type check and update the
1051    /// stop-tracking flag. Shared by both receive paths so they cannot drift —
1052    /// the two paths must stay in lockstep (dora-rs/adora#172, #174).
1053    fn note_produced_event(&mut self, event: &Event) {
1054        // First-message type validation: check once per input, then remove.
1055        // `contains_key` short-circuits cheaply once the check is consumed, so
1056        // steady-state topic messages pay a single map lookup after the check
1057        // is consumed. The check is consumed on the first *non-`Null`* message
1058        // (see the `Null` note below), so an input that only ever carries
1059        // `Null` (an annotated timer, an empty-payload stream) keeps re-running
1060        // this cheap, allocation-free inspection each message rather than a
1061        // single lookup — the deliberate cost of not disabling validation on a
1062        // `Null` first message.
1063        //
1064        // Skip the check (and keep it armed) when the message carries pattern
1065        // metadata (`request_id`, `goal_id`, or `goal_status`) — the input is
1066        // polymorphic by pattern design and a single declared type cannot cover
1067        // all variants (dora-rs/adora#150). The membership test runs before the
1068        // pattern predicate so the common consumed-check path avoids the
1069        // parameter lookups, and before `remove` so an armed pattern input's
1070        // stored `DataType` is never cloned.
1071        if let Event::Input { id, metadata, data } = event
1072            && self.input_type_checks.contains_key(id)
1073            && !crate::node::carries_pattern_correlation(&metadata.parameters)
1074        {
1075            let raw = dora_arrow_convert::internal::array_ref(data);
1076            let actual = raw.data_type();
1077            // A `Null` first message (timer ticks, empty/metadata-only
1078            // payloads) carries no type to validate. Skip it *without*
1079            // consuming the one-shot check, so the first genuinely-typed
1080            // message on this input is still validated instead of silently
1081            // escaping the check. The check is consumed (`remove`) only once
1082            // a non-`Null` payload has actually been inspected.
1083            if *actual != arrow_schema::DataType::Null
1084                && let Some(expected) = self.input_type_checks.remove(id)
1085                && *actual != expected
1086            {
1087                tracing::warn!(
1088                    input = %id,
1089                    expected = ?expected,
1090                    actual = ?actual,
1091                    "input type mismatch on first message"
1092                );
1093            }
1094        }
1095
1096        if matches!(event, Event::Stop(_)) {
1097            self.stop_received = true;
1098        }
1099    }
1100
1101    /// Check if there are any buffered events in the scheduler, the
1102    /// receiver, or the passthrough buffer used by pattern-aware helpers.
1103    pub fn is_empty(&self) -> bool {
1104        self.pending_passthrough.is_empty() && self.scheduler.is_empty() && self.receiver.is_empty()
1105    }
1106
1107    /// Returns and resets the accumulated drop counts per input ID.
1108    ///
1109    /// When inputs overflow their queue limits, events are discarded to keep memory bounded. For
1110    /// `drop_oldest` inputs the cap is `queue_size` (clamped to at least 1); an overflow normally
1111    /// evicts the oldest queued event, but correlated service/action messages and the `Stop` event
1112    /// are preserved where possible, so the evicted event may instead be a newer one (or the
1113    /// incoming event itself). For `backpressure` inputs the hard safety cap is
1114    /// `max(10 × queue_size, 100)`.
1115    /// This method returns a map from input ID to the number of messages dropped
1116    /// since the last call.
1117    /// Drops are reported per input across both loss sites — the scheduler's
1118    /// per-input eviction and the shared zenoh ingress channel — so a caller
1119    /// does not have to know which transport a payload took to learn it was
1120    /// lost (#3282).
1121    pub fn drain_drop_counts(&mut self) -> HashMap<DataId, u64> {
1122        let mut counts = self.scheduler.drain_drop_counts();
1123        for (id, dropped) in &self.ingress_drops {
1124            // Load before swapping: a node polling this every `recv()` pays a
1125            // read-modify-write per input otherwise, and the counter is zero on
1126            // every healthy call.
1127            if dropped.load(Ordering::Relaxed) > 0 {
1128                let n = dropped.swap(0, Ordering::Relaxed);
1129                *counts.entry(id.clone()).or_insert(0) += n;
1130            }
1131        }
1132        counts
1133    }
1134
1135    fn add_event(&mut self, event: EventItem) {
1136        // Event recording is observability-only (writes to the optional
1137        // `write_events_to` log). A write failure must not panic the event
1138        // loop — drop the log line and continue scheduling.
1139        if let Err(err) = self.record_event(&event) {
1140            tracing::warn!(
1141                node = %self.node_id,
1142                "failed to record event to write_events_to log: {err:?}"
1143            );
1144            // Mark the recording poisoned so consumers can detect events
1145            // are missing from the final JSON. `write_out()` surfaces this
1146            // as a top-level `recording_status` field (#1857).
1147            if let Some(write_events_to) = self.write_events_to.as_mut() {
1148                let time_offset_secs = self
1149                    .clock
1150                    .new_timestamp()
1151                    .get_diff_duration(&self.start_timestamp)
1152                    .as_secs_f64();
1153                write_events_to.mark_poisoned(&err, time_offset_secs);
1154            }
1155        }
1156        self.scheduler.add_event(event);
1157        self.scheduler_maybe_nonempty = true;
1158    }
1159
1160    fn record_event(&mut self, event: &EventItem) -> eyre::Result<()> {
1161        if let Some(write_events_to) = &mut self.write_events_to {
1162            let event_json = match event {
1163                EventItem::NodeEvent { event, .. } => match event {
1164                    NodeEvent::Stop => Some(control_event_json(
1165                        &self.clock,
1166                        &self.start_timestamp,
1167                        "Stop",
1168                        None,
1169                    )),
1170                    NodeEvent::Reload { .. } => None,
1171                    NodeEvent::Input { id, metadata, data } => {
1172                        let mut event_json = convert_output_to_json(
1173                            id,
1174                            metadata,
1175                            data,
1176                            self.start_timestamp,
1177                            false,
1178                        )?;
1179                        event_json.insert("type".into(), "Input".into());
1180                        Some(event_json.into())
1181                    }
1182                    NodeEvent::InputClosed { id } => Some(control_event_json(
1183                        &self.clock,
1184                        &self.start_timestamp,
1185                        "InputClosed",
1186                        Some(id.to_string()),
1187                    )),
1188                    NodeEvent::InputRecovered { id } => Some(control_event_json(
1189                        &self.clock,
1190                        &self.start_timestamp,
1191                        "InputRecovered",
1192                        Some(id.to_string()),
1193                    )),
1194                    NodeEvent::NodeRestarted { id } => Some(control_event_json(
1195                        &self.clock,
1196                        &self.start_timestamp,
1197                        "NodeRestarted",
1198                        Some(id.to_string()),
1199                    )),
1200                    NodeEvent::AllInputsClosed => Some(control_event_json(
1201                        &self.clock,
1202                        &self.start_timestamp,
1203                        "AllInputsClosed",
1204                        None,
1205                    )),
1206                    _ => None,
1207                },
1208                // Zenoh-delivered inputs surface to the user as `Event::Input`
1209                // exactly like the daemon-path `NodeEvent::Input` above, but
1210                // bypass the daemon, so neither this recorder nor the daemon
1211                // would otherwise capture them — silently dropping inputs from
1212                // the `write_events_to` recording. Record them here too.
1213                EventItem::ZenohInput { id, metadata, data } => {
1214                    let array = arrow::array::make_array(data.clone());
1215                    let mut event_json = convert_arrow_input_to_json(
1216                        id,
1217                        metadata,
1218                        array,
1219                        self.start_timestamp,
1220                        false,
1221                    )?;
1222                    event_json.insert("type".into(), "Input".into());
1223                    Some(event_json.into())
1224                }
1225                _ => None,
1226            };
1227            if let Some(event_json) = event_json {
1228                write_events_to.events_buffer.push(event_json);
1229            }
1230        }
1231        Ok(())
1232    }
1233
1234    /// Receives the next buffered [`Event`] (if any) without blocking, using an
1235    /// [`EventScheduler`] for fairness.
1236    ///
1237    /// Returns [`TryRecvError::Empty`] if no event is available right now.
1238    /// Returns [`TryRecvError::Closed`] once the event stream is closed.
1239    ///
1240    /// This method never blocks and is safe to use in asynchronous contexts.
1241    ///
1242    /// ## Event Reordering
1243    ///
1244    /// This method uses an [`EventScheduler`] internally to **reorder events**. This means that the
1245    /// events might be returned in a different order than they occurred. For details, check the
1246    /// documentation of the [`EventScheduler`] struct.
1247    ///
1248    /// If you want to receive the events in their original chronological order, use the
1249    /// [`StreamExt::next`](futures::StreamExt::next) method with a custom timeout future instead
1250    /// ([`EventStream`] implements the [`Stream`] trait).
1251    pub fn try_recv(&mut self) -> Result<Event, TryRecvError> {
1252        match self.recv_async().now_or_never() {
1253            Some(Some(event)) => Ok(event),
1254            Some(None) => Err(TryRecvError::Closed),
1255            None => Err(TryRecvError::Empty),
1256        }
1257    }
1258
1259    /// Receives all buffered [`Event`]s without blocking, using an [`EventScheduler`] for fairness.
1260    ///
1261    /// Return `Some(Vec::new())` if no events are ready.
1262    /// Returns [`None`] once the event stream is closed and no events are buffered anymore.
1263    ///
1264    /// This method never blocks and is safe to use in asynchronous contexts.
1265    ///
1266    /// This method is equivalent to repeatedly calling [`try_recv`][Self::try_recv]. See its docs
1267    /// for details on event reordering.
1268    pub fn drain(&mut self) -> Option<Vec<Event>> {
1269        let mut events = Vec::new();
1270        loop {
1271            match self.try_recv() {
1272                Ok(event) => events.push(event),
1273                Err(TryRecvError::Empty) => break,
1274                Err(TryRecvError::Closed) => {
1275                    if events.is_empty() {
1276                        return None;
1277                    } else {
1278                        break;
1279                    }
1280                }
1281            }
1282        }
1283        Some(events)
1284    }
1285
1286    /// Receives the next incoming [`Event`] asynchronously with a timeout.
1287    ///
1288    /// Returns a [`Event::Error`] if no event was received within the given duration.
1289    ///
1290    /// Returns [`None`] once the event stream is closed.
1291    ///
1292    /// ## Event Reordering
1293    ///
1294    /// This method uses an [`EventScheduler`] internally to **reorder events**. This means that the
1295    /// events might be returned in a different order than they occurred. For details, check the
1296    /// documentation of the [`EventScheduler`] struct.
1297    ///
1298    /// If you want to receive the events in their original chronological order, use the
1299    /// [`StreamExt::next`](futures::StreamExt::next) method with a custom timeout future instead
1300    /// ([`EventStream`] implements the [`Stream`] trait).
1301    pub async fn recv_async_timeout(&mut self, dur: Duration) -> Option<Event> {
1302        match select(Delay::new(dur), pin!(self.recv_async())).await {
1303            Either::Left((_elapsed, _)) => Some(Self::convert_event_item(EventItem::TimeoutError(
1304                eyre!("Receiver timed out"),
1305            ))),
1306            Either::Right((event, _)) => event,
1307        }
1308    }
1309
1310    /// Waits for a service response carrying `request_id` in its metadata.
1311    ///
1312    /// Drives the event loop internally and returns the matching
1313    /// [`Event::Input`] as soon as it arrives. Non-matching events are
1314    /// buffered and replayed on the next call to `recv()` / `recv_async()`,
1315    /// so your main event loop does not lose intermediate events.
1316    ///
1317    /// Terminal conditions return a [`PatternError`]:
1318    ///
1319    /// - `Timeout` — `timeout` elapsed before any matching response arrived.
1320    /// - `ServerRestarted(expected_server)` — the expected server node
1321    ///   restarted, which means its in-flight `request_id` correlation
1322    ///   was orphaned. The caller should retry against the new instance.
1323    /// - `StreamEnded` — the event stream closed (dataflow stopping)
1324    ///   before a response arrived. The terminal `Stop` event is still
1325    ///   returned to the caller's next `recv()`.
1326    /// - `StreamError` — an upstream error event surfaced during the wait.
1327    ///
1328    /// # Example
1329    ///
1330    /// ```no_run
1331    /// # async fn run() -> eyre::Result<()> {
1332    /// use dora_node_api::{DoraNode, Event, MetadataParameters, PatternError};
1333    /// use dora_core::config::{DataId, NodeId};
1334    /// use std::time::Duration;
1335    ///
1336    /// let (mut node, mut events) = DoraNode::init_from_env()?;
1337    /// let server = NodeId::from("service_server".to_owned());
1338    ///
1339    /// // `send_service_request` returns the id this response is matched against.
1340    /// let request_id = node.send_service_request(
1341    ///     DataId::from("request".to_owned()),
1342    ///     MetadataParameters::default(),
1343    ///     vec![1u8, 2, 3],
1344    /// )?;
1345    ///
1346    /// match events
1347    ///     .recv_service_response(&request_id, &server, Duration::from_secs(5))
1348    ///     .await
1349    /// {
1350    ///     Ok(Event::Input { data, .. }) => { let _ = data; /* handle the response */ }
1351    ///     Err(PatternError::Timeout) => { /* no response in time; take a fallback path */ }
1352    ///     Err(PatternError::ServerRestarted(_)) => { /* retry against the new instance */ }
1353    ///     Err(e) => return Err(e.into()),
1354    ///     _ => {}
1355    /// }
1356    /// # Ok(())
1357    /// # }
1358    /// ```
1359    pub async fn recv_service_response(
1360        &mut self,
1361        request_id: &str,
1362        expected_server: &NodeId,
1363        timeout: Duration,
1364    ) -> Result<Event, PatternError> {
1365        self.wait_for_correlation(
1366            timeout,
1367            expected_server,
1368            |event, request_id| match event {
1369                Event::Input { metadata, .. } => {
1370                    dora_message::metadata::get_string_param(
1371                        &metadata.parameters,
1372                        dora_message::metadata::REQUEST_ID,
1373                    ) == Some(request_id)
1374                }
1375                _ => false,
1376            },
1377            request_id,
1378        )
1379        .await
1380    }
1381
1382    /// Waits for a terminal action result (`goal_status` ∈
1383    /// {`succeeded`, `aborted`, `canceled`}) with a matching `goal_id`.
1384    ///
1385    /// Semantics mirror [`recv_service_response`](Self::recv_service_response)
1386    /// but match on `goal_id` + a terminal `goal_status` instead of
1387    /// `request_id`. Intermediate feedback events (matching `goal_id`
1388    /// without a terminal `goal_status`) are stashed for the caller's
1389    /// main loop — use `recv()` separately if you need to observe them.
1390    pub async fn recv_action_result(
1391        &mut self,
1392        goal_id: &str,
1393        expected_server: &NodeId,
1394        timeout: Duration,
1395    ) -> Result<Event, PatternError> {
1396        self.wait_for_correlation(
1397            timeout,
1398            expected_server,
1399            |event, goal_id| match event {
1400                Event::Input { metadata, .. } => {
1401                    let matches_goal = dora_message::metadata::get_string_param(
1402                        &metadata.parameters,
1403                        dora_message::metadata::GOAL_ID,
1404                    ) == Some(goal_id);
1405                    if !matches_goal {
1406                        return false;
1407                    }
1408                    matches!(
1409                        dora_message::metadata::get_string_param(
1410                            &metadata.parameters,
1411                            dora_message::metadata::GOAL_STATUS,
1412                        ),
1413                        Some(dora_message::metadata::GOAL_STATUS_SUCCEEDED)
1414                            | Some(dora_message::metadata::GOAL_STATUS_ABORTED)
1415                            | Some(dora_message::metadata::GOAL_STATUS_CANCELED)
1416                    )
1417                }
1418                _ => false,
1419            },
1420            goal_id,
1421        )
1422        .await
1423    }
1424
1425    /// Stash an event that a pattern-aware wait consumed while searching for a
1426    /// correlation match, so the caller's own event loop can still see it on
1427    /// the next `recv()` / `recv_async()` / `next()`.
1428    ///
1429    /// The buffer escapes the scheduler's per-input `queue_size` /
1430    /// `queue_policy` — an event that moves scheduler → passthrough left that
1431    /// policy behind and, before dora-rs/dora#3197, was retained without limit
1432    /// until someone read it. A node that lets pattern-aware waits own the
1433    /// stream and never reads events itself has no drain, so a high-rate input
1434    /// it never correlates for (or a flapping upstream spamming control events)
1435    /// grew unbounded where the scheduler would have dropped oldest. We re-apply
1436    /// a bound here (see [`enforce_passthrough_bound`](Self::enforce_passthrough_bound))
1437    /// so the retention stays bounded and per-input policy means the same thing
1438    /// whether or not a correlated receive touched the event.
1439    fn buffer_passthrough(&mut self, event: Event) {
1440        let (bucket, cap) = match &event {
1441            Event::Input { id, .. } => {
1442                let id = id.clone();
1443                let cap = self.scheduler.effective_cap_for(&id);
1444                (PassthroughBucket::Input(id), cap)
1445            }
1446            // Control events (`Reload`, a non-expected-server `NodeRestarted`,
1447            // `ParamUpdate`, …) share one cap, mirroring the scheduler's single
1448            // `NON_INPUT_EVENT` queue. `Stop` is exempt from eviction below.
1449            _ => (PassthroughBucket::NonInput, NON_INPUT_EVENT_QUEUE_SIZE),
1450        };
1451        self.pending_passthrough.push_back(event);
1452        self.enforce_passthrough_bound(&bucket, cap);
1453    }
1454
1455    /// Enforce `bucket`'s bound over the buffered passthrough events belonging
1456    /// to it, mirroring `Scheduler::select_eviction`: sacrifice the oldest
1457    /// ordinary (non-correlated) event first so service responses and action
1458    /// results survive; only when every buffered event in the bucket is
1459    /// correlated do we drop the oldest correlated one, loudly — the same
1460    /// request/response-contract-breaking last resort the scheduler logs
1461    /// (dora-rs/adora#145, dora-rs/dora#3197). `Stop` is never dropped, matching
1462    /// the scheduler's `is_stop`.
1463    ///
1464    /// `buffer_passthrough` calls this after every push, so the buffer is over
1465    /// capacity by at most one and a single eviction restores the bound.
1466    fn enforce_passthrough_bound(&mut self, bucket: &PassthroughBucket, cap: usize) {
1467        use dora_message::metadata::carries_pattern_correlation;
1468
1469        let in_bucket = |event: &Event| match (bucket, event) {
1470            (PassthroughBucket::Input(id), Event::Input { id: eid, .. }) => eid == id,
1471            (PassthroughBucket::Input(_), _) => false,
1472            // Non-input bucket: every non-input event counts (no correlation
1473            // concept applies); `Stop` is handled as eviction-immune below.
1474            (PassthroughBucket::NonInput, event) => !matches!(event, Event::Input { .. }),
1475        };
1476        let correlated = |event: &Event| {
1477            matches!(event, Event::Input { metadata, .. }
1478                if carries_pattern_correlation(&metadata.parameters))
1479        };
1480        // Droppable: in this bucket and not the eviction-immune `Stop` (matching
1481        // the scheduler's `is_stop`).
1482        let droppable = |event: &Event| in_bucket(event) && !matches!(event, Event::Stop(_));
1483
1484        if self
1485            .pending_passthrough
1486            .iter()
1487            .filter(|e| in_bucket(e))
1488            .count()
1489            <= cap
1490        {
1491            return;
1492        }
1493
1494        // Mirror `Scheduler::select_eviction`: sacrifice the oldest ordinary
1495        // event first (`RemoveAt` / `DropIncoming`); only when the whole bucket
1496        // is correlated drop the oldest correlated one, loudly
1497        // (`DropCorrelatedLoud`).
1498        let evict_at = self
1499            .pending_passthrough
1500            .iter()
1501            .position(|e| droppable(e) && !correlated(e))
1502            .or_else(|| self.pending_passthrough.iter().position(droppable));
1503        let Some(evict_at) = evict_at else {
1504            // Only a `Stop` in the bucket (at most one) — nothing to drop.
1505            return;
1506        };
1507
1508        // Log before removing; the immutable borrow ends with the `match`.
1509        match self.pending_passthrough.get(evict_at) {
1510            Some(Event::Input { id, metadata, .. })
1511                if carries_pattern_correlation(&metadata.parameters) =>
1512            {
1513                scheduler::log_correlation_drop_params(id, &metadata.parameters);
1514            }
1515            Some(Event::Input { id, .. }) => {
1516                tracing::warn!(input = %id, "discarding buffered input due to queue size limit");
1517            }
1518            _ => {
1519                tracing::warn!("discarding buffered control event due to passthrough buffer limit");
1520            }
1521        }
1522        self.pending_passthrough.remove(evict_at);
1523        match bucket {
1524            PassthroughBucket::Input(id) => self.scheduler.record_drop(id),
1525            PassthroughBucket::NonInput => self.scheduler.record_non_input_drop(),
1526        }
1527    }
1528
1529    /// Core loop for the pattern-aware helpers. Waits up to `timeout`
1530    /// for an event that satisfies `is_match(event, needle)`. Buffers
1531    /// every non-matching event so the caller's main event loop can
1532    /// still see them via `recv()`.
1533    async fn wait_for_correlation<F>(
1534        &mut self,
1535        timeout: Duration,
1536        expected_server: &NodeId,
1537        is_match: F,
1538        needle: &str,
1539    ) -> Result<Event, PatternError>
1540    where
1541        F: Fn(&Event, &str) -> bool,
1542    {
1543        // A previous pattern-aware wait may already have buffered the event
1544        // we are now looking for. With pipelined requests, the response to
1545        // `req-2` can arrive — and be classified non-matching, so buffered
1546        // into `pending_passthrough` — *during* the wait for `req-1`. The loop
1547        // below pumps `recv_from_stream`, which never reads
1548        // `pending_passthrough`, so without this scan that already-buffered
1549        // response would be invisible and the wait would wrongly time out.
1550        // Extract a buffered match in place (preserving the order of the
1551        // remaining events for the caller's own `recv()`/`recv_async()`).
1552        //
1553        // Only a `Match` is pulled from the buffer: a buffered `Stop` is
1554        // already handled by the `stop_received` short-circuit in
1555        // `recv_from_stream`, and a buffered `NodeRestarted` was reported to
1556        // the caller when it was first seen, so it is left for the caller's
1557        // own event loop rather than re-surfaced here.
1558        if let Some(pos) = self
1559            .pending_passthrough
1560            .iter()
1561            .position(|event| is_match(event, needle))
1562            && let Some(event) = self.pending_passthrough.remove(pos)
1563        {
1564            return Ok(event);
1565        }
1566
1567        // Compute the wait deadline, clamping a `timeout` so large that the
1568        // addition would overflow the monotonic clock, so a caller passing e.g.
1569        // `Duration::MAX` (a natural way to say "wait effectively forever") gets
1570        // the longest representable wait instead of aborting the process. See
1571        // `representable_wait_deadline`.
1572        let deadline = representable_wait_deadline(timeout);
1573        loop {
1574            let remaining = deadline.saturating_duration_since(std::time::Instant::now());
1575            if remaining.is_zero() {
1576                return Err(PatternError::Timeout);
1577            }
1578            // Pump the stream via `recv_from_stream`, NOT `recv_async`: the
1579            // latter drains `pending_passthrough` first, which would re-hand us
1580            // the very events we buffer below and livelock the loop (see
1581            // `recv_from_stream`'s docs).
1582            let event = match select(Delay::new(remaining), pin!(self.recv_from_stream())).await {
1583                Either::Left((_elapsed, _)) => return Err(PatternError::Timeout),
1584                Either::Right((None, _)) => return Err(PatternError::StreamEnded),
1585                Either::Right((Some(e), _)) => e,
1586            };
1587
1588            match classify_correlation_event(&event, expected_server, |e| is_match(e, needle)) {
1589                CorrelationOutcome::Match => return Ok(event),
1590                CorrelationOutcome::ServerRestarted => {
1591                    self.buffer_passthrough(event);
1592                    return Err(PatternError::ServerRestarted(expected_server.to_string()));
1593                }
1594                CorrelationOutcome::StreamEnded => {
1595                    self.buffer_passthrough(event);
1596                    return Err(PatternError::StreamEnded);
1597                }
1598                CorrelationOutcome::StreamError => {
1599                    if let Event::Error(err) = event {
1600                        return Err(PatternError::StreamError(err));
1601                    }
1602                    unreachable!("StreamError only returned for Event::Error");
1603                }
1604                CorrelationOutcome::Passthrough => {
1605                    self.buffer_passthrough(event);
1606                }
1607            }
1608        }
1609    }
1610}
1611
1612/// Compute the wait deadline `now + timeout`, clamping a `timeout` so large that
1613/// the addition would overflow the monotonic clock by halving it until the sum
1614/// is representable. Reads the clock exactly once, so the `now` used to validate
1615/// the clamp is the same `now` the deadline is built from.
1616///
1617/// The pattern-wait helpers ([`EventStream::recv_service_response`],
1618/// [`EventStream::recv_action_result`]) take a caller-supplied [`Duration`] and
1619/// wait until this deadline. A very large value — e.g. `Duration::MAX`, a
1620/// natural way to express "wait effectively forever" — would otherwise panic
1621/// (`overflow when adding duration to instant`) and abort the process. Clamping
1622/// yields the longest representable deadline instead. The C++ binding's
1623/// `clamp_pattern_timeout` guards the identical computation on the FFI side;
1624/// this covers the Rust-native callers.
1625fn representable_wait_deadline(timeout: Duration) -> Instant {
1626    let now = Instant::now();
1627    let mut timeout = timeout;
1628    while !timeout.is_zero() && now.checked_add(timeout).is_none() {
1629        timeout /= 2;
1630    }
1631    now + timeout
1632}
1633
1634/// Build the JSON for a "control" event that carries only a type tag, an
1635/// optional input/node id, and the elapsed time offset since the node started.
1636///
1637/// Shared by the `Stop` / `InputClosed` / `InputRecovered` / `NodeRestarted` /
1638/// `AllInputsClosed` arms of [`EventStream::record_event`], which differ only in
1639/// the `"type"` string and whether an `"id"` field is present. A free function
1640/// (rather than a `&self` method) so it can take the `clock` and
1641/// `start_timestamp` fields by reference while `record_event` holds a mutable
1642/// borrow of the sibling `write_events_to` field.
1643fn control_event_json(
1644    clock: &uhlc::HLC,
1645    start_timestamp: &uhlc::Timestamp,
1646    ty: &str,
1647    id: Option<String>,
1648) -> serde_json::Value {
1649    let time_offset = clock.new_timestamp().get_diff_duration(start_timestamp);
1650    // Build the map explicitly (rather than via `json!`) so the key order
1651    // matches the previous per-arm literals byte-for-byte under serde_json's
1652    // `preserve_order`: `type`, then the optional `id`, then `time_offset_secs`.
1653    let mut event_json = serde_json::Map::new();
1654    event_json.insert("type".to_owned(), ty.into());
1655    if let Some(id) = id {
1656        event_json.insert("id".to_owned(), serde_json::Value::String(id));
1657    }
1658    event_json.insert(
1659        "time_offset_secs".to_owned(),
1660        time_offset.as_secs_f64().into(),
1661    );
1662    serde_json::Value::Object(event_json)
1663}
1664
1665/// Outcome of classifying a single event during a pattern-aware wait.
1666/// Separated from `wait_for_correlation` so the decision logic can be
1667/// unit-tested without a live `EventStream`.
1668#[derive(Debug, PartialEq, Eq)]
1669enum CorrelationOutcome {
1670    /// The event satisfies the caller's predicate — return it.
1671    Match,
1672    /// `Event::NodeRestarted { id }` where `id == expected_server`.
1673    ServerRestarted,
1674    /// `Event::Stop(_)` — the dataflow is shutting down.
1675    StreamEnded,
1676    /// `Event::Error(_)` — the stream surfaced an error.
1677    StreamError,
1678    /// Unrelated event — buffer it and keep waiting.
1679    Passthrough,
1680}
1681
1682fn classify_correlation_event<F>(
1683    event: &Event,
1684    expected_server: &NodeId,
1685    is_match: F,
1686) -> CorrelationOutcome
1687where
1688    F: Fn(&Event) -> bool,
1689{
1690    if is_match(event) {
1691        return CorrelationOutcome::Match;
1692    }
1693    match event {
1694        Event::NodeRestarted { id } if id == expected_server => CorrelationOutcome::ServerRestarted,
1695        Event::Stop(_) => CorrelationOutcome::StreamEnded,
1696        Event::Error(_) => CorrelationOutcome::StreamError,
1697        _ => CorrelationOutcome::Passthrough,
1698    }
1699}
1700
1701/// Which bound a buffered passthrough event counts against.
1702enum PassthroughBucket {
1703    /// An `Event::Input`, bounded by its input's effective `queue_size`.
1704    Input(DataId),
1705    /// Any non-input control event, bounded — like the scheduler's single
1706    /// `NON_INPUT_EVENT` queue — by
1707    /// [`NON_INPUT_EVENT_QUEUE_SIZE`](scheduler::NON_INPUT_EVENT_QUEUE_SIZE).
1708    NonInput,
1709}
1710
1711impl EventStream {
1712    fn convert_event_item(item: EventItem) -> Event {
1713        match item {
1714            EventItem::NodeEvent { event } => match event {
1715                NodeEvent::Stop => Event::Stop(event::StopCause::Manual),
1716                NodeEvent::Reload { operator_id } => Event::Reload { operator_id },
1717                NodeEvent::InputClosed { id } => Event::InputClosed { id },
1718                NodeEvent::InputRecovered { id } => Event::InputRecovered { id },
1719                NodeEvent::NodeRestarted { id } => Event::NodeRestarted { id },
1720                NodeEvent::Input { id, metadata, data } => {
1721                    let data_inner = data.map(Arc::unwrap_or_clone);
1722                    let result = data_to_arrow_array(data_inner);
1723                    match result {
1724                        Ok(data) => {
1725                            let mut metadata = Arc::unwrap_or_clone(metadata);
1726                            dora_message::metadata::strip_internal_parameters(
1727                                &mut metadata.parameters,
1728                            );
1729                            Event::Input {
1730                                id,
1731                                metadata,
1732                                data: dora_arrow_convert::internal::from_array_ref(data),
1733                            }
1734                        }
1735                        Err(err) => Event::Error(format!("{err:?}")),
1736                    }
1737                }
1738                NodeEvent::AllInputsClosed => Event::Stop(event::StopCause::AllInputsClosed),
1739                NodeEvent::ParamUpdate { key, value_json } => {
1740                    match serde_json::from_slice(&value_json) {
1741                        Ok(value) => Event::ParamUpdate { key, value },
1742                        Err(err) => Event::Error(format!(
1743                            "failed to deserialize ParamUpdate value for `{key}`: {err}"
1744                        )),
1745                    }
1746                }
1747                NodeEvent::ParamDeleted { key } => Event::ParamDeleted { key },
1748                NodeEvent::NodeFailed {
1749                    affected_input_ids,
1750                    error,
1751                    source_node_id,
1752                } => Event::NodeFailed {
1753                    affected_input_ids,
1754                    error,
1755                    source_node_id,
1756                },
1757                other => {
1758                    tracing::warn!("ignoring unrecognized NodeEvent variant: {other:?}");
1759                    Event::Error(format!("unrecognized node event: {other:?}"))
1760                }
1761            },
1762
1763            EventItem::ZenohInput { id, metadata, data } => {
1764                let mut metadata = Arc::unwrap_or_clone(metadata);
1765                dora_message::metadata::strip_internal_parameters(&mut metadata.parameters);
1766                Event::Input {
1767                    id,
1768                    metadata,
1769                    // Already decoded in the subscriber callback (receipt order).
1770                    data: dora_arrow_convert::internal::from_array_data(data),
1771                }
1772            }
1773
1774            EventItem::FatalError(err) => {
1775                Event::Error(format!("fatal event stream error: {err:?}"))
1776            }
1777            EventItem::TimeoutError(err) => {
1778                Event::Error(format!("Timeout event stream error: {err:?}"))
1779            }
1780        }
1781    }
1782}
1783
1784/// No event is available right now or the event stream has been closed.
1785#[derive(Debug)]
1786pub enum TryRecvError {
1787    /// No new event is available right now.
1788    Empty,
1789    /// The event stream has been closed.
1790    Closed,
1791}
1792
1793/// Newtype that owns a Zenoh [`ZBytes`](zenoh::bytes::ZBytes) payload so it can
1794/// back an Arrow `Buffer` via `Buffer::from_custom_allocation`. Keeping the
1795/// `ZBytes` alive keeps the underlying SHM mapping (or heap buffer) valid for
1796/// the lifetime of the zero-copy Arrow buffer.
1797#[allow(dead_code)] // field kept alive to own the zenoh buffer
1798struct ZBytesAllocation(zenoh::bytes::ZBytes);
1799// SAFETY: the wrapped `ZBytes` is only used to keep the backing allocation
1800// alive; the bytes are treated as immutable for the Buffer's lifetime.
1801unsafe impl Sync for ZBytesAllocation {}
1802unsafe impl Send for ZBytesAllocation {}
1803impl std::panic::RefUnwindSafe for ZBytesAllocation {}
1804
1805/// Wrap a zenoh payload as an Arrow `Buffer` — aliasing the zenoh SHM mapping
1806/// for borrowed payloads (zero-copy), owning the materialized `Vec` otherwise.
1807fn zenoh_payload_to_buffer(payload: zenoh::bytes::ZBytes) -> arrow::buffer::Buffer {
1808    use std::ptr::NonNull;
1809    match payload.to_bytes() {
1810        std::borrow::Cow::Borrowed(slice) => {
1811            let ptr =
1812                NonNull::new(slice.as_ptr() as *mut u8).expect("zenoh SHM payload ptr is null");
1813            let len = slice.len();
1814            // SAFETY: `ptr` points into the SHM region owned by `payload`;
1815            // moving `payload` into the Arc keeps the region mapped for the
1816            // lifetime of the Buffer.
1817            unsafe {
1818                arrow::buffer::Buffer::from_custom_allocation(
1819                    ptr,
1820                    len,
1821                    Arc::new(ZBytesAllocation(payload)),
1822                )
1823            }
1824        }
1825        std::borrow::Cow::Owned(vec) => arrow::buffer::Buffer::from_vec(vec),
1826    }
1827}
1828
1829/// Declare the `@schema` AdvancedSubscriber for an input: its callback primes
1830/// `decoder` from the schema published on the output's schema subtopic. The
1831/// history query fetches the cached schema on join; `detect_late_publishers`
1832/// re-queries a producer that appears after this subscriber.
1833///
1834/// On failure, `schema_plane_failed` is set so the data subscriber surfaces a
1835/// `FatalError` if the input stays undecodable past
1836/// [`SCHEMA_PLANE_FATAL_GRACE`]: a failed declare means a degraded zenoh
1837/// session, so exit loudly rather than limp along on the in-band full-stream
1838/// refresh alone — but give that refresh (which fully heals the input) its
1839/// chance first instead of killing a node that would recover within seconds.
1840/// It never blocks the data subscriber.
1841#[allow(clippy::too_many_arguments)]
1842fn declare_schema_subscriber(
1843    session: &zenoh::Session,
1844    dataflow_id: DataflowId,
1845    source_node: &NodeId,
1846    source_output: &DataId,
1847    input_id: &DataId,
1848    decoder: Arc<std::sync::Mutex<crate::arrow_utils::ipc_encode::InputDecoder>>,
1849    tx: tokio::sync::mpsc::Sender<EventItem>,
1850    schema_plane_failed: Arc<std::sync::atomic::AtomicBool>,
1851    out: &mut Vec<zenoh_ext::AdvancedSubscriber<()>>,
1852) {
1853    use zenoh::Wait;
1854    use zenoh_ext::{AdvancedSubscriberBuilderExt, HistoryConfig};
1855
1856    let topic =
1857        dora_core::topics::zenoh_output_schema_topic(dataflow_id, source_node, source_output);
1858    let key = match zenoh::key_expr::KeyExpr::new(topic) {
1859        Ok(k) => k.into_owned(),
1860        Err(e) => {
1861            tracing::warn!(input = %input_id, "invalid @schema zenoh key ({e}); schema-once disabled for this input");
1862            schema_plane_failed.store(true, std::sync::atomic::Ordering::Relaxed);
1863            return;
1864        }
1865    };
1866    let input_id_cb = input_id.clone();
1867    let sub = session
1868        .declare_subscriber(key)
1869        .history(HistoryConfig::default().detect_late_publishers())
1870        .callback(move |sample| {
1871            // catch_unwind: a panic must not unwind through zenoh's IO worker.
1872            let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1873                let buffer = zenoh_payload_to_buffer(sample.payload().clone());
1874                let hash = crate::node::fnv1a(buffer.as_slice());
1875                let mut decoder = decoder.lock().unwrap_or_else(|poison| {
1876                    let mut guard = poison.into_inner();
1877                    guard.reset();
1878                    guard
1879                });
1880                if let Err(e) = decoder.set_schema_raw(hash, buffer) {
1881                    tracing::warn!(input = %input_id_cb, "failed to prime decoder from @schema sample: {e}");
1882                }
1883            }));
1884            if result.is_err() {
1885                tracing::error!(input = %input_id_cb, "zenoh @schema subscriber callback panicked");
1886                let _ = tx.try_send(EventItem::FatalError(eyre!(
1887                    "zenoh @schema subscriber for input `{input_id_cb}` panicked"
1888                )));
1889            }
1890        })
1891        .wait();
1892    match sub {
1893        Ok(s) => out.push(s),
1894        Err(e) => {
1895            tracing::warn!(input = %input_id, "failed to declare @schema subscriber ({e}); schema-once disabled for this input");
1896            schema_plane_failed.store(true, std::sync::atomic::Ordering::Relaxed);
1897        }
1898    }
1899}
1900
1901/// How long a schema-once input may stay continuously undecodable — with the
1902/// `@schema` plane dead — before the node exits with a `FatalError`. Three
1903/// producer full-stream refresh intervals: the refresh re-primes the input
1904/// in-band, so if it hasn't healed after three periods the input is genuinely
1905/// dead (producer gone or data plane dropping every refresh), not just waiting
1906/// out the documented recovery window.
1907const SCHEMA_PLANE_FATAL_GRACE: Duration =
1908    crate::node::SCHEMA_ONCE_REFRESH_INTERVAL.saturating_mul(3);
1909
1910/// Whether the undecodable-input condition has persisted past
1911/// [`SCHEMA_PLANE_FATAL_GRACE`]. Records the start of the window on first call;
1912/// the caller clears `first_undecodable` on any successful decode.
1913fn schema_plane_fatal_due(first_undecodable: &mut Option<Instant>, now: Instant) -> bool {
1914    let start = *first_undecodable.get_or_insert(now);
1915    now.duration_since(start) >= SCHEMA_PLANE_FATAL_GRACE
1916}
1917
1918/// Decode a zenoh data sample in receipt order. A schema-less batch (the
1919/// `SCHEMA_HASH` parameter is present) decodes against the per-input decoder
1920/// primed from the output's `@schema` subtopic or in-band from an earlier full
1921/// stream — returning `Ok(None)` if that schema hasn't been received yet, in
1922/// which case the caller drops it. Other messages (large/SHM full streams,
1923/// daemon-path payloads) decode standalone — and additionally prime the decoder
1924/// in-band (see [`prime_in_band`]).
1925fn decode_zenoh_sample(
1926    decoder: &mut crate::arrow_utils::ipc_encode::InputDecoder,
1927    metadata: &dora_message::metadata::Metadata,
1928    payload: zenoh::bytes::ZBytes,
1929) -> eyre::Result<Option<arrow::array::ArrayData>> {
1930    use crate::arrow_utils::decode_arrow_ipc_zero_copy_raw;
1931    use dora_message::metadata::{SCHEMA_HASH, get_integer_param};
1932
1933    if payload.is_empty() {
1934        return Ok(Some(
1935            dora_arrow_convert::internal::into_array_ref(().into_arrow()).to_data(),
1936        ));
1937    }
1938    let buffer = zenoh_payload_to_buffer(payload);
1939    match get_integer_param(&metadata.parameters, SCHEMA_HASH) {
1940        Some(hash) => {
1941            tracing::debug!("received schema-less batch with SCHEMA_HASH={}", hash);
1942            decoder.decode_batch_raw(buffer, hash as u64)
1943        }
1944        None => {
1945            tracing::debug!("received full IPC stream (no SCHEMA_HASH)");
1946            // Service/action messages are excluded from schema-once (a server
1947            // multiplexes per-request schemas through one output); don't let
1948            // them churn the retained schema set.
1949            if !crate::node::carries_pattern_correlation(&metadata.parameters) {
1950                prime_in_band(decoder, &buffer);
1951            }
1952            decode_arrow_ipc_zero_copy_raw(buffer).map(Some)
1953        }
1954    }
1955}
1956
1957/// Prime the per-input decoder from the schema block of a full self-describing
1958/// stream received on the data topic.
1959///
1960/// The producer sends a full stream for the first message of an output (and
1961/// after every schema change, failed `@schema` publish, or periodic refresh —
1962/// see `publish_schema_once`). Since all data-plane puts share one publisher,
1963/// zenoh delivers them in order, so this priming happens strictly before the
1964/// schema-less batches that reference the schema — closing the QoS race where
1965/// an express batch overtakes the `@schema` plane's non-express schema, and
1966/// re-priming (within the refresh interval) any consumer whose `@schema`
1967/// history query missed the single schema emission.
1968fn prime_in_band(
1969    decoder: &mut crate::arrow_utils::ipc_encode::InputDecoder,
1970    buffer: &arrow::buffer::Buffer,
1971) {
1972    let Some((hash, schema)) =
1973        crate::arrow_utils::ipc_encode::schema_block_and_hash(buffer.as_slice())
1974    else {
1975        return;
1976    };
1977    // A known schema (live or retained) needs no eager re-prime: the full
1978    // stream decodes standalone, and `decode_batch` re-primes lazily from the
1979    // retained set when a schema-less batch actually references it.
1980    if decoder.knows_schema(hash) {
1981        return;
1982    }
1983    // Copy the schema block out of the payload: retaining a slice of an
1984    // SHM-backed buffer would pin the whole segment for the decoder's lifetime.
1985    let schema = arrow::buffer::Buffer::from(schema);
1986    if let Err(e) = decoder.set_schema_raw(hash, schema) {
1987        tracing::debug!("in-band schema priming failed: {e}");
1988    }
1989}
1990
1991/// Convert an optional [`DataMessage`] into an Arrow array, or return an
1992/// empty null array when no data is present.
1993pub fn data_to_arrow_array(
1994    data: Option<DataMessage>,
1995) -> eyre::Result<Arc<dyn arrow::array::Array>> {
1996    // `DataMessage` has a single infallible variant, so the conversion cannot
1997    // fail; the only fallible step is `into_arrow_array`.
1998    let raw_data = data.map_or(RawData::Empty, |DataMessage::Vec(v)| RawData::Vec(v));
1999    raw_data.into_arrow_array().map(arrow::array::make_array)
2000}
2001
2002impl Stream for EventStream {
2003    type Item = Event;
2004
2005    fn poll_next(
2006        mut self: std::pin::Pin<&mut Self>,
2007        cx: &mut std::task::Context<'_>,
2008    ) -> std::task::Poll<Option<Self::Item>> {
2009        // Drain events that were buffered by pattern-aware helpers
2010        // (`recv_service_response`, `recv_action_result`) before
2011        // polling the underlying receiver. Mirrors the drain at the
2012        // top of `recv_async` so `StreamExt::next()` and `recv()`
2013        // return the same events in the same order
2014        // (dora-rs/adora#172).
2015        if let Some(event) = self.pending_passthrough.pop_front() {
2016            return std::task::Poll::Ready(Some(event));
2017        }
2018
2019        // Close the stream after a Stop event: zenoh subscriber threads
2020        // hold sender clones that would otherwise keep `receiver` open.
2021        // Inputs still buffered in the scheduler are delivered first, as
2022        // `recv_async` does.
2023        if self.stop_received {
2024            return std::task::Poll::Ready(self.pop_scheduled(true));
2025        }
2026
2027        // `recv_async` / `try_recv` and the pattern-aware wait helpers drain
2028        // the receiver into the scheduler and take one event out of it; the
2029        // rest stay buffered there. They arrived before anything still in
2030        // `receiver`, so hand them out first — otherwise a node that mixes
2031        // those calls with `StreamExt::next()` never sees them.
2032        if self.scheduler_maybe_nonempty {
2033            if let Some(event) = self.pop_scheduled(false) {
2034                return std::task::Poll::Ready(Some(event));
2035            }
2036            self.scheduler_maybe_nonempty = false;
2037        }
2038
2039        let poll = self
2040            .receiver
2041            .poll_recv(cx)
2042            .map(|item| item.map(Self::convert_event_item));
2043
2044        // Mirror recv_async(): run the first-message type check and stop
2045        // tracking on the Stream path too, via the shared helper so the two
2046        // paths stay in lockstep (dora-rs/adora#172, #174).
2047        if let std::task::Poll::Ready(Some(ref event)) = poll {
2048            self.note_produced_event(event);
2049        }
2050        poll
2051    }
2052}
2053
2054impl Drop for EventStream {
2055    fn drop(&mut self) {
2056        // Tear down the per-input zenoh callback subscribers under a deadline.
2057        // `Subscriber::drop` undeclares the subscription on the shared zenoh
2058        // session, which blocks indefinitely when zenoh's net runtime is
2059        // wedged (e.g. stuck retrying an unreachable scouted peer). Left
2060        // unbounded, that hangs the whole node in `EventStream`'s field-drop
2061        // sequence — before `DoraNode::Drop` (which already bounds its own
2062        // session teardown) even runs — so the daemon never sees the node
2063        // finish and the dataflow stalls until an outer timeout (dora-rs/dora#2425).
2064        // The callbacks use `try_send`, so undeclaring before `receiver` drops
2065        // cannot deadlock on a blocked callback.
2066        //
2067        // The `@schema` `AdvancedSubscriber`s (added with the Arrow IPC data
2068        // plane in #2366) undeclare on the same shared session and can wedge
2069        // the same way, so they must be torn down under the same deadline —
2070        // mirroring `DoraNode::Drop`, which drops both publisher maps inside
2071        // one guard. Left out, they would otherwise drop unbounded in the
2072        // implicit field-drop phase after this `Drop` body returns (#2583).
2073        let subscribers = std::mem::take(&mut self._zenoh_subscribers);
2074        let schema_subscribers = std::mem::take(&mut self._zenoh_schema_subscribers);
2075        let startup_acker = self.startup_acker.take();
2076        if !subscribers.is_empty() || !schema_subscribers.is_empty() || startup_acker.is_some() {
2077            let completed =
2078                teardown_with_timeout("zenoh-subscribers", ZENOH_TEARDOWN_TIMEOUT, move || {
2079                    drop(subscribers);
2080                    drop(schema_subscribers);
2081                    // Dropping the subscribers dropped their callbacks — the
2082                    // only senders into the acker's queue — so the acker
2083                    // thread exits (undeclaring its ack publishers, also
2084                    // bounded by this deadline) and can be joined.
2085                    if let Some(handle) = startup_acker {
2086                        let _ = handle.join();
2087                    }
2088                });
2089            if !completed {
2090                tracing::warn!(
2091                    "zenoh subscriber teardown timed out after {}s; continuing node shutdown",
2092                    ZENOH_TEARDOWN_TIMEOUT.as_secs()
2093                );
2094            }
2095        }
2096
2097        let request = Timestamped {
2098            inner: DaemonRequest::EventStreamDropped,
2099            timestamp: self.clock.new_timestamp(),
2100        };
2101        // Interrupt a testing-daemon `next_event` sleep before the blocking
2102        // close handshake so Drop cannot deadlock (dora-rs/dora#2855).
2103        if let Some(shutdown) = &self.testing_shutdown {
2104            shutdown.store(true, Ordering::Relaxed);
2105        }
2106        let result = self
2107            .close_channel
2108            .request(&request)
2109            .map_err(|e| eyre!(e))
2110            .wrap_err("failed to signal event stream closure to dora-daemon")
2111            .and_then(|r| match r {
2112                DaemonReply::Result(Ok(())) => Ok(()),
2113                DaemonReply::Result(Err(err)) => Err(eyre!("EventStreamClosed failed: {err}")),
2114                other => Err(eyre!("unexpected EventStreamClosed reply: {other:?}")),
2115            });
2116        if let Err(err) = result {
2117            tracing::warn!("{err:?}")
2118        }
2119
2120        if let Some(write_events_to) = self.write_events_to.take()
2121            && let Err(err) = write_events_to.write_out()
2122        {
2123            tracing::warn!(
2124                "failed to write out events for node {}: {err:?}",
2125                self.node_id
2126            );
2127        }
2128    }
2129}
2130
2131pub(crate) struct WriteEventsTo {
2132    node_id: NodeId,
2133    file: std::fs::File,
2134    events_buffer: Vec<serde_json::Value>,
2135    /// `None` while the recording is complete. Becomes `Some(...)` on
2136    /// the first `record_event` failure; subsequent failures bump the
2137    /// counter inside. Surfaced in `write_out()` as a top-level
2138    /// `recording_status` field so consumers (replay tools, audit
2139    /// pipelines) can detect partial recordings instead of silently
2140    /// treating a syntactically-valid file as complete (#1857).
2141    poisoned: Option<PoisonInfo>,
2142}
2143
2144#[derive(Debug)]
2145pub(crate) struct PoisonInfo {
2146    /// `events_buffer.len()` at the moment of the first failure — i.e.
2147    /// the number of events successfully recorded before the gap.
2148    first_failure_event_index: usize,
2149    /// Seconds since `EventStream::start_timestamp` at the first failure.
2150    first_failure_time_offset_secs: f64,
2151    /// `format!("{err:?}")` of the first `record_event()` error.
2152    first_failure_error: String,
2153    /// Count of subsequent failures after the first one.
2154    additional_failures: u64,
2155}
2156
2157impl WriteEventsTo {
2158    /// Mark the recording poisoned. First call captures the failure
2159    /// detail; later calls just bump `additional_failures`.
2160    fn mark_poisoned(&mut self, err: &eyre::Report, time_offset_secs: f64) {
2161        match &mut self.poisoned {
2162            None => {
2163                self.poisoned = Some(PoisonInfo {
2164                    first_failure_event_index: self.events_buffer.len(),
2165                    first_failure_time_offset_secs: time_offset_secs,
2166                    first_failure_error: format!("{err:?}"),
2167                    additional_failures: 0,
2168                });
2169            }
2170            Some(info) => {
2171                info.additional_failures += 1;
2172            }
2173        }
2174    }
2175
2176    fn write_out(self) -> eyre::Result<()> {
2177        use dora_message::integration_testing_format::RecordingStatus;
2178
2179        let Self {
2180            node_id,
2181            file,
2182            events_buffer,
2183            poisoned,
2184        } = self;
2185        let mut inputs_file = serde_json::Map::new();
2186        inputs_file.insert("id".into(), node_id.to_string().into());
2187        // Emit `recording_status` for clean recordings too, so consumers
2188        // can rely on its presence as a definitive signal rather than
2189        // having to treat "field absent" as ambiguous between "clean"
2190        // and "older format" (#1857). Serialized via the canonical
2191        // `RecordingStatus` enum in `dora-message` so the wire format
2192        // stays in lockstep with the consumer-side type. The wire
2193        // shape is unaffected by `IntegrationTestInput`'s
2194        // `Option<Box<RecordingStatus>>` storage choice — serde
2195        // transparently serializes through the `Box`.
2196        let recording_status = match poisoned {
2197            None => RecordingStatus::Clean,
2198            Some(info) => RecordingStatus::Poisoned {
2199                first_failure_event_index: info.first_failure_event_index,
2200                first_failure_time_offset_secs: info.first_failure_time_offset_secs,
2201                first_failure_error: info.first_failure_error,
2202                additional_failures: info.additional_failures,
2203            },
2204        };
2205        inputs_file.insert(
2206            "recording_status".into(),
2207            serde_json::to_value(&recording_status)
2208                .context("failed to serialize recording_status")?,
2209        );
2210        inputs_file.insert("events".into(), events_buffer.into());
2211
2212        serde_json::to_writer_pretty(file, &inputs_file)
2213            .context("failed to write events to file")?;
2214        Ok(())
2215    }
2216}
2217
2218#[cfg(test)]
2219impl EventStream {
2220    /// Test-only: inject an event into the passthrough buffer so we can
2221    /// verify that `is_empty`, `recv_async`, and `Stream::poll_next` all
2222    /// drain it correctly (dora-rs/adora#172).
2223    fn push_passthrough_for_testing(&mut self, event: Event) {
2224        self.pending_passthrough.push_back(event);
2225    }
2226
2227    /// Test-only: buffer an empty input directly in the scheduler and force
2228    /// scheduler mode, simulating an input the scheduler held back while
2229    /// prioritizing `Stop`. Used to verify `recv_async` drains buffered inputs
2230    /// after `Stop` instead of dropping them (dora-rs/dora#2027).
2231    fn push_scheduler_input_for_testing(&mut self, id: &str) {
2232        use crate::event_stream::thread::EventItem;
2233        use dora_message::{daemon_to_node::NodeEvent, metadata::Metadata};
2234        self.use_scheduler = true;
2235        let meta = Metadata::new(dora_core::uhlc::HLC::default().new_timestamp());
2236        self.add_event(EventItem::NodeEvent {
2237            event: NodeEvent::Input {
2238                id: id.into(),
2239                metadata: std::sync::Arc::new(meta),
2240                data: None,
2241            },
2242        });
2243    }
2244
2245    /// Test-only: buffer a `Stop` directly in the scheduler (a NON_INPUT_EVENT)
2246    /// and force scheduler mode, to verify the post-Stop drain discards trailing
2247    /// control events instead of re-delivering a second `Stop` (dora-rs/dora#2027).
2248    fn push_scheduler_stop_for_testing(&mut self) {
2249        use crate::event_stream::thread::EventItem;
2250        use dora_message::daemon_to_node::NodeEvent;
2251        self.use_scheduler = true;
2252        self.add_event(EventItem::NodeEvent {
2253            event: NodeEvent::Stop,
2254        });
2255    }
2256}
2257
2258#[cfg(test)]
2259mod tests {
2260    use super::*;
2261
2262    fn input_with_queue_size(queue_size: usize) -> Input {
2263        serde_yaml::from_str(&format!("source: a/out\nqueue_size: {queue_size}\n"))
2264            .expect("parse input")
2265    }
2266
2267    #[test]
2268    fn ingress_channel_capacity_saturates_for_huge_queue_sizes() {
2269        for sizes in [
2270            vec![usize::MAX],
2271            vec![usize::MAX / 2 + 1, usize::MAX / 2 + 1],
2272        ] {
2273            let inputs: Vec<Input> = sizes.into_iter().map(input_with_queue_size).collect();
2274            let capacity = ingress_channel_capacity(inputs.iter());
2275            assert_eq!(capacity, usize::MAX >> 3);
2276            // The capacity must be one tokio accepts.
2277            let _ = tokio::sync::mpsc::channel::<()>(capacity);
2278        }
2279    }
2280
2281    #[test]
2282    fn ingress_channel_capacity_sums_queue_sizes_with_a_floor() {
2283        let inputs = [input_with_queue_size(50), input_with_queue_size(70)];
2284        assert_eq!(ingress_channel_capacity(inputs.iter()), 120);
2285        let small = [input_with_queue_size(1)];
2286        assert_eq!(ingress_channel_capacity(small.iter()), 64);
2287        assert_eq!(ingress_channel_capacity(std::iter::empty()), 64);
2288    }
2289
2290    // A caller may pass a very large `Duration` to a pattern-wait helper to mean
2291    // "wait effectively forever". The `Instant::now() + timeout` deadline used to
2292    // panic on such a value (monotonic-clock overflow), aborting the process.
2293    // `representable_wait_deadline` clamps it to the longest representable wait.
2294    #[test]
2295    fn representable_wait_deadline_clamps_overflow_without_panic() {
2296        // A normal timeout lands roughly `timeout` in the future (not clamped).
2297        let before = Instant::now();
2298        let deadline = representable_wait_deadline(Duration::from_secs(5));
2299        assert!(
2300            deadline > before,
2301            "a real timeout must produce a future deadline"
2302        );
2303        assert!(
2304            deadline <= Instant::now() + Duration::from_secs(6),
2305            "a small timeout must not be clamped to a huge one"
2306        );
2307        // `Duration::MAX` would overflow `Instant::now() + timeout`; the clamp
2308        // yields a real future deadline instead of panicking.
2309        let deadline_max = representable_wait_deadline(Duration::MAX);
2310        assert!(
2311            deadline_max > Instant::now(),
2312            "an overflowing timeout must still produce a future deadline"
2313        );
2314    }
2315
2316    #[test]
2317    fn control_event_json_shape_and_key_order() {
2318        let clock = uhlc::HLC::default();
2319        let start = clock.new_timestamp();
2320
2321        // An id-bearing control event: keys in `type`, `id`, `time_offset_secs`
2322        // order (serde_json's `preserve_order` makes the order observable).
2323        let with_id = control_event_json(&clock, &start, "InputClosed", Some("cam".to_owned()));
2324        let obj = with_id.as_object().expect("object");
2325        assert_eq!(
2326            obj.keys().collect::<Vec<_>>(),
2327            vec!["type", "id", "time_offset_secs"]
2328        );
2329        assert_eq!(obj["type"], serde_json::json!("InputClosed"));
2330        assert_eq!(obj["id"], serde_json::json!("cam"));
2331        assert!(obj["time_offset_secs"].is_f64());
2332
2333        // A control event without an id omits the `id` field entirely.
2334        let without_id = control_event_json(&clock, &start, "AllInputsClosed", None);
2335        let obj = without_id.as_object().expect("object");
2336        assert_eq!(
2337            obj.keys().collect::<Vec<_>>(),
2338            vec!["type", "time_offset_secs"]
2339        );
2340        assert_eq!(obj["type"], serde_json::json!("AllInputsClosed"));
2341    }
2342
2343    #[test]
2344    fn convert_param_update() {
2345        let item = EventItem::NodeEvent {
2346            event: NodeEvent::ParamUpdate {
2347                key: "fps".into(),
2348                value_json: serde_json::to_vec(&serde_json::json!(60)).unwrap(),
2349            },
2350        };
2351        let event = EventStream::convert_event_item(item);
2352        match event {
2353            Event::ParamUpdate { key, value } => {
2354                assert_eq!(key, "fps");
2355                assert_eq!(value, serde_json::json!(60));
2356            }
2357            other => panic!("expected ParamUpdate, got {other:?}"),
2358        }
2359    }
2360
2361    /// Regression test for the daemon↔node wire protocol: `NodeEvent`
2362    /// is sent over TCP with postcard, so any field type that uses
2363    /// `Deserializer::deserialize_any` (like `serde_json::Value`)
2364    /// breaks the channel and kills the node at the next receive.
2365    /// `NodeEvent::ParamUpdate` carries its value as JSON-encoded
2366    /// bytes for that reason. This test pins the invariant so we
2367    /// don't regress back to a `deserialize_any` field.
2368    #[test]
2369    fn node_event_param_update_round_trips_through_postcard() {
2370        let cases = [
2371            serde_json::json!(42),
2372            serde_json::json!(1.5),
2373            serde_json::json!("hello"),
2374            serde_json::json!(null),
2375            serde_json::json!([1, 2, 3]),
2376            serde_json::json!({"nested": {"array": [true, false]}}),
2377        ];
2378        for value in cases {
2379            let event = NodeEvent::ParamUpdate {
2380                key: "rate".into(),
2381                value_json: serde_json::to_vec(&value).unwrap(),
2382            };
2383            let bytes = dora_message::encode(&event).expect("serialize");
2384            let back: NodeEvent = dora_message::decode(&bytes).expect("deserialize");
2385            match back {
2386                NodeEvent::ParamUpdate { key, value_json } => {
2387                    assert_eq!(key, "rate");
2388                    let decoded: serde_json::Value =
2389                        serde_json::from_slice(&value_json).expect("value_json is JSON");
2390                    assert_eq!(decoded, value);
2391                }
2392                other => panic!("expected ParamUpdate, got {other:?}"),
2393            }
2394        }
2395    }
2396
2397    // -- WriteEventsTo poisoned-state tests (#1857) ------------------------
2398    //
2399    // Build a `WriteEventsTo` against a tempfile, exercise the public
2400    // surface (push events / mark_poisoned / write_out), then parse the
2401    // resulting JSON and assert on the `recording_status` field shape.
2402    // No new dev-deps — uses std::env::temp_dir() + uuid (already a dep).
2403
2404    fn write_events_to_with_tempfile() -> (WriteEventsTo, std::path::PathBuf) {
2405        let path = std::env::temp_dir().join(format!(
2406            "dora-write-events-test-{}.json",
2407            uuid::Uuid::new_v4()
2408        ));
2409        let file = std::fs::File::create(&path).expect("create tempfile");
2410        let w = WriteEventsTo {
2411            node_id: "test-node".parse().unwrap(),
2412            file,
2413            events_buffer: Vec::new(),
2414            poisoned: None,
2415        };
2416        (w, path)
2417    }
2418
2419    fn read_back(path: &std::path::Path) -> serde_json::Value {
2420        let s = std::fs::read_to_string(path).expect("read back tempfile");
2421        std::fs::remove_file(path).ok();
2422        serde_json::from_str(&s).expect("output is valid JSON")
2423    }
2424
2425    #[test]
2426    fn write_events_clean_recording_emits_state_clean() {
2427        let (mut w, path) = write_events_to_with_tempfile();
2428        w.events_buffer.push(serde_json::json!({"type": "Stop"}));
2429        w.write_out().expect("write_out clean recording");
2430
2431        let v = read_back(&path);
2432        assert_eq!(v["recording_status"]["state"], "clean");
2433        assert_eq!(v["events"].as_array().unwrap().len(), 1);
2434        assert_eq!(v["id"], "test-node");
2435    }
2436
2437    #[test]
2438    fn write_events_poisoned_recording_emits_state_poisoned_with_first_failure() {
2439        let (mut w, path) = write_events_to_with_tempfile();
2440        // 2 events recorded cleanly, then a failure, then 1 more event
2441        w.events_buffer.push(serde_json::json!({"type": "Input"}));
2442        w.events_buffer.push(serde_json::json!({"type": "Input"}));
2443        w.mark_poisoned(&eyre!("arrow conversion failed: bad type"), 1.5);
2444        w.events_buffer.push(serde_json::json!({"type": "Stop"}));
2445        w.write_out().expect("write_out poisoned recording");
2446
2447        let v = read_back(&path);
2448        let status = &v["recording_status"];
2449        assert_eq!(status["state"], "poisoned");
2450        assert_eq!(status["first_failure_event_index"], 2);
2451        assert_eq!(status["first_failure_time_offset_secs"], 1.5);
2452        assert!(
2453            status["first_failure_error"]
2454                .as_str()
2455                .unwrap()
2456                .contains("arrow conversion failed: bad type")
2457        );
2458        assert_eq!(status["additional_failures"], 0);
2459        // `events` keeps the 2 clean + 1 post-failure-but-successful events.
2460        assert_eq!(v["events"].as_array().unwrap().len(), 3);
2461    }
2462
2463    #[test]
2464    fn write_events_multiple_failures_keep_first_and_count_rest() {
2465        let (mut w, path) = write_events_to_with_tempfile();
2466        w.mark_poisoned(&eyre!("first error"), 0.5);
2467        w.mark_poisoned(&eyre!("second error"), 1.0);
2468        w.mark_poisoned(&eyre!("third error"), 1.5);
2469        w.write_out().expect("write_out with multiple failures");
2470
2471        let v = read_back(&path);
2472        let status = &v["recording_status"];
2473        assert_eq!(status["state"], "poisoned");
2474        // First failure detail is preserved, NOT overwritten by later ones.
2475        assert_eq!(status["first_failure_event_index"], 0);
2476        assert_eq!(status["first_failure_time_offset_secs"], 0.5);
2477        assert!(
2478            status["first_failure_error"]
2479                .as_str()
2480                .unwrap()
2481                .contains("first error")
2482        );
2483        // Two additional failures after the first.
2484        assert_eq!(status["additional_failures"], 2);
2485    }
2486
2487    #[test]
2488    fn convert_param_deleted() {
2489        let item = EventItem::NodeEvent {
2490            event: NodeEvent::ParamDeleted { key: "fps".into() },
2491        };
2492        let event = EventStream::convert_event_item(item);
2493        match event {
2494            Event::ParamDeleted { key } => {
2495                assert_eq!(key, "fps");
2496            }
2497            other => panic!("expected ParamDeleted, got {other:?}"),
2498        }
2499    }
2500
2501    #[test]
2502    fn convert_stop_event() {
2503        let item = EventItem::NodeEvent {
2504            event: NodeEvent::Stop,
2505        };
2506        let event = EventStream::convert_event_item(item);
2507        assert!(matches!(event, Event::Stop(StopCause::Manual)));
2508    }
2509
2510    #[test]
2511    fn convert_all_inputs_closed() {
2512        let item = EventItem::NodeEvent {
2513            event: NodeEvent::AllInputsClosed,
2514        };
2515        let event = EventStream::convert_event_item(item);
2516        assert!(matches!(event, Event::Stop(StopCause::AllInputsClosed)));
2517    }
2518
2519    #[test]
2520    fn convert_input_closed() {
2521        let item = EventItem::NodeEvent {
2522            event: NodeEvent::InputClosed {
2523                id: "input_1".to_string().into(),
2524            },
2525        };
2526        let event = EventStream::convert_event_item(item);
2527        match event {
2528            Event::InputClosed { id } => assert_eq!(AsRef::<str>::as_ref(&id), "input_1"),
2529            other => panic!("expected InputClosed, got {other:?}"),
2530        }
2531    }
2532
2533    #[test]
2534    fn convert_node_restarted() {
2535        let item = EventItem::NodeEvent {
2536            event: NodeEvent::NodeRestarted {
2537                id: "upstream".to_string().into(),
2538            },
2539        };
2540        let event = EventStream::convert_event_item(item);
2541        match event {
2542            Event::NodeRestarted { id } => assert_eq!(AsRef::<str>::as_ref(&id), "upstream"),
2543            other => panic!("expected NodeRestarted, got {other:?}"),
2544        }
2545    }
2546
2547    // ---- dora-rs/adora#148: pattern-aware correlation classification ----
2548
2549    use arrow::array::new_empty_array;
2550    use arrow::datatypes::DataType as ArrowDataType;
2551    use dora_arrow_convert::internal::from_array_ref;
2552    use dora_message::metadata::{
2553        GOAL_ID, GOAL_STATUS, GOAL_STATUS_ABORTED, GOAL_STATUS_SUCCEEDED, Metadata,
2554        MetadataParameters, Parameter, REQUEST_ID,
2555    };
2556
2557    fn make_metadata(params: MetadataParameters) -> Metadata {
2558        Metadata::from_parameters(dora_core::uhlc::HLC::default().new_timestamp(), params)
2559    }
2560
2561    fn make_input_event(id: &str, params: MetadataParameters) -> Event {
2562        Event::Input {
2563            id: id.into(),
2564            metadata: make_metadata(params),
2565            data: from_array_ref(new_empty_array(&ArrowDataType::Null)),
2566        }
2567    }
2568
2569    fn request_id_params(id: &str) -> MetadataParameters {
2570        let mut p = MetadataParameters::new();
2571        p.insert(REQUEST_ID.into(), Parameter::String(id.to_string()));
2572        p
2573    }
2574
2575    fn goal_params(goal_id: &str, status: Option<&str>) -> MetadataParameters {
2576        let mut p = MetadataParameters::new();
2577        p.insert(GOAL_ID.into(), Parameter::String(goal_id.to_string()));
2578        if let Some(s) = status {
2579            p.insert(GOAL_STATUS.into(), Parameter::String(s.to_string()));
2580        }
2581        p
2582    }
2583
2584    fn is_request_match(needle: &str) -> impl Fn(&Event) -> bool + '_ {
2585        move |event: &Event| match event {
2586            Event::Input { metadata, .. } => {
2587                dora_message::metadata::get_string_param(&metadata.parameters, REQUEST_ID)
2588                    == Some(needle)
2589            }
2590            _ => false,
2591        }
2592    }
2593
2594    fn is_action_result_match(needle: &str) -> impl Fn(&Event) -> bool + '_ {
2595        move |event: &Event| match event {
2596            Event::Input { metadata, .. } => {
2597                let p = &metadata.parameters;
2598                dora_message::metadata::get_string_param(p, GOAL_ID) == Some(needle)
2599                    && matches!(
2600                        dora_message::metadata::get_string_param(p, GOAL_STATUS),
2601                        Some(GOAL_STATUS_SUCCEEDED)
2602                            | Some(GOAL_STATUS_ABORTED)
2603                            | Some(dora_message::metadata::GOAL_STATUS_CANCELED)
2604                    )
2605            }
2606            _ => false,
2607        }
2608    }
2609
2610    #[test]
2611    fn classify_matching_request_id_returns_match() {
2612        let server = NodeId::from("calc".to_string());
2613        let event = make_input_event("response", request_id_params("req-42"));
2614        assert_eq!(
2615            classify_correlation_event(&event, &server, is_request_match("req-42")),
2616            CorrelationOutcome::Match
2617        );
2618    }
2619
2620    #[test]
2621    fn classify_different_request_id_is_passthrough() {
2622        let server = NodeId::from("calc".to_string());
2623        let event = make_input_event("response", request_id_params("req-99"));
2624        assert_eq!(
2625            classify_correlation_event(&event, &server, is_request_match("req-42")),
2626            CorrelationOutcome::Passthrough
2627        );
2628    }
2629
2630    #[test]
2631    fn classify_expected_server_restart_returns_server_restarted() {
2632        let server = NodeId::from("calc".to_string());
2633        let event = Event::NodeRestarted { id: server.clone() };
2634        assert_eq!(
2635            classify_correlation_event(&event, &server, is_request_match("req-42")),
2636            CorrelationOutcome::ServerRestarted
2637        );
2638    }
2639
2640    #[test]
2641    fn classify_unrelated_node_restart_is_passthrough() {
2642        let server = NodeId::from("calc".to_string());
2643        let event = Event::NodeRestarted {
2644            id: NodeId::from("other".to_string()),
2645        };
2646        assert_eq!(
2647            classify_correlation_event(&event, &server, is_request_match("req-42")),
2648            CorrelationOutcome::Passthrough
2649        );
2650    }
2651
2652    #[test]
2653    fn classify_stop_returns_stream_ended() {
2654        let server = NodeId::from("calc".to_string());
2655        let event = Event::Stop(StopCause::Manual);
2656        assert_eq!(
2657            classify_correlation_event(&event, &server, is_request_match("req-42")),
2658            CorrelationOutcome::StreamEnded
2659        );
2660    }
2661
2662    #[test]
2663    fn classify_error_returns_stream_error() {
2664        let server = NodeId::from("calc".to_string());
2665        let event = Event::Error("boom".to_string());
2666        assert_eq!(
2667            classify_correlation_event(&event, &server, is_request_match("req-42")),
2668            CorrelationOutcome::StreamError
2669        );
2670    }
2671
2672    #[test]
2673    fn classify_unrelated_input_is_passthrough() {
2674        let server = NodeId::from("calc".to_string());
2675        let event = make_input_event("sensor", MetadataParameters::new());
2676        assert_eq!(
2677            classify_correlation_event(&event, &server, is_request_match("req-42")),
2678            CorrelationOutcome::Passthrough
2679        );
2680    }
2681
2682    #[test]
2683    fn classify_param_update_is_passthrough() {
2684        // Runtime parameter updates must survive a helper wait.
2685        let server = NodeId::from("calc".to_string());
2686        let event = Event::ParamUpdate {
2687            key: "threshold".to_string(),
2688            value: serde_json::json!(0.85),
2689        };
2690        assert_eq!(
2691            classify_correlation_event(&event, &server, is_request_match("req-42")),
2692            CorrelationOutcome::Passthrough
2693        );
2694    }
2695
2696    #[test]
2697    fn classify_action_result_terminal_succeeded_matches() {
2698        let server = NodeId::from("nav".to_string());
2699        let event = make_input_event("result", goal_params("goal-1", Some(GOAL_STATUS_SUCCEEDED)));
2700        assert_eq!(
2701            classify_correlation_event(&event, &server, is_action_result_match("goal-1")),
2702            CorrelationOutcome::Match
2703        );
2704    }
2705
2706    #[test]
2707    fn classify_action_result_terminal_aborted_matches() {
2708        let server = NodeId::from("nav".to_string());
2709        let event = make_input_event("result", goal_params("goal-1", Some(GOAL_STATUS_ABORTED)));
2710        assert_eq!(
2711            classify_correlation_event(&event, &server, is_action_result_match("goal-1")),
2712            CorrelationOutcome::Match
2713        );
2714    }
2715
2716    #[test]
2717    fn classify_action_feedback_without_terminal_status_is_passthrough() {
2718        // Intermediate feedback (no terminal goal_status) should pass
2719        // through so the caller's main loop can observe it.
2720        let server = NodeId::from("nav".to_string());
2721        let event = make_input_event("feedback", goal_params("goal-1", None));
2722        assert_eq!(
2723            classify_correlation_event(&event, &server, is_action_result_match("goal-1")),
2724            CorrelationOutcome::Passthrough
2725        );
2726    }
2727
2728    #[test]
2729    fn classify_action_result_for_different_goal_is_passthrough() {
2730        let server = NodeId::from("nav".to_string());
2731        let event = make_input_event("result", goal_params("goal-2", Some(GOAL_STATUS_SUCCEEDED)));
2732        assert_eq!(
2733            classify_correlation_event(&event, &server, is_action_result_match("goal-1")),
2734            CorrelationOutcome::Passthrough
2735        );
2736    }
2737
2738    // ---- dora-rs/adora#172: pending_passthrough integration ----
2739
2740    use crate::integration_testing::{
2741        IntegrationTestInput, TestingInput, TestingOptions, TestingOutput,
2742        integration_testing_format::{IncomingEvent, TimedIncomingEvent},
2743    };
2744
2745    /// Create a minimal EventStream via the testing path.
2746    fn test_event_stream() -> (crate::DoraNode, EventStream) {
2747        let events = vec![TimedIncomingEvent {
2748            time_offset_secs: 0.0,
2749            event: IncomingEvent::Stop,
2750        }];
2751        let inputs = TestingInput::Input(IntegrationTestInput::new(
2752            "test-node".parse().unwrap(),
2753            events,
2754        ));
2755        let (tx, _rx) = crate::integration_testing::output_channel();
2756        let outputs = TestingOutput::ToChannel(tx);
2757        let options = TestingOptions {
2758            skip_output_time_offsets: true,
2759        };
2760        crate::DoraNode::init_testing(inputs, outputs, options).unwrap()
2761    }
2762
2763    /// #2956: outputs sent through `TestingOutput::ToChannel` must reach the
2764    /// receiver, in order, when drained after the node has finished — the
2765    /// documented usage pattern, and previously untested (every other
2766    /// `ToChannel` test here drops the receiver).
2767    ///
2768    /// Plain `#[test]` on purpose: the testing bridge uses
2769    /// `blocking_send`/`blocking_recv` on the request channel, which panic
2770    /// inside a tokio runtime.
2771    #[test]
2772    fn to_channel_delivers_outputs_in_order() {
2773        use arrow::array::Int32Array;
2774
2775        let events = vec![TimedIncomingEvent {
2776            time_offset_secs: 0.0,
2777            event: IncomingEvent::Stop,
2778        }];
2779        let inputs = TestingInput::Input(IntegrationTestInput::new(
2780            "test-node".parse().unwrap(),
2781            events,
2782        ));
2783        let (tx, mut rx) = crate::integration_testing::output_channel();
2784        let outputs = TestingOutput::ToChannel(tx);
2785        let options = TestingOptions {
2786            skip_output_time_offsets: true,
2787        };
2788        let (mut node, _events) = crate::DoraNode::init_testing(inputs, outputs, options).unwrap();
2789
2790        for i in 0..3 {
2791            node.send_output(
2792                "out".parse().unwrap(),
2793                Default::default(),
2794                dora_arrow_convert::internal::from_array_ref(std::sync::Arc::new(
2795                    Int32Array::from(vec![i]),
2796                )),
2797            )
2798            .unwrap();
2799        }
2800
2801        let received = crate::integration_testing::drain_outputs(&mut rx);
2802
2803        assert_eq!(received.len(), 3, "every sent output should be delivered");
2804        for (i, output) in received.iter().enumerate() {
2805            assert_eq!(output.get("id").and_then(|v| v.as_str()), Some("out"));
2806            assert_eq!(
2807                output.get("data"),
2808                Some(&serde_json::json!([i as i32])),
2809                "outputs should arrive in send order"
2810            );
2811        }
2812    }
2813
2814    /// A `Null` first message on a type-checked input must NOT consume the
2815    /// one-shot first-message type check: the check has to survive so the
2816    /// first genuinely-typed message is still validated. Before the fix the
2817    /// check was `remove`d before the `Null` guard, so any input whose first
2818    /// message was `Null` (a timer tick, an empty/metadata-only payload)
2819    /// silently disabled type validation for all its later messages.
2820    #[test]
2821    fn null_first_message_does_not_consume_type_check() {
2822        use arrow::array::{Int32Array, NullArray};
2823        use dora_message::metadata::Metadata;
2824
2825        let (_node, mut events) = test_event_stream();
2826        let id = DataId::from("cam".to_string());
2827        events
2828            .input_type_checks
2829            .insert(id.clone(), arrow_schema::DataType::Int32);
2830
2831        let clock = dora_core::uhlc::HLC::default();
2832        let input = |arr: std::sync::Arc<dyn arrow::array::Array>| Event::Input {
2833            id: id.clone(),
2834            metadata: Metadata::new(clock.new_timestamp()),
2835            data: dora_arrow_convert::internal::from_array_ref(arr),
2836        };
2837
2838        // A `Null` first message leaves the check armed.
2839        events.note_produced_event(&input(std::sync::Arc::new(NullArray::new(1))));
2840        assert!(
2841            events.input_type_checks.contains_key(&id),
2842            "a Null first message must not consume the type check"
2843        );
2844
2845        // The first non-Null message consumes it (validation happened).
2846        events.note_produced_event(&input(std::sync::Arc::new(Int32Array::from(vec![1]))));
2847        assert!(
2848            !events.input_type_checks.contains_key(&id),
2849            "the first non-Null message must consume the one-shot type check"
2850        );
2851    }
2852
2853    #[test]
2854    fn is_empty_reflects_pending_passthrough() {
2855        let (_node, mut events) = test_event_stream();
2856        // Drain the initial Stop event so the stream is empty.
2857        let _ = events.recv();
2858        assert!(events.is_empty(), "should be empty after draining");
2859
2860        // Inject a passthrough event — is_empty must now return false.
2861        events.push_passthrough_for_testing(Event::ParamDeleted {
2862            key: "k".to_string(),
2863        });
2864        assert!(
2865            !events.is_empty(),
2866            "should not be empty with pending passthrough"
2867        );
2868    }
2869
2870    #[test]
2871    fn stream_poll_next_drains_pending_passthrough() {
2872        use futures::StreamExt;
2873        let (_node, mut events) = test_event_stream();
2874        // Drain the initial Stop event.
2875        let _ = events.recv();
2876
2877        // Inject a passthrough event.
2878        events.push_passthrough_for_testing(Event::ParamUpdate {
2879            key: "threshold".to_string(),
2880            value: serde_json::json!(42),
2881        });
2882
2883        // StreamExt::next() should return the passthrough event, not
2884        // block waiting on the underlying receiver.
2885        let next = futures::executor::block_on(events.next());
2886        match next {
2887            Some(Event::ParamUpdate { key, value }) => {
2888                assert_eq!(key, "threshold");
2889                assert_eq!(value, serde_json::json!(42));
2890            }
2891            other => panic!("expected ParamUpdate from passthrough, got {other:?}"),
2892        }
2893    }
2894
2895    #[test]
2896    fn recv_async_drains_pending_passthrough_before_receiver() {
2897        let (_node, mut events) = test_event_stream();
2898
2899        // Inject a passthrough event BEFORE the Stop in the receiver.
2900        events.push_passthrough_for_testing(Event::ParamDeleted {
2901            key: "x".to_string(),
2902        });
2903
2904        // First recv should return the passthrough event.
2905        let first = events.recv();
2906        assert!(
2907            matches!(first, Some(Event::ParamDeleted { .. })),
2908            "expected passthrough ParamDeleted first, got {first:?}"
2909        );
2910
2911        // Second recv should return the Stop from the receiver.
2912        let second = events.recv();
2913        assert!(
2914            matches!(second, Some(Event::Stop(_))),
2915            "expected Stop second, got {second:?}"
2916        );
2917    }
2918
2919    // ---- dora-rs/dora#3197: the passthrough buffer is bounded per input ----
2920
2921    /// Build an `Event::Input` for `id`, optionally tagged with a `request_id`
2922    /// so `carries_pattern_correlation` treats it as a correlated response.
2923    fn passthrough_input(id: &DataId, request_id: Option<&str>) -> Event {
2924        use dora_message::metadata::{Metadata, MetadataParameters, Parameter, REQUEST_ID};
2925        let mut params = MetadataParameters::new();
2926        if let Some(request_id) = request_id {
2927            params.insert(REQUEST_ID.into(), Parameter::String(request_id.to_string()));
2928        }
2929        let ts = dora_core::uhlc::HLC::default().new_timestamp();
2930        Event::Input {
2931            id: id.clone(),
2932            metadata: Metadata::from_parameters(ts, params),
2933            data: dora_arrow_convert::internal::from_array_ref(std::sync::Arc::new(
2934                arrow::array::NullArray::new(1),
2935            )),
2936        }
2937    }
2938
2939    fn count_buffered_for(events: &EventStream, id: &DataId) -> usize {
2940        events
2941            .pending_passthrough
2942            .iter()
2943            .filter(|e| matches!(e, Event::Input { id: eid, .. } if eid == id))
2944            .count()
2945    }
2946
2947    /// A node that lets pattern-aware waits own the stream and never reads
2948    /// events itself must not accumulate an unbounded passthrough buffer: an
2949    /// ordinary input stashed there is capped at its effective `queue_size`
2950    /// (default 10, `drop_oldest`), and the overflow is accounted as drops.
2951    #[test]
2952    fn buffer_passthrough_bounds_ordinary_inputs_per_queue_size() {
2953        let (_node, mut events) = test_event_stream();
2954        let _ = events.recv(); // drain the initial Stop
2955
2956        let id = DataId::from("sensor".to_string());
2957        let cap = 10; // unconfigured input -> DEFAULT_QUEUE_SIZE, drop_oldest
2958        for _ in 0..(cap + 5) {
2959            events.buffer_passthrough(passthrough_input(&id, None));
2960        }
2961
2962        assert_eq!(
2963            count_buffered_for(&events, &id),
2964            cap,
2965            "passthrough retention must be capped at the input's queue_size"
2966        );
2967        assert_eq!(
2968            events.drain_drop_counts().get(&id),
2969            Some(&5),
2970            "over-cap passthrough events must be counted as drops for the input"
2971        );
2972    }
2973
2974    /// The passthrough bound honours the same correlation preference the
2975    /// scheduler uses: when the buffer for an input is full of correlated
2976    /// responses, an incoming ordinary event on that input is dropped rather
2977    /// than a correlation — a client waiting on a `request_id` never loses its
2978    /// response to unrelated traffic.
2979    #[test]
2980    fn buffer_passthrough_preserves_correlated_over_ordinary() {
2981        use dora_message::metadata::{REQUEST_ID, get_string_param};
2982
2983        let (_node, mut events) = test_event_stream();
2984        let _ = events.recv();
2985
2986        let id = DataId::from("svc".to_string());
2987        let cap = 10;
2988
2989        for i in 0..cap {
2990            events.buffer_passthrough(passthrough_input(&id, Some(&format!("req-{i}"))));
2991        }
2992        // Flood with ordinary events on the same input.
2993        for _ in 0..20 {
2994            events.buffer_passthrough(passthrough_input(&id, None));
2995        }
2996
2997        let correlated = events
2998            .pending_passthrough
2999            .iter()
3000            .filter(|e| {
3001                matches!(e, Event::Input { metadata, .. }
3002                if get_string_param(&metadata.parameters, REQUEST_ID).is_some())
3003            })
3004            .count();
3005        assert_eq!(
3006            correlated, cap,
3007            "every correlated response must be preserved"
3008        );
3009        assert_eq!(
3010            events.pending_passthrough.len(),
3011            cap,
3012            "ordinary floods must not push the buffer past the cap"
3013        );
3014    }
3015
3016    /// A buffered `Stop` has no input id and must never be evicted by an input
3017    /// flooding the buffer — mirroring the scheduler, where `Stop` is
3018    /// eviction-immune.
3019    #[test]
3020    fn buffer_passthrough_never_drops_stop() {
3021        let (_node, mut events) = test_event_stream();
3022        let _ = events.recv();
3023
3024        events.buffer_passthrough(Event::Stop(StopCause::Manual));
3025
3026        let id = DataId::from("flood".to_string());
3027        for _ in 0..30 {
3028            events.buffer_passthrough(passthrough_input(&id, None));
3029        }
3030
3031        assert!(
3032            events
3033                .pending_passthrough
3034                .iter()
3035                .any(|e| matches!(e, Event::Stop(_))),
3036            "a buffered Stop must survive an input flood"
3037        );
3038        assert_eq!(
3039            count_buffered_for(&events, &id),
3040            10,
3041            "the flooding input is still capped at its queue_size"
3042        );
3043    }
3044
3045    /// Non-input control events (a flapping upstream's `NodeRestarted`,
3046    /// `Reload`, `ParamUpdate`, …) that a pattern-aware wait keeps stashing are
3047    /// also bounded — otherwise a never-reading node grows the buffer without
3048    /// limit even though no input is involved (dora-rs/dora#3197). `Stop` still
3049    /// survives.
3050    #[test]
3051    fn buffer_passthrough_bounds_control_events() {
3052        let (_node, mut events) = test_event_stream();
3053        let _ = events.recv();
3054
3055        // One eviction-immune `Stop` plus a control-event flood. The cap counts
3056        // the `Stop` (as the scheduler's `NON_INPUT_EVENT` queue length does),
3057        // so the bucket settles at `cap` = 1 `Stop` + (cap - 1) `Reload`s.
3058        events.buffer_passthrough(Event::Stop(StopCause::Manual));
3059        let pushed_reloads = NON_INPUT_EVENT_QUEUE_SIZE + 25;
3060        for _ in 0..pushed_reloads {
3061            events.buffer_passthrough(Event::Reload { operator_id: None });
3062        }
3063
3064        let non_input = events
3065            .pending_passthrough
3066            .iter()
3067            .filter(|e| !matches!(e, Event::Input { .. }))
3068            .count();
3069        assert_eq!(
3070            non_input, NON_INPUT_EVENT_QUEUE_SIZE,
3071            "buffered control events must be capped at the non-input limit"
3072        );
3073        assert!(
3074            events
3075                .pending_passthrough
3076                .iter()
3077                .any(|e| matches!(e, Event::Stop(_))),
3078            "Stop must survive a control-event flood"
3079        );
3080        let expected_drops = (pushed_reloads + 1 - NON_INPUT_EVENT_QUEUE_SIZE) as u64;
3081        assert_eq!(
3082            events
3083                .drain_drop_counts()
3084                .get(&DataId::from(NON_INPUT_EVENT.to_string())),
3085            Some(&expected_drops),
3086            "control-event overflow must be accounted under the non-input key"
3087        );
3088    }
3089
3090    /// Regression: a pattern-aware wait (`recv_service_response`) must make
3091    /// progress when a non-matching event arrives *before* the correlated
3092    /// response.
3093    ///
3094    /// The wait loop buffers every non-matching event into
3095    /// `pending_passthrough` so the caller's own event loop can still see it.
3096    /// Before the fix the loop pumped the stream via `recv_async`, which
3097    /// drains `pending_passthrough` first — so it kept re-serving the buffered
3098    /// non-matching event, re-buffering it, and spinning forever without ever
3099    /// reading the response off the receiver. Every such call pinned a CPU core
3100    /// and returned `Timeout`. The fix pumps via `recv_from_stream`, which
3101    /// bypasses the passthrough buffer.
3102    #[test]
3103    fn recv_service_response_matches_after_non_matching_event() {
3104        // Delivered FIFO (integration tests disable the reordering scheduler):
3105        // the non-matching "sensor" input, then the correlated "response".
3106        let events = vec![
3107            TimedIncomingEvent {
3108                time_offset_secs: 0.0,
3109                event: IncomingEvent::Input {
3110                    id: "sensor".parse().unwrap(),
3111                    metadata: None,
3112                    data: None,
3113                },
3114            },
3115            TimedIncomingEvent {
3116                time_offset_secs: 0.0,
3117                event: IncomingEvent::Input {
3118                    id: "response".parse().unwrap(),
3119                    metadata: Some(request_id_params("req-1")),
3120                    data: None,
3121                },
3122            },
3123            TimedIncomingEvent {
3124                time_offset_secs: 0.0,
3125                event: IncomingEvent::Stop,
3126            },
3127        ];
3128        let inputs = TestingInput::Input(IntegrationTestInput::new(
3129            "test-node".parse().unwrap(),
3130            events,
3131        ));
3132        let (tx, _rx) = crate::integration_testing::output_channel();
3133        let outputs = TestingOutput::ToChannel(tx);
3134        let options = TestingOptions {
3135            skip_output_time_offsets: true,
3136        };
3137        let (_node, mut events) = crate::DoraNode::init_testing(inputs, outputs, options).unwrap();
3138
3139        let server = NodeId::from("calc".to_string());
3140        let response = futures::executor::block_on(events.recv_service_response(
3141            "req-1",
3142            &server,
3143            Duration::from_secs(5),
3144        ));
3145        match response {
3146            Ok(Event::Input { id, .. }) => assert_eq!(id.as_str(), "response"),
3147            other => panic!("expected the correlated response Input, got {other:?}"),
3148        }
3149
3150        // The non-matching "sensor" input must not be lost — it is replayed
3151        // to the caller's own event loop after the wait returns.
3152        let buffered = events.recv();
3153        assert!(
3154            matches!(&buffered, Some(Event::Input { id, .. }) if id.as_str() == "sensor"),
3155            "expected the buffered non-matching 'sensor' input, got {buffered:?}"
3156        );
3157    }
3158
3159    /// Regression: a pattern-aware wait must find its correlated response even
3160    /// when a *previous* wait already buffered it.
3161    ///
3162    /// This is the pipelined / out-of-order case: two requests are in flight,
3163    /// and `req-2`'s response arrives before `req-1`'s. The wait for `req-1`
3164    /// buffers `resp-2` into `pending_passthrough` as non-matching, then
3165    /// returns `resp-1`. The subsequent wait for `req-2` must return the
3166    /// already-buffered `resp-2` — the wait loop pumps `recv_from_stream`,
3167    /// which never reads `pending_passthrough`, so `wait_for_correlation`
3168    /// scans the buffer for a match before reading the stream. Without that
3169    /// scan the buffered response is invisible and the wait wrongly times out.
3170    #[test]
3171    fn recv_service_response_matches_buffered_response_from_prior_wait() {
3172        // Delivered FIFO: `resp-2` arrives before `resp-1`, then `Stop`.
3173        let events = vec![
3174            TimedIncomingEvent {
3175                time_offset_secs: 0.0,
3176                event: IncomingEvent::Input {
3177                    id: "response".parse().unwrap(),
3178                    metadata: Some(request_id_params("req-2")),
3179                    data: None,
3180                },
3181            },
3182            TimedIncomingEvent {
3183                time_offset_secs: 0.0,
3184                event: IncomingEvent::Input {
3185                    id: "response".parse().unwrap(),
3186                    metadata: Some(request_id_params("req-1")),
3187                    data: None,
3188                },
3189            },
3190            TimedIncomingEvent {
3191                time_offset_secs: 0.0,
3192                event: IncomingEvent::Stop,
3193            },
3194        ];
3195        let inputs = TestingInput::Input(IntegrationTestInput::new(
3196            "test-node".parse().unwrap(),
3197            events,
3198        ));
3199        let (tx, _rx) = crate::integration_testing::output_channel();
3200        let outputs = TestingOutput::ToChannel(tx);
3201        let options = TestingOptions {
3202            skip_output_time_offsets: true,
3203        };
3204        let (_node, mut events) = crate::DoraNode::init_testing(inputs, outputs, options).unwrap();
3205
3206        let server = NodeId::from("calc".to_string());
3207        let request_id_of = |event: &Event| match event {
3208            Event::Input { metadata, .. } => dora_message::metadata::get_string_param(
3209                &metadata.parameters,
3210                dora_message::metadata::REQUEST_ID,
3211            )
3212            .map(str::to_owned),
3213            _ => None,
3214        };
3215
3216        // Wait for `req-1`: reads `resp-2` (buffered as non-matching), then
3217        // returns `resp-1`.
3218        let first = futures::executor::block_on(events.recv_service_response(
3219            "req-1",
3220            &server,
3221            Duration::from_secs(5),
3222        ));
3223        match &first {
3224            Ok(event) => assert_eq!(request_id_of(event).as_deref(), Some("req-1")),
3225            other => panic!("expected the req-1 response, got {other:?}"),
3226        }
3227
3228        // Wait for `req-2`: `resp-2` is already in `pending_passthrough`, so
3229        // this must return it from the buffer rather than time out.
3230        let second = futures::executor::block_on(events.recv_service_response(
3231            "req-2",
3232            &server,
3233            Duration::from_secs(5),
3234        ));
3235        match &second {
3236            Ok(event) => assert_eq!(request_id_of(event).as_deref(), Some("req-2")),
3237            other => panic!("expected the buffered req-2 response, got {other:?}"),
3238        }
3239    }
3240
3241    /// After a `Stop` event is delivered, subsequent `recv` calls must
3242    /// return `None` so the node can exit cleanly even when zenoh
3243    /// subscriber threads still hold clones of the event channel
3244    /// sender (which would otherwise keep the receiver open).
3245    #[test]
3246    fn recv_returns_none_after_stop() {
3247        let (_node, mut events) = test_event_stream();
3248
3249        // First recv delivers the seeded Stop.
3250        let first = events.recv();
3251        assert!(matches!(first, Some(Event::Stop(_))));
3252
3253        // Second recv must return None even though the underlying
3254        // receiver may still have live senders.
3255        let second = events.recv();
3256        assert!(
3257            second.is_none(),
3258            "recv must return None after Stop, got {second:?}"
3259        );
3260    }
3261
3262    /// #2027: the scheduler gives `Stop` (a NON_INPUT_EVENT) strict priority
3263    /// over buffered inputs, so an input enqueued before `Stop` is still in the
3264    /// scheduler when `Stop` is delivered. `recv` must drain that input before
3265    /// closing rather than dropping it silently (the previous `return None`
3266    /// after `stop_received` lost it).
3267    #[test]
3268    fn recv_drains_buffered_scheduler_inputs_after_stop() {
3269        let (_node, mut events) = test_event_stream();
3270
3271        // Deliver the seeded Stop (sets `stop_received`).
3272        assert!(matches!(events.recv(), Some(Event::Stop(_))));
3273
3274        // Simulate the input the scheduler held back behind the prioritized Stop.
3275        events.push_scheduler_input_for_testing("cam");
3276
3277        let drained = events.recv();
3278        assert!(
3279            matches!(&drained, Some(Event::Input { id, .. }) if id.as_str() == "cam"),
3280            "buffered input must be drained after Stop, got {drained:?}"
3281        );
3282
3283        // Once the scheduler is empty the stream closes.
3284        assert!(
3285            events.recv().is_none(),
3286            "stream must close after draining buffered inputs"
3287        );
3288    }
3289
3290    /// Inputs left in the scheduler by `recv`/`try_recv` or the pattern-aware
3291    /// wait helpers (which drain the receiver into it) must still come out of
3292    /// `StreamExt::next()`; `poll_next` used to only poll the receiver, so a
3293    /// node mixing the two APIs silently lost them.
3294    #[test]
3295    fn stream_next_delivers_inputs_buffered_in_scheduler() {
3296        use futures::{FutureExt, StreamExt};
3297
3298        let (_node, mut events) = test_event_stream();
3299        events.push_scheduler_input_for_testing("cam");
3300
3301        let next = events.next().now_or_never();
3302        assert!(
3303            matches!(&next, Some(Some(Event::Input { id, .. })) if id.as_str() == "cam"),
3304            "buffered input must be delivered by the Stream path, got {next:?}"
3305        );
3306    }
3307
3308    /// The Stream path must drain buffered inputs after `Stop` exactly like
3309    /// `recv` does (see `recv_drains_buffered_scheduler_inputs_after_stop`).
3310    #[test]
3311    fn stream_next_drains_buffered_inputs_after_stop() {
3312        use futures::{FutureExt, StreamExt};
3313
3314        let (_node, mut events) = test_event_stream();
3315        assert!(matches!(events.recv(), Some(Event::Stop(_))));
3316        events.push_scheduler_stop_for_testing();
3317        events.push_scheduler_input_for_testing("cam");
3318
3319        let drained = events.next().now_or_never();
3320        assert!(
3321            matches!(&drained, Some(Some(Event::Input { id, .. })) if id.as_str() == "cam"),
3322            "buffered input must be drained after Stop, got {drained:?}"
3323        );
3324        assert!(
3325            matches!(events.next().now_or_never(), Some(None)),
3326            "stream must close after draining buffered inputs"
3327        );
3328    }
3329
3330    /// #2027 review (P2): the post-Stop drain must deliver buffered *inputs*
3331    /// only. A non-input control event buffered behind Stop (e.g. a second
3332    /// `Stop`) must NOT be re-delivered to a loop-until-`None` caller.
3333    #[test]
3334    fn recv_after_stop_skips_trailing_control_events() {
3335        let (_node, mut events) = test_event_stream();
3336
3337        // Deliver the seeded Stop (sets `stop_received`).
3338        assert!(matches!(events.recv(), Some(Event::Stop(_))));
3339
3340        // Buffer a trailing Stop AND a real input behind it. The scheduler
3341        // prioritizes the Stop (NON_INPUT), so the drain meets it first.
3342        events.push_scheduler_stop_for_testing();
3343        events.push_scheduler_input_for_testing("cam");
3344
3345        // The drain must skip the trailing Stop and return only the input...
3346        let drained = events.recv();
3347        assert!(
3348            matches!(&drained, Some(Event::Input { id, .. }) if id.as_str() == "cam"),
3349            "expected the buffered input, not a re-delivered Stop, got {drained:?}"
3350        );
3351        // ...then close (no second Stop ever surfaces).
3352        assert!(events.recv().is_none(), "stream must close after the input");
3353    }
3354
3355    /// The post-`Stop` scheduler drain must run the same first-message
3356    /// bookkeeping (`note_produced_event`) as the normal receive path — the two
3357    /// paths are documented to stay in lockstep (dora-rs/adora#172, #174). An
3358    /// input buffered before `Stop` and drained after it used to be returned
3359    /// directly, bypassing that call, so its one-shot first-message type check
3360    /// was never consumed and a type mismatch on such an input never warned.
3361    /// Regression guard: reverting the fix (returning the item without
3362    /// `note_produced_event`) leaves the check armed and fails this test.
3363    #[test]
3364    fn post_stop_drain_runs_first_message_type_check() {
3365        use crate::event_stream::thread::EventItem;
3366        use dora_message::metadata::Metadata;
3367
3368        let (_node, mut events) = test_event_stream();
3369        let id = DataId::from("cam".to_string());
3370        events
3371            .input_type_checks
3372            .insert(id.clone(), arrow_schema::DataType::Int32);
3373
3374        // Deliver the seeded Stop (sets `stop_received`).
3375        assert!(matches!(events.recv(), Some(Event::Stop(_))));
3376
3377        // An input the scheduler held back behind the prioritized Stop, carrying
3378        // a real (non-`Null`) typed payload so the one-shot check is consumed.
3379        events.use_scheduler = true;
3380        events.scheduler.add_event(EventItem::ZenohInput {
3381            id: id.clone(),
3382            metadata: std::sync::Arc::new(Metadata::new(
3383                dora_core::uhlc::HLC::default().new_timestamp(),
3384            )),
3385            data: {
3386                use arrow::array::Array;
3387                arrow::array::Int32Array::from(vec![1]).into_data()
3388            },
3389        });
3390
3391        let drained = events.recv();
3392        assert!(
3393            matches!(&drained, Some(Event::Input { id: got, .. }) if got == &id),
3394            "buffered input must be drained after Stop, got {drained:?}"
3395        );
3396        assert!(
3397            !events.input_type_checks.contains_key(&id),
3398            "the post-Stop drain must consume the one-shot type check, \
3399             staying in lockstep with the normal receive path"
3400        );
3401    }
3402
3403    /// The zenoh receive path is Arrow-IPC-only. An empty payload is a
3404    /// metadata-only message and maps to the unit array; a non-empty payload is
3405    /// a self-describing IPC stream and round-trips to its original array (with
3406    /// no type sidecar involved).
3407    #[test]
3408    fn zenoh_payload_ipc_roundtrip_and_empty_is_unit() {
3409        use crate::arrow_utils::ipc_encode::{
3410            InputDecoder, encode_ipc_into_data, ipc_fast_path_len_data,
3411        };
3412        use arrow::array::{Array, Int32Array};
3413
3414        // A standalone full stream (no SCHEMA_HASH parameter) decodes directly.
3415        let metadata =
3416            dora_message::metadata::Metadata::new(dora_core::uhlc::HLC::default().new_timestamp());
3417        let mut decoder = InputDecoder::new();
3418
3419        // Empty payload -> unit array (metadata-only message).
3420        let unit = decode_zenoh_sample(&mut decoder, &metadata, zenoh::bytes::ZBytes::new())
3421            .unwrap()
3422            .unwrap();
3423        assert_eq!(
3424            unit.data_type(),
3425            &arrow_schema::DataType::Null,
3426            "empty payload maps to the unit array"
3427        );
3428
3429        // Non-empty IPC payload round-trips to the original array.
3430        let data = Int32Array::from(vec![10, 20, 30]).into_data();
3431        let len = ipc_fast_path_len_data(&data).expect("primitive is fast-path eligible");
3432        let mut buf = vec![0u8; len];
3433        encode_ipc_into_data(&data, &mut buf).unwrap();
3434
3435        let decoded = decode_zenoh_sample(&mut decoder, &metadata, zenoh::bytes::ZBytes::from(buf))
3436            .unwrap()
3437            .unwrap();
3438        assert_eq!(decoded.data_type(), &arrow_schema::DataType::Int32);
3439        assert_eq!(&decoded, &data);
3440    }
3441
3442    /// A full self-describing stream on the data topic must prime the per-input
3443    /// decoder in-band: the schema-less batches that follow decode against it
3444    /// without any `@schema`-plane delivery. This is what makes the producer's
3445    /// "full stream until the schema is confirmed published" strategy race-free
3446    /// — data-plane puts share one publisher, so zenoh preserves their order,
3447    /// while the separate `@schema` plane can lose the race with an express
3448    /// batch (dora-rs/dora#2366 review: first-message QoS race).
3449    #[test]
3450    fn full_stream_primes_decoder_in_band_for_schema_less_batches() {
3451        use crate::arrow_utils::ipc_encode::{
3452            InputDecoder, batch_fast_path_len_data, encode_batch_into_data, encode_ipc_into_data,
3453            ipc_fast_path_len_data, schema_block_len,
3454        };
3455        use arrow::array::{Array, Int32Array};
3456        use dora_message::metadata::{Metadata, Parameter, SCHEMA_HASH};
3457
3458        let hlc = dora_core::uhlc::HLC::default();
3459        let mut decoder = InputDecoder::new();
3460
3461        // Message 1: full stream (no SCHEMA_HASH), as the producer sends while
3462        // the schema is not yet confirmed published on the `@schema` plane.
3463        let first = Int32Array::from(vec![1, 2]).into_data();
3464        let mut full = vec![0u8; ipc_fast_path_len_data(&first).unwrap()];
3465        encode_ipc_into_data(&first, &mut full).unwrap();
3466        let block = schema_block_len(&full).unwrap();
3467        let hash = dora_message::metadata::fnv1a(&full[..block]);
3468
3469        let plain = Metadata::new(hlc.new_timestamp());
3470        let got = decode_zenoh_sample(&mut decoder, &plain, zenoh::bytes::ZBytes::from(full))
3471            .unwrap()
3472            .unwrap();
3473        assert_eq!(&got, &first);
3474
3475        // Message 2: schema-less batch tagged with the schema hash. No
3476        // `set_schema` call happened — decoding must succeed purely from the
3477        // in-band priming above.
3478        let second = Int32Array::from(vec![3]).into_data();
3479        let mut batch = vec![0u8; batch_fast_path_len_data(&second).unwrap()];
3480        encode_batch_into_data(&second, &mut batch).unwrap();
3481        let mut tagged = Metadata::new(hlc.new_timestamp());
3482        tagged
3483            .parameters
3484            .insert(SCHEMA_HASH.to_string(), Parameter::Integer(hash as i64));
3485
3486        let got = decode_zenoh_sample(&mut decoder, &tagged, zenoh::bytes::ZBytes::from(batch))
3487            .unwrap()
3488            .expect("schema-less batch must decode against the in-band-primed decoder");
3489        assert_eq!(&got, &second);
3490    }
3491
3492    /// Service/action full streams (pattern-correlated) are excluded from
3493    /// schema-once and must NOT prime the decoder in-band: a server multiplexes
3494    /// per-request schemas through one output, and letting those prime the
3495    /// decoder would churn the retained schema set for no benefit.
3496    #[test]
3497    fn pattern_correlated_full_stream_does_not_prime_in_band() {
3498        use crate::arrow_utils::ipc_encode::{
3499            InputDecoder, batch_fast_path_len_data, encode_batch_into_data, encode_ipc_into_data,
3500            ipc_fast_path_len_data, schema_block_len,
3501        };
3502        use arrow::array::{Array, Int32Array};
3503        use dora_message::metadata::{Metadata, Parameter, REQUEST_ID, SCHEMA_HASH};
3504
3505        let hlc = dora_core::uhlc::HLC::default();
3506        let mut decoder = InputDecoder::new();
3507
3508        let reply = Int32Array::from(vec![7]).into_data();
3509        let mut full = vec![0u8; ipc_fast_path_len_data(&reply).unwrap()];
3510        encode_ipc_into_data(&reply, &mut full).unwrap();
3511        let block = schema_block_len(&full).unwrap();
3512        let hash = dora_message::metadata::fnv1a(&full[..block]);
3513
3514        // A service reply (request_id) decodes standalone…
3515        let mut service = Metadata::new(hlc.new_timestamp());
3516        service
3517            .parameters
3518            .insert(REQUEST_ID.to_string(), Parameter::String("req-1".into()));
3519        let got = decode_zenoh_sample(&mut decoder, &service, zenoh::bytes::ZBytes::from(full))
3520            .unwrap()
3521            .unwrap();
3522        assert_eq!(&got, &reply);
3523
3524        // …but must not have primed the decoder for its schema hash.
3525        let batch_array = Int32Array::from(vec![8]).into_data();
3526        let mut batch = vec![0u8; batch_fast_path_len_data(&batch_array).unwrap()];
3527        encode_batch_into_data(&batch_array, &mut batch).unwrap();
3528        let mut tagged = Metadata::new(hlc.new_timestamp());
3529        tagged
3530            .parameters
3531            .insert(SCHEMA_HASH.to_string(), Parameter::Integer(hash as i64));
3532        assert!(
3533            decode_zenoh_sample(&mut decoder, &tagged, zenoh::bytes::ZBytes::from(batch))
3534                .unwrap()
3535                .is_none(),
3536            "a pattern-correlated stream must not prime the schema-once decoder"
3537        );
3538    }
3539
3540    /// Internal wire-protocol keys (`_schema_hash`, `_framing`) must be
3541    /// stripped from the metadata handed to user code — on both the zenoh and
3542    /// the daemon receive paths. A forwarded `_schema_hash` would otherwise
3543    /// ride onto a large/service output (which does not overwrite it) and make
3544    /// receivers hash-mismatch and silently drop the message
3545    /// (dora-rs/dora#2366 review).
3546    #[test]
3547    fn internal_wire_keys_are_stripped_from_user_visible_metadata() {
3548        use dora_message::metadata::{
3549            FRAMING, FRAMING_ARROW_IPC, Metadata, Parameter, SCHEMA_HASH,
3550        };
3551
3552        let hlc = dora_core::uhlc::HLC::default();
3553        let mut metadata = Metadata::new(hlc.new_timestamp());
3554        metadata
3555            .parameters
3556            .insert(SCHEMA_HASH.to_string(), Parameter::Integer(42));
3557        metadata.parameters.insert(
3558            FRAMING.to_string(),
3559            Parameter::String(FRAMING_ARROW_IPC.to_string()),
3560        );
3561        metadata
3562            .parameters
3563            .insert("user_key".to_string(), Parameter::Integer(7));
3564
3565        // Zenoh receive path.
3566        let zenoh_item = EventItem::ZenohInput {
3567            id: DataId::from("in".to_string()),
3568            metadata: Arc::new(metadata.clone()),
3569            data: {
3570                use arrow::array::Array;
3571                arrow::array::Int32Array::from(vec![1]).into_data()
3572            },
3573        };
3574        let Event::Input {
3575            metadata: user_metadata,
3576            ..
3577        } = EventStream::convert_event_item(zenoh_item)
3578        else {
3579            panic!("expected an input event");
3580        };
3581        assert!(!user_metadata.parameters.contains_key(SCHEMA_HASH));
3582        assert!(!user_metadata.parameters.contains_key(FRAMING));
3583        assert_eq!(
3584            user_metadata.parameters.get("user_key"),
3585            Some(&Parameter::Integer(7)),
3586            "user-provided keys must survive the strip"
3587        );
3588
3589        // Daemon receive path.
3590        let daemon_item = EventItem::NodeEvent {
3591            event: dora_message::daemon_to_node::NodeEvent::Input {
3592                id: DataId::from("in".to_string()),
3593                metadata: Arc::new(metadata),
3594                data: None,
3595            },
3596        };
3597        let Event::Input {
3598            metadata: user_metadata,
3599            ..
3600        } = EventStream::convert_event_item(daemon_item)
3601        else {
3602            panic!("expected an input event");
3603        };
3604        assert!(!user_metadata.parameters.contains_key(SCHEMA_HASH));
3605        assert!(!user_metadata.parameters.contains_key(FRAMING));
3606    }
3607
3608    /// A zenoh-delivered input must be serializable into the same recording
3609    /// JSON shape as a daemon-path input, so `write_events_to` recordings do
3610    /// not silently drop inputs that take the direct zenoh data plane.
3611    #[test]
3612    fn zenoh_input_serializes_into_recording_json() {
3613        use crate::daemon_connection::node_integration_testing::convert_arrow_input_to_json;
3614
3615        let hlc = dora_core::uhlc::HLC::default();
3616        let start = hlc.new_timestamp();
3617        let metadata = Metadata::new(hlc.new_timestamp());
3618        let array: arrow::array::ArrayRef =
3619            std::sync::Arc::new(arrow::array::Int32Array::from(vec![1, 2, 3]));
3620
3621        let json = convert_arrow_input_to_json(
3622            &DataId::from("in".to_string()),
3623            &metadata,
3624            array,
3625            start,
3626            true,
3627        )
3628        .expect("zenoh input must serialize");
3629
3630        assert_eq!(json["id"], "in");
3631        assert!(json.contains_key("data"), "recorded event must carry data");
3632        assert!(
3633            json.contains_key("data_type"),
3634            "recorded event must carry data_type"
3635        );
3636        assert_eq!(
3637            json["data"].as_array().map(|a| a.len()),
3638            Some(3),
3639            "all array elements must be recorded"
3640        );
3641    }
3642
3643    /// A zenoh input can carry a remote HLC timestamp that predates this node's
3644    /// `start_timestamp`. The recording must clamp the offset to zero instead of
3645    /// underflowing the NTP64 subtraction (a debug panic that would kill the
3646    /// event loop, or a release wraparound to a garbage offset).
3647    #[test]
3648    fn zenoh_input_with_earlier_timestamp_does_not_underflow() {
3649        use crate::daemon_connection::node_integration_testing::convert_arrow_input_to_json;
3650
3651        let hlc = dora_core::uhlc::HLC::default();
3652        // `input_ts` is created first, so it is strictly before `start`.
3653        let input_ts = hlc.new_timestamp();
3654        let start = hlc.new_timestamp();
3655        let metadata = Metadata::new(input_ts);
3656        let array: arrow::array::ArrayRef =
3657            std::sync::Arc::new(arrow::array::Int32Array::from(vec![1]));
3658
3659        // `skip_output_time_offsets = false` exercises the time-offset path.
3660        let json = convert_arrow_input_to_json(
3661            &DataId::from("in".to_string()),
3662            &metadata,
3663            array,
3664            start,
3665            false,
3666        )
3667        .expect("recording an earlier-timestamped input must not fail");
3668        assert_eq!(
3669            json["time_offset_secs"], 0.0,
3670            "an input predating start must clamp to a zero offset"
3671        );
3672    }
3673
3674    /// The schema-plane FatalError only fires after the grace window: the
3675    /// producer's periodic full-stream refresh heals an unprimed input in-band,
3676    /// so a node with a dead `@schema` plane must not be killed on the first
3677    /// dropped batch when it would recover within seconds.
3678    #[test]
3679    fn schema_plane_fatal_waits_out_the_grace_window() {
3680        let start = Instant::now();
3681        let mut first = None;
3682
3683        // First undecodable batch starts the window — not fatal yet.
3684        assert!(!schema_plane_fatal_due(&mut first, start));
3685        assert_eq!(first, Some(start));
3686        // Still inside the grace window — not fatal.
3687        assert!(!schema_plane_fatal_due(
3688            &mut first,
3689            start + SCHEMA_PLANE_FATAL_GRACE / 2
3690        ));
3691        // Past the window — fatal.
3692        assert!(schema_plane_fatal_due(
3693            &mut first,
3694            start + SCHEMA_PLANE_FATAL_GRACE
3695        ));
3696
3697        // A successful decode clears the window (caller side); the next drop
3698        // starts a fresh one.
3699        let mut first = None;
3700        let later = start + SCHEMA_PLANE_FATAL_GRACE * 2;
3701        assert!(!schema_plane_fatal_due(&mut first, later));
3702        assert_eq!(first, Some(later));
3703    }
3704
3705    /// Same invariant as `recv_returns_none_after_stop`, verified via
3706    /// the `Stream` impl (`StreamExt::next`).
3707    #[test]
3708    fn stream_returns_none_after_stop() {
3709        use futures::StreamExt;
3710        let (_node, mut events) = test_event_stream();
3711
3712        let first = futures::executor::block_on(events.next());
3713        assert!(matches!(first, Some(Event::Stop(_))));
3714
3715        let second = futures::executor::block_on(events.next());
3716        assert!(
3717            second.is_none(),
3718            "Stream::next must yield None after Stop, got {second:?}"
3719        );
3720    }
3721
3722    // ---- #3282: zenoh-path ingress drops must reach drain_drop_counts() ----
3723
3724    fn zenoh_item(id: &str) -> EventItem {
3725        use dora_arrow_convert::IntoArrow;
3726        let ts = uhlc::HLC::default().new_timestamp();
3727        EventItem::ZenohInput {
3728            id: DataId::from(id.to_string()),
3729            metadata: std::sync::Arc::new(Metadata::new(ts)),
3730            data: dora_arrow_convert::internal::into_array_ref(().into_arrow()).to_data(),
3731        }
3732    }
3733
3734    /// A zero-copy payload never reaches the scheduler's per-input queue: the
3735    /// zenoh callback `try_send`s it into the node's single shared ingress
3736    /// channel, and a full channel drops it there. Before #3282 that site only
3737    /// logged, so `drain_drop_counts()` — the one programmatic way a node can
3738    /// learn it lost data — reported zero while data was being lost. This is the
3739    /// audit item in `docs/audit-2026-06-04-soundness.md`.
3740    #[test]
3741    fn ingress_overflow_is_counted() {
3742        let (tx, _rx) = tokio::sync::mpsc::channel::<EventItem>(1);
3743        let id = DataId::from("camera".to_string());
3744        let drops = std::sync::Arc::new(AtomicU64::new(0));
3745
3746        // First fits the capacity-1 channel.
3747        send_or_count_ingress_drop(&tx, zenoh_item("camera"), &id, &drops);
3748        assert_eq!(drops.load(Ordering::Relaxed), 0);
3749
3750        // The next two find it full and must be counted, not just logged.
3751        send_or_count_ingress_drop(&tx, zenoh_item("camera"), &id, &drops);
3752        send_or_count_ingress_drop(&tx, zenoh_item("camera"), &id, &drops);
3753        assert_eq!(drops.load(Ordering::Relaxed), 2);
3754    }
3755
3756    /// The ingress-drop `warn!` must recur while a node keeps losing input, not
3757    /// go silent after the first drop (#3555). The log re-arm is decoupled from
3758    /// `drain_drop_counts()` — the only production caller of which is the record
3759    /// node — and follows a power-of-two cadence instead, so a node that never
3760    /// drains still gets ongoing (if sparse) evidence of loss.
3761    #[test]
3762    fn ingress_drop_warning_recurs_without_draining() {
3763        // First drop always warns…
3764        assert!(should_warn_ingress_drop(1));
3765        // …and it must keep warning as loss continues, even though the counter
3766        // is never reset by a drain call. Before the fix only count 1 warned.
3767        assert!(should_warn_ingress_drop(2));
3768        assert!(should_warn_ingress_drop(4));
3769        assert!(should_warn_ingress_drop(1024));
3770        // But not on every drop — the cadence is logarithmic so the ~1.6 us
3771        // `warn!` cannot amplify the backlog it reports on the IO worker.
3772        assert!(!should_warn_ingress_drop(3));
3773        assert!(!should_warn_ingress_drop(5));
3774        assert!(!should_warn_ingress_drop(1000));
3775    }
3776
3777    /// A closed channel is ordinary shutdown, not data loss — counting it would
3778    /// make every node report phantom drops as it tears down.
3779    #[test]
3780    fn ingress_send_to_a_closed_channel_is_not_a_drop() {
3781        let (tx, rx) = tokio::sync::mpsc::channel::<EventItem>(1);
3782        drop(rx);
3783        let id = DataId::from("camera".to_string());
3784        let drops = std::sync::Arc::new(AtomicU64::new(0));
3785
3786        send_or_count_ingress_drop(&tx, zenoh_item("camera"), &id, &drops);
3787        assert_eq!(drops.load(Ordering::Relaxed), 0);
3788    }
3789
3790    /// `drain_drop_counts()` must report one number per input, summing the
3791    /// scheduler's per-input evictions and the ingress-channel drops — a caller
3792    /// asking "did I lose anything on `camera`?" should not have to know which
3793    /// transport the payload happened to take. And draining must reset, so two
3794    /// calls don't double-count.
3795    #[test]
3796    fn drain_drop_counts_merges_ingress_and_scheduler_drops() {
3797        let (_node, mut events) = test_event_stream();
3798        let id = DataId::from("camera".to_string());
3799
3800        events.scheduler.record_drop(&id);
3801        events
3802            .ingress_drops
3803            .entry(id.clone())
3804            .or_default()
3805            .fetch_add(2, Ordering::Relaxed);
3806
3807        let counts = events.drain_drop_counts();
3808        assert_eq!(counts.get(&id), Some(&3));
3809
3810        assert!(
3811            events.drain_drop_counts().is_empty(),
3812            "drain must reset both sources"
3813        );
3814    }
3815}