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