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