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, events are discarded to keep memory bounded. For
993 /// `drop_oldest` inputs the cap is `queue_size` (clamped to at least 1); an overflow normally
994 /// evicts the oldest queued event, but correlated service/action messages and the `Stop` event
995 /// are preserved where possible, so the evicted event may instead be a newer one (or the
996 /// incoming event itself). For `backpressure` inputs the hard safety cap is
997 /// `max(10 × queue_size, 100)`.
998 /// This method returns a map from input ID to the number of messages dropped
999 /// since the last call.
1000 pub fn drain_drop_counts(&mut self) -> HashMap<DataId, u64> {
1001 self.scheduler.drain_drop_counts()
1002 }
1003
1004 fn add_event(&mut self, event: EventItem) {
1005 // Event recording is observability-only (writes to the optional
1006 // `write_events_to` log). A write failure must not panic the event
1007 // loop — drop the log line and continue scheduling.
1008 if let Err(err) = self.record_event(&event) {
1009 tracing::warn!(
1010 node = %self.node_id,
1011 "failed to record event to write_events_to log: {err:?}"
1012 );
1013 // Mark the recording poisoned so consumers can detect events
1014 // are missing from the final JSON. `write_out()` surfaces this
1015 // as a top-level `recording_status` field (#1857).
1016 if let Some(write_events_to) = self.write_events_to.as_mut() {
1017 let time_offset_secs = self
1018 .clock
1019 .new_timestamp()
1020 .get_diff_duration(&self.start_timestamp)
1021 .as_secs_f64();
1022 write_events_to.mark_poisoned(&err, time_offset_secs);
1023 }
1024 }
1025 self.scheduler.add_event(event);
1026 }
1027
1028 fn record_event(&mut self, event: &EventItem) -> eyre::Result<()> {
1029 if let Some(write_events_to) = &mut self.write_events_to {
1030 let event_json = match event {
1031 EventItem::NodeEvent { event, .. } => match event {
1032 NodeEvent::Stop => Some(control_event_json(
1033 &self.clock,
1034 &self.start_timestamp,
1035 "Stop",
1036 None,
1037 )),
1038 NodeEvent::Reload { .. } => None,
1039 NodeEvent::Input { id, metadata, data } => {
1040 let mut event_json = convert_output_to_json(
1041 id,
1042 metadata,
1043 data,
1044 self.start_timestamp,
1045 false,
1046 )?;
1047 event_json.insert("type".into(), "Input".into());
1048 Some(event_json.into())
1049 }
1050 NodeEvent::InputClosed { id } => Some(control_event_json(
1051 &self.clock,
1052 &self.start_timestamp,
1053 "InputClosed",
1054 Some(id.to_string()),
1055 )),
1056 NodeEvent::InputRecovered { id } => Some(control_event_json(
1057 &self.clock,
1058 &self.start_timestamp,
1059 "InputRecovered",
1060 Some(id.to_string()),
1061 )),
1062 NodeEvent::NodeRestarted { id } => Some(control_event_json(
1063 &self.clock,
1064 &self.start_timestamp,
1065 "NodeRestarted",
1066 Some(id.to_string()),
1067 )),
1068 NodeEvent::AllInputsClosed => Some(control_event_json(
1069 &self.clock,
1070 &self.start_timestamp,
1071 "AllInputsClosed",
1072 None,
1073 )),
1074 _ => None,
1075 },
1076 // Zenoh-delivered inputs surface to the user as `Event::Input`
1077 // exactly like the daemon-path `NodeEvent::Input` above, but
1078 // bypass the daemon, so neither this recorder nor the daemon
1079 // would otherwise capture them — silently dropping inputs from
1080 // the `write_events_to` recording. Record them here too.
1081 EventItem::ZenohInput { id, metadata, data } => {
1082 let array = arrow::array::make_array(data.clone());
1083 let mut event_json = convert_arrow_input_to_json(
1084 id,
1085 metadata,
1086 array,
1087 self.start_timestamp,
1088 false,
1089 )?;
1090 event_json.insert("type".into(), "Input".into());
1091 Some(event_json.into())
1092 }
1093 _ => None,
1094 };
1095 if let Some(event_json) = event_json {
1096 write_events_to.events_buffer.push(event_json);
1097 }
1098 }
1099 Ok(())
1100 }
1101
1102 /// Receives the next buffered [`Event`] (if any) without blocking, using an
1103 /// [`EventScheduler`] for fairness.
1104 ///
1105 /// Returns [`TryRecvError::Empty`] if no event is available right now.
1106 /// Returns [`TryRecvError::Closed`] once the event stream is closed.
1107 ///
1108 /// This method never blocks and is safe to use in asynchronous contexts.
1109 ///
1110 /// ## Event Reordering
1111 ///
1112 /// This method uses an [`EventScheduler`] internally to **reorder events**. This means that the
1113 /// events might be returned in a different order than they occurred. For details, check the
1114 /// documentation of the [`EventScheduler`] struct.
1115 ///
1116 /// If you want to receive the events in their original chronological order, use the
1117 /// [`StreamExt::next`](futures::StreamExt::next) method with a custom timeout future instead
1118 /// ([`EventStream`] implements the [`Stream`] trait).
1119 pub fn try_recv(&mut self) -> Result<Event, TryRecvError> {
1120 match self.recv_async().now_or_never() {
1121 Some(Some(event)) => Ok(event),
1122 Some(None) => Err(TryRecvError::Closed),
1123 None => Err(TryRecvError::Empty),
1124 }
1125 }
1126
1127 /// Receives all buffered [`Event`]s without blocking, using an [`EventScheduler`] for fairness.
1128 ///
1129 /// Return `Some(Vec::new())` if no events are ready.
1130 /// Returns [`None`] once the event stream is closed and no events are buffered anymore.
1131 ///
1132 /// This method never blocks and is safe to use in asynchronous contexts.
1133 ///
1134 /// This method is equivalent to repeatedly calling [`try_recv`][Self::try_recv]. See its docs
1135 /// for details on event reordering.
1136 pub fn drain(&mut self) -> Option<Vec<Event>> {
1137 let mut events = Vec::new();
1138 loop {
1139 match self.try_recv() {
1140 Ok(event) => events.push(event),
1141 Err(TryRecvError::Empty) => break,
1142 Err(TryRecvError::Closed) => {
1143 if events.is_empty() {
1144 return None;
1145 } else {
1146 break;
1147 }
1148 }
1149 }
1150 }
1151 Some(events)
1152 }
1153
1154 /// Receives the next incoming [`Event`] asynchronously with a timeout.
1155 ///
1156 /// Returns a [`Event::Error`] if no event was received within the given duration.
1157 ///
1158 /// Returns [`None`] once the event stream is closed.
1159 ///
1160 /// ## Event Reordering
1161 ///
1162 /// This method uses an [`EventScheduler`] internally to **reorder events**. This means that the
1163 /// events might be returned in a different order than they occurred. For details, check the
1164 /// documentation of the [`EventScheduler`] struct.
1165 ///
1166 /// If you want to receive the events in their original chronological order, use the
1167 /// [`StreamExt::next`](futures::StreamExt::next) method with a custom timeout future instead
1168 /// ([`EventStream`] implements the [`Stream`] trait).
1169 pub async fn recv_async_timeout(&mut self, dur: Duration) -> Option<Event> {
1170 match select(Delay::new(dur), pin!(self.recv_async())).await {
1171 Either::Left((_elapsed, _)) => Some(Self::convert_event_item(EventItem::TimeoutError(
1172 eyre!("Receiver timed out"),
1173 ))),
1174 Either::Right((event, _)) => event,
1175 }
1176 }
1177
1178 /// Waits for a service response carrying `request_id` in its metadata.
1179 ///
1180 /// Drives the event loop internally and returns the matching
1181 /// [`Event::Input`] as soon as it arrives. Non-matching events are
1182 /// buffered and replayed on the next call to `recv()` / `recv_async()`,
1183 /// so your main event loop does not lose intermediate events.
1184 ///
1185 /// Terminal conditions return a [`PatternError`]:
1186 ///
1187 /// - `Timeout` — `timeout` elapsed before any matching response arrived.
1188 /// - `ServerRestarted(expected_server)` — the expected server node
1189 /// restarted, which means its in-flight `request_id` correlation
1190 /// was orphaned. The caller should retry against the new instance.
1191 /// - `StreamEnded` — the event stream closed (dataflow stopping)
1192 /// before a response arrived. The terminal `Stop` event is still
1193 /// returned to the caller's next `recv()`.
1194 /// - `StreamError` — an upstream error event surfaced during the wait.
1195 ///
1196 /// # Example
1197 ///
1198 /// ```ignore
1199 /// let request_id = node.send_service_request(...)?;
1200 /// match events
1201 /// .recv_service_response(&request_id, &server_id, Duration::from_secs(5))
1202 /// .await
1203 /// {
1204 /// Ok(Event::Input { data, .. }) => handle_response(data),
1205 /// Err(PatternError::Timeout) => fallback_path(),
1206 /// Err(PatternError::ServerRestarted(_)) => retry_with_new_instance(),
1207 /// Err(e) => return Err(e.into()),
1208 /// _ => unreachable!(),
1209 /// }
1210 /// ```
1211 pub async fn recv_service_response(
1212 &mut self,
1213 request_id: &str,
1214 expected_server: &NodeId,
1215 timeout: Duration,
1216 ) -> Result<Event, PatternError> {
1217 self.wait_for_correlation(
1218 timeout,
1219 expected_server,
1220 |event, request_id| match event {
1221 Event::Input { metadata, .. } => {
1222 dora_message::metadata::get_string_param(
1223 &metadata.parameters,
1224 dora_message::metadata::REQUEST_ID,
1225 ) == Some(request_id)
1226 }
1227 _ => false,
1228 },
1229 request_id,
1230 )
1231 .await
1232 }
1233
1234 /// Waits for a terminal action result (`goal_status` ∈
1235 /// {`succeeded`, `aborted`, `canceled`}) with a matching `goal_id`.
1236 ///
1237 /// Semantics mirror [`recv_service_response`](Self::recv_service_response)
1238 /// but match on `goal_id` + a terminal `goal_status` instead of
1239 /// `request_id`. Intermediate feedback events (matching `goal_id`
1240 /// without a terminal `goal_status`) are stashed for the caller's
1241 /// main loop — use `recv()` separately if you need to observe them.
1242 pub async fn recv_action_result(
1243 &mut self,
1244 goal_id: &str,
1245 expected_server: &NodeId,
1246 timeout: Duration,
1247 ) -> Result<Event, PatternError> {
1248 self.wait_for_correlation(
1249 timeout,
1250 expected_server,
1251 |event, goal_id| match event {
1252 Event::Input { metadata, .. } => {
1253 let matches_goal = dora_message::metadata::get_string_param(
1254 &metadata.parameters,
1255 dora_message::metadata::GOAL_ID,
1256 ) == Some(goal_id);
1257 if !matches_goal {
1258 return false;
1259 }
1260 matches!(
1261 dora_message::metadata::get_string_param(
1262 &metadata.parameters,
1263 dora_message::metadata::GOAL_STATUS,
1264 ),
1265 Some(dora_message::metadata::GOAL_STATUS_SUCCEEDED)
1266 | Some(dora_message::metadata::GOAL_STATUS_ABORTED)
1267 | Some(dora_message::metadata::GOAL_STATUS_CANCELED)
1268 )
1269 }
1270 _ => false,
1271 },
1272 goal_id,
1273 )
1274 .await
1275 }
1276
1277 /// Core loop for the pattern-aware helpers. Waits up to `timeout`
1278 /// for an event that satisfies `is_match(event, needle)`. Buffers
1279 /// every non-matching event so the caller's main event loop can
1280 /// still see them via `recv()`.
1281 async fn wait_for_correlation<F>(
1282 &mut self,
1283 timeout: Duration,
1284 expected_server: &NodeId,
1285 is_match: F,
1286 needle: &str,
1287 ) -> Result<Event, PatternError>
1288 where
1289 F: Fn(&Event, &str) -> bool,
1290 {
1291 // A previous pattern-aware wait may already have buffered the event
1292 // we are now looking for. With pipelined requests, the response to
1293 // `req-2` can arrive — and be classified non-matching, so buffered
1294 // into `pending_passthrough` — *during* the wait for `req-1`. The loop
1295 // below pumps `recv_from_stream`, which never reads
1296 // `pending_passthrough`, so without this scan that already-buffered
1297 // response would be invisible and the wait would wrongly time out.
1298 // Extract a buffered match in place (preserving the order of the
1299 // remaining events for the caller's own `recv()`/`recv_async()`).
1300 //
1301 // Only a `Match` is pulled from the buffer: a buffered `Stop` is
1302 // already handled by the `stop_received` short-circuit in
1303 // `recv_from_stream`, and a buffered `NodeRestarted` was reported to
1304 // the caller when it was first seen, so it is left for the caller's
1305 // own event loop rather than re-surfaced here.
1306 if let Some(pos) = self
1307 .pending_passthrough
1308 .iter()
1309 .position(|event| is_match(event, needle))
1310 && let Some(event) = self.pending_passthrough.remove(pos)
1311 {
1312 return Ok(event);
1313 }
1314
1315 let deadline = std::time::Instant::now() + timeout;
1316 loop {
1317 let remaining = deadline.saturating_duration_since(std::time::Instant::now());
1318 if remaining.is_zero() {
1319 return Err(PatternError::Timeout);
1320 }
1321 // Pump the stream via `recv_from_stream`, NOT `recv_async`: the
1322 // latter drains `pending_passthrough` first, which would re-hand us
1323 // the very events we buffer below and livelock the loop (see
1324 // `recv_from_stream`'s docs).
1325 let event = match select(Delay::new(remaining), pin!(self.recv_from_stream())).await {
1326 Either::Left((_elapsed, _)) => return Err(PatternError::Timeout),
1327 Either::Right((None, _)) => return Err(PatternError::StreamEnded),
1328 Either::Right((Some(e), _)) => e,
1329 };
1330
1331 match classify_correlation_event(&event, expected_server, |e| is_match(e, needle)) {
1332 CorrelationOutcome::Match => return Ok(event),
1333 CorrelationOutcome::ServerRestarted => {
1334 self.pending_passthrough.push_back(event);
1335 return Err(PatternError::ServerRestarted(expected_server.to_string()));
1336 }
1337 CorrelationOutcome::StreamEnded => {
1338 self.pending_passthrough.push_back(event);
1339 return Err(PatternError::StreamEnded);
1340 }
1341 CorrelationOutcome::StreamError => {
1342 if let Event::Error(err) = event {
1343 return Err(PatternError::StreamError(err));
1344 }
1345 unreachable!("StreamError only returned for Event::Error");
1346 }
1347 CorrelationOutcome::Passthrough => {
1348 self.pending_passthrough.push_back(event);
1349 }
1350 }
1351 }
1352 }
1353}
1354
1355/// Build the JSON for a "control" event that carries only a type tag, an
1356/// optional input/node id, and the elapsed time offset since the node started.
1357///
1358/// Shared by the `Stop` / `InputClosed` / `InputRecovered` / `NodeRestarted` /
1359/// `AllInputsClosed` arms of [`EventStream::record_event`], which differ only in
1360/// the `"type"` string and whether an `"id"` field is present. A free function
1361/// (rather than a `&self` method) so it can take the `clock` and
1362/// `start_timestamp` fields by reference while `record_event` holds a mutable
1363/// borrow of the sibling `write_events_to` field.
1364fn control_event_json(
1365 clock: &uhlc::HLC,
1366 start_timestamp: &uhlc::Timestamp,
1367 ty: &str,
1368 id: Option<String>,
1369) -> serde_json::Value {
1370 let time_offset = clock.new_timestamp().get_diff_duration(start_timestamp);
1371 // Build the map explicitly (rather than via `json!`) so the key order
1372 // matches the previous per-arm literals byte-for-byte under serde_json's
1373 // `preserve_order`: `type`, then the optional `id`, then `time_offset_secs`.
1374 let mut event_json = serde_json::Map::new();
1375 event_json.insert("type".to_owned(), ty.into());
1376 if let Some(id) = id {
1377 event_json.insert("id".to_owned(), serde_json::Value::String(id));
1378 }
1379 event_json.insert(
1380 "time_offset_secs".to_owned(),
1381 time_offset.as_secs_f64().into(),
1382 );
1383 serde_json::Value::Object(event_json)
1384}
1385
1386/// Outcome of classifying a single event during a pattern-aware wait.
1387/// Separated from `wait_for_correlation` so the decision logic can be
1388/// unit-tested without a live `EventStream`.
1389#[derive(Debug, PartialEq, Eq)]
1390enum CorrelationOutcome {
1391 /// The event satisfies the caller's predicate — return it.
1392 Match,
1393 /// `Event::NodeRestarted { id }` where `id == expected_server`.
1394 ServerRestarted,
1395 /// `Event::Stop(_)` — the dataflow is shutting down.
1396 StreamEnded,
1397 /// `Event::Error(_)` — the stream surfaced an error.
1398 StreamError,
1399 /// Unrelated event — buffer it and keep waiting.
1400 Passthrough,
1401}
1402
1403fn classify_correlation_event<F>(
1404 event: &Event,
1405 expected_server: &NodeId,
1406 is_match: F,
1407) -> CorrelationOutcome
1408where
1409 F: Fn(&Event) -> bool,
1410{
1411 if is_match(event) {
1412 return CorrelationOutcome::Match;
1413 }
1414 match event {
1415 Event::NodeRestarted { id } if id == expected_server => CorrelationOutcome::ServerRestarted,
1416 Event::Stop(_) => CorrelationOutcome::StreamEnded,
1417 Event::Error(_) => CorrelationOutcome::StreamError,
1418 _ => CorrelationOutcome::Passthrough,
1419 }
1420}
1421
1422impl EventStream {
1423 fn convert_event_item(item: EventItem) -> Event {
1424 match item {
1425 EventItem::NodeEvent { event } => match event {
1426 NodeEvent::Stop => Event::Stop(event::StopCause::Manual),
1427 NodeEvent::Reload { operator_id } => Event::Reload { operator_id },
1428 NodeEvent::InputClosed { id } => Event::InputClosed { id },
1429 NodeEvent::InputRecovered { id } => Event::InputRecovered { id },
1430 NodeEvent::NodeRestarted { id } => Event::NodeRestarted { id },
1431 NodeEvent::Input { id, metadata, data } => {
1432 let data_inner = data.map(Arc::unwrap_or_clone);
1433 let result = data_to_arrow_array(data_inner);
1434 match result {
1435 Ok(data) => {
1436 let mut metadata = Arc::unwrap_or_clone(metadata);
1437 dora_message::metadata::strip_internal_parameters(
1438 &mut metadata.parameters,
1439 );
1440 Event::Input {
1441 id,
1442 metadata,
1443 data: dora_arrow_convert::internal::from_array_ref(data),
1444 }
1445 }
1446 Err(err) => Event::Error(format!("{err:?}")),
1447 }
1448 }
1449 NodeEvent::AllInputsClosed => Event::Stop(event::StopCause::AllInputsClosed),
1450 NodeEvent::ParamUpdate { key, value_json } => {
1451 match serde_json::from_slice(&value_json) {
1452 Ok(value) => Event::ParamUpdate { key, value },
1453 Err(err) => Event::Error(format!(
1454 "failed to deserialize ParamUpdate value for `{key}`: {err}"
1455 )),
1456 }
1457 }
1458 NodeEvent::ParamDeleted { key } => Event::ParamDeleted { key },
1459 NodeEvent::NodeFailed {
1460 affected_input_ids,
1461 error,
1462 source_node_id,
1463 } => Event::NodeFailed {
1464 affected_input_ids,
1465 error,
1466 source_node_id,
1467 },
1468 other => {
1469 tracing::warn!("ignoring unrecognized NodeEvent variant: {other:?}");
1470 Event::Error(format!("unrecognized node event: {other:?}"))
1471 }
1472 },
1473
1474 EventItem::ZenohInput { id, metadata, data } => {
1475 let mut metadata = Arc::unwrap_or_clone(metadata);
1476 dora_message::metadata::strip_internal_parameters(&mut metadata.parameters);
1477 Event::Input {
1478 id,
1479 metadata,
1480 // Already decoded in the subscriber callback (receipt order).
1481 data: dora_arrow_convert::internal::from_array_data(data),
1482 }
1483 }
1484
1485 EventItem::FatalError(err) => {
1486 Event::Error(format!("fatal event stream error: {err:?}"))
1487 }
1488 EventItem::TimeoutError(err) => {
1489 Event::Error(format!("Timeout event stream error: {err:?}"))
1490 }
1491 }
1492 }
1493}
1494
1495/// No event is available right now or the event stream has been closed.
1496#[derive(Debug)]
1497pub enum TryRecvError {
1498 /// No new event is available right now.
1499 Empty,
1500 /// The event stream has been closed.
1501 Closed,
1502}
1503
1504/// Convert a zenoh `ZBytes` payload into an Arrow array without copying
1505/// for contiguous buffers (e.g. Zenoh SHM).
1506///
1507/// For `Cow::Borrowed` payloads (SHM), the Arrow `Buffer` is backed by
1508/// the original `ZBytes` allocation via `Buffer::from_custom_allocation`,
1509/// achieving true zero-copy. For `Cow::Owned` (normal network path),
1510/// copy into Dora's aligned buffer type before reconstructing Arrow arrays.
1511/// Newtype that owns a Zenoh [`ZBytes`](zenoh::bytes::ZBytes) payload so it can
1512/// back an Arrow `Buffer` via `Buffer::from_custom_allocation`. Keeping the
1513/// `ZBytes` alive keeps the underlying SHM mapping (or heap buffer) valid for
1514/// the lifetime of the zero-copy Arrow buffer.
1515#[allow(dead_code)] // field kept alive to own the zenoh buffer
1516struct ZBytesAllocation(zenoh::bytes::ZBytes);
1517// SAFETY: the wrapped `ZBytes` is only used to keep the backing allocation
1518// alive; the bytes are treated as immutable for the Buffer's lifetime.
1519unsafe impl Sync for ZBytesAllocation {}
1520unsafe impl Send for ZBytesAllocation {}
1521impl std::panic::RefUnwindSafe for ZBytesAllocation {}
1522
1523/// Convert a zenoh payload to an Arrow array (dora-rs/adora#132).
1524///
1525/// Every data-plane payload is a self-describing Arrow IPC stream, so the
1526/// decode needs no type sidecar. An empty payload is a metadata-only message
1527/// and maps to the unit array.
1528/// Wrap a zenoh payload as an Arrow `Buffer` — aliasing the zenoh SHM mapping
1529/// for borrowed payloads (zero-copy), owning the materialized `Vec` otherwise.
1530fn zenoh_payload_to_buffer(payload: zenoh::bytes::ZBytes) -> arrow::buffer::Buffer {
1531 use std::ptr::NonNull;
1532 match payload.to_bytes() {
1533 std::borrow::Cow::Borrowed(slice) => {
1534 let ptr =
1535 NonNull::new(slice.as_ptr() as *mut u8).expect("zenoh SHM payload ptr is null");
1536 let len = slice.len();
1537 // SAFETY: `ptr` points into the SHM region owned by `payload`;
1538 // moving `payload` into the Arc keeps the region mapped for the
1539 // lifetime of the Buffer.
1540 unsafe {
1541 arrow::buffer::Buffer::from_custom_allocation(
1542 ptr,
1543 len,
1544 Arc::new(ZBytesAllocation(payload)),
1545 )
1546 }
1547 }
1548 std::borrow::Cow::Owned(vec) => arrow::buffer::Buffer::from_vec(vec),
1549 }
1550}
1551
1552/// Declare the `@schema` AdvancedSubscriber for an input: its callback primes
1553/// `decoder` from the schema published on the output's schema subtopic. The
1554/// history query fetches the cached schema on join; `detect_late_publishers`
1555/// re-queries a producer that appears after this subscriber.
1556///
1557/// On failure, `schema_plane_failed` is set so the data subscriber surfaces a
1558/// `FatalError` if the input stays undecodable past
1559/// [`SCHEMA_PLANE_FATAL_GRACE`]: a failed declare means a degraded zenoh
1560/// session, so exit loudly rather than limp along on the in-band full-stream
1561/// refresh alone — but give that refresh (which fully heals the input) its
1562/// chance first instead of killing a node that would recover within seconds.
1563/// It never blocks the data subscriber.
1564#[allow(clippy::too_many_arguments)]
1565fn declare_schema_subscriber(
1566 session: &zenoh::Session,
1567 dataflow_id: DataflowId,
1568 source_node: &NodeId,
1569 source_output: &DataId,
1570 input_id: &DataId,
1571 decoder: Arc<std::sync::Mutex<crate::arrow_utils::ipc_encode::InputDecoder>>,
1572 tx: tokio::sync::mpsc::Sender<EventItem>,
1573 schema_plane_failed: Arc<std::sync::atomic::AtomicBool>,
1574 out: &mut Vec<zenoh_ext::AdvancedSubscriber<()>>,
1575) {
1576 use zenoh::Wait;
1577 use zenoh_ext::{AdvancedSubscriberBuilderExt, HistoryConfig};
1578
1579 let topic =
1580 dora_core::topics::zenoh_output_schema_topic(dataflow_id, source_node, source_output);
1581 let key = match zenoh::key_expr::KeyExpr::new(topic) {
1582 Ok(k) => k.into_owned(),
1583 Err(e) => {
1584 tracing::warn!(input = %input_id, "invalid @schema zenoh key ({e}); schema-once disabled for this input");
1585 schema_plane_failed.store(true, std::sync::atomic::Ordering::Relaxed);
1586 return;
1587 }
1588 };
1589 let input_id_cb = input_id.clone();
1590 let sub = session
1591 .declare_subscriber(key)
1592 .history(HistoryConfig::default().detect_late_publishers())
1593 .callback(move |sample| {
1594 // catch_unwind: a panic must not unwind through zenoh's IO worker.
1595 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1596 let buffer = zenoh_payload_to_buffer(sample.payload().clone());
1597 let hash = crate::node::fnv1a(buffer.as_slice());
1598 let mut decoder = decoder.lock().unwrap_or_else(|poison| {
1599 let mut guard = poison.into_inner();
1600 guard.reset();
1601 guard
1602 });
1603 if let Err(e) = decoder.set_schema_raw(hash, buffer) {
1604 tracing::warn!(input = %input_id_cb, "failed to prime decoder from @schema sample: {e}");
1605 }
1606 }));
1607 if result.is_err() {
1608 tracing::error!(input = %input_id_cb, "zenoh @schema subscriber callback panicked");
1609 let _ = tx.try_send(EventItem::FatalError(eyre!(
1610 "zenoh @schema subscriber for input `{input_id_cb}` panicked"
1611 )));
1612 }
1613 })
1614 .wait();
1615 match sub {
1616 Ok(s) => out.push(s),
1617 Err(e) => {
1618 tracing::warn!(input = %input_id, "failed to declare @schema subscriber ({e}); schema-once disabled for this input");
1619 schema_plane_failed.store(true, std::sync::atomic::Ordering::Relaxed);
1620 }
1621 }
1622}
1623
1624/// How long a schema-once input may stay continuously undecodable — with the
1625/// `@schema` plane dead — before the node exits with a `FatalError`. Three
1626/// producer full-stream refresh intervals: the refresh re-primes the input
1627/// in-band, so if it hasn't healed after three periods the input is genuinely
1628/// dead (producer gone or data plane dropping every refresh), not just waiting
1629/// out the documented recovery window.
1630const SCHEMA_PLANE_FATAL_GRACE: Duration =
1631 crate::node::SCHEMA_ONCE_REFRESH_INTERVAL.saturating_mul(3);
1632
1633/// Whether the undecodable-input condition has persisted past
1634/// [`SCHEMA_PLANE_FATAL_GRACE`]. Records the start of the window on first call;
1635/// the caller clears `first_undecodable` on any successful decode.
1636fn schema_plane_fatal_due(first_undecodable: &mut Option<Instant>, now: Instant) -> bool {
1637 let start = *first_undecodable.get_or_insert(now);
1638 now.duration_since(start) >= SCHEMA_PLANE_FATAL_GRACE
1639}
1640
1641/// Decode a zenoh data sample in receipt order. A schema-less batch (the
1642/// `SCHEMA_HASH` parameter is present) decodes against the per-input decoder
1643/// primed from the output's `@schema` subtopic or in-band from an earlier full
1644/// stream — returning `Ok(None)` if that schema hasn't been received yet, in
1645/// which case the caller drops it. Other messages (large/SHM full streams,
1646/// daemon-path payloads) decode standalone — and additionally prime the decoder
1647/// in-band (see [`prime_in_band`]).
1648fn decode_zenoh_sample(
1649 decoder: &mut crate::arrow_utils::ipc_encode::InputDecoder,
1650 metadata: &dora_message::metadata::Metadata,
1651 payload: zenoh::bytes::ZBytes,
1652) -> eyre::Result<Option<arrow::array::ArrayData>> {
1653 use crate::arrow_utils::decode_arrow_ipc_zero_copy_raw;
1654 use dora_message::metadata::{SCHEMA_HASH, get_integer_param};
1655
1656 if payload.is_empty() {
1657 return Ok(Some(
1658 dora_arrow_convert::internal::into_array_ref(().into_arrow()).to_data(),
1659 ));
1660 }
1661 let buffer = zenoh_payload_to_buffer(payload);
1662 match get_integer_param(&metadata.parameters, SCHEMA_HASH) {
1663 Some(hash) => {
1664 tracing::debug!("received schema-less batch with SCHEMA_HASH={}", hash);
1665 decoder.decode_batch_raw(buffer, hash as u64)
1666 }
1667 None => {
1668 tracing::debug!("received full IPC stream (no SCHEMA_HASH)");
1669 // Service/action messages are excluded from schema-once (a server
1670 // multiplexes per-request schemas through one output); don't let
1671 // them churn the retained schema set.
1672 if !crate::node::carries_pattern_correlation(&metadata.parameters) {
1673 prime_in_band(decoder, &buffer);
1674 }
1675 decode_arrow_ipc_zero_copy_raw(buffer).map(Some)
1676 }
1677 }
1678}
1679
1680/// Prime the per-input decoder from the schema block of a full self-describing
1681/// stream received on the data topic.
1682///
1683/// The producer sends a full stream for the first message of an output (and
1684/// after every schema change, failed `@schema` publish, or periodic refresh —
1685/// see `publish_schema_once`). Since all data-plane puts share one publisher,
1686/// zenoh delivers them in order, so this priming happens strictly before the
1687/// schema-less batches that reference the schema — closing the QoS race where
1688/// an express batch overtakes the `@schema` plane's non-express schema, and
1689/// re-priming (within the refresh interval) any consumer whose `@schema`
1690/// history query missed the single schema emission.
1691fn prime_in_band(
1692 decoder: &mut crate::arrow_utils::ipc_encode::InputDecoder,
1693 buffer: &arrow::buffer::Buffer,
1694) {
1695 let Some((hash, schema)) =
1696 crate::arrow_utils::ipc_encode::schema_block_and_hash(buffer.as_slice())
1697 else {
1698 return;
1699 };
1700 // A known schema (live or retained) needs no eager re-prime: the full
1701 // stream decodes standalone, and `decode_batch` re-primes lazily from the
1702 // retained set when a schema-less batch actually references it.
1703 if decoder.knows_schema(hash) {
1704 return;
1705 }
1706 // Copy the schema block out of the payload: retaining a slice of an
1707 // SHM-backed buffer would pin the whole segment for the decoder's lifetime.
1708 let schema = arrow::buffer::Buffer::from(schema);
1709 if let Err(e) = decoder.set_schema_raw(hash, schema) {
1710 tracing::debug!("in-band schema priming failed: {e}");
1711 }
1712}
1713
1714/// Convert an optional [`DataMessage`] into an Arrow array, or return an
1715/// empty null array when no data is present.
1716pub fn data_to_arrow_array(
1717 data: Option<DataMessage>,
1718) -> eyre::Result<Arc<dyn arrow::array::Array>> {
1719 // `DataMessage` has a single infallible variant, so the conversion cannot
1720 // fail; the only fallible step is `into_arrow_array`.
1721 let raw_data = data.map_or(RawData::Empty, |DataMessage::Vec(v)| RawData::Vec(v));
1722 raw_data.into_arrow_array().map(arrow::array::make_array)
1723}
1724
1725impl Stream for EventStream {
1726 type Item = Event;
1727
1728 fn poll_next(
1729 mut self: std::pin::Pin<&mut Self>,
1730 cx: &mut std::task::Context<'_>,
1731 ) -> std::task::Poll<Option<Self::Item>> {
1732 // Drain events that were buffered by pattern-aware helpers
1733 // (`recv_service_response`, `recv_action_result`) before
1734 // polling the underlying receiver. Mirrors the drain at the
1735 // top of `recv_async` so `StreamExt::next()` and `recv()`
1736 // return the same events in the same order
1737 // (dora-rs/adora#172).
1738 if let Some(event) = self.pending_passthrough.pop_front() {
1739 return std::task::Poll::Ready(Some(event));
1740 }
1741
1742 // Close the stream after a Stop event: zenoh subscriber threads
1743 // hold sender clones that would otherwise keep `receiver` open.
1744 if self.stop_received {
1745 return std::task::Poll::Ready(None);
1746 }
1747
1748 let poll = self
1749 .receiver
1750 .poll_recv(cx)
1751 .map(|item| item.map(Self::convert_event_item));
1752
1753 // Mirror recv_async(): run the first-message type check and stop
1754 // tracking on the Stream path too, via the shared helper so the two
1755 // paths stay in lockstep (dora-rs/adora#172, #174).
1756 if let std::task::Poll::Ready(Some(ref event)) = poll {
1757 self.note_produced_event(event);
1758 }
1759 poll
1760 }
1761}
1762
1763impl Drop for EventStream {
1764 fn drop(&mut self) {
1765 // Tear down the per-input zenoh callback subscribers under a deadline.
1766 // `Subscriber::drop` undeclares the subscription on the shared zenoh
1767 // session, which blocks indefinitely when zenoh's net runtime is
1768 // wedged (e.g. stuck retrying an unreachable scouted peer). Left
1769 // unbounded, that hangs the whole node in `EventStream`'s field-drop
1770 // sequence — before `DoraNode::Drop` (which already bounds its own
1771 // session teardown) even runs — so the daemon never sees the node
1772 // finish and the dataflow stalls until an outer timeout (dora-rs/dora#2425).
1773 // The callbacks use `try_send`, so undeclaring before `receiver` drops
1774 // cannot deadlock on a blocked callback.
1775 //
1776 // The `@schema` `AdvancedSubscriber`s (added with the Arrow IPC data
1777 // plane in #2366) undeclare on the same shared session and can wedge
1778 // the same way, so they must be torn down under the same deadline —
1779 // mirroring `DoraNode::Drop`, which drops both publisher maps inside
1780 // one guard. Left out, they would otherwise drop unbounded in the
1781 // implicit field-drop phase after this `Drop` body returns (#2583).
1782 let subscribers = std::mem::take(&mut self._zenoh_subscribers);
1783 let schema_subscribers = std::mem::take(&mut self._zenoh_schema_subscribers);
1784 let startup_acker = self.startup_acker.take();
1785 if !subscribers.is_empty() || !schema_subscribers.is_empty() || startup_acker.is_some() {
1786 let completed =
1787 teardown_with_timeout("zenoh-subscribers", ZENOH_TEARDOWN_TIMEOUT, move || {
1788 drop(subscribers);
1789 drop(schema_subscribers);
1790 // Dropping the subscribers dropped their callbacks — the
1791 // only senders into the acker's queue — so the acker
1792 // thread exits (undeclaring its ack publishers, also
1793 // bounded by this deadline) and can be joined.
1794 if let Some(handle) = startup_acker {
1795 let _ = handle.join();
1796 }
1797 });
1798 if !completed {
1799 tracing::warn!(
1800 "zenoh subscriber teardown timed out after {}s; continuing node shutdown",
1801 ZENOH_TEARDOWN_TIMEOUT.as_secs()
1802 );
1803 }
1804 }
1805
1806 let request = Timestamped {
1807 inner: DaemonRequest::EventStreamDropped,
1808 timestamp: self.clock.new_timestamp(),
1809 };
1810 // Interrupt a testing-daemon `next_event` sleep before the blocking
1811 // close handshake so Drop cannot deadlock (dora-rs/dora#2855).
1812 if let Some(shutdown) = &self.testing_shutdown {
1813 shutdown.store(true, Ordering::Relaxed);
1814 }
1815 let result = self
1816 .close_channel
1817 .request(&request)
1818 .map_err(|e| eyre!(e))
1819 .wrap_err("failed to signal event stream closure to dora-daemon")
1820 .and_then(|r| match r {
1821 DaemonReply::Result(Ok(())) => Ok(()),
1822 DaemonReply::Result(Err(err)) => Err(eyre!("EventStreamClosed failed: {err}")),
1823 other => Err(eyre!("unexpected EventStreamClosed reply: {other:?}")),
1824 });
1825 if let Err(err) = result {
1826 tracing::warn!("{err:?}")
1827 }
1828
1829 if let Some(write_events_to) = self.write_events_to.take()
1830 && let Err(err) = write_events_to.write_out()
1831 {
1832 tracing::warn!(
1833 "failed to write out events for node {}: {err:?}",
1834 self.node_id
1835 );
1836 }
1837 }
1838}
1839
1840pub(crate) struct WriteEventsTo {
1841 node_id: NodeId,
1842 file: std::fs::File,
1843 events_buffer: Vec<serde_json::Value>,
1844 /// `None` while the recording is complete. Becomes `Some(...)` on
1845 /// the first `record_event` failure; subsequent failures bump the
1846 /// counter inside. Surfaced in `write_out()` as a top-level
1847 /// `recording_status` field so consumers (replay tools, audit
1848 /// pipelines) can detect partial recordings instead of silently
1849 /// treating a syntactically-valid file as complete (#1857).
1850 poisoned: Option<PoisonInfo>,
1851}
1852
1853#[derive(Debug)]
1854pub(crate) struct PoisonInfo {
1855 /// `events_buffer.len()` at the moment of the first failure — i.e.
1856 /// the number of events successfully recorded before the gap.
1857 first_failure_event_index: usize,
1858 /// Seconds since `EventStream::start_timestamp` at the first failure.
1859 first_failure_time_offset_secs: f64,
1860 /// `format!("{err:?}")` of the first `record_event()` error.
1861 first_failure_error: String,
1862 /// Count of subsequent failures after the first one.
1863 additional_failures: u64,
1864}
1865
1866impl WriteEventsTo {
1867 /// Mark the recording poisoned. First call captures the failure
1868 /// detail; later calls just bump `additional_failures`.
1869 fn mark_poisoned(&mut self, err: &eyre::Report, time_offset_secs: f64) {
1870 match &mut self.poisoned {
1871 None => {
1872 self.poisoned = Some(PoisonInfo {
1873 first_failure_event_index: self.events_buffer.len(),
1874 first_failure_time_offset_secs: time_offset_secs,
1875 first_failure_error: format!("{err:?}"),
1876 additional_failures: 0,
1877 });
1878 }
1879 Some(info) => {
1880 info.additional_failures += 1;
1881 }
1882 }
1883 }
1884
1885 fn write_out(self) -> eyre::Result<()> {
1886 use dora_message::integration_testing_format::RecordingStatus;
1887
1888 let Self {
1889 node_id,
1890 file,
1891 events_buffer,
1892 poisoned,
1893 } = self;
1894 let mut inputs_file = serde_json::Map::new();
1895 inputs_file.insert("id".into(), node_id.to_string().into());
1896 // Emit `recording_status` for clean recordings too, so consumers
1897 // can rely on its presence as a definitive signal rather than
1898 // having to treat "field absent" as ambiguous between "clean"
1899 // and "older format" (#1857). Serialized via the canonical
1900 // `RecordingStatus` enum in `dora-message` so the wire format
1901 // stays in lockstep with the consumer-side type. The wire
1902 // shape is unaffected by `IntegrationTestInput`'s
1903 // `Option<Box<RecordingStatus>>` storage choice — serde
1904 // transparently serializes through the `Box`.
1905 let recording_status = match poisoned {
1906 None => RecordingStatus::Clean,
1907 Some(info) => RecordingStatus::Poisoned {
1908 first_failure_event_index: info.first_failure_event_index,
1909 first_failure_time_offset_secs: info.first_failure_time_offset_secs,
1910 first_failure_error: info.first_failure_error,
1911 additional_failures: info.additional_failures,
1912 },
1913 };
1914 inputs_file.insert(
1915 "recording_status".into(),
1916 serde_json::to_value(&recording_status)
1917 .context("failed to serialize recording_status")?,
1918 );
1919 inputs_file.insert("events".into(), events_buffer.into());
1920
1921 serde_json::to_writer_pretty(file, &inputs_file)
1922 .context("failed to write events to file")?;
1923 Ok(())
1924 }
1925}
1926
1927#[cfg(test)]
1928impl EventStream {
1929 /// Test-only: inject an event into the passthrough buffer so we can
1930 /// verify that `is_empty`, `recv_async`, and `Stream::poll_next` all
1931 /// drain it correctly (dora-rs/adora#172).
1932 fn push_passthrough_for_testing(&mut self, event: Event) {
1933 self.pending_passthrough.push_back(event);
1934 }
1935
1936 /// Test-only: buffer an empty input directly in the scheduler and force
1937 /// scheduler mode, simulating an input the scheduler held back while
1938 /// prioritizing `Stop`. Used to verify `recv_async` drains buffered inputs
1939 /// after `Stop` instead of dropping them (dora-rs/dora#2027).
1940 fn push_scheduler_input_for_testing(&mut self, id: &str) {
1941 use crate::event_stream::thread::EventItem;
1942 use dora_message::{daemon_to_node::NodeEvent, metadata::Metadata};
1943 self.use_scheduler = true;
1944 let meta = Metadata::new(dora_core::uhlc::HLC::default().new_timestamp());
1945 self.scheduler.add_event(EventItem::NodeEvent {
1946 event: NodeEvent::Input {
1947 id: id.into(),
1948 metadata: std::sync::Arc::new(meta),
1949 data: None,
1950 },
1951 });
1952 }
1953
1954 /// Test-only: buffer a `Stop` directly in the scheduler (a NON_INPUT_EVENT)
1955 /// and force scheduler mode, to verify the post-Stop drain discards trailing
1956 /// control events instead of re-delivering a second `Stop` (dora-rs/dora#2027).
1957 fn push_scheduler_stop_for_testing(&mut self) {
1958 use crate::event_stream::thread::EventItem;
1959 use dora_message::daemon_to_node::NodeEvent;
1960 self.use_scheduler = true;
1961 self.scheduler.add_event(EventItem::NodeEvent {
1962 event: NodeEvent::Stop,
1963 });
1964 }
1965}
1966
1967#[cfg(test)]
1968mod tests {
1969 use super::*;
1970
1971 #[test]
1972 fn control_event_json_shape_and_key_order() {
1973 let clock = uhlc::HLC::default();
1974 let start = clock.new_timestamp();
1975
1976 // An id-bearing control event: keys in `type`, `id`, `time_offset_secs`
1977 // order (serde_json's `preserve_order` makes the order observable).
1978 let with_id = control_event_json(&clock, &start, "InputClosed", Some("cam".to_owned()));
1979 let obj = with_id.as_object().expect("object");
1980 assert_eq!(
1981 obj.keys().collect::<Vec<_>>(),
1982 vec!["type", "id", "time_offset_secs"]
1983 );
1984 assert_eq!(obj["type"], serde_json::json!("InputClosed"));
1985 assert_eq!(obj["id"], serde_json::json!("cam"));
1986 assert!(obj["time_offset_secs"].is_f64());
1987
1988 // A control event without an id omits the `id` field entirely.
1989 let without_id = control_event_json(&clock, &start, "AllInputsClosed", None);
1990 let obj = without_id.as_object().expect("object");
1991 assert_eq!(
1992 obj.keys().collect::<Vec<_>>(),
1993 vec!["type", "time_offset_secs"]
1994 );
1995 assert_eq!(obj["type"], serde_json::json!("AllInputsClosed"));
1996 }
1997
1998 #[test]
1999 fn convert_param_update() {
2000 let item = EventItem::NodeEvent {
2001 event: NodeEvent::ParamUpdate {
2002 key: "fps".into(),
2003 value_json: serde_json::to_vec(&serde_json::json!(60)).unwrap(),
2004 },
2005 };
2006 let event = EventStream::convert_event_item(item);
2007 match event {
2008 Event::ParamUpdate { key, value } => {
2009 assert_eq!(key, "fps");
2010 assert_eq!(value, serde_json::json!(60));
2011 }
2012 other => panic!("expected ParamUpdate, got {other:?}"),
2013 }
2014 }
2015
2016 /// Regression test for the daemon↔node wire protocol: `NodeEvent`
2017 /// is sent over TCP with postcard, so any field type that uses
2018 /// `Deserializer::deserialize_any` (like `serde_json::Value`)
2019 /// breaks the channel and kills the node at the next receive.
2020 /// `NodeEvent::ParamUpdate` carries its value as JSON-encoded
2021 /// bytes for that reason. This test pins the invariant so we
2022 /// don't regress back to a `deserialize_any` field.
2023 #[test]
2024 fn node_event_param_update_round_trips_through_postcard() {
2025 let cases = [
2026 serde_json::json!(42),
2027 serde_json::json!(1.5),
2028 serde_json::json!("hello"),
2029 serde_json::json!(null),
2030 serde_json::json!([1, 2, 3]),
2031 serde_json::json!({"nested": {"array": [true, false]}}),
2032 ];
2033 for value in cases {
2034 let event = NodeEvent::ParamUpdate {
2035 key: "rate".into(),
2036 value_json: serde_json::to_vec(&value).unwrap(),
2037 };
2038 let bytes = dora_message::encode(&event).expect("serialize");
2039 let back: NodeEvent = dora_message::decode(&bytes).expect("deserialize");
2040 match back {
2041 NodeEvent::ParamUpdate { key, value_json } => {
2042 assert_eq!(key, "rate");
2043 let decoded: serde_json::Value =
2044 serde_json::from_slice(&value_json).expect("value_json is JSON");
2045 assert_eq!(decoded, value);
2046 }
2047 other => panic!("expected ParamUpdate, got {other:?}"),
2048 }
2049 }
2050 }
2051
2052 // -- WriteEventsTo poisoned-state tests (#1857) ------------------------
2053 //
2054 // Build a `WriteEventsTo` against a tempfile, exercise the public
2055 // surface (push events / mark_poisoned / write_out), then parse the
2056 // resulting JSON and assert on the `recording_status` field shape.
2057 // No new dev-deps — uses std::env::temp_dir() + uuid (already a dep).
2058
2059 fn write_events_to_with_tempfile() -> (WriteEventsTo, std::path::PathBuf) {
2060 let path = std::env::temp_dir().join(format!(
2061 "dora-write-events-test-{}.json",
2062 uuid::Uuid::new_v4()
2063 ));
2064 let file = std::fs::File::create(&path).expect("create tempfile");
2065 let w = WriteEventsTo {
2066 node_id: "test-node".parse().unwrap(),
2067 file,
2068 events_buffer: Vec::new(),
2069 poisoned: None,
2070 };
2071 (w, path)
2072 }
2073
2074 fn read_back(path: &std::path::Path) -> serde_json::Value {
2075 let s = std::fs::read_to_string(path).expect("read back tempfile");
2076 std::fs::remove_file(path).ok();
2077 serde_json::from_str(&s).expect("output is valid JSON")
2078 }
2079
2080 #[test]
2081 fn write_events_clean_recording_emits_state_clean() {
2082 let (mut w, path) = write_events_to_with_tempfile();
2083 w.events_buffer.push(serde_json::json!({"type": "Stop"}));
2084 w.write_out().expect("write_out clean recording");
2085
2086 let v = read_back(&path);
2087 assert_eq!(v["recording_status"]["state"], "clean");
2088 assert_eq!(v["events"].as_array().unwrap().len(), 1);
2089 assert_eq!(v["id"], "test-node");
2090 }
2091
2092 #[test]
2093 fn write_events_poisoned_recording_emits_state_poisoned_with_first_failure() {
2094 let (mut w, path) = write_events_to_with_tempfile();
2095 // 2 events recorded cleanly, then a failure, then 1 more event
2096 w.events_buffer.push(serde_json::json!({"type": "Input"}));
2097 w.events_buffer.push(serde_json::json!({"type": "Input"}));
2098 w.mark_poisoned(&eyre!("arrow conversion failed: bad type"), 1.5);
2099 w.events_buffer.push(serde_json::json!({"type": "Stop"}));
2100 w.write_out().expect("write_out poisoned recording");
2101
2102 let v = read_back(&path);
2103 let status = &v["recording_status"];
2104 assert_eq!(status["state"], "poisoned");
2105 assert_eq!(status["first_failure_event_index"], 2);
2106 assert_eq!(status["first_failure_time_offset_secs"], 1.5);
2107 assert!(
2108 status["first_failure_error"]
2109 .as_str()
2110 .unwrap()
2111 .contains("arrow conversion failed: bad type")
2112 );
2113 assert_eq!(status["additional_failures"], 0);
2114 // `events` keeps the 2 clean + 1 post-failure-but-successful events.
2115 assert_eq!(v["events"].as_array().unwrap().len(), 3);
2116 }
2117
2118 #[test]
2119 fn write_events_multiple_failures_keep_first_and_count_rest() {
2120 let (mut w, path) = write_events_to_with_tempfile();
2121 w.mark_poisoned(&eyre!("first error"), 0.5);
2122 w.mark_poisoned(&eyre!("second error"), 1.0);
2123 w.mark_poisoned(&eyre!("third error"), 1.5);
2124 w.write_out().expect("write_out with multiple failures");
2125
2126 let v = read_back(&path);
2127 let status = &v["recording_status"];
2128 assert_eq!(status["state"], "poisoned");
2129 // First failure detail is preserved, NOT overwritten by later ones.
2130 assert_eq!(status["first_failure_event_index"], 0);
2131 assert_eq!(status["first_failure_time_offset_secs"], 0.5);
2132 assert!(
2133 status["first_failure_error"]
2134 .as_str()
2135 .unwrap()
2136 .contains("first error")
2137 );
2138 // Two additional failures after the first.
2139 assert_eq!(status["additional_failures"], 2);
2140 }
2141
2142 #[test]
2143 fn convert_param_deleted() {
2144 let item = EventItem::NodeEvent {
2145 event: NodeEvent::ParamDeleted { key: "fps".into() },
2146 };
2147 let event = EventStream::convert_event_item(item);
2148 match event {
2149 Event::ParamDeleted { key } => {
2150 assert_eq!(key, "fps");
2151 }
2152 other => panic!("expected ParamDeleted, got {other:?}"),
2153 }
2154 }
2155
2156 #[test]
2157 fn convert_stop_event() {
2158 let item = EventItem::NodeEvent {
2159 event: NodeEvent::Stop,
2160 };
2161 let event = EventStream::convert_event_item(item);
2162 assert!(matches!(event, Event::Stop(StopCause::Manual)));
2163 }
2164
2165 #[test]
2166 fn convert_all_inputs_closed() {
2167 let item = EventItem::NodeEvent {
2168 event: NodeEvent::AllInputsClosed,
2169 };
2170 let event = EventStream::convert_event_item(item);
2171 assert!(matches!(event, Event::Stop(StopCause::AllInputsClosed)));
2172 }
2173
2174 #[test]
2175 fn convert_input_closed() {
2176 let item = EventItem::NodeEvent {
2177 event: NodeEvent::InputClosed {
2178 id: "input_1".to_string().into(),
2179 },
2180 };
2181 let event = EventStream::convert_event_item(item);
2182 match event {
2183 Event::InputClosed { id } => assert_eq!(AsRef::<str>::as_ref(&id), "input_1"),
2184 other => panic!("expected InputClosed, got {other:?}"),
2185 }
2186 }
2187
2188 #[test]
2189 fn convert_node_restarted() {
2190 let item = EventItem::NodeEvent {
2191 event: NodeEvent::NodeRestarted {
2192 id: "upstream".to_string().into(),
2193 },
2194 };
2195 let event = EventStream::convert_event_item(item);
2196 match event {
2197 Event::NodeRestarted { id } => assert_eq!(AsRef::<str>::as_ref(&id), "upstream"),
2198 other => panic!("expected NodeRestarted, got {other:?}"),
2199 }
2200 }
2201
2202 // ---- dora-rs/adora#148: pattern-aware correlation classification ----
2203
2204 use arrow::array::new_empty_array;
2205 use arrow::datatypes::DataType as ArrowDataType;
2206 use dora_arrow_convert::internal::from_array_ref;
2207 use dora_message::metadata::{
2208 GOAL_ID, GOAL_STATUS, GOAL_STATUS_ABORTED, GOAL_STATUS_SUCCEEDED, Metadata,
2209 MetadataParameters, Parameter, REQUEST_ID,
2210 };
2211
2212 fn make_metadata(params: MetadataParameters) -> Metadata {
2213 Metadata::from_parameters(dora_core::uhlc::HLC::default().new_timestamp(), params)
2214 }
2215
2216 fn make_input_event(id: &str, params: MetadataParameters) -> Event {
2217 Event::Input {
2218 id: id.into(),
2219 metadata: make_metadata(params),
2220 data: from_array_ref(new_empty_array(&ArrowDataType::Null)),
2221 }
2222 }
2223
2224 fn request_id_params(id: &str) -> MetadataParameters {
2225 let mut p = MetadataParameters::new();
2226 p.insert(REQUEST_ID.into(), Parameter::String(id.to_string()));
2227 p
2228 }
2229
2230 fn goal_params(goal_id: &str, status: Option<&str>) -> MetadataParameters {
2231 let mut p = MetadataParameters::new();
2232 p.insert(GOAL_ID.into(), Parameter::String(goal_id.to_string()));
2233 if let Some(s) = status {
2234 p.insert(GOAL_STATUS.into(), Parameter::String(s.to_string()));
2235 }
2236 p
2237 }
2238
2239 fn is_request_match(needle: &str) -> impl Fn(&Event) -> bool + '_ {
2240 move |event: &Event| match event {
2241 Event::Input { metadata, .. } => {
2242 dora_message::metadata::get_string_param(&metadata.parameters, REQUEST_ID)
2243 == Some(needle)
2244 }
2245 _ => false,
2246 }
2247 }
2248
2249 fn is_action_result_match(needle: &str) -> impl Fn(&Event) -> bool + '_ {
2250 move |event: &Event| match event {
2251 Event::Input { metadata, .. } => {
2252 let p = &metadata.parameters;
2253 dora_message::metadata::get_string_param(p, GOAL_ID) == Some(needle)
2254 && matches!(
2255 dora_message::metadata::get_string_param(p, GOAL_STATUS),
2256 Some(GOAL_STATUS_SUCCEEDED)
2257 | Some(GOAL_STATUS_ABORTED)
2258 | Some(dora_message::metadata::GOAL_STATUS_CANCELED)
2259 )
2260 }
2261 _ => false,
2262 }
2263 }
2264
2265 #[test]
2266 fn classify_matching_request_id_returns_match() {
2267 let server = NodeId::from("calc".to_string());
2268 let event = make_input_event("response", request_id_params("req-42"));
2269 assert_eq!(
2270 classify_correlation_event(&event, &server, is_request_match("req-42")),
2271 CorrelationOutcome::Match
2272 );
2273 }
2274
2275 #[test]
2276 fn classify_different_request_id_is_passthrough() {
2277 let server = NodeId::from("calc".to_string());
2278 let event = make_input_event("response", request_id_params("req-99"));
2279 assert_eq!(
2280 classify_correlation_event(&event, &server, is_request_match("req-42")),
2281 CorrelationOutcome::Passthrough
2282 );
2283 }
2284
2285 #[test]
2286 fn classify_expected_server_restart_returns_server_restarted() {
2287 let server = NodeId::from("calc".to_string());
2288 let event = Event::NodeRestarted { id: server.clone() };
2289 assert_eq!(
2290 classify_correlation_event(&event, &server, is_request_match("req-42")),
2291 CorrelationOutcome::ServerRestarted
2292 );
2293 }
2294
2295 #[test]
2296 fn classify_unrelated_node_restart_is_passthrough() {
2297 let server = NodeId::from("calc".to_string());
2298 let event = Event::NodeRestarted {
2299 id: NodeId::from("other".to_string()),
2300 };
2301 assert_eq!(
2302 classify_correlation_event(&event, &server, is_request_match("req-42")),
2303 CorrelationOutcome::Passthrough
2304 );
2305 }
2306
2307 #[test]
2308 fn classify_stop_returns_stream_ended() {
2309 let server = NodeId::from("calc".to_string());
2310 let event = Event::Stop(StopCause::Manual);
2311 assert_eq!(
2312 classify_correlation_event(&event, &server, is_request_match("req-42")),
2313 CorrelationOutcome::StreamEnded
2314 );
2315 }
2316
2317 #[test]
2318 fn classify_error_returns_stream_error() {
2319 let server = NodeId::from("calc".to_string());
2320 let event = Event::Error("boom".to_string());
2321 assert_eq!(
2322 classify_correlation_event(&event, &server, is_request_match("req-42")),
2323 CorrelationOutcome::StreamError
2324 );
2325 }
2326
2327 #[test]
2328 fn classify_unrelated_input_is_passthrough() {
2329 let server = NodeId::from("calc".to_string());
2330 let event = make_input_event("sensor", MetadataParameters::new());
2331 assert_eq!(
2332 classify_correlation_event(&event, &server, is_request_match("req-42")),
2333 CorrelationOutcome::Passthrough
2334 );
2335 }
2336
2337 #[test]
2338 fn classify_param_update_is_passthrough() {
2339 // Runtime parameter updates must survive a helper wait.
2340 let server = NodeId::from("calc".to_string());
2341 let event = Event::ParamUpdate {
2342 key: "threshold".to_string(),
2343 value: serde_json::json!(0.85),
2344 };
2345 assert_eq!(
2346 classify_correlation_event(&event, &server, is_request_match("req-42")),
2347 CorrelationOutcome::Passthrough
2348 );
2349 }
2350
2351 #[test]
2352 fn classify_action_result_terminal_succeeded_matches() {
2353 let server = NodeId::from("nav".to_string());
2354 let event = make_input_event("result", goal_params("goal-1", Some(GOAL_STATUS_SUCCEEDED)));
2355 assert_eq!(
2356 classify_correlation_event(&event, &server, is_action_result_match("goal-1")),
2357 CorrelationOutcome::Match
2358 );
2359 }
2360
2361 #[test]
2362 fn classify_action_result_terminal_aborted_matches() {
2363 let server = NodeId::from("nav".to_string());
2364 let event = make_input_event("result", goal_params("goal-1", Some(GOAL_STATUS_ABORTED)));
2365 assert_eq!(
2366 classify_correlation_event(&event, &server, is_action_result_match("goal-1")),
2367 CorrelationOutcome::Match
2368 );
2369 }
2370
2371 #[test]
2372 fn classify_action_feedback_without_terminal_status_is_passthrough() {
2373 // Intermediate feedback (no terminal goal_status) should pass
2374 // through so the caller's main loop can observe it.
2375 let server = NodeId::from("nav".to_string());
2376 let event = make_input_event("feedback", goal_params("goal-1", None));
2377 assert_eq!(
2378 classify_correlation_event(&event, &server, is_action_result_match("goal-1")),
2379 CorrelationOutcome::Passthrough
2380 );
2381 }
2382
2383 #[test]
2384 fn classify_action_result_for_different_goal_is_passthrough() {
2385 let server = NodeId::from("nav".to_string());
2386 let event = make_input_event("result", goal_params("goal-2", Some(GOAL_STATUS_SUCCEEDED)));
2387 assert_eq!(
2388 classify_correlation_event(&event, &server, is_action_result_match("goal-1")),
2389 CorrelationOutcome::Passthrough
2390 );
2391 }
2392
2393 // ---- dora-rs/adora#172: pending_passthrough integration ----
2394
2395 use crate::integration_testing::{
2396 IntegrationTestInput, TestingInput, TestingOptions, TestingOutput,
2397 integration_testing_format::{IncomingEvent, TimedIncomingEvent},
2398 };
2399
2400 /// Create a minimal EventStream via the testing path.
2401 fn test_event_stream() -> (crate::DoraNode, EventStream) {
2402 let events = vec![TimedIncomingEvent {
2403 time_offset_secs: 0.0,
2404 event: IncomingEvent::Stop,
2405 }];
2406 let inputs = TestingInput::Input(IntegrationTestInput::new(
2407 "test-node".parse().unwrap(),
2408 events,
2409 ));
2410 let (tx, _rx) = crate::integration_testing::output_channel();
2411 let outputs = TestingOutput::ToChannel(tx);
2412 let options = TestingOptions {
2413 skip_output_time_offsets: true,
2414 };
2415 crate::DoraNode::init_testing(inputs, outputs, options).unwrap()
2416 }
2417
2418 /// #2956: outputs sent through `TestingOutput::ToChannel` must reach the
2419 /// receiver, in order, when drained after the node has finished — the
2420 /// documented usage pattern, and previously untested (every other
2421 /// `ToChannel` test here drops the receiver).
2422 ///
2423 /// Plain `#[test]` on purpose: the testing bridge uses
2424 /// `blocking_send`/`blocking_recv` on the request channel, which panic
2425 /// inside a tokio runtime.
2426 #[test]
2427 fn to_channel_delivers_outputs_in_order() {
2428 use arrow::array::Int32Array;
2429
2430 let events = vec![TimedIncomingEvent {
2431 time_offset_secs: 0.0,
2432 event: IncomingEvent::Stop,
2433 }];
2434 let inputs = TestingInput::Input(IntegrationTestInput::new(
2435 "test-node".parse().unwrap(),
2436 events,
2437 ));
2438 let (tx, mut rx) = crate::integration_testing::output_channel();
2439 let outputs = TestingOutput::ToChannel(tx);
2440 let options = TestingOptions {
2441 skip_output_time_offsets: true,
2442 };
2443 let (mut node, _events) = crate::DoraNode::init_testing(inputs, outputs, options).unwrap();
2444
2445 for i in 0..3 {
2446 node.send_output(
2447 "out".parse().unwrap(),
2448 Default::default(),
2449 dora_arrow_convert::internal::from_array_ref(std::sync::Arc::new(
2450 Int32Array::from(vec![i]),
2451 )),
2452 )
2453 .unwrap();
2454 }
2455
2456 let received = crate::integration_testing::drain_outputs(&mut rx);
2457
2458 assert_eq!(received.len(), 3, "every sent output should be delivered");
2459 for (i, output) in received.iter().enumerate() {
2460 assert_eq!(output.get("id").and_then(|v| v.as_str()), Some("out"));
2461 assert_eq!(
2462 output.get("data"),
2463 Some(&serde_json::json!([i as i32])),
2464 "outputs should arrive in send order"
2465 );
2466 }
2467 }
2468
2469 #[test]
2470 fn is_empty_reflects_pending_passthrough() {
2471 let (_node, mut events) = test_event_stream();
2472 // Drain the initial Stop event so the stream is empty.
2473 let _ = events.recv();
2474 assert!(events.is_empty(), "should be empty after draining");
2475
2476 // Inject a passthrough event — is_empty must now return false.
2477 events.push_passthrough_for_testing(Event::ParamDeleted {
2478 key: "k".to_string(),
2479 });
2480 assert!(
2481 !events.is_empty(),
2482 "should not be empty with pending passthrough"
2483 );
2484 }
2485
2486 #[test]
2487 fn stream_poll_next_drains_pending_passthrough() {
2488 use futures::StreamExt;
2489 let (_node, mut events) = test_event_stream();
2490 // Drain the initial Stop event.
2491 let _ = events.recv();
2492
2493 // Inject a passthrough event.
2494 events.push_passthrough_for_testing(Event::ParamUpdate {
2495 key: "threshold".to_string(),
2496 value: serde_json::json!(42),
2497 });
2498
2499 // StreamExt::next() should return the passthrough event, not
2500 // block waiting on the underlying receiver.
2501 let next = futures::executor::block_on(events.next());
2502 match next {
2503 Some(Event::ParamUpdate { key, value }) => {
2504 assert_eq!(key, "threshold");
2505 assert_eq!(value, serde_json::json!(42));
2506 }
2507 other => panic!("expected ParamUpdate from passthrough, got {other:?}"),
2508 }
2509 }
2510
2511 #[test]
2512 fn recv_async_drains_pending_passthrough_before_receiver() {
2513 let (_node, mut events) = test_event_stream();
2514
2515 // Inject a passthrough event BEFORE the Stop in the receiver.
2516 events.push_passthrough_for_testing(Event::ParamDeleted {
2517 key: "x".to_string(),
2518 });
2519
2520 // First recv should return the passthrough event.
2521 let first = events.recv();
2522 assert!(
2523 matches!(first, Some(Event::ParamDeleted { .. })),
2524 "expected passthrough ParamDeleted first, got {first:?}"
2525 );
2526
2527 // Second recv should return the Stop from the receiver.
2528 let second = events.recv();
2529 assert!(
2530 matches!(second, Some(Event::Stop(_))),
2531 "expected Stop second, got {second:?}"
2532 );
2533 }
2534
2535 /// Regression: a pattern-aware wait (`recv_service_response`) must make
2536 /// progress when a non-matching event arrives *before* the correlated
2537 /// response.
2538 ///
2539 /// The wait loop buffers every non-matching event into
2540 /// `pending_passthrough` so the caller's own event loop can still see it.
2541 /// Before the fix the loop pumped the stream via `recv_async`, which
2542 /// drains `pending_passthrough` first — so it kept re-serving the buffered
2543 /// non-matching event, re-buffering it, and spinning forever without ever
2544 /// reading the response off the receiver. Every such call pinned a CPU core
2545 /// and returned `Timeout`. The fix pumps via `recv_from_stream`, which
2546 /// bypasses the passthrough buffer.
2547 #[test]
2548 fn recv_service_response_matches_after_non_matching_event() {
2549 // Delivered FIFO (integration tests disable the reordering scheduler):
2550 // the non-matching "sensor" input, then the correlated "response".
2551 let events = vec![
2552 TimedIncomingEvent {
2553 time_offset_secs: 0.0,
2554 event: IncomingEvent::Input {
2555 id: "sensor".parse().unwrap(),
2556 metadata: None,
2557 data: None,
2558 },
2559 },
2560 TimedIncomingEvent {
2561 time_offset_secs: 0.0,
2562 event: IncomingEvent::Input {
2563 id: "response".parse().unwrap(),
2564 metadata: Some(request_id_params("req-1")),
2565 data: None,
2566 },
2567 },
2568 TimedIncomingEvent {
2569 time_offset_secs: 0.0,
2570 event: IncomingEvent::Stop,
2571 },
2572 ];
2573 let inputs = TestingInput::Input(IntegrationTestInput::new(
2574 "test-node".parse().unwrap(),
2575 events,
2576 ));
2577 let (tx, _rx) = crate::integration_testing::output_channel();
2578 let outputs = TestingOutput::ToChannel(tx);
2579 let options = TestingOptions {
2580 skip_output_time_offsets: true,
2581 };
2582 let (_node, mut events) = crate::DoraNode::init_testing(inputs, outputs, options).unwrap();
2583
2584 let server = NodeId::from("calc".to_string());
2585 let response = futures::executor::block_on(events.recv_service_response(
2586 "req-1",
2587 &server,
2588 Duration::from_secs(5),
2589 ));
2590 match response {
2591 Ok(Event::Input { id, .. }) => assert_eq!(id.as_str(), "response"),
2592 other => panic!("expected the correlated response Input, got {other:?}"),
2593 }
2594
2595 // The non-matching "sensor" input must not be lost — it is replayed
2596 // to the caller's own event loop after the wait returns.
2597 let buffered = events.recv();
2598 assert!(
2599 matches!(&buffered, Some(Event::Input { id, .. }) if id.as_str() == "sensor"),
2600 "expected the buffered non-matching 'sensor' input, got {buffered:?}"
2601 );
2602 }
2603
2604 /// Regression: a pattern-aware wait must find its correlated response even
2605 /// when a *previous* wait already buffered it.
2606 ///
2607 /// This is the pipelined / out-of-order case: two requests are in flight,
2608 /// and `req-2`'s response arrives before `req-1`'s. The wait for `req-1`
2609 /// buffers `resp-2` into `pending_passthrough` as non-matching, then
2610 /// returns `resp-1`. The subsequent wait for `req-2` must return the
2611 /// already-buffered `resp-2` — the wait loop pumps `recv_from_stream`,
2612 /// which never reads `pending_passthrough`, so `wait_for_correlation`
2613 /// scans the buffer for a match before reading the stream. Without that
2614 /// scan the buffered response is invisible and the wait wrongly times out.
2615 #[test]
2616 fn recv_service_response_matches_buffered_response_from_prior_wait() {
2617 // Delivered FIFO: `resp-2` arrives before `resp-1`, then `Stop`.
2618 let events = vec![
2619 TimedIncomingEvent {
2620 time_offset_secs: 0.0,
2621 event: IncomingEvent::Input {
2622 id: "response".parse().unwrap(),
2623 metadata: Some(request_id_params("req-2")),
2624 data: None,
2625 },
2626 },
2627 TimedIncomingEvent {
2628 time_offset_secs: 0.0,
2629 event: IncomingEvent::Input {
2630 id: "response".parse().unwrap(),
2631 metadata: Some(request_id_params("req-1")),
2632 data: None,
2633 },
2634 },
2635 TimedIncomingEvent {
2636 time_offset_secs: 0.0,
2637 event: IncomingEvent::Stop,
2638 },
2639 ];
2640 let inputs = TestingInput::Input(IntegrationTestInput::new(
2641 "test-node".parse().unwrap(),
2642 events,
2643 ));
2644 let (tx, _rx) = crate::integration_testing::output_channel();
2645 let outputs = TestingOutput::ToChannel(tx);
2646 let options = TestingOptions {
2647 skip_output_time_offsets: true,
2648 };
2649 let (_node, mut events) = crate::DoraNode::init_testing(inputs, outputs, options).unwrap();
2650
2651 let server = NodeId::from("calc".to_string());
2652 let request_id_of = |event: &Event| match event {
2653 Event::Input { metadata, .. } => dora_message::metadata::get_string_param(
2654 &metadata.parameters,
2655 dora_message::metadata::REQUEST_ID,
2656 )
2657 .map(str::to_owned),
2658 _ => None,
2659 };
2660
2661 // Wait for `req-1`: reads `resp-2` (buffered as non-matching), then
2662 // returns `resp-1`.
2663 let first = futures::executor::block_on(events.recv_service_response(
2664 "req-1",
2665 &server,
2666 Duration::from_secs(5),
2667 ));
2668 match &first {
2669 Ok(event) => assert_eq!(request_id_of(event).as_deref(), Some("req-1")),
2670 other => panic!("expected the req-1 response, got {other:?}"),
2671 }
2672
2673 // Wait for `req-2`: `resp-2` is already in `pending_passthrough`, so
2674 // this must return it from the buffer rather than time out.
2675 let second = futures::executor::block_on(events.recv_service_response(
2676 "req-2",
2677 &server,
2678 Duration::from_secs(5),
2679 ));
2680 match &second {
2681 Ok(event) => assert_eq!(request_id_of(event).as_deref(), Some("req-2")),
2682 other => panic!("expected the buffered req-2 response, got {other:?}"),
2683 }
2684 }
2685
2686 /// After a `Stop` event is delivered, subsequent `recv` calls must
2687 /// return `None` so the node can exit cleanly even when zenoh
2688 /// subscriber threads still hold clones of the event channel
2689 /// sender (which would otherwise keep the receiver open).
2690 #[test]
2691 fn recv_returns_none_after_stop() {
2692 let (_node, mut events) = test_event_stream();
2693
2694 // First recv delivers the seeded Stop.
2695 let first = events.recv();
2696 assert!(matches!(first, Some(Event::Stop(_))));
2697
2698 // Second recv must return None even though the underlying
2699 // receiver may still have live senders.
2700 let second = events.recv();
2701 assert!(
2702 second.is_none(),
2703 "recv must return None after Stop, got {second:?}"
2704 );
2705 }
2706
2707 /// #2027: the scheduler gives `Stop` (a NON_INPUT_EVENT) strict priority
2708 /// over buffered inputs, so an input enqueued before `Stop` is still in the
2709 /// scheduler when `Stop` is delivered. `recv` must drain that input before
2710 /// closing rather than dropping it silently (the previous `return None`
2711 /// after `stop_received` lost it).
2712 #[test]
2713 fn recv_drains_buffered_scheduler_inputs_after_stop() {
2714 let (_node, mut events) = test_event_stream();
2715
2716 // Deliver the seeded Stop (sets `stop_received`).
2717 assert!(matches!(events.recv(), Some(Event::Stop(_))));
2718
2719 // Simulate the input the scheduler held back behind the prioritized Stop.
2720 events.push_scheduler_input_for_testing("cam");
2721
2722 let drained = events.recv();
2723 assert!(
2724 matches!(&drained, Some(Event::Input { id, .. }) if id.as_str() == "cam"),
2725 "buffered input must be drained after Stop, got {drained:?}"
2726 );
2727
2728 // Once the scheduler is empty the stream closes.
2729 assert!(
2730 events.recv().is_none(),
2731 "stream must close after draining buffered inputs"
2732 );
2733 }
2734
2735 /// #2027 review (P2): the post-Stop drain must deliver buffered *inputs*
2736 /// only. A non-input control event buffered behind Stop (e.g. a second
2737 /// `Stop`) must NOT be re-delivered to a loop-until-`None` caller.
2738 #[test]
2739 fn recv_after_stop_skips_trailing_control_events() {
2740 let (_node, mut events) = test_event_stream();
2741
2742 // Deliver the seeded Stop (sets `stop_received`).
2743 assert!(matches!(events.recv(), Some(Event::Stop(_))));
2744
2745 // Buffer a trailing Stop AND a real input behind it. The scheduler
2746 // prioritizes the Stop (NON_INPUT), so the drain meets it first.
2747 events.push_scheduler_stop_for_testing();
2748 events.push_scheduler_input_for_testing("cam");
2749
2750 // The drain must skip the trailing Stop and return only the input...
2751 let drained = events.recv();
2752 assert!(
2753 matches!(&drained, Some(Event::Input { id, .. }) if id.as_str() == "cam"),
2754 "expected the buffered input, not a re-delivered Stop, got {drained:?}"
2755 );
2756 // ...then close (no second Stop ever surfaces).
2757 assert!(events.recv().is_none(), "stream must close after the input");
2758 }
2759
2760 /// The zenoh receive path is Arrow-IPC-only. An empty payload is a
2761 /// metadata-only message and maps to the unit array; a non-empty payload is
2762 /// a self-describing IPC stream and round-trips to its original array (with
2763 /// no type sidecar involved).
2764 #[test]
2765 fn zenoh_payload_ipc_roundtrip_and_empty_is_unit() {
2766 use crate::arrow_utils::ipc_encode::{
2767 InputDecoder, encode_ipc_into_data, ipc_fast_path_len_data,
2768 };
2769 use arrow::array::{Array, Int32Array};
2770
2771 // A standalone full stream (no SCHEMA_HASH parameter) decodes directly.
2772 let metadata =
2773 dora_message::metadata::Metadata::new(dora_core::uhlc::HLC::default().new_timestamp());
2774 let mut decoder = InputDecoder::new();
2775
2776 // Empty payload -> unit array (metadata-only message).
2777 let unit = decode_zenoh_sample(&mut decoder, &metadata, zenoh::bytes::ZBytes::new())
2778 .unwrap()
2779 .unwrap();
2780 assert_eq!(
2781 unit.data_type(),
2782 &arrow_schema::DataType::Null,
2783 "empty payload maps to the unit array"
2784 );
2785
2786 // Non-empty IPC payload round-trips to the original array.
2787 let data = Int32Array::from(vec![10, 20, 30]).into_data();
2788 let len = ipc_fast_path_len_data(&data).expect("primitive is fast-path eligible");
2789 let mut buf = vec![0u8; len];
2790 encode_ipc_into_data(&data, &mut buf).unwrap();
2791
2792 let decoded = decode_zenoh_sample(&mut decoder, &metadata, zenoh::bytes::ZBytes::from(buf))
2793 .unwrap()
2794 .unwrap();
2795 assert_eq!(decoded.data_type(), &arrow_schema::DataType::Int32);
2796 assert_eq!(&decoded, &data);
2797 }
2798
2799 /// A full self-describing stream on the data topic must prime the per-input
2800 /// decoder in-band: the schema-less batches that follow decode against it
2801 /// without any `@schema`-plane delivery. This is what makes the producer's
2802 /// "full stream until the schema is confirmed published" strategy race-free
2803 /// — data-plane puts share one publisher, so zenoh preserves their order,
2804 /// while the separate `@schema` plane can lose the race with an express
2805 /// batch (dora-rs/dora#2366 review: first-message QoS race).
2806 #[test]
2807 fn full_stream_primes_decoder_in_band_for_schema_less_batches() {
2808 use crate::arrow_utils::ipc_encode::{
2809 InputDecoder, batch_fast_path_len_data, encode_batch_into_data, encode_ipc_into_data,
2810 ipc_fast_path_len_data, schema_block_len,
2811 };
2812 use arrow::array::{Array, Int32Array};
2813 use dora_message::metadata::{Metadata, Parameter, SCHEMA_HASH};
2814
2815 let hlc = dora_core::uhlc::HLC::default();
2816 let mut decoder = InputDecoder::new();
2817
2818 // Message 1: full stream (no SCHEMA_HASH), as the producer sends while
2819 // the schema is not yet confirmed published on the `@schema` plane.
2820 let first = Int32Array::from(vec![1, 2]).into_data();
2821 let mut full = vec![0u8; ipc_fast_path_len_data(&first).unwrap()];
2822 encode_ipc_into_data(&first, &mut full).unwrap();
2823 let block = schema_block_len(&full).unwrap();
2824 let hash = dora_message::metadata::fnv1a(&full[..block]);
2825
2826 let plain = Metadata::new(hlc.new_timestamp());
2827 let got = decode_zenoh_sample(&mut decoder, &plain, zenoh::bytes::ZBytes::from(full))
2828 .unwrap()
2829 .unwrap();
2830 assert_eq!(&got, &first);
2831
2832 // Message 2: schema-less batch tagged with the schema hash. No
2833 // `set_schema` call happened — decoding must succeed purely from the
2834 // in-band priming above.
2835 let second = Int32Array::from(vec![3]).into_data();
2836 let mut batch = vec![0u8; batch_fast_path_len_data(&second).unwrap()];
2837 encode_batch_into_data(&second, &mut batch).unwrap();
2838 let mut tagged = Metadata::new(hlc.new_timestamp());
2839 tagged
2840 .parameters
2841 .insert(SCHEMA_HASH.to_string(), Parameter::Integer(hash as i64));
2842
2843 let got = decode_zenoh_sample(&mut decoder, &tagged, zenoh::bytes::ZBytes::from(batch))
2844 .unwrap()
2845 .expect("schema-less batch must decode against the in-band-primed decoder");
2846 assert_eq!(&got, &second);
2847 }
2848
2849 /// Service/action full streams (pattern-correlated) are excluded from
2850 /// schema-once and must NOT prime the decoder in-band: a server multiplexes
2851 /// per-request schemas through one output, and letting those prime the
2852 /// decoder would churn the retained schema set for no benefit.
2853 #[test]
2854 fn pattern_correlated_full_stream_does_not_prime_in_band() {
2855 use crate::arrow_utils::ipc_encode::{
2856 InputDecoder, batch_fast_path_len_data, encode_batch_into_data, encode_ipc_into_data,
2857 ipc_fast_path_len_data, schema_block_len,
2858 };
2859 use arrow::array::{Array, Int32Array};
2860 use dora_message::metadata::{Metadata, Parameter, REQUEST_ID, SCHEMA_HASH};
2861
2862 let hlc = dora_core::uhlc::HLC::default();
2863 let mut decoder = InputDecoder::new();
2864
2865 let reply = Int32Array::from(vec![7]).into_data();
2866 let mut full = vec![0u8; ipc_fast_path_len_data(&reply).unwrap()];
2867 encode_ipc_into_data(&reply, &mut full).unwrap();
2868 let block = schema_block_len(&full).unwrap();
2869 let hash = dora_message::metadata::fnv1a(&full[..block]);
2870
2871 // A service reply (request_id) decodes standalone…
2872 let mut service = Metadata::new(hlc.new_timestamp());
2873 service
2874 .parameters
2875 .insert(REQUEST_ID.to_string(), Parameter::String("req-1".into()));
2876 let got = decode_zenoh_sample(&mut decoder, &service, zenoh::bytes::ZBytes::from(full))
2877 .unwrap()
2878 .unwrap();
2879 assert_eq!(&got, &reply);
2880
2881 // …but must not have primed the decoder for its schema hash.
2882 let batch_array = Int32Array::from(vec![8]).into_data();
2883 let mut batch = vec![0u8; batch_fast_path_len_data(&batch_array).unwrap()];
2884 encode_batch_into_data(&batch_array, &mut batch).unwrap();
2885 let mut tagged = Metadata::new(hlc.new_timestamp());
2886 tagged
2887 .parameters
2888 .insert(SCHEMA_HASH.to_string(), Parameter::Integer(hash as i64));
2889 assert!(
2890 decode_zenoh_sample(&mut decoder, &tagged, zenoh::bytes::ZBytes::from(batch))
2891 .unwrap()
2892 .is_none(),
2893 "a pattern-correlated stream must not prime the schema-once decoder"
2894 );
2895 }
2896
2897 /// Internal wire-protocol keys (`_schema_hash`, `_framing`) must be
2898 /// stripped from the metadata handed to user code — on both the zenoh and
2899 /// the daemon receive paths. A forwarded `_schema_hash` would otherwise
2900 /// ride onto a large/service output (which does not overwrite it) and make
2901 /// receivers hash-mismatch and silently drop the message
2902 /// (dora-rs/dora#2366 review).
2903 #[test]
2904 fn internal_wire_keys_are_stripped_from_user_visible_metadata() {
2905 use dora_message::metadata::{
2906 FRAMING, FRAMING_ARROW_IPC, Metadata, Parameter, SCHEMA_HASH,
2907 };
2908
2909 let hlc = dora_core::uhlc::HLC::default();
2910 let mut metadata = Metadata::new(hlc.new_timestamp());
2911 metadata
2912 .parameters
2913 .insert(SCHEMA_HASH.to_string(), Parameter::Integer(42));
2914 metadata.parameters.insert(
2915 FRAMING.to_string(),
2916 Parameter::String(FRAMING_ARROW_IPC.to_string()),
2917 );
2918 metadata
2919 .parameters
2920 .insert("user_key".to_string(), Parameter::Integer(7));
2921
2922 // Zenoh receive path.
2923 let zenoh_item = EventItem::ZenohInput {
2924 id: DataId::from("in".to_string()),
2925 metadata: Arc::new(metadata.clone()),
2926 data: {
2927 use arrow::array::Array;
2928 arrow::array::Int32Array::from(vec![1]).into_data()
2929 },
2930 };
2931 let Event::Input {
2932 metadata: user_metadata,
2933 ..
2934 } = EventStream::convert_event_item(zenoh_item)
2935 else {
2936 panic!("expected an input event");
2937 };
2938 assert!(!user_metadata.parameters.contains_key(SCHEMA_HASH));
2939 assert!(!user_metadata.parameters.contains_key(FRAMING));
2940 assert_eq!(
2941 user_metadata.parameters.get("user_key"),
2942 Some(&Parameter::Integer(7)),
2943 "user-provided keys must survive the strip"
2944 );
2945
2946 // Daemon receive path.
2947 let daemon_item = EventItem::NodeEvent {
2948 event: dora_message::daemon_to_node::NodeEvent::Input {
2949 id: DataId::from("in".to_string()),
2950 metadata: Arc::new(metadata),
2951 data: None,
2952 },
2953 };
2954 let Event::Input {
2955 metadata: user_metadata,
2956 ..
2957 } = EventStream::convert_event_item(daemon_item)
2958 else {
2959 panic!("expected an input event");
2960 };
2961 assert!(!user_metadata.parameters.contains_key(SCHEMA_HASH));
2962 assert!(!user_metadata.parameters.contains_key(FRAMING));
2963 }
2964
2965 /// A zenoh-delivered input must be serializable into the same recording
2966 /// JSON shape as a daemon-path input, so `write_events_to` recordings do
2967 /// not silently drop inputs that take the direct zenoh data plane.
2968 #[test]
2969 fn zenoh_input_serializes_into_recording_json() {
2970 use crate::daemon_connection::node_integration_testing::convert_arrow_input_to_json;
2971
2972 let hlc = dora_core::uhlc::HLC::default();
2973 let start = hlc.new_timestamp();
2974 let metadata = Metadata::new(hlc.new_timestamp());
2975 let array: arrow::array::ArrayRef =
2976 std::sync::Arc::new(arrow::array::Int32Array::from(vec![1, 2, 3]));
2977
2978 let json = convert_arrow_input_to_json(
2979 &DataId::from("in".to_string()),
2980 &metadata,
2981 array,
2982 start,
2983 true,
2984 )
2985 .expect("zenoh input must serialize");
2986
2987 assert_eq!(json["id"], "in");
2988 assert!(json.contains_key("data"), "recorded event must carry data");
2989 assert!(
2990 json.contains_key("data_type"),
2991 "recorded event must carry data_type"
2992 );
2993 assert_eq!(
2994 json["data"].as_array().map(|a| a.len()),
2995 Some(3),
2996 "all array elements must be recorded"
2997 );
2998 }
2999
3000 /// A zenoh input can carry a remote HLC timestamp that predates this node's
3001 /// `start_timestamp`. The recording must clamp the offset to zero instead of
3002 /// underflowing the NTP64 subtraction (a debug panic that would kill the
3003 /// event loop, or a release wraparound to a garbage offset).
3004 #[test]
3005 fn zenoh_input_with_earlier_timestamp_does_not_underflow() {
3006 use crate::daemon_connection::node_integration_testing::convert_arrow_input_to_json;
3007
3008 let hlc = dora_core::uhlc::HLC::default();
3009 // `input_ts` is created first, so it is strictly before `start`.
3010 let input_ts = hlc.new_timestamp();
3011 let start = hlc.new_timestamp();
3012 let metadata = Metadata::new(input_ts);
3013 let array: arrow::array::ArrayRef =
3014 std::sync::Arc::new(arrow::array::Int32Array::from(vec![1]));
3015
3016 // `skip_output_time_offsets = false` exercises the time-offset path.
3017 let json = convert_arrow_input_to_json(
3018 &DataId::from("in".to_string()),
3019 &metadata,
3020 array,
3021 start,
3022 false,
3023 )
3024 .expect("recording an earlier-timestamped input must not fail");
3025 assert_eq!(
3026 json["time_offset_secs"], 0.0,
3027 "an input predating start must clamp to a zero offset"
3028 );
3029 }
3030
3031 /// The schema-plane FatalError only fires after the grace window: the
3032 /// producer's periodic full-stream refresh heals an unprimed input in-band,
3033 /// so a node with a dead `@schema` plane must not be killed on the first
3034 /// dropped batch when it would recover within seconds.
3035 #[test]
3036 fn schema_plane_fatal_waits_out_the_grace_window() {
3037 let start = Instant::now();
3038 let mut first = None;
3039
3040 // First undecodable batch starts the window — not fatal yet.
3041 assert!(!schema_plane_fatal_due(&mut first, start));
3042 assert_eq!(first, Some(start));
3043 // Still inside the grace window — not fatal.
3044 assert!(!schema_plane_fatal_due(
3045 &mut first,
3046 start + SCHEMA_PLANE_FATAL_GRACE / 2
3047 ));
3048 // Past the window — fatal.
3049 assert!(schema_plane_fatal_due(
3050 &mut first,
3051 start + SCHEMA_PLANE_FATAL_GRACE
3052 ));
3053
3054 // A successful decode clears the window (caller side); the next drop
3055 // starts a fresh one.
3056 let mut first = None;
3057 let later = start + SCHEMA_PLANE_FATAL_GRACE * 2;
3058 assert!(!schema_plane_fatal_due(&mut first, later));
3059 assert_eq!(first, Some(later));
3060 }
3061
3062 /// Same invariant as `recv_returns_none_after_stop`, verified via
3063 /// the `Stream` impl (`StreamExt::next`).
3064 #[test]
3065 fn stream_returns_none_after_stop() {
3066 use futures::StreamExt;
3067 let (_node, mut events) = test_event_stream();
3068
3069 let first = futures::executor::block_on(events.next());
3070 assert!(matches!(first, Some(Event::Stop(_))));
3071
3072 let second = futures::executor::block_on(events.next());
3073 assert!(
3074 second.is_none(),
3075 "Stream::next must yield None after Stop, got {second:?}"
3076 );
3077 }
3078}