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