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