dora_node_api/node/mod.rs
1use crate::{
2 DaemonCommunicationWrapper, EventStream, NodeError, NodeResult,
3 daemon_connection::{DaemonChannel, IntegrationTestingEvents},
4 integration_testing::{
5 TestingCommunication, TestingInput, TestingOptions, TestingOutput,
6 take_testing_communication,
7 },
8};
9
10use self::{arrow_utils::ipc_encode, control_channel::ControlChannel};
11use aligned_vec::{AVec, ConstAlign};
12use arrow::array::{Array, ArrayData};
13use colored::Colorize;
14use dora_arrow_convert::{DoraArray, IntoArrow};
15use dora_core::{
16 config::{DataId, NodeId, NodeRunConfig},
17 descriptor::Descriptor,
18 topics::{DORA_DAEMON_LOCAL_LISTEN_PORT_DEFAULT, DORA_DAEMON_LOCAL_LISTEN_PORT_ENV, LOCALHOST},
19 types::TypeRegistry,
20 uhlc,
21};
22use dora_message::{
23 DataflowId,
24 daemon_to_node::{DaemonCommunication, DaemonReply, NodeConfig, OutputRouting},
25 metadata::{
26 FIN, FLUSH, FRAMING, FRAMING_ARROW_IPC, Metadata, MetadataParameters, Parameter,
27 SCHEMA_HASH, SEGMENT_ID, SEQ, SESSION_ID,
28 },
29 node_to_daemon::{DaemonRequest, DataMessage, Timestamped},
30};
31use eyre::WrapErr;
32use is_terminal::IsTerminal;
33
34use std::{
35 collections::{BTreeMap, BTreeSet, HashMap},
36 path::PathBuf,
37 sync::{
38 Arc, Mutex,
39 atomic::{AtomicBool, Ordering},
40 },
41 time::{Duration, Instant},
42};
43#[cfg(feature = "tracing")]
44use tokio::runtime::Handle;
45
46#[cfg(feature = "tracing")]
47use dora_tracing::{OtelGuard, TracingBuilder};
48use tracing::{debug, error, info, warn};
49
50pub mod arrow_utils;
51mod control_channel;
52
53/// Runtime type checking mode, controlled by `DORA_RUNTIME_TYPE_CHECK` env var.
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55enum RuntimeTypeCheck {
56 /// No runtime type checking (default).
57 Off,
58 /// Log warnings on type mismatches.
59 Warn,
60 /// Return errors on type mismatches.
61 Error,
62}
63
64impl RuntimeTypeCheck {
65 fn from_env() -> Self {
66 Self::from_value(std::env::var("DORA_RUNTIME_TYPE_CHECK").ok().as_deref())
67 }
68
69 /// Parse the `DORA_RUNTIME_TYPE_CHECK` value (`None` when the var is unset).
70 fn from_value(value: Option<&str>) -> Self {
71 match value {
72 Some("error") => Self::Error,
73 Some("1" | "warn" | "true" | "on") => Self::Warn,
74 // Accept the natural "disable" spellings without a warning: a user
75 // who sets `=0`/`=false`/`=off` to turn the feature off means to
76 // disable it, not to type an unrecognized value.
77 Some("" | "0" | "false" | "off") | None => Self::Off,
78 Some(other) => {
79 tracing::warn!(
80 "unknown DORA_RUNTIME_TYPE_CHECK value \"{other}\", \
81 expected \"warn\" or \"error\"; disabling runtime type check"
82 );
83 Self::Off
84 }
85 }
86 }
87}
88
89#[cfg(test)]
90mod runtime_type_check_tests {
91 use super::RuntimeTypeCheck;
92
93 #[test]
94 fn parses_enable_spellings() {
95 for v in ["1", "warn", "true", "on"] {
96 assert_eq!(
97 RuntimeTypeCheck::from_value(Some(v)),
98 RuntimeTypeCheck::Warn
99 );
100 }
101 assert_eq!(
102 RuntimeTypeCheck::from_value(Some("error")),
103 RuntimeTypeCheck::Error
104 );
105 }
106
107 #[test]
108 fn disable_spellings_and_unset_are_off() {
109 for v in ["", "0", "false", "off"] {
110 assert_eq!(RuntimeTypeCheck::from_value(Some(v)), RuntimeTypeCheck::Off);
111 }
112 assert_eq!(RuntimeTypeCheck::from_value(None), RuntimeTypeCheck::Off);
113 }
114
115 #[test]
116 fn unknown_value_falls_back_to_off() {
117 assert_eq!(
118 RuntimeTypeCheck::from_value(Some("maybe")),
119 RuntimeTypeCheck::Off
120 );
121 }
122}
123
124/// The data size threshold at which we start using shared memory.
125///
126/// Shared memory works by sharing memory pages. This means that the smallest
127/// memory region that can be shared is one memory page, which is typically
128/// 4KiB.
129///
130/// Using shared memory for messages smaller than the page size still requires
131/// sharing a full page, so we have some memory overhead. We also have some
132/// performance overhead because setting up a shared segment is not free. For
133/// small messages it is cheaper to copy them into a heap-buffered publish.
134///
135/// On the zenoh data plane this threshold selects *how* an output is
136/// published: payloads at or above it go through zenoh shared memory
137/// (zero-copy for local subscribers), while smaller payloads are published via
138/// zenoh with a heap-buffered `put`. A large payload that did not get a
139/// shared-memory buffer takes the reliable daemon path instead of the zenoh
140/// one, because a fragmented express publish would be silently dropped
141/// (dora-rs/dora#2366). See [`DoraNode::zero_copy_threshold`] for the runtime
142/// value (overridable via `DORA_ZERO_COPY_THRESHOLD`).
143pub const ZERO_COPY_THRESHOLD: usize = 4096;
144
145/// How many large outbound sends are traced hop-by-hop
146/// (dora-rs/dora#2742 diagnostic; see [`DoraNode::send_output_sample`]).
147///
148/// The Windows nightly wedge happens on a node's *first* large output, so a
149/// handful of traced sends is enough to name the blocked call while keeping a
150/// healthy run's logs clean.
151const LARGE_SEND_DIAG_LIMIT: u32 = 3;
152
153/// How often a starting node re-publishes its startup route-probe markers.
154///
155/// See [`StartupHandshake`]. Markers stop per output as soon as that output's
156/// required acks have arrived (or the grace boundary froze it on the daemon
157/// path), so this rate only applies while the handshake is in flight.
158const ZENOH_STARTUP_MARKER_INTERVAL: Duration = Duration::from_millis(5);
159
160/// The whole budget the startup handshake gets, measured from the daemon's
161/// "all nodes ready" barrier: `init` waits this long for the handshake, and
162/// whatever is still un-acked at the end is frozen on the daemon path for the
163/// run (see [`wait_for_grace`] for why the freeze is unconditional).
164///
165/// The window starts at the barrier, not at spawn: by then every static
166/// consumer has declared its subscribers and ack publishers, so a consumer
167/// that is merely slow to *start* (a Python node importing heavy libraries for
168/// a minute) cannot burn it. The healthy case completes in one marker→ack
169/// round-trip (single-digit milliseconds); half a second of 5 ms markers with
170/// no ack means the route is broken, not slow. Nothing fails at the boundary —
171/// a frozen output just keeps riding the lossless daemon path, trading the
172/// fast path for ordering. This is the single knob for that trade: raising it
173/// gives slow-to-establish routes more chance at direct zenoh, at the cost of
174/// delaying every node whose routes are genuinely broken.
175///
176/// For a **dynamic or restarted** producer the barrier releases immediately —
177/// its consumers have long been running — so this window instead starts a few
178/// milliseconds after the zenoh session opens, while the peer links it needs
179/// may still be dialing. Such a producer is therefore the most likely to end
180/// up frozen on the daemon path; if that shows up as a measurable regression,
181/// this constant (not a late upgrade) is the thing to raise.
182const ZENOH_STARTUP_GRACE: Duration = Duration::from_millis(500);
183
184/// Poll interval for the post-barrier grace wait.
185const ZENOH_STARTUP_GRACE_POLL_INTERVAL: Duration = Duration::from_millis(2);
186
187/// A declared direct-zenoh data publisher plus its startup-handshake state.
188struct DirectOutput {
189 publisher: zenoh::pubsub::Publisher<'static>,
190 /// `false` until the startup handshake proves this output's routes — every
191 /// required consumer acked one of its markers (see [`StartupHandshake`]).
192 /// Settled by [`wait_for_grace`] before `init` returns and immutable from
193 /// then on, so every send of a given output takes the same path: direct
194 /// zenoh when `true`, the reliable daemon path when `false`.
195 ready: Arc<AtomicBool>,
196}
197
198type ZenohPublishers = HashMap<DataId, DirectOutput>;
199
200/// Declare a direct-zenoh data publisher for every output that may ever take
201/// the direct path, plus the per-output ack state the startup handshake needs.
202///
203/// Outputs the daemon pinned `daemon_only` — a consumer only inter-daemon
204/// forwarding can reach (a dynamic node on another daemon, or a remote static
205/// one with no dialable endpoint for this node), and forwarding is fed solely
206/// by daemon-path sends (#2738) — get no publisher and no markers: they stay on
207/// the daemon path for the node's lifetime. Every other output gets a publisher declared eagerly
208/// at init (rather than on first send) for two reasons: zenoh starts wiring
209/// routes immediately, and [`StartupHandshake`] needs the publishers to probe
210/// those routes before the node's first real send. An output with no required
211/// ackers (no consumers, or only dynamic local ones) is `ready` immediately.
212///
213/// QoS is set at declare time so it applies to every put: `express(true)` bypasses
214/// zenoh's adaptive batch timer (the single biggest small-message latency win —
215/// without it, per-put delivery on the bare local config collapses to a few
216/// msg/s), `Priority::RealTime` keeps data-plane messages off the bulk-data
217/// queues, and `CongestionControl::Drop` prevents a stalled subscriber from
218/// back-pressuring the publishing node.
219///
220/// An output whose publisher fails to declare is simply absent from the map; its
221/// sends then fall back to the reliable daemon path.
222fn declare_output_publishers(
223 session: &zenoh::Session,
224 dataflow_id: DataflowId,
225 node_id: &NodeId,
226 outputs: &BTreeSet<DataId>,
227 routing: &BTreeMap<DataId, OutputRouting>,
228) -> (ZenohPublishers, Vec<Arc<AckState>>) {
229 use zenoh::Wait;
230 use zenoh::qos::{CongestionControl, Priority};
231
232 let mut publishers = HashMap::new();
233 let mut ack_states = Vec::new();
234 for output_id in outputs {
235 let Some(output_routing) = routing.get(output_id) else {
236 // Defensive: the daemon computes an entry for every declared
237 // output. An output it doesn't know stays on the daemon path.
238 warn!(output = %output_id, "no routing entry for output; staying on the daemon path");
239 continue;
240 };
241 if output_routing.daemon_only {
242 debug!(
243 output = %output_id,
244 "output pinned to the daemon path (a consumer is reachable only by \
245 inter-daemon forwarding)"
246 );
247 continue;
248 }
249 let topic = dora_core::topics::zenoh_output_publish_topic(dataflow_id, node_id, output_id);
250 let key_expr = match zenoh::key_expr::KeyExpr::new(topic) {
251 Ok(key) => key.into_owned(),
252 Err(e) => {
253 warn!(output = %output_id, "invalid zenoh key ({e}); falling back to daemon path");
254 continue;
255 }
256 };
257 match session
258 .declare_publisher(key_expr)
259 .congestion_control(CongestionControl::Drop)
260 .express(true)
261 .priority(Priority::RealTime)
262 .wait()
263 {
264 Ok(publisher) => {
265 let ready = Arc::new(AtomicBool::new(output_routing.required_ackers.is_empty()));
266 if !output_routing.required_ackers.is_empty() {
267 ack_states.push(Arc::new(AckState::new(
268 output_id.clone(),
269 &output_routing.required_ackers,
270 ready.clone(),
271 )));
272 }
273 publishers.insert(output_id.clone(), DirectOutput { publisher, ready });
274 }
275 Err(e) => {
276 warn!(output = %output_id, "failed to declare zenoh publisher ({e}); falling back to daemon path");
277 }
278 }
279 }
280 (publishers, ack_states)
281}
282
283/// Ack bookkeeping for one output whose startup handshake is in flight.
284///
285/// Shared between the output's ack-subscriber callback (which records incoming
286/// acks) and the [`StartupHandshake`] thread (which publishes markers until
287/// completion or the freeze).
288struct AckState {
289 output_id: DataId,
290 /// The (consumer node, input) identities that must ack before the output
291 /// may switch to the direct zenoh path — the daemon's required-acker set,
292 /// derived from actual placement (local static consumers only).
293 required: BTreeSet<(String, String)>,
294 /// Identities that have acked so far.
295 received: Mutex<BTreeSet<(String, String)>>,
296 /// The same flag as the output's [`DirectOutput::ready`]; flipped exactly
297 /// once, when `received` covers `required` before the freeze.
298 ready: Arc<AtomicBool>,
299 /// Set once the grace boundary passed with this output still un-acked: the
300 /// output is pinned to the daemon path and can never upgrade (see
301 /// [`Self::freeze`]).
302 frozen: AtomicBool,
303}
304
305impl AckState {
306 fn new(
307 output_id: DataId,
308 required: &BTreeSet<dora_message::daemon_to_node::RequiredAcker>,
309 ready: Arc<AtomicBool>,
310 ) -> Self {
311 Self {
312 output_id,
313 required: required
314 .iter()
315 .map(|acker| (acker.node_id.to_string(), acker.input_id.to_string()))
316 .collect(),
317 received: Mutex::new(BTreeSet::new()),
318 ready,
319 frozen: AtomicBool::new(false),
320 }
321 }
322
323 /// The ack lock, recovered from poisoning: a panicking callback leaves the
324 /// set intact and losing acks would silently cost the fast path.
325 ///
326 /// Holding this guard is what makes [`Self::record`] and [`Self::freeze`]
327 /// atomic against each other, so every method that touches `received` or
328 /// `frozen` goes through here.
329 fn received(&self) -> std::sync::MutexGuard<'_, BTreeSet<(String, String)>> {
330 self.received
331 .lock()
332 .unwrap_or_else(|poisoned| poisoned.into_inner())
333 }
334
335 /// Records one ack. Identities outside the required set — a dynamic or
336 /// debug consumer may ack too — are ignored; they must never count toward
337 /// completion. Flips `ready` once the required set is covered, unless the
338 /// output was already frozen on the daemon path.
339 fn record(&self, consumer_node: &str, input_id: &str) {
340 let identity = (consumer_node.to_owned(), input_id.to_owned());
341 if !self.required.contains(&identity) {
342 return;
343 }
344 let mut received = self.received();
345 // Read under the same lock `freeze` takes, so the two can't interleave
346 // into a `ready` flip that outlives the freeze.
347 if self.frozen.load(Ordering::Relaxed) {
348 return;
349 }
350 received.insert(identity);
351 if received.len() == self.required.len() {
352 self.ready.store(true, Ordering::Relaxed);
353 }
354 }
355
356 /// Pins this output to the daemon path for the rest of the run: no later
357 /// ack may flip `ready`. Called once, at the grace boundary — see
358 /// [`wait_for_grace`].
359 ///
360 /// Returns whether the output was actually frozen — `false` means it had
361 /// already completed its handshake and keeps the direct-zenoh path. The
362 /// `received` lock makes that decision atomic against a concurrent
363 /// [`Self::record`]: either the ack completed the set before the freeze, or
364 /// it is ignored.
365 fn freeze(&self) -> bool {
366 let _guard = self.received();
367 if self.ready.load(Ordering::Relaxed) {
368 return false;
369 }
370 self.frozen.store(true, Ordering::Relaxed);
371 true
372 }
373
374 /// Whether this output was frozen on the daemon path (marker-thread view;
375 /// no lock needed, a stale `false` just costs one more marker).
376 fn is_frozen(&self) -> bool {
377 self.frozen.load(Ordering::Relaxed)
378 }
379
380 /// The required identities that have not acked (for the freeze warning).
381 fn missing(&self) -> Vec<String> {
382 let received = self.received();
383 self.required
384 .difference(&received)
385 .map(|(node, input)| format!("{node}/{input}"))
386 .collect()
387 }
388}
389
390/// Declare one exact-key ack subscriber per awaited output.
391///
392/// The callback records acks into the output's [`AckState`]. An output whose
393/// ack subscriber fails to declare can never complete its handshake, so it is
394/// removed from the awaited set right away (its `ready` flag stays `false`
395/// and it keeps riding the daemon path) instead of publishing markers no ack
396/// could ever answer.
397fn declare_ack_subscribers(
398 session: &zenoh::Session,
399 dataflow_id: DataflowId,
400 node_id: &NodeId,
401 ack_states: &mut Vec<Arc<AckState>>,
402) -> Vec<zenoh::pubsub::Subscriber<()>> {
403 use zenoh::Wait;
404
405 let mut subscribers = Vec::new();
406 let mut awaited = Vec::new();
407 for state in ack_states.drain(..) {
408 let topic =
409 dora_core::topics::zenoh_output_ack_topic(dataflow_id, node_id, &state.output_id);
410 let state_cb = state.clone();
411 let subscriber = session
412 .declare_subscriber(topic)
413 .callback(move |sample| {
414 let Some(attachment) = sample.attachment() else {
415 return;
416 };
417 let Ok(metadata) = dora_message::decode::<Metadata>(&attachment.to_bytes()) else {
418 // Not a dora ack (foreign publisher on the ack key): ignore.
419 return;
420 };
421 if metadata.metadata_version() != Metadata::CURRENT_VERSION {
422 // A peer speaking another wire format cannot be attributed
423 // reliably; never count its acks.
424 return;
425 }
426 if let Some((consumer, input)) = metadata.startup_ack_identity() {
427 state_cb.record(consumer, input);
428 }
429 })
430 .wait();
431 match subscriber {
432 Ok(subscriber) => {
433 subscribers.push(subscriber);
434 awaited.push(state);
435 }
436 Err(e) => {
437 warn!(
438 output = %state.output_id,
439 "failed to declare startup-ack subscriber ({e}); output stays on the daemon path"
440 );
441 }
442 }
443 }
444 *ack_states = awaited;
445 subscribers
446}
447
448/// The producer half of the startup handshake: publishes route-probe markers
449/// per output until that output's required consumers have acked, then lets the
450/// send path switch it from the reliable daemon path to direct zenoh.
451///
452/// The zenoh data plane is direct node-to-node pub/sub: zenoh drops samples for
453/// a subscription that hasn't propagated to this publisher yet, so a fast
454/// source could otherwise lose its first messages. Rather than infer
455/// route-readiness from zenoh declarations, the handshake proves it end to end
456/// and in both directions: a marker rides the output's *real* topic, and the
457/// consumer's ack rides the output's `@ack` topic back — an arrived ack is
458/// evidence that the route pair carries data. Until then every send takes the
459/// daemon path, so nothing is ever lost; a route that never proves itself only
460/// costs the fast path, never correctness ([`ZENOH_STARTUP_GRACE`]).
461///
462/// The handshake is over by the time `init` returns: [`Self::settle`] either
463/// sees an output acked or freezes it on the daemon path.
464///
465/// Runs on its own thread because the node blocks inside the daemon's "all
466/// nodes ready" barrier while markers must already be flowing: consumers ack
467/// from their subscriber callbacks (also while parked in the barrier), which is
468/// what makes cycles (`a -> b -> a`, and self-loops) resolve rather than
469/// deadlock — no node ever waits on another node's post-barrier progress.
470///
471/// Markers carry an empty payload (so they never touch shared memory) and are
472/// tagged with [`dora_message::metadata::STARTUP_MARKER_PARAM`], which
473/// consumers filter out before decoding — they never reach user code. This
474/// works for late producers too: a dynamic node or a restarted producer runs
475/// the same handshake at join time against consumers that are already running
476/// (their ack publishers answer markers for the consumer's whole lifetime).
477struct StartupHandshake {
478 stop: Arc<AtomicBool>,
479 /// The outputs whose handshake is (or was) in flight.
480 ack_states: Vec<Arc<AckState>>,
481 handle: Option<std::thread::JoinHandle<()>>,
482 /// Per-output ack subscribers. Kept for the node's lifetime (idle once the
483 /// handshake resolves) and dropped before the session in `DoraNode::drop`.
484 ack_subscribers: Vec<zenoh::pubsub::Subscriber<()>>,
485}
486
487impl StartupHandshake {
488 fn start(
489 session: &zenoh::Session,
490 dataflow_id: DataflowId,
491 node_id: &NodeId,
492 publishers: &Arc<ZenohPublishers>,
493 mut ack_states: Vec<Arc<AckState>>,
494 clock: Arc<uhlc::HLC>,
495 ) -> Self {
496 use zenoh::Wait;
497
498 let stop = Arc::new(AtomicBool::new(false));
499 let ack_subscribers =
500 declare_ack_subscribers(session, dataflow_id, node_id, &mut ack_states);
501 if ack_states.is_empty() {
502 // Nothing awaits acks (no consumers, or every ack subscriber
503 // failed): no markers to publish, nothing to stop.
504 return Self {
505 stop,
506 ack_states,
507 handle: None,
508 ack_subscribers,
509 };
510 }
511
512 let thread_stop = stop.clone();
513 let thread_states = ack_states.clone();
514 let thread_publishers = publishers.clone();
515 let handle = std::thread::Builder::new()
516 .name("dora-startup-handshake".into())
517 .spawn(move || {
518 loop {
519 if thread_stop.load(Ordering::Relaxed) {
520 return;
521 }
522 let mut awaiting = false;
523 for state in &thread_states {
524 // A frozen output can never upgrade, so its markers can
525 // only cost bandwidth; `wait_for_grace` already logged
526 // why it stays on the daemon path.
527 if state.ready.load(Ordering::Relaxed) || state.is_frozen() {
528 continue;
529 }
530 awaiting = true;
531 let Some(output) = thread_publishers.get(&state.output_id) else {
532 continue;
533 };
534 let metadata = Metadata::startup_marker(clock.new_timestamp());
535 let attachment = match dora_message::encode(&metadata) {
536 Ok(bytes) => bytes,
537 Err(e) => {
538 debug!(output = %state.output_id, "failed to serialize startup marker ({e})");
539 continue;
540 }
541 };
542 if let Err(e) = output
543 .publisher
544 .put(&[][..])
545 .attachment(&attachment[..])
546 .wait()
547 {
548 // Expected while the route is still coming up.
549 tracing::trace!(output = %state.output_id, "startup marker put failed ({e})");
550 }
551 }
552 if !awaiting {
553 // Every output is acked or frozen: the handshake is over.
554 return;
555 }
556 std::thread::sleep(ZENOH_STARTUP_MARKER_INTERVAL);
557 }
558 });
559 match handle {
560 Ok(handle) => Self {
561 stop,
562 ack_states,
563 handle: Some(handle),
564 ack_subscribers,
565 },
566 Err(e) => {
567 // Without markers no consumer will ack, so the awaited outputs
568 // simply stay on the reliable daemon path. Loud because the
569 // fast path is silently lost for this node.
570 error!(
571 "failed to spawn startup-handshake thread ({e}); outputs stay on the daemon path"
572 );
573 Self {
574 stop,
575 ack_states,
576 handle: None,
577 ack_subscribers,
578 }
579 }
580 }
581 }
582
583 /// Settle every output's transport: wait out `grace`, then freeze whatever
584 /// has not proven its routes. See [`wait_for_grace`].
585 ///
586 /// `DoraNode::init` **must** call this before returning to user code —
587 /// that is what makes an output's path constant for the run
588 /// (dora-rs/dora#2891). It is a method (rather than only the free function
589 /// the tests drive) so the requirement is discoverable from the type.
590 fn settle(&self, grace: Duration) {
591 wait_for_grace(&self.ack_states, grace);
592 }
593
594 /// Signal the handshake thread to stop and wait for it to exit, so its
595 /// `Arc` clone of the publishers is released. Idempotent.
596 fn shutdown(&mut self) {
597 self.stop.store(true, Ordering::Relaxed);
598 if let Some(handle) = self.handle.take() {
599 let _ = handle.join();
600 }
601 }
602}
603
604impl Drop for StartupHandshake {
605 fn drop(&mut self) {
606 // Safety net for error paths that return before `DoraNode::drop` (e.g.
607 // a failed `EventStream::init`). Without this the handshake thread
608 // would keep publishing and keep the publishers (and session) alive.
609 self.shutdown();
610 }
611}
612
613/// Post-barrier grace wait: give the handshake `grace` to complete, then freeze
614/// whatever is left. This is the boundary that settles every output's transport
615/// before user code runs.
616///
617/// Returns as soon as every awaited output is ready. Anything still un-acked
618/// when `grace` expires is pinned to the daemon path for the rest of the run
619/// ([`AckState::freeze`]) rather than left to upgrade later. Upgrading later is
620/// the bug in dora-rs/dora#2891: user code sends from the moment this returns,
621/// so *any* subsequent upgrade is mid-stream, and the direct-zenoh path has
622/// fewer hops than the daemon-relay path — a message sent just after the switch
623/// can overtake an earlier one still in flight on the daemon path, which the
624/// consumer merges into a single arrival-ordered channel with no cross-path
625/// resequencing. Freezing keeps a topic's per-input FIFO order unconditional;
626/// the cost is the fast path for routes that fail to establish within `grace`.
627///
628/// A free function taking the states (rather than a `StartupHandshake` method)
629/// so tests can exercise the boundary without zenoh, with a `grace` shorter
630/// than [`ZENOH_STARTUP_GRACE`].
631fn wait_for_grace(ack_states: &[Arc<AckState>], grace: Duration) {
632 let grace_deadline = Instant::now() + grace;
633 loop {
634 // Vacuously true when nothing awaits acks (no consumers, or every ack
635 // subscriber failed to declare): there is nothing to wait for.
636 if ack_states
637 .iter()
638 .all(|state| state.ready.load(Ordering::Relaxed))
639 {
640 break;
641 }
642 if Instant::now() >= grace_deadline {
643 break;
644 }
645 std::thread::sleep(ZENOH_STARTUP_GRACE_POLL_INTERVAL);
646 }
647
648 for state in ack_states {
649 // `freeze` re-checks readiness under the ack lock, so an ack landing
650 // right now either completes the handshake or is ignored — never a
651 // half-applied upgrade.
652 if state.freeze() {
653 warn!(
654 output = %state.output_id,
655 missing = ?state.missing(),
656 "startup handshake incomplete after {}ms; output stays on the \
657 reliable daemon path for the rest of the run",
658 grace.as_millis()
659 );
660 } else {
661 // The positive half of the same decision, and the only signal that
662 // an output is *off* the daemon path — which for a consumer on
663 // another machine means its data no longer crosses two daemons.
664 // Logged per output, once, at the moment it is settled for the run.
665 debug!(
666 output = %state.output_id,
667 "startup handshake complete; output takes the direct zenoh path"
668 );
669 }
670 }
671}
672
673/// The per-output routing the daemon computed for this node, or the safe
674/// all-daemon-path fallback when it provided none.
675///
676/// A missing map means the node was spawned by an older daemon (or an
677/// interactive/manual setup) that doesn't know about the startup handshake.
678/// Without required-acker sets no route can be proven, so every output stays
679/// on the reliable daemon path — correct, just without the zenoh fast path.
680fn normalize_output_routing(
681 routing: Option<BTreeMap<DataId, OutputRouting>>,
682 outputs: &BTreeSet<DataId>,
683) -> BTreeMap<DataId, OutputRouting> {
684 match routing {
685 Some(routing) => routing,
686 None => {
687 if !outputs.is_empty() {
688 warn!(
689 "node config carries no output routing (spawned by an older daemon?); \
690 all outputs stay on the reliable daemon path"
691 );
692 }
693 outputs
694 .iter()
695 .map(|output_id| {
696 (
697 output_id.clone(),
698 OutputRouting {
699 daemon_only: true,
700 required_ackers: Default::default(),
701 },
702 )
703 })
704 .collect()
705 }
706 }
707}
708
709/// Per-phase deadline for tearing down zenoh state on node shutdown (subscribers,
710/// liveliness tokens, and the session/publishers — see [`DoraNode::drop`] and
711/// `EventStream::drop`). Each `undeclare`/close blocks indefinitely when zenoh's net
712/// runtime is wedged (e.g. retrying an unreachable scouted peer on a headless CI
713/// runner), so it is bounded here and abandoned on timeout.
714///
715/// This MUST stay comfortably below the daemon's force-kill grace
716/// (`DEFAULT_STOP_GRACE (10s) + DEFAULT_STOP_GRACE/2 = 15s`, see
717/// `binaries/daemon/src/running_dataflow.rs`), including the worst case where all
718/// three phases wedge sequentially (`3 *` this value). Otherwise a node with a wedged
719/// net runtime is still tearing down when the daemon `TerminateProcess`es it, which on
720/// Windows surfaces as `ExitCode(1)` and reddens the nightly (dora-rs/dora#2742). The
721/// `zenoh_teardown_fits_within_daemon_force_kill_grace` test guards the invariant.
722///
723/// This deliberately no longer exceeds zenoh's internal 10s session-close timeout: a
724/// semi-wedged close that would settle at ~10s is abandoned instead, and peers fall
725/// back to liveliness expiry. That is the right trade for a *shutting-down* node —
726/// waiting out the 10s only to be force-killed anyway yields a worse (unclean) exit.
727pub(crate) const ZENOH_TEARDOWN_TIMEOUT: Duration = Duration::from_secs(3);
728
729/// Capacity of the testing-mode daemon request channel. Kept comfortably above
730/// the number of concurrent requesters (event stream + close channel + control
731/// channel) so Drop's CloseOutputs send does not block on a full queue while
732/// the daemon thread is busy inside `next_event` (dora-rs/dora#2855).
733const TESTING_DAEMON_CHANNEL_CAPACITY: usize = 256;
734
735/// Allows sending outputs and retrieving node information.
736///
737/// The main purpose of this struct is to send outputs via Dora. There are also functions available
738/// for retrieving the node configuration.
739pub struct DoraNode {
740 id: NodeId,
741 dataflow_id: DataflowId,
742 node_config: NodeRunConfig,
743 control_channel: ControlChannel,
744 clock: Arc<uhlc::HLC>,
745
746 /// Zenoh session for direct node-to-node pub/sub (data plane).
747 /// `None` in interactive/testing mode.
748 zenoh_session: Option<zenoh::Session>,
749 /// Zenoh shared memory provider for zero-copy publishing.
750 /// Owns the SHM provider and the zero-copy threshold. Cloned out by
751 /// [`DoraNode::sample_allocator`] so an operator thread can build output
752 /// samples without reaching into the node (dora-rs/dora#2742).
753 sample_allocator: SampleAllocator,
754 /// Per-output zenoh publishers with their handshake state, declared eagerly
755 /// at init (see [`declare_output_publishers`]) so zenoh wires routes
756 /// immediately and [`StartupHandshake`] can probe them before the first
757 /// real send. An output missing here (declaration failed, or pinned to the
758 /// daemon path by the daemon's routing) falls back to the daemon path; one
759 /// whose `ready` flag is still `false` (handshake in flight or frozen)
760 /// does too. Shared with the handshake thread via `Arc`; the thread is
761 /// joined in `drop` before the map is torn down.
762 /// `'static` is sound because zenoh `Publisher` internally holds `Arc<Session>`,
763 /// so it doesn't borrow from the session field on this struct.
764 /// Publishers must be dropped BEFORE the session (enforced in Drop impl).
765 zenoh_publishers: Arc<ZenohPublishers>,
766 /// The producer half of the startup handshake (marker thread + ack
767 /// subscribers). `None` without a zenoh session (interactive/testing).
768 startup_handshake: Option<StartupHandshake>,
769 /// Per-output schema publishers on the `@schema` subtopic (lazily created on
770 /// the first small message). Their cache retains the last schema so a
771 /// late-joining subscriber fetches it via a history query, letting the data
772 /// topic carry only schema-less batches. Dropped before the session, like
773 /// [`zenoh_publishers`](Self::zenoh_publishers).
774 zenoh_schema_publishers: HashMap<DataId, zenoh_ext::AdvancedPublisher<'static>>,
775 /// Per-output schema-once state: the confirmed-published schema hash (so
776 /// the schema is only re-published when it changes or a publish failed) and
777 /// the time of the last full-stream send (for the periodic in-band refresh).
778 zenoh_schema_state: HashMap<DataId, SchemaOnceState>,
779 /// Threshold for using zenoh SHM vs inline bytes (default 4096).
780
781 /// Diagnostic (dora-rs/dora#2742): how many large sends have already been
782 /// traced hop-by-hop. The Windows nightly wedges the *runtime's* main loop
783 /// inside `send_output` on the very first large output, so tracing only the
784 /// first few large sends pins the stuck hop without spamming a healthy run.
785 /// Remove together with the runtime's stall watchdog once #2742 is closed.
786 large_send_diag_count: u32,
787
788 dataflow_descriptor: serde_yaml::Result<Descriptor>,
789 warned_unknown_output: BTreeSet<DataId>,
790 interactive: bool,
791 restart_count: u32,
792
793 /// Runtime type checking state. `None` when off (zero overhead).
794 /// When `Some`, holds the mode (Warn/Error) and a map of output DataId -> expected Arrow DataType.
795 runtime_type_checks: Option<(RuntimeTypeCheck, HashMap<DataId, arrow_schema::DataType>)>,
796
797 /// Tokio runtime owned by the node. Populated only when no ambient
798 /// runtime was available at init. Must drop after the zenoh session
799 /// (which is drained explicitly at the top of [`Drop`]) so that any
800 /// async cleanup triggered by session shutdown can still run.
801 _owned_runtime: Option<tokio::runtime::Runtime>,
802
803 /// Join handle for the in-process testing daemon thread spawned by
804 /// [`Self::init_testing`] / the testing branch of `init_with_options`.
805 /// Joined in [`Drop`] after closing the request channel (dora-rs/dora#2855).
806 testing_daemon: Option<std::thread::JoinHandle<()>>,
807 /// Signals the testing daemon to abort a scheduled `next_event` sleep so
808 /// Drop's CloseOutputs handshake can complete.
809 testing_shutdown: Option<Arc<AtomicBool>>,
810}
811
812impl DoraNode {
813 /// Initiate a node from environment variables set by the Dora daemon or fall back to
814 /// interactive mode.
815 ///
816 /// This is the recommended initialization function for Dora nodes, which are spawned by
817 /// Dora daemon instances. The daemon will set a `DORA_NODE_CONFIG` environment variable to
818 /// configure the node.
819 ///
820 /// When the node is started manually without the `DORA_NODE_CONFIG` environment variable set,
821 /// the initialization will fall back to [`init_interactive`](Self::init_interactive) if `stdin`
822 /// is a terminal (detected through
823 /// [`isatty`](https://www.man7.org/linux/man-pages/man3/isatty.3.html)).
824 ///
825 /// If the `DORA_NODE_CONFIG` environment variable is not set and `DORA_TEST_WITH_INPUTS` is
826 /// set, the node will be initialized in integration test mode. See the
827 /// [integration testing](crate::integration_testing) module for details.
828 ///
829 /// This function will also initialize the node in integration test mode when the
830 /// [`setup_integration_testing`](crate::integration_testing::setup_integration_testing)
831 /// function was called before. This takes precedence over all environment variables.
832 ///
833 /// ```no_run
834 /// use dora_node_api::DoraNode;
835 ///
836 /// let (mut node, mut events) = DoraNode::init_from_env().expect("Could not init node.");
837 /// ```
838 pub fn init_from_env() -> NodeResult<(Self, EventStream)> {
839 Self::init_from_env_inner(true)
840 }
841
842 /// Initialize the node from environment variables set by the Dora daemon; error if not set.
843 ///
844 /// This function behaves the same as [`init_from_env`](Self::init_from_env), but it does _not_
845 /// fall back to [`init_interactive`](Self::init_interactive). Instead, an error is returned
846 /// when the `DORA_NODE_CONFIG` environment variable is missing.
847 pub fn init_from_env_force() -> NodeResult<(Self, EventStream)> {
848 Self::init_from_env_inner(false)
849 }
850
851 fn init_from_env_inner(fallback_to_interactive: bool) -> NodeResult<(Self, EventStream)> {
852 if let Some(testing_comm) = take_testing_communication() {
853 let TestingCommunication {
854 input,
855 output,
856 options,
857 } = *testing_comm;
858 return Self::init_testing(input, output, options);
859 }
860
861 // normal execution (started by dora daemon)
862 match std::env::var("DORA_NODE_CONFIG") {
863 Ok(raw) => {
864 let node_config: NodeConfig =
865 serde_yaml::from_str(&raw).context("failed to deserialize node config")?;
866 return Self::init(node_config);
867 }
868 Err(std::env::VarError::NotUnicode(_)) => {
869 return Err(NodeError::Init(
870 "DORA_NODE_CONFIG env variable is not valid unicode".into(),
871 ));
872 }
873 Err(std::env::VarError::NotPresent) => {} // continue trying other init methods
874 };
875
876 // node integration test mode
877 match std::env::var("DORA_TEST_WITH_INPUTS") {
878 Ok(raw) => {
879 let input_file = PathBuf::from(raw);
880 let output_file = match std::env::var("DORA_TEST_WRITE_OUTPUTS_TO") {
881 Ok(raw) => PathBuf::from(raw),
882 Err(std::env::VarError::NotUnicode(_)) => {
883 return Err(NodeError::Init(
884 "DORA_TEST_WRITE_OUTPUTS_TO env variable is not valid unicode".into(),
885 ));
886 }
887 Err(std::env::VarError::NotPresent) => {
888 input_file.with_file_name("outputs.jsonl")
889 }
890 };
891 let skip_output_time_offsets =
892 std::env::var_os("DORA_TEST_NO_OUTPUT_TIME_OFFSET").is_some();
893
894 let input = TestingInput::FromJsonFile(input_file);
895 let output = TestingOutput::ToFile(output_file);
896 let options = TestingOptions {
897 skip_output_time_offsets,
898 };
899
900 return Self::init_testing(input, output, options);
901 }
902 Err(std::env::VarError::NotUnicode(_)) => {
903 return Err(NodeError::Init(
904 "DORA_TEST_WITH_INPUTS env variable is not valid unicode".into(),
905 ));
906 }
907 Err(std::env::VarError::NotPresent) => {} // continue trying other init methods
908 }
909
910 // interactive mode
911 if fallback_to_interactive && std::io::stdin().is_terminal() {
912 println!(
913 "{}",
914 "Starting node in interactive mode as DORA_NODE_CONFIG env variable is not set"
915 .green()
916 );
917 return Self::init_interactive();
918 }
919
920 // no run mode applicable
921 Err(NodeError::Init(
922 "DORA_NODE_CONFIG env variable is not set".into(),
923 ))
924 }
925
926 /// Create a builder for configuring a node connection.
927 ///
928 /// Setting a `node_id` selects the dynamic-node path; without one, `build()`
929 /// falls back to [`init_from_env`](Self::init_from_env). Use this builder
930 /// when you need a custom daemon port — the other init functions cover the
931 /// common cases. Source-compatible with upstream dora 0.5.x: `.dynamic()`
932 /// is accepted (no-op) so code written against upstream still compiles.
933 ///
934 /// ```no_run
935 /// use dora_node_api::DoraNode;
936 /// use dora_node_api::dora_core::config::NodeId;
937 ///
938 /// let (mut node, mut events) = DoraNode::builder()
939 /// .node_id(NodeId::from("plot".to_string()))
940 /// .daemon_port(6789)
941 /// .build()
942 /// .expect("Could not init node");
943 /// ```
944 pub fn builder() -> DoraNodeBuilder {
945 DoraNodeBuilder::default()
946 }
947
948 /// Initiate a node from a dataflow id and a node id.
949 ///
950 /// This initialization function should be used for [_dynamic nodes_](index.html#dynamic-nodes).
951 ///
952 /// ```no_run
953 /// use dora_node_api::DoraNode;
954 /// use dora_node_api::dora_core::config::NodeId;
955 ///
956 /// let (mut node, mut events) = DoraNode::init_from_node_id(NodeId::from("plot".to_string())).expect("Could not init node plot");
957 /// ```
958 ///
959 pub fn init_from_node_id(node_id: NodeId) -> NodeResult<(Self, EventStream)> {
960 Self::builder().node_id(node_id).build()
961 }
962
963 /// Dynamic initialization function for nodes that are sometimes used as dynamic nodes.
964 ///
965 /// This function first tries initializing the traditional way through
966 /// [`init_from_env`][Self::init_from_env]. If this fails, it falls back to
967 /// [`init_from_node_id`][Self::init_from_node_id].
968 pub fn init_flexible(node_id: NodeId) -> NodeResult<(Self, EventStream)> {
969 if std::env::var("DORA_NODE_CONFIG").is_ok() {
970 info!(
971 "Skipping {node_id} specified within the node initialization in favor of `DORA_NODE_CONFIG` specified by `dora start`"
972 );
973 Self::init_from_env()
974 } else {
975 Self::init_from_node_id(node_id)
976 }
977 }
978
979 /// Initialize the node in a standalone mode that prompts for inputs on the terminal.
980 ///
981 /// Instead of connecting to a `dora daemon`, this interactive mode prompts for node inputs
982 /// on the terminal. In this mode, the node is completely isolated from the dora daemon and
983 /// other nodes, so it cannot be part of a dataflow.
984 ///
985 /// Note that this function will hang indefinitely if no input is supplied to the interactive
986 /// prompt. So it should be only used through a terminal.
987 ///
988 /// Because of the above limitations, it is not recommended to use this function directly.
989 /// Use [**`init_from_env`**](Self::init_from_env) instead, which supports both normal daemon
990 /// connections and manual interactive runs.
991 ///
992 /// ## Example
993 ///
994 /// Run any node that uses `init_interactive` or [`init_from_env`](Self::init_from_env) directly
995 /// from a terminal. The node will then start in "interactive mode" and prompt you for the next
996 /// input:
997 ///
998 /// ```bash
999 /// > cargo build -p rust-dataflow-example-node
1000 /// > target/debug/rust-dataflow-example-node
1001 /// hello
1002 /// Starting node in interactive mode as DORA_NODE_CONFIG env variable is not set
1003 /// Node asks for next input
1004 /// ? Input ID
1005 /// [empty input ID to stop]
1006 /// ```
1007 ///
1008 /// The `rust-dataflow-example-node` expects a `tick` input, so let's set the input ID to
1009 /// `tick`. Tick messages don't have any data, so we leave the "Data" empty when prompted:
1010 ///
1011 /// ```bash
1012 /// Node asks for next input
1013 /// > Input ID tick
1014 /// > Data
1015 /// tick 0, sending 0x943ed1be20c711a4
1016 /// node sends output random with data: PrimitiveArray<UInt64>
1017 /// [
1018 /// 10682205980693303716,
1019 /// ]
1020 /// Node asks for next input
1021 /// ? Input ID
1022 /// [empty input ID to stop]
1023 /// ```
1024 ///
1025 /// We see that both the `stdout` output of the node and also the output messages that it sends
1026 /// are printed to the terminal. Then we get another prompt for the next input.
1027 ///
1028 /// If you want to send an input with data, you can either send it as text (for string data)
1029 /// or as a JSON object (for struct, string, or array data). Other data types are not supported
1030 /// currently.
1031 ///
1032 /// Empty input IDs are interpreted as stop instructions:
1033 ///
1034 /// ```bash
1035 /// > Input ID
1036 /// given input ID is empty -> stopping
1037 /// Received stop
1038 /// Node asks for next input
1039 /// event channel was stopped -> returning empty event list
1040 /// node reports EventStreamDropped
1041 /// node reports closed outputs []
1042 /// node reports OutputsDone
1043 /// ```
1044 ///
1045 /// In addition to the node output, we see log messages for the different events that the node
1046 /// reports. After `OutputsDone`, the node should exit.
1047 ///
1048 /// ### JSON data
1049 ///
1050 /// In addition to text input, the `Data` prompt also supports JSON objects, which will be
1051 /// converted to Apache Arrow struct arrays:
1052 ///
1053 /// ```bash
1054 /// Node asks for next input
1055 /// > Input ID some_input
1056 /// > Data { "field_1": 42, "field_2": { "inner": "foo" } }
1057 /// ```
1058 ///
1059 /// This JSON data is converted to the following Arrow array:
1060 ///
1061 /// ```text
1062 /// StructArray
1063 /// -- validity: [valid, ]
1064 /// [
1065 /// -- child 0: "field_1" (Int64)
1066 /// PrimitiveArray<Int64>
1067 /// [42,]
1068 /// -- child 1: "field_2" (Struct([Field { name: "inner", data_type: Utf8, nullable: true, dict_id: 0, dict_is_ordered: false, metadata: {} }]))
1069 /// StructArray
1070 /// -- validity: [valid,]
1071 /// [
1072 /// -- child 0: "inner" (Utf8)
1073 /// StringArray
1074 /// ["foo",]
1075 /// ]
1076 /// ]
1077 /// ```
1078 pub fn init_interactive() -> NodeResult<(Self, EventStream)> {
1079 #[cfg(feature = "tracing")]
1080 {
1081 TracingBuilder::new("node")
1082 .with_stdout("debug", false)
1083 .build()
1084 .wrap_err("failed to set up tracing subscriber")?;
1085 }
1086
1087 let node_config = NodeConfig {
1088 dataflow_id: DataflowId::new_v4(),
1089 node_id: "test-node"
1090 .parse()
1091 .map_err(|e| NodeError::Init(format!("{e}")))?,
1092 run_config: NodeRunConfig {
1093 inputs: Default::default(),
1094 outputs: Default::default(),
1095 output_types: Default::default(),
1096 output_framing: Default::default(),
1097 input_types: Default::default(),
1098 shared_memory_pool_size: None,
1099 },
1100 daemon_communication: Some(DaemonCommunication::Interactive),
1101 dataflow_descriptor: serde_yaml::Value::Null,
1102 dynamic: false,
1103 write_events_to: None,
1104 restart_count: 0,
1105 output_routing: None,
1106 };
1107 let (mut node, events) = Self::init(node_config)?;
1108 node.interactive = true;
1109 Ok((node, events))
1110 }
1111
1112 /// Initializes a node in integration test mode.
1113 ///
1114 /// No connection to a dora daemon is made in this mode. Instead, inputs are read from the
1115 /// specified `TestingInput`, and outputs are written to the specified `TestingOutput`.
1116 /// Additional options for the testing mode can be specified through `TestingOptions`.
1117 ///
1118 /// It is recommended to use this function only within test functions.
1119 pub fn init_testing(
1120 input: TestingInput,
1121 output: TestingOutput,
1122 options: TestingOptions,
1123 ) -> NodeResult<(Self, EventStream)> {
1124 let node_config = NodeConfig {
1125 dataflow_id: DataflowId::new_v4(),
1126 node_id: "test-node"
1127 .parse()
1128 .map_err(|e| NodeError::Init(format!("{e}")))?,
1129 run_config: NodeRunConfig {
1130 inputs: Default::default(),
1131 outputs: Default::default(),
1132 output_types: Default::default(),
1133 output_framing: Default::default(),
1134 input_types: Default::default(),
1135 shared_memory_pool_size: None,
1136 },
1137 daemon_communication: None,
1138 dataflow_descriptor: serde_yaml::Value::Null,
1139 dynamic: false,
1140 write_events_to: None,
1141 restart_count: 0,
1142 output_routing: None,
1143 };
1144 let testing_comm = TestingCommunication {
1145 input,
1146 output,
1147 options,
1148 };
1149 let (mut node, events) = Self::init_with_options(node_config, Some(testing_comm))?;
1150 node.interactive = true;
1151 Ok((node, events))
1152 }
1153
1154 /// Internal initialization routine that should not be used outside of Dora.
1155 #[doc(hidden)]
1156 #[tracing::instrument]
1157 pub fn init(node_config: NodeConfig) -> NodeResult<(Self, EventStream)> {
1158 Self::init_with_options(node_config, None)
1159 }
1160
1161 #[tracing::instrument(skip(testing_communication))]
1162 fn init_with_options(
1163 node_config: NodeConfig,
1164 testing_communication: Option<TestingCommunication>,
1165 ) -> NodeResult<(Self, EventStream)> {
1166 // Before anything that can fail or block: a node spawned by `dora run`
1167 // must not outlive the CLI even if the rest of this initialization
1168 // stalls (dora-rs/dora#2856). A no-op on every other spawn path.
1169 crate::orphan_guard::arm_if_run_child();
1170
1171 let NodeConfig {
1172 dataflow_id,
1173 node_id,
1174 run_config,
1175 daemon_communication,
1176 dataflow_descriptor,
1177 dynamic,
1178 write_events_to,
1179 restart_count,
1180 output_routing,
1181 } = node_config;
1182 let clock = Arc::new(uhlc::HLC::default());
1183 let input_config = run_config.inputs.clone();
1184
1185 let (daemon_communication, testing_daemon, testing_shutdown) = match daemon_communication {
1186 Some(comm) => (comm.into(), None, None),
1187 None => match testing_communication {
1188 Some(comm) => {
1189 let TestingCommunication {
1190 input,
1191 output,
1192 options,
1193 } = comm;
1194 let (sender, mut receiver) =
1195 tokio::sync::mpsc::channel(TESTING_DAEMON_CHANNEL_CAPACITY);
1196 let shutdown = Arc::new(AtomicBool::new(false));
1197 let new_communication = DaemonCommunicationWrapper::Testing {
1198 channel: sender,
1199 shutdown: shutdown.clone(),
1200 };
1201 let mut events =
1202 IntegrationTestingEvents::new(input, output, options, shutdown.clone())?;
1203 let shutdown_for_loop = shutdown.clone();
1204 let handle = std::thread::Builder::new()
1205 .name("dora-testing-daemon".into())
1206 .spawn(move || {
1207 while let Some((request, reply_sender)) = receiver.blocking_recv() {
1208 let outputs_done =
1209 matches!(request.inner, DaemonRequest::OutputsDone);
1210 let reply = events.request(&request);
1211 if reply_sender
1212 .send(reply.unwrap_or_else(|err| {
1213 DaemonReply::Result(Err(format!("{err:?}")))
1214 }))
1215 .is_err()
1216 {
1217 eprintln!("failed to send reply");
1218 }
1219 // Exit after OutputsDone under shutdown even if
1220 // EventStream still holds a sender clone — otherwise
1221 // node-first Drop waits forever on blocking_recv
1222 // (dora-rs/dora#2855).
1223 if outputs_done && shutdown_for_loop.load(Ordering::Relaxed) {
1224 break;
1225 }
1226 }
1227 })
1228 .map_err(|e| {
1229 NodeError::Init(format!("failed to spawn testing daemon thread: {e}"))
1230 })?;
1231 (new_communication, Some(handle), Some(shutdown))
1232 }
1233 None => {
1234 return Err(NodeError::Init(
1235 "no daemon communication method specified".into(),
1236 ));
1237 }
1238 },
1239 };
1240
1241 // Initialize zenoh session for direct node-to-node data plane.
1242 // Skip in interactive/testing mode (no daemon, no dataflow topology).
1243 let is_standard_mode = matches!(
1244 daemon_communication,
1245 DaemonCommunicationWrapper::Standard(_)
1246 );
1247 // Pool size priority: per-node YAML config > env var > built-in default.
1248 let shm_pool_size = run_config
1249 .shared_memory_pool_size
1250 .map(|bs| bs.as_bytes())
1251 .or_else(|| {
1252 std::env::var("DORA_NODE_SHM_POOL_SIZE")
1253 .ok()
1254 .and_then(|s| s.parse::<usize>().ok())
1255 })
1256 // 8 MB default — kept deliberately small so the pool fits a
1257 // constrained `/dev/shm` (Docker/Kubernetes commonly cap it at
1258 // 64 MB). A pool that doesn't fit backing memory was observed to
1259 // break large-output delivery entirely (the segment can't be backed
1260 // as it fills), so bumping this default is NOT a safe way to widen
1261 // the large-message pipeline. Throughput under large-message bursts
1262 // is handled instead by the non-blocking `GarbageCollect` alloc
1263 // policy (see `allocate_data_sample` / `zenoh_publish`): the producer
1264 // never stalls on a momentarily-full pool, it just copies via the
1265 // heap path. Raise this only alongside a matching `/dev/shm` (via
1266 // `shared_memory_pool_size` / `DORA_NODE_SHM_POOL_SIZE`) to keep more
1267 // large outputs zero-copy.
1268 .unwrap_or(8 * 1024 * 1024);
1269 let zenoh_zero_copy_threshold = std::env::var("DORA_ZERO_COPY_THRESHOLD")
1270 .ok()
1271 .and_then(|s| s.parse::<usize>().ok())
1272 .unwrap_or(ZERO_COPY_THRESHOLD);
1273 let (zenoh_session, zenoh_shm_provider, owned_runtime) = if !is_standard_mode {
1274 (None, None, None)
1275 } else {
1276 let (handle, owned_runtime) = match tokio::runtime::Handle::try_current() {
1277 Ok(handle) => (handle, None),
1278 Err(_) => {
1279 let rt = tokio::runtime::Builder::new_multi_thread()
1280 .enable_all()
1281 .thread_name("dora-node-runtime")
1282 .build()
1283 .map_err(|e| {
1284 NodeError::Init(format!("failed to create owned tokio runtime: {e}"))
1285 })?;
1286 let handle = rt.handle().clone();
1287 (handle, Some(rt))
1288 }
1289 };
1290 // Use scope + spawn to avoid panicking when called from a tokio
1291 // worker thread (block_on panics in that context on
1292 // current-thread runtimes).
1293 let session = std::thread::scope(|s| {
1294 match s
1295 .spawn(|| handle.block_on(dora_core::topics::open_zenoh_session(None)))
1296 .join()
1297 {
1298 Ok(Ok(session)) => Ok(session),
1299 Ok(Err(e)) => Err(NodeError::Init(format!(
1300 "failed to open zenoh session: {e:?}"
1301 ))),
1302 Err(_panic) => Err(NodeError::Init("zenoh session init panicked".into())),
1303 }
1304 })?;
1305 // SHM provider is best-effort: if the OS rejects the segment
1306 // allocation (e.g. `/dev/shm` exhausted in CI), fall back to
1307 // `None`. `send_output_sample` already publishes via heap
1308 // buffers when the provider is missing.
1309 let provider = {
1310 use zenoh::Wait;
1311 use zenoh::shm::{AllocAlignment, MemoryLayout, ShmProviderBuilder};
1312
1313 let alignment =
1314 AllocAlignment::new(crate::arrow_utils::ARROW_BUFFER_ALIGNMENT_EXPONENT)
1315 .expect("ARROW_BUFFER_ALIGNMENT is a valid power-of-two alignment");
1316 let layout = shm_pool_size
1317 .checked_next_multiple_of(crate::arrow_utils::ARROW_BUFFER_ALIGNMENT)
1318 .and_then(|aligned| MemoryLayout::new(aligned, alignment).ok());
1319
1320 match layout {
1321 Some(layout) => match ShmProviderBuilder::default_backend(layout).wait() {
1322 Ok(provider) => Some(Arc::new(provider)),
1323 Err(e) => {
1324 warn!(
1325 "failed to create zenoh SHM provider ({e}); \
1326 falling back to heap-buffered publishes"
1327 );
1328 None
1329 }
1330 },
1331 None => {
1332 warn!(
1333 "invalid zenoh SHM pool size ({shm_pool_size}); \
1334 falling back to heap-buffered publishes"
1335 );
1336 None
1337 }
1338 }
1339 };
1340 (Some(session), provider, owned_runtime)
1341 };
1342
1343 // Declare output publishers and start the startup handshake *before*
1344 // `EventStream::init`, which blocks in the daemon's "all nodes ready"
1345 // barrier: markers must be in flight while we are parked there so that
1346 // consumers (whose subscriber callbacks ack them, also while parked)
1347 // can prove the routes. This ordering is what keeps cycles
1348 // (`a -> b -> a`) and self-loops from deadlocking — every node emits
1349 // markers before it waits on anyone, and no one blocks on acks.
1350 let (zenoh_publishers, startup_handshake) = match zenoh_session.as_ref() {
1351 Some(session) => {
1352 let routing = normalize_output_routing(output_routing, &run_config.outputs);
1353 let (publishers, ack_states) = declare_output_publishers(
1354 session,
1355 dataflow_id,
1356 &node_id,
1357 &run_config.outputs,
1358 &routing,
1359 );
1360 let publishers = Arc::new(publishers);
1361 let handshake = StartupHandshake::start(
1362 session,
1363 dataflow_id,
1364 &node_id,
1365 &publishers,
1366 ack_states,
1367 clock.clone(),
1368 );
1369 (publishers, Some(handshake))
1370 }
1371 None => (Arc::new(HashMap::new()), None),
1372 };
1373
1374 let event_stream = EventStream::init(
1375 dataflow_id,
1376 &node_id,
1377 &daemon_communication,
1378 input_config,
1379 &run_config.input_types,
1380 clock.clone(),
1381 write_events_to,
1382 zenoh_session.as_ref(),
1383 )
1384 .wrap_err("failed to init event stream")?;
1385
1386 // The barrier has released: every static consumer is subscribed and
1387 // acking, so the handshake now gets its bounded window. This settles
1388 // every output's transport — acked onto direct zenoh, or frozen on the
1389 // daemon path — before user code sends its first message.
1390 if let Some(handshake) = &startup_handshake {
1391 handshake.settle(ZENOH_STARTUP_GRACE);
1392 }
1393 let control_channel =
1394 ControlChannel::init(dataflow_id, &node_id, &daemon_communication, clock.clone())
1395 .wrap_err("failed to init control channel")?;
1396 let runtime_type_checks = match RuntimeTypeCheck::from_env() {
1397 RuntimeTypeCheck::Off => None,
1398 mode => {
1399 let registry = TypeRegistry::new();
1400 let mut checks = HashMap::new();
1401 for (id, urn) in &run_config.output_types {
1402 match registry.resolve_arrow_type(urn) {
1403 Some(dt) => {
1404 checks.insert(id.clone(), dt);
1405 }
1406 None => {
1407 if registry.resolve(urn).is_some() {
1408 info!(
1409 "runtime type check: skipping complex type \"{urn}\" on output \"{id}\""
1410 );
1411 } else {
1412 warn!(
1413 "runtime type check: unknown type URN \"{urn}\" on output \"{id}\""
1414 );
1415 }
1416 }
1417 }
1418 }
1419 Some((mode, checks))
1420 }
1421 };
1422
1423 let node = Self {
1424 id: node_id,
1425 dataflow_id,
1426 node_config: run_config.clone(),
1427 control_channel,
1428 clock,
1429 zenoh_session,
1430 zenoh_publishers,
1431 startup_handshake,
1432 zenoh_schema_publishers: HashMap::new(),
1433 zenoh_schema_state: HashMap::new(),
1434 sample_allocator: SampleAllocator {
1435 shm_provider: zenoh_shm_provider,
1436 zero_copy_threshold: zenoh_zero_copy_threshold,
1437 },
1438 large_send_diag_count: 0,
1439 dataflow_descriptor: serde_yaml::from_value(dataflow_descriptor),
1440 warned_unknown_output: BTreeSet::new(),
1441 interactive: false,
1442 restart_count,
1443 runtime_type_checks,
1444 _owned_runtime: owned_runtime,
1445 testing_daemon,
1446 testing_shutdown,
1447 };
1448
1449 if dynamic {
1450 // Env vars from the dataflow descriptor are already injected by the
1451 // daemon at spawn time via `Command::env()`. Setting them here with
1452 // `std::env::set_var` would be undefined behavior because the tokio
1453 // multi-threaded runtime is already running and other threads may
1454 // call `std::env::var` concurrently.
1455 //
1456 // If the node was started outside the daemon (manual dynamic node),
1457 // the user must set the required env vars before launching the
1458 // process.
1459 if let Ok(descriptor) = &node.dataflow_descriptor
1460 && let Some(env_vars) = descriptor
1461 .nodes
1462 .iter()
1463 .find(|n| n.id == node.id)
1464 .and_then(|n| n.env.as_ref())
1465 {
1466 for key in env_vars.keys() {
1467 if std::env::var(key).is_err() {
1468 warn!(
1469 "env var `{key}` declared in dataflow descriptor is not set; \
1470 it should have been injected by the daemon at spawn time"
1471 );
1472 }
1473 }
1474 }
1475 }
1476
1477 Ok((node, event_stream))
1478 }
1479
1480 /// Check whether `output_id` is declared as an output of this node.
1481 ///
1482 /// Returns `true` if the output is declared (or this node is `interactive`,
1483 /// which has no static output declaration); `false` and emits a one-time
1484 /// warning if the output is unknown. Public so callers building higher-level
1485 /// send helpers (e.g. the Python `send_output_raw` zero-copy path) can
1486 /// validate before allocating a buffer.
1487 pub fn validate_output(&mut self, output_id: &DataId) -> bool {
1488 if !self.node_config.outputs.contains(output_id) && !self.interactive {
1489 if !self.warned_unknown_output.contains(output_id) {
1490 warn!("Ignoring output `{output_id}` not in node's output list.");
1491 self.warned_unknown_output.insert(output_id.clone());
1492 }
1493 false
1494 } else {
1495 true
1496 }
1497 }
1498
1499 /// Send raw data from the node to the other nodes.
1500 ///
1501 /// We take a closure as an input to enable zero copy on send.
1502 ///
1503 /// ```no_run
1504 /// use dora_node_api::{DoraNode, MetadataParameters};
1505 /// use dora_core::config::DataId;
1506 ///
1507 /// let (mut node, mut events) = DoraNode::init_from_env().expect("Could not init node.");
1508 ///
1509 /// let output = DataId::from("output_id".to_owned());
1510 ///
1511 /// let data: &[u8] = &[0, 1, 2, 3];
1512 /// let parameters = MetadataParameters::default();
1513 ///
1514 /// node.send_output_raw(
1515 /// output,
1516 /// parameters,
1517 /// data.len(),
1518 /// |out| {
1519 /// out.copy_from_slice(data);
1520 /// }).expect("Could not send output");
1521 /// ```
1522 ///
1523 /// Ignores the output if the given `output_id` is not specified as node output in the dataflow
1524 /// configuration file.
1525 pub fn send_output_raw<F>(
1526 &mut self,
1527 output_id: DataId,
1528 parameters: MetadataParameters,
1529 data_len: usize,
1530 data: F,
1531 ) -> NodeResult<()>
1532 where
1533 F: FnOnce(&mut [u8]),
1534 {
1535 if !self.validate_output(&output_id) {
1536 return Ok(());
1537 };
1538 // The receiver expects a self-describing Arrow IPC stream. Build it in
1539 // place: pre-write the UInt8 IPC header into the (shared-memory) sample,
1540 // then let the caller write their bytes straight into the data region —
1541 // zero payload copies (and the SHM sample is moved into zenoh's `put`).
1542 // Prepare the UInt8 IPC header once, then size and fill the sample from
1543 // it — avoids rebuilding the layout + IPC headers for the length query.
1544 let prepared = ipc_encode::PreparedUint8Ipc::new(data_len)
1545 .map_err(|e| NodeError::Output(format!("Arrow IPC encode: {e}")))?;
1546 let mut sample = self.allocate_data_sample(prepared.byte_len())?;
1547 let offset = prepared
1548 .encode_header_into(&mut sample)
1549 .map_err(|e| NodeError::Output(format!("Arrow IPC encode: {e}")))?;
1550 data(&mut sample[offset..offset + data_len]);
1551
1552 let mut parameters = parameters;
1553 parameters.insert(
1554 FRAMING.to_string(),
1555 Parameter::String(FRAMING_ARROW_IPC.to_string()),
1556 );
1557 self.send_output_sample(output_id, parameters, Some(sample))
1558 }
1559
1560 /// Sends the given Arrow array as an output message.
1561 ///
1562 /// This is the recommended way to emit data from a node: pass any value that
1563 /// implements [`IntoArrow`] (primitives, `Vec<T>`, `&str`, an Arrow array,
1564 /// …) and dora moves it into shared memory for an efficient, near-zero-copy
1565 /// transfer to downstream nodes.
1566 ///
1567 /// Uses shared memory for efficient data transfer if suitable. This method
1568 /// might copy the message once to move it to shared memory.
1569 ///
1570 /// Ignores the output if the given `output_id` is not specified as node output in the dataflow
1571 /// configuration file.
1572 ///
1573 /// ```no_run
1574 /// use dora_node_api::{DoraNode, MetadataParameters};
1575 /// use dora_core::config::DataId;
1576 ///
1577 /// let (mut node, _events) = DoraNode::init_from_env()?;
1578 ///
1579 /// let output = DataId::from("output_id".to_owned());
1580 /// let parameters = MetadataParameters::default();
1581 ///
1582 /// node.send_output(output, parameters, vec![1.0f32, 2.0, 3.0])?;
1583 /// # Ok::<(), eyre::Report>(())
1584 /// ```
1585 pub fn send_output(
1586 &mut self,
1587 output_id: DataId,
1588 parameters: MetadataParameters,
1589 data: impl IntoArrow,
1590 ) -> NodeResult<()> {
1591 if !self.validate_output(&output_id) {
1592 return Ok(());
1593 };
1594
1595 let data = data.into_arrow();
1596 let arrow_array = dora_arrow_convert::internal::array_ref(&data).to_data();
1597 self.check_output_type(&output_id, arrow_array.data_type(), ¶meters)?;
1598
1599 let encoded = self.sample_allocator.encode_arrow_data(&arrow_array)?;
1600 self.send_encoded_unchecked(output_id, parameters, encoded.sample)
1601 }
1602
1603 /// Send a payload that has already been IPC-encoded into a dora-owned
1604 /// [`EncodedSample`] by [`SampleAllocator::encode_arrow`].
1605 ///
1606 /// This is the entry point the runtime uses for operator outputs: the
1607 /// operator thread does the encoding so that no memory owned by the
1608 /// operator's language runtime is ever released on the node's thread — see
1609 /// [`SampleAllocator`] (dora-rs/dora#2742).
1610 pub fn send_output_encoded(
1611 &mut self,
1612 output_id: DataId,
1613 parameters: MetadataParameters,
1614 encoded: EncodedSample,
1615 ) -> NodeResult<()> {
1616 if !self.validate_output(&output_id) {
1617 return Ok(());
1618 };
1619 self.check_output_type(&output_id, &encoded.data_type, ¶meters)?;
1620 self.send_encoded_unchecked(output_id, parameters, encoded.sample)
1621 }
1622
1623 /// Tag an already-encoded sample as an Arrow IPC stream and send it. The
1624 /// caller has already run `validate_output` and `check_output_type`.
1625 fn send_encoded_unchecked(
1626 &mut self,
1627 output_id: DataId,
1628 mut parameters: MetadataParameters,
1629 sample: DataSample,
1630 ) -> NodeResult<()> {
1631 parameters.insert(
1632 FRAMING.to_string(),
1633 Parameter::String(FRAMING_ARROW_IPC.to_string()),
1634 );
1635
1636 self.send_output_sample(output_id, parameters, Some(sample))
1637 .wrap_err("failed to send output")?;
1638
1639 Ok(())
1640 }
1641
1642 /// Runtime type check (only when `DORA_RUNTIME_TYPE_CHECK` is set).
1643 ///
1644 /// Skips the check when this message carries pattern metadata
1645 /// (`request_id`, `goal_id`, or `goal_status`). Service, action, and
1646 /// streaming patterns legitimately multiplex multiple Arrow schemas through
1647 /// a single output — a service server may reply with different response
1648 /// shapes for different request types — so a single declared Arrow type
1649 /// cannot cover all variants. Non-pattern messages still get full
1650 /// validation (dora-rs/adora#150).
1651 fn check_output_type(
1652 &self,
1653 output_id: &DataId,
1654 actual: &arrow_schema::DataType,
1655 parameters: &MetadataParameters,
1656 ) -> NodeResult<()> {
1657 if let Some((mode, checks)) = &self.runtime_type_checks
1658 && let Some(expected) = checks.get(output_id)
1659 && !carries_pattern_correlation(parameters)
1660 && actual != expected
1661 {
1662 let msg =
1663 format!("output \"{output_id}\": expected Arrow type {expected:?}, got {actual:?}");
1664 match mode {
1665 RuntimeTypeCheck::Error => {
1666 return Err(NodeError::Output(msg));
1667 }
1668 RuntimeTypeCheck::Warn => {
1669 warn!("type mismatch: {msg}");
1670 }
1671 RuntimeTypeCheck::Off => unreachable!(),
1672 }
1673 }
1674 Ok(())
1675 }
1676
1677 /// Send the given raw byte data as output.
1678 ///
1679 /// Might copy the data once to move it into shared memory.
1680 ///
1681 /// Ignores the output if the given `output_id` is not specified as node output in the dataflow
1682 /// configuration file.
1683 pub fn send_output_bytes(
1684 &mut self,
1685 output_id: DataId,
1686 parameters: MetadataParameters,
1687 data_len: usize,
1688 data: &[u8],
1689 ) -> NodeResult<()> {
1690 if !self.validate_output(&output_id) {
1691 return Ok(());
1692 };
1693 // `send_output_raw` allocates a `data_len`-byte sample and the closure
1694 // below copies `data` into it. A mismatch would otherwise panic deep
1695 // inside `copy_from_slice` ("source slice length .. does not match
1696 // destination slice length .."); return a clear error instead.
1697 if data.len() != data_len {
1698 return Err(NodeError::Output(format!(
1699 "send_output_bytes: data_len ({data_len}) does not match data.len() ({})",
1700 data.len()
1701 )));
1702 }
1703 self.send_output_raw(output_id, parameters, data_len, |sample| {
1704 sample.copy_from_slice(data)
1705 })
1706 }
1707
1708 /// Sends the given [`DataSample`] as output.
1709 ///
1710 /// The sample must already be a self-describing Arrow IPC stream (the
1711 /// `FRAMING_ARROW_IPC` parameter should be set). It is recommended to use a
1712 /// function like [`send_output`][Self::send_output] instead, which handles
1713 /// the encoding.
1714 ///
1715 /// Ignores the output if the given `output_id` is not specified as node output in the dataflow
1716 /// configuration file.
1717 pub fn send_output_sample(
1718 &mut self,
1719 output_id: DataId,
1720 mut parameters: MetadataParameters,
1721 sample: Option<DataSample>,
1722 ) -> NodeResult<()> {
1723 // `SCHEMA_HASH` is an internal wire-protocol key that only
1724 // `publish_schema_once` may set, and only for the schema-less batch it
1725 // belongs to. A stale value forwarded from an input's metadata (the
1726 // receive path strips it, but a recorded/hand-built parameter map can
1727 // still carry one) would make receivers route this output's full
1728 // self-describing stream to the schema-once decoder, hash-mismatch, and
1729 // silently drop it (dora-rs/dora#2366 review).
1730 parameters.remove(SCHEMA_HASH);
1731 // Auto-inject OpenTelemetry trace context when telemetry is enabled.
1732 // Uses the ambient OTel context, which is populated when the tracing
1733 // subscriber has an OpenTelemetry layer (e.g., via with_otlp_tracing).
1734 // Only trace/span IDs are propagated (via W3C TraceContext propagator).
1735 // OTel Baggage is NOT propagated to avoid leaking sensitive data across
1736 // node boundaries. If a user explicitly provides this key, it wins.
1737 #[cfg(feature = "tracing")]
1738 if !parameters.contains_key(crate::OPEN_TELEMETRY_CONTEXT) {
1739 let cx = opentelemetry::Context::current();
1740 let serialized = dora_tracing::telemetry::serialize_context(&cx);
1741 if !serialized.is_empty() {
1742 parameters.insert(
1743 crate::OPEN_TELEMETRY_CONTEXT.to_string(),
1744 crate::Parameter::String(serialized),
1745 );
1746 }
1747 }
1748
1749 let metadata = Metadata::from_parameters(self.clock.new_timestamp(), parameters);
1750
1751 let finalized = sample.map(|sample| sample.finalize());
1752
1753 // Diagnostic (dora-rs/dora#2742): the Windows nightly wedges a runtime's
1754 // main loop inside this function on the first large output and is
1755 // force-killed at the daemon's grace period, so nothing that only logs
1756 // *after* a hop returns can ever show where it parked. Log on entry to
1757 // each hop instead: the last line printed names the blocked call. Capped
1758 // at the first few large sends (the wedge is on the first), so a healthy
1759 // run pays one comparison per send and prints a handful of lines.
1760 // `warn!` so it survives the default stdout filter and reaches CI logs.
1761 let diag_bytes = finalized.as_ref().map_or(0, |f| f.byte_len());
1762 let diag = diag_bytes >= self.sample_allocator.zero_copy_threshold
1763 && self.large_send_diag_count < LARGE_SEND_DIAG_LIMIT;
1764 if diag {
1765 self.large_send_diag_count += 1;
1766 }
1767
1768 // How a data-plane message should be delivered.
1769 enum Delivery {
1770 /// zenoh delivered the payload (or it was consumed by a failed SHM
1771 /// put); only the daemon's control-plane state needs syncing.
1772 Zenoh,
1773 /// Deliver via the daemon control channel. `None` is a metadata-only
1774 /// message with no payload.
1775 Daemon(Option<DataMessage>),
1776 }
1777
1778 // Publish via direct zenoh only when the output may take the direct
1779 // path: its publisher exists and the startup handshake has proven its
1780 // routes — every required consumer acked a marker (see
1781 // `StartupHandshake`). Everything else takes the reliable daemon path:
1782 // no zenoh session (interactive/testing mode), an output the daemon
1783 // pinned there (a consumer on another daemon needs inter-daemon
1784 // forwarding, which only daemon-path sends feed — #2738), or an output
1785 // whose handshake did not complete before `init` returned and is
1786 // therefore frozen there for the run. An SHM-backed sample is moved
1787 // straight into zenoh's `put` (no extra copy); only the daemon path
1788 // copies it out into a `DataMessage::Vec`.
1789 let delivery = match finalized {
1790 Some(finalized) if self.output_direct_ready(&output_id) => {
1791 tracing::trace!(
1792 output = %output_id,
1793 size = finalized.byte_len(),
1794 "publishing via zenoh"
1795 );
1796 if diag {
1797 warn!(
1798 "output `{output_id}`: {diag_bytes} B -> zenoh direct path \
1799 (dora-rs/dora#2742 diagnostic)"
1800 );
1801 }
1802 match self.zenoh_publish(&output_id, &metadata, finalized, diag) {
1803 Ok(PublishOutcome::Published) => Delivery::Zenoh,
1804 Ok(PublishOutcome::NotPublished(sample)) => {
1805 Delivery::Daemon(Some(sample.into_data_message()))
1806 }
1807 Err(e) => {
1808 tracing::warn!(
1809 "zenoh publish failed ({e}); message dropped \
1810 (SHM payload consumed, no daemon fallback)"
1811 );
1812 Delivery::Zenoh
1813 }
1814 }
1815 }
1816 Some(finalized) => {
1817 if diag {
1818 warn!(
1819 "output `{output_id}`: {diag_bytes} B -> daemon path \
1820 (dora-rs/dora#2742 diagnostic)"
1821 );
1822 }
1823 Delivery::Daemon(Some(finalized.into_data_message()))
1824 }
1825 None => Delivery::Daemon(None),
1826 };
1827
1828 match delivery {
1829 Delivery::Zenoh => {
1830 // Keep the daemon's control-plane state in sync (input
1831 // deadlines, circuit-breaker recovery) without duplicating the
1832 // data payload that zenoh already delivered.
1833 if diag {
1834 warn!(
1835 "output `{output_id}`: entering report_output_sent \
1836 (dora-rs/dora#2742 diagnostic)"
1837 );
1838 }
1839 self.control_channel
1840 .report_output_sent(output_id.clone(), metadata)
1841 .wrap_err_with(|| format!("failed to report output {output_id}"))?;
1842 }
1843 Delivery::Daemon(data) => {
1844 // The daemon/TCP path serializes the whole message; an oversized
1845 // IPC payload would otherwise fail deep in the transport with a
1846 // generic error. Reject it here with a clear, output-specific
1847 // message. Large payloads are expected to reach a zenoh
1848 // subscriber instead, which has no such limit.
1849 if let Some(DataMessage::Vec(v)) = &data
1850 && v.len() > dora_message::MAX_MESSAGE_BYTES
1851 {
1852 return Err(NodeError::Output(format!(
1853 "output \"{output_id}\": IPC-encoded message is {} bytes, exceeding \
1854 the {}-byte daemon transport limit (the output is on the daemon \
1855 path: pinned for a consumer only forwarding can reach, its \
1856 startup handshake did not complete, or no zenoh route is \
1857 available)",
1858 v.len(),
1859 dora_message::MAX_MESSAGE_BYTES,
1860 )));
1861 }
1862 if diag {
1863 warn!(
1864 "output `{output_id}`: entering control_channel.send_message \
1865 (dora-rs/dora#2742 diagnostic)"
1866 );
1867 }
1868 self.control_channel
1869 .send_message(output_id.clone(), metadata, data)
1870 .wrap_err_with(|| format!("failed to send output {output_id}"))?;
1871 }
1872 }
1873
1874 Ok(())
1875 }
1876
1877 /// Report the given outputs IDs as closed.
1878 ///
1879 /// The node is not allowed to send more outputs with the closed IDs.
1880 ///
1881 /// Closing outputs early can be helpful to receivers.
1882 pub fn close_outputs(&mut self, outputs_ids: Vec<DataId>) -> NodeResult<()> {
1883 // Validate the whole batch before mutating any local state. Removing
1884 // outputs eagerly would leave the node's local output set out of sync
1885 // with the daemon if a later id is unknown: the early ones would be
1886 // gone locally, yet `report_closed_outputs` is skipped on error so the
1887 // daemon never learns about them.
1888 for output_id in &outputs_ids {
1889 if !self.node_config.outputs.contains(output_id) {
1890 return Err(NodeError::Output(format!("unknown output {output_id}")));
1891 }
1892 }
1893 for output_id in &outputs_ids {
1894 self.node_config.outputs.remove(output_id);
1895 }
1896
1897 self.control_channel
1898 .report_closed_outputs(outputs_ids)
1899 .wrap_err("failed to report closed outputs to daemon")?;
1900
1901 Ok(())
1902 }
1903
1904 /// Whether `output_id` may take the direct zenoh path: its publisher exists
1905 /// (declared at init, not pinned to the daemon path) and the startup
1906 /// handshake proved its routes — see [`StartupHandshake`].
1907 ///
1908 /// Constant for the node's lifetime — see [`wait_for_grace`].
1909 fn output_direct_ready(&self, output_id: &DataId) -> bool {
1910 self.zenoh_publishers
1911 .get(output_id)
1912 .is_some_and(|output| output.ready.load(Ordering::Relaxed))
1913 }
1914
1915 /// Publish data directly via zenoh (node-to-node, bypassing daemon for data).
1916 /// Uses SHM for zero-copy when possible, falls back to heap buffer.
1917 ///
1918 /// The publisher was declared at init (see [`declare_output_publishers`]) with
1919 /// `express(true)` to bypass zenoh's adaptive batch timer and with
1920 /// `Priority::RealTime` so data-plane messages don't share queues with bulk
1921 /// traffic. Its routes were already proven by the startup handshake
1922 /// ([`StartupHandshake`]) before [`Self::output_direct_ready`] let the send
1923 /// take this path, so the first send here cannot be dropped for a
1924 /// not-yet-established subscription.
1925 ///
1926 /// `diag` enables the per-hop entry logging described in
1927 /// [`Self::send_output_sample`] (dora-rs/dora#2742); it is only ever set for
1928 /// the first few large sends of a node's lifetime.
1929 fn zenoh_publish(
1930 &mut self,
1931 output_id: &DataId,
1932 metadata: &Metadata,
1933 finalized: FinalizedSample,
1934 diag: bool,
1935 ) -> eyre::Result<PublishOutcome> {
1936 use zenoh::Wait;
1937
1938 // Every failure *before* the payload is moved into `put` returns the
1939 // sample as `NotPublished` so the caller can still deliver it via the
1940 // daemon. Only a failed `put` of an SHM buffer (which consumes it)
1941 // returns `Err` — that is the single non-recoverable case.
1942 //
1943 // No publisher means there is no zenoh session, its declaration failed
1944 // at init, or the output is pinned to the daemon path: fall back to the
1945 // reliable daemon path (defense in depth — `output_direct_ready`
1946 // already gates the caller).
1947 let Some(DirectOutput { publisher, .. }) = self.zenoh_publishers.get(output_id) else {
1948 return Ok(PublishOutcome::NotPublished(finalized));
1949 };
1950 let session = self
1951 .zenoh_session
1952 .as_ref()
1953 .expect("a declared publisher implies a zenoh session");
1954
1955 // Serialize metadata as zenoh attachment.
1956 let metadata_bytes = match dora_message::encode(metadata) {
1957 Ok(bytes) => bytes,
1958 Err(e) => {
1959 tracing::warn!(output = %output_id, "failed to serialize metadata ({e}); falling back to daemon path");
1960 return Ok(PublishOutcome::NotPublished(finalized));
1961 }
1962 };
1963
1964 match finalized {
1965 // The producer already wrote into shared memory. Move the SHM
1966 // buffer straight into `put` — no realloc, no copy. This is the
1967 // path that eliminates the former heap-to-SHM second copy.
1968 //
1969 // On a put error the buffer has been consumed and cannot be
1970 // recovered for the daemon fallback, so this returns an error
1971 // (the caller logs and drops the message). This is a deliberate
1972 // trade-off for zero-copy on the common matched-subscriber path.
1973 // Producer-constructed SHM sample: `put` *moves* (consumes) the SHM
1974 // buffer, so — unlike the borrowed-heap `Vec` arm below — there is no
1975 // intact payload left to retry on error. A put failure is therefore
1976 // best-effort: the message is dropped (the caller logs it). This is
1977 // the deliberate, accepted trade-off for the zero-copy large-output
1978 // path, not an oversight.
1979 FinalizedSample::Shm(sbuf) => {
1980 if diag {
1981 tracing::warn!(
1982 "output `{output_id}`: entering zenoh put of an SHM buffer \
1983 (dora-rs/dora#2742 diagnostic)"
1984 );
1985 }
1986 publisher
1987 .put(sbuf)
1988 .attachment(&metadata_bytes[..])
1989 .wait()
1990 .map_err(|e| eyre::eyre!("zenoh SHM publish failed: {e}"))?;
1991 Ok(PublishOutcome::Published)
1992 }
1993 // Heap payload. At or above the threshold, copy once into a fresh
1994 // SHM buffer so local subscribers still get zero-copy delivery;
1995 // below it, a heap-buffered put is cheaper than a full SHM page.
1996 // The heap buffer is only borrowed, so any put error can fall back
1997 // to the daemon path with the payload intact.
1998 FinalizedSample::Vec(avec) => {
1999 if avec.len() >= self.sample_allocator.zero_copy_threshold
2000 && let Some(provider) = &self.sample_allocator.shm_provider
2001 {
2002 use zenoh::shm::GarbageCollect;
2003 // Non-blocking: garbage-collect freed chunks and allocate, but
2004 // do NOT block waiting for the pool to drain. Under a burst of
2005 // large messages the zero-copy receiver pins each segment for
2006 // the whole receive pipeline, so the pool can be momentarily
2007 // exhausted; `BlockOn` would then sleep 1 ms per retry (zenoh
2008 // 1.8 has no alloc signalling yet), throttling throughput to
2009 // ~1k msg/s. Falling back to a heap-buffered put instead keeps
2010 // the producer moving (PR #2366).
2011 if diag {
2012 tracing::warn!(
2013 "output `{output_id}`: entering SHM alloc of {} B \
2014 (dora-rs/dora#2742 diagnostic)",
2015 avec.len()
2016 );
2017 }
2018 match provider
2019 .alloc(avec.len())
2020 .with_policy::<GarbageCollect>()
2021 .wait()
2022 {
2023 Ok(mut sbuf) => {
2024 // Mirror the guard in `allocate_data_sample`: only
2025 // copy into the SHM buffer when it is exactly the
2026 // requested size. zenoh 1.8 guarantees the logical
2027 // length matches the request, but `copy_from_slice`
2028 // requires equal lengths and would panic on the
2029 // node's send thread if a future provider ever
2030 // over-allocated. Fall through to the reliable
2031 // daemon path instead of risking that panic.
2032 if sbuf.as_mut().len() == avec.len() {
2033 sbuf.as_mut().copy_from_slice(&avec);
2034 if diag {
2035 tracing::warn!(
2036 "output `{output_id}`: entering zenoh put of a \
2037 copied SHM buffer (dora-rs/dora#2742 diagnostic)"
2038 );
2039 }
2040 return match publisher
2041 .put(sbuf)
2042 .attachment(&metadata_bytes[..])
2043 .wait()
2044 {
2045 Ok(()) => Ok(PublishOutcome::Published),
2046 Err(e) => {
2047 tracing::warn!(
2048 "zenoh SHM publish failed ({e}); \
2049 falling back to daemon path"
2050 );
2051 Ok(PublishOutcome::NotPublished(FinalizedSample::Vec(avec)))
2052 }
2053 };
2054 }
2055 tracing::debug!(
2056 "zenoh SHM alloc returned {} bytes for a {}-byte \
2057 request; using daemon path",
2058 sbuf.as_ref().len(),
2059 avec.len()
2060 );
2061 }
2062 Err(e) => {
2063 tracing::debug!("SHM alloc failed ({e}), using heap buffer");
2064 }
2065 }
2066 }
2067
2068 // A large payload that did not make it into SHM (no provider, or
2069 // the pool was momentarily full) must NOT be published over the
2070 // zenoh data plane: a payload larger than the transport batch
2071 // size is fragmented, and the express/`Drop` data publisher
2072 // silently drops fragmented messages — `put` reports success but
2073 // the subscriber never receives them (PR #2366). Route it via the
2074 // reliable daemon path instead (TCP, up to `MAX_MESSAGE_BYTES`).
2075 // Only sub-threshold payloads, which fit a single batch and never
2076 // fragment, take the zenoh heap put below.
2077 if avec.len() >= self.sample_allocator.zero_copy_threshold {
2078 return Ok(PublishOutcome::NotPublished(FinalizedSample::Vec(avec)));
2079 }
2080
2081 // Only sub-threshold (single-batch, never-fragmented) payloads
2082 // reach this point — large payloads were routed to the daemon
2083 // path above. Apply the schema-once optimization to small
2084 // messages with a stable Arrow schema: publish the schema on the
2085 // `@schema` subtopic (only on change) and send just the
2086 // schema-less batch on the data topic, tagged with the schema
2087 // hash so the receiver matches it to the decoder primed from the
2088 // subtopic.
2089 //
2090 // The message that (re)publishes the schema — the output's first,
2091 // every schema change, any message after a failed `@schema` put,
2092 // and a periodic refresh — is itself sent as a full
2093 // self-describing stream (`publish_schema_once` returns `None`
2094 // for it). It decodes standalone and primes receivers in-band,
2095 // in data-plane order, so the express batch can never outrun its
2096 // own schema (the `@schema` plane's non-express `Block` publish
2097 // otherwise loses that race) and a one-shot output cannot lose
2098 // its only message.
2099 //
2100 // Service/action request-reply messages (carrying
2101 // `request_id`/`goal_id`/`goal_status`) are excluded: a server
2102 // legitimately multiplexes multiple response schemas through one
2103 // output, interleaved per request, and each per-message schema
2104 // change would force a full stream + `@schema` publish anyway.
2105 // Sending them as full self-describing streams (the pre-PR
2106 // behavior) makes each message decode standalone regardless of
2107 // schema order, at the cost of ~400 B of framing per message —
2108 // acceptable for these request/reply-rate patterns.
2109 //
2110 // Streaming (`session_id`/`segment_id`) is deliberately NOT
2111 // excluded: every chunk of a stream shares one schema, so
2112 // schema-once primes once and each chunk reuses it — streaming is
2113 // the high-rate small-message case schema-once exists for. A
2114 // schema change at a segment boundary is just the one-time
2115 // re-prime window any schema-once output has, not the per-message
2116 // alternation that makes service/action lossy.
2117 //
2118 // `schema_once` is bound here, not inside the match, so its
2119 // attachment bytes outlive the `put` below.
2120 let schema_once = if schema_once_eligible(
2121 avec.len(),
2122 self.sample_allocator.zero_copy_threshold,
2123 &metadata.parameters,
2124 ) {
2125 publish_schema_once(
2126 &mut self.zenoh_schema_publishers,
2127 &mut self.zenoh_schema_state,
2128 session,
2129 self.dataflow_id,
2130 &self.id,
2131 output_id,
2132 &avec,
2133 metadata,
2134 )
2135 } else {
2136 None
2137 };
2138 // Fall back to a full standalone stream if the batch slice can't
2139 // be taken (a real IPC stream always can — defensive).
2140 let (payload, attachment): (&[u8], &[u8]) = match schema_once.as_ref() {
2141 Some(att) => match arrow_utils::ipc_encode::batch_slice(&avec) {
2142 Some(slice) => (slice, att.as_slice()),
2143 None => (&avec[..], &metadata_bytes[..]),
2144 },
2145 None => (&avec[..], &metadata_bytes[..]),
2146 };
2147 match publisher.put(payload).attachment(attachment).wait() {
2148 Ok(()) => Ok(PublishOutcome::Published),
2149 Err(e) => {
2150 tracing::warn!("zenoh publish failed ({e}); falling back to daemon path");
2151 // The zenoh data plane did not deliver this message. If
2152 // it was the one meant to prime receivers in-band (the
2153 // first message of a schema, or a periodic refresh),
2154 // `publish_schema_once` already recorded its state and
2155 // the following messages would go out schema-less with
2156 // no delivered priming stream. Forget the output's
2157 // schema-once state so the next message sends a full
2158 // stream and re-publishes the schema. (A congestion
2159 // drop reports `Ok` and stays undetectable — inherent
2160 // to `CongestionControl::Drop`; the periodic refresh
2161 // bounds that residual window.)
2162 self.zenoh_schema_state.remove(output_id);
2163 Ok(PublishOutcome::NotPublished(FinalizedSample::Vec(avec)))
2164 }
2165 }
2166 }
2167 }
2168 }
2169
2170 /// Returns the ID of the node as specified in the dataflow configuration file.
2171 pub fn id(&self) -> &NodeId {
2172 &self.id
2173 }
2174
2175 /// Returns the unique identifier for the running dataflow instance.
2176 ///
2177 /// Dora assigns each dataflow instance a random identifier when started.
2178 pub fn dataflow_id(&self) -> &DataflowId {
2179 &self.dataflow_id
2180 }
2181
2182 /// Returns the input and output configuration of this node.
2183 pub fn node_config(&self) -> &NodeRunConfig {
2184 &self.node_config
2185 }
2186
2187 /// Returns the zero-copy SHM threshold in bytes.
2188 ///
2189 /// Outputs whose raw payload is at least this many bytes are published via
2190 /// zenoh shared memory (zero-copy for local subscribers); smaller outputs
2191 /// are published via zenoh with a heap-buffered put. Configured via the
2192 /// `DORA_ZERO_COPY_THRESHOLD` env var, defaulting to
2193 /// [`ZERO_COPY_THRESHOLD`].
2194 pub fn zero_copy_threshold(&self) -> usize {
2195 self.sample_allocator.zero_copy_threshold
2196 }
2197
2198 /// Returns true if this node was restarted after a previous exit or failure.
2199 ///
2200 /// Nodes can use this to decide whether to restore saved state or start fresh.
2201 pub fn is_restart(&self) -> bool {
2202 self.restart_count > 0
2203 }
2204
2205 /// Returns how many times this node has been restarted.
2206 ///
2207 /// Returns 0 on the first run, 1 after the first restart, etc.
2208 pub fn restart_count(&self) -> u32 {
2209 self.restart_count
2210 }
2211
2212 /// Returns the current timestamp from the node's Hybrid Logical Clock.
2213 ///
2214 /// This generates a new HLC timestamp, which combines the physical
2215 /// wall-clock time with a logical counter to ensure uniqueness and
2216 /// monotonicity even across nodes. The HLC is the same clock dora
2217 /// stamps every outgoing message with, so this is the right value
2218 /// to subtract from an input event's `metadata.timestamp` when
2219 /// measuring per-event processing latency — using
2220 /// `std::time::SystemTime::now()` instead would mix two unrelated
2221 /// clocks and give meaningless results across daemons.
2222 pub fn timestamp(&self) -> uhlc::Timestamp {
2223 self.clock.new_timestamp()
2224 }
2225
2226 /// Send a structured log message.
2227 ///
2228 /// Outputs a JSONL line to stdout that the daemon parses automatically.
2229 /// Works with `min_log_level` filtering and `send_logs_as` routing.
2230 ///
2231 /// `level` should be one of: `"error"`, `"warn"`, `"info"`, `"debug"`, `"trace"`.
2232 /// Unknown levels default to `"info"`.
2233 pub fn log(&self, level: &str, message: &str, target: Option<&str>) {
2234 self.log_with_fields(level, message, target, None);
2235 }
2236
2237 /// Maximum serialized size of the log `fields` object before it is
2238 /// dropped (60 KB). Matches the downstream 64 KB parse limit with headroom
2239 /// for the message envelope. Measured on the serialized JSON (see
2240 /// [`log_fields_within_budget`]), not the raw key/value byte sum.
2241 const MAX_LOG_FIELDS_BYTES: usize = 60 * 1024;
2242
2243 /// Send a structured log message with optional key-value fields.
2244 ///
2245 /// Like [`log`](Self::log), but accepts additional structured fields that
2246 /// are included in the JSON payload and preserved through `send_logs_as`.
2247 pub fn log_with_fields(
2248 &self,
2249 level: &str,
2250 message: &str,
2251 target: Option<&str>,
2252 fields: Option<&std::collections::BTreeMap<String, String>>,
2253 ) {
2254 let level_str = match level.to_lowercase().as_str() {
2255 "error" => "error",
2256 "warn" | "warning" => "warn",
2257 "info" => "info",
2258 "debug" => "debug",
2259 "trace" => "trace",
2260 _ => "info",
2261 };
2262 let timestamp = chrono::Utc::now().to_rfc3339();
2263 let mut entry = serde_json::json!({
2264 "timestamp": timestamp,
2265 "level": level_str,
2266 "node_id": self.id.to_string(),
2267 "message": message,
2268 });
2269 if let Some(target) = target {
2270 entry["target"] = serde_json::Value::String(target.to_string());
2271 }
2272 if let Some(fields) = fields {
2273 match log_fields_within_budget(fields, Self::MAX_LOG_FIELDS_BYTES) {
2274 Some(value) => entry["fields"] = value,
2275 None => {
2276 eprintln!("dora log: fields too large, dropping fields");
2277 entry["fields_dropped"] = serde_json::Value::Bool(true);
2278 }
2279 }
2280 }
2281 match serde_json::to_string(&entry) {
2282 Ok(json) => println!("{json}"),
2283 Err(e) => eprintln!("dora log serialization error: {e}"),
2284 }
2285 }
2286
2287 /// Log an error message.
2288 pub fn log_error(&self, message: &str) {
2289 self.log("error", message, None);
2290 }
2291
2292 /// Log a warning message.
2293 pub fn log_warn(&self, message: &str) {
2294 self.log("warn", message, None);
2295 }
2296
2297 /// Log an info message.
2298 pub fn log_info(&self, message: &str) {
2299 self.log("info", message, None);
2300 }
2301
2302 /// Log a debug message.
2303 pub fn log_debug(&self, message: &str) {
2304 self.log("debug", message, None);
2305 }
2306
2307 /// Log a trace message.
2308 pub fn log_trace(&self, message: &str) {
2309 self.log("trace", message, None);
2310 }
2311
2312 // -----------------------------------------------------------------
2313 // Service / Action helpers
2314 // -----------------------------------------------------------------
2315
2316 /// Generate a new unique request/goal ID (UUID v7, time-ordered).
2317 ///
2318 /// Uses a per-thread monotonic counter context to guarantee uniqueness
2319 /// even when multiple IDs are generated within the same clock tick.
2320 pub fn new_request_id() -> String {
2321 thread_local! {
2322 static CTX: uuid::ContextV7 = const { uuid::ContextV7::new() };
2323 }
2324 CTX.with(|ctx| uuid::Uuid::new_v7(uuid::Timestamp::now(ctx)).to_string())
2325 }
2326
2327 /// Generate a new unique goal ID (UUID v7, time-ordered).
2328 ///
2329 /// This is an alias for [`new_request_id`](Self::new_request_id) that
2330 /// reads more naturally in action (goal/feedback/result) contexts.
2331 pub fn new_goal_id() -> String {
2332 Self::new_request_id()
2333 }
2334
2335 /// Send a service request, automatically injecting a `request_id` into the
2336 /// metadata parameters. Returns the generated request ID.
2337 ///
2338 /// Any existing `request_id` key in `parameters` is replaced.
2339 pub fn send_service_request(
2340 &mut self,
2341 output_id: DataId,
2342 mut parameters: MetadataParameters,
2343 data: impl IntoArrow,
2344 ) -> NodeResult<String> {
2345 if parameters.contains_key(dora_message::metadata::REQUEST_ID) {
2346 tracing::warn!("send_service_request: caller-provided request_id will be overwritten");
2347 }
2348 let request_id = Self::new_request_id();
2349 parameters.insert(
2350 dora_message::metadata::REQUEST_ID.to_string(),
2351 dora_message::metadata::Parameter::String(request_id.clone()),
2352 );
2353 self.send_output(output_id, parameters, data)?;
2354 Ok(request_id)
2355 }
2356
2357 /// Send a service response. This is a semantic alias for [`send_output`](Self::send_output).
2358 ///
2359 /// The caller is expected to pass through the `request_id` parameter from
2360 /// the incoming request's metadata.
2361 pub fn send_service_response(
2362 &mut self,
2363 output_id: DataId,
2364 parameters: MetadataParameters,
2365 data: impl IntoArrow,
2366 ) -> NodeResult<()> {
2367 self.send_output(output_id, parameters, data)
2368 }
2369
2370 // -----------------------------------------------------------------
2371 // Streaming helpers
2372 // -----------------------------------------------------------------
2373
2374 /// Send a streaming segment chunk. Convenience wrapper around
2375 /// [`send_output`](Self::send_output) that builds metadata from the
2376 /// [`StreamSegment`] builder.
2377 pub fn send_stream_chunk(
2378 &mut self,
2379 output_id: DataId,
2380 segment: &mut StreamSegment,
2381 fin: bool,
2382 data: impl IntoArrow,
2383 ) -> NodeResult<()> {
2384 self.send_output(output_id, segment.chunk(fin), data)
2385 }
2386
2387 /// Allocates a [`DataSample`] of the specified size.
2388 ///
2389 /// See [`SampleAllocator::allocate`] for the allocation strategy.
2390 pub fn allocate_data_sample(&mut self, data_len: usize) -> NodeResult<DataSample> {
2391 self.sample_allocator.allocate(data_len)
2392 }
2393
2394 /// A handle for building output samples off this node's thread.
2395 ///
2396 /// The runtime hands one to each operator thread so the operator can encode
2397 /// its payload into a dora-owned [`DataSample`] itself — see
2398 /// [`SampleAllocator`] for why that matters (dora-rs/dora#2742).
2399 pub fn sample_allocator(&self) -> SampleAllocator {
2400 self.sample_allocator.clone()
2401 }
2402
2403 /// Returns the full dataflow descriptor that this node is part of.
2404 ///
2405 /// This method returns the parsed dataflow YAML file.
2406 pub fn dataflow_descriptor(&self) -> NodeResult<&Descriptor> {
2407 match &self.dataflow_descriptor {
2408 Ok(d) => Ok(d),
2409 Err(err) => Err(NodeError::Data(format!(
2410 "failed to parse dataflow descriptor: {err}\n\n\
2411 This might be caused by mismatched version numbers of dora \
2412 daemon and the dora node API"
2413 ))),
2414 }
2415 }
2416
2417 /// Store an opaque value in the daemon's dataflow-scoped extension table.
2418 ///
2419 /// This is the seam for transports that live outside the dora tree: dora
2420 /// brokers the value's lifetime and nothing else — it never interprets
2421 /// `namespace`, `key` or `value`. See `docs/extensions.md`.
2422 ///
2423 /// The daemon remembers which nodes touched a key so that dropping it
2424 /// notifies them, and reclaims the entry when the dataflow ends or the
2425 /// storing node exits. Drain the notifications with
2426 /// [`event_stream::extensions::drain_dropped_keys`](crate::event_stream::extensions::drain_dropped_keys).
2427 pub fn extension_store(
2428 &mut self,
2429 namespace: impl Into<String>,
2430 key: impl Into<String>,
2431 value: Vec<u8>,
2432 ) -> Result<(), eyre::Error> {
2433 self.control_channel
2434 .extension_store(namespace.into(), key.into(), value)
2435 }
2436
2437 /// Read an opaque value back, optionally removing it in the same round trip.
2438 ///
2439 /// Returns `None` if the key is not in the table — never stored, or
2440 /// already dropped.
2441 pub fn extension_load(
2442 &mut self,
2443 namespace: impl Into<String>,
2444 key: impl Into<String>,
2445 remove: bool,
2446 ) -> Result<Option<Vec<u8>>, eyre::Error> {
2447 self.control_channel
2448 .extension_load(namespace.into(), key.into(), remove)
2449 }
2450
2451 /// Drop an opaque value, notifying every node that stored or loaded it.
2452 pub fn extension_drop(
2453 &mut self,
2454 namespace: impl Into<String>,
2455 key: impl Into<String>,
2456 ) -> Result<(), eyre::Error> {
2457 self.control_channel
2458 .extension_drop(namespace.into(), key.into())
2459 }
2460
2461 /// Send an opaque request to the extension registered under
2462 /// `namespace` on this node's daemon, and return its opaque reply.
2463 ///
2464 /// Companion to [`DoraNode::extension_store`] / [`DoraNode::extension_load`]:
2465 /// those broker a descriptor's lifetime, this one carries a call the
2466 /// extension's daemon half must service. dora interprets neither the
2467 /// namespace nor the bytes — see `docs/extensions.md`.
2468 pub fn extension_request(
2469 &mut self,
2470 namespace: impl Into<String>,
2471 payload: Vec<u8>,
2472 ) -> Result<Vec<u8>, eyre::Error> {
2473 self.control_channel
2474 .extension_request(namespace.into(), payload)
2475 }
2476}
2477
2478/// Return the serialized log `fields` object when it fits `limit`, else `None`.
2479///
2480/// The budget guards a downstream JSON-line parse limit, so it must measure
2481/// the *serialized* size: `"fields":{...}` adds structural bytes (quotes,
2482/// colons, commas) and JSON escaping — a value full of `"`/`\` doubles and
2483/// control characters expand ~6x via `\uXXXX`. Summing raw key/value byte
2484/// lengths can pass a map whose serialized form is well over the limit, which
2485/// the downstream parser then drops or truncates whole.
2486fn log_fields_within_budget(
2487 fields: &std::collections::BTreeMap<String, String>,
2488 limit: usize,
2489) -> Option<serde_json::Value> {
2490 // Count the serialized bytes without allocating a throwaway string, then
2491 // build the JSON value only when it fits.
2492 struct ByteCounter(usize);
2493 impl std::io::Write for ByteCounter {
2494 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
2495 self.0 += buf.len();
2496 Ok(buf.len())
2497 }
2498 fn flush(&mut self) -> std::io::Result<()> {
2499 Ok(())
2500 }
2501 }
2502 let mut counter = ByteCounter(0);
2503 serde_json::to_writer(&mut counter, fields).ok()?;
2504 (counter.0 <= limit).then(|| serde_json::json!(fields))
2505}
2506
2507/// Builder for initializing a node with custom connection parameters.
2508///
2509/// Created via [`DoraNode::builder()`]. Callers who don't need a custom daemon
2510/// port should prefer [`DoraNode::init_from_env`] or
2511/// [`DoraNode::init_from_node_id`]. Setting [`node_id`](Self::node_id) selects
2512/// the dynamic-node path; otherwise [`build`](Self::build) falls back to
2513/// [`DoraNode::init_from_env`].
2514#[derive(Default)]
2515pub struct DoraNodeBuilder {
2516 node_id: Option<NodeId>,
2517 daemon_port: Option<u16>,
2518}
2519
2520impl DoraNodeBuilder {
2521 /// Set the node ID. Presence of a node ID selects the dynamic-node path.
2522 pub fn node_id(mut self, node_id: NodeId) -> Self {
2523 self.node_id = Some(node_id);
2524 self
2525 }
2526
2527 /// No-op kept for source compatibility with upstream dora 0.5.x
2528 /// [`#1591`](https://github.com/dora-rs/dora/pull/1591). Upstream gates the
2529 /// dynamic-node path on an explicit `.dynamic()` call; here, dynamic mode
2530 /// is selected by the presence of `node_id`, making the flag redundant.
2531 /// Kept so that `.node_id(id).dynamic().build()` written against upstream
2532 /// still compiles.
2533 #[inline]
2534 pub fn dynamic(self) -> Self {
2535 self
2536 }
2537
2538 /// Override the daemon port. When unset, the builder honours the
2539 /// `DORA_DAEMON_LOCAL_LISTEN_PORT` env var and falls back to
2540 /// `DORA_DAEMON_LOCAL_LISTEN_PORT_DEFAULT`.
2541 pub fn daemon_port(mut self, port: u16) -> Self {
2542 self.daemon_port = Some(port);
2543 self
2544 }
2545
2546 /// Build and connect the node.
2547 pub fn build(self) -> NodeResult<(DoraNode, EventStream)> {
2548 let Some(node_id) = self.node_id else {
2549 return DoraNode::init_from_env();
2550 };
2551
2552 let port = self.daemon_port.unwrap_or_else(|| {
2553 match std::env::var(DORA_DAEMON_LOCAL_LISTEN_PORT_ENV) {
2554 Ok(p) => p.parse().unwrap_or_else(|e| {
2555 tracing::warn!(
2556 "invalid {DORA_DAEMON_LOCAL_LISTEN_PORT_ENV}={p:?}: {e}, using default port"
2557 );
2558 DORA_DAEMON_LOCAL_LISTEN_PORT_DEFAULT
2559 }),
2560 Err(_) => DORA_DAEMON_LOCAL_LISTEN_PORT_DEFAULT,
2561 }
2562 });
2563 let daemon_address = (LOCALHOST, port).into();
2564
2565 let mut channel =
2566 DaemonChannel::new_tcp(daemon_address).context("Could not connect to the daemon")?;
2567 let clock = Arc::new(uhlc::HLC::default());
2568
2569 let reply = channel
2570 .request(&Timestamped {
2571 inner: DaemonRequest::NodeConfig { node_id },
2572 timestamp: clock.new_timestamp(),
2573 })
2574 .wrap_err("failed to request node config from daemon")?;
2575
2576 match reply {
2577 DaemonReply::NodeConfig {
2578 result: Ok(node_config),
2579 } => DoraNode::init(node_config),
2580 DaemonReply::NodeConfig { result: Err(error) } => {
2581 let capped: String = error.chars().take(512).collect();
2582 Err(NodeError::Init(format!(
2583 "failed to get node config from daemon: {capped}"
2584 )))
2585 }
2586 _ => Err(NodeError::Init("unexpected reply from daemon".into())),
2587 }
2588 }
2589}
2590
2591/// Runs `teardown` on a dedicated thread, waiting at most `timeout` for it to
2592/// complete. Returns `true` if the teardown finished in time. Panics in
2593/// `teardown` are contained and count as completion. If spawning the thread
2594/// fails, the teardown runs inline without a deadline.
2595///
2596/// On timeout the thread keeps running detached: everything moved into the
2597/// closure (zenoh sockets, SHM segments, the owned tokio runtime) is leaked
2598/// until process exit. That is acceptable for nodes dropped right before
2599/// exit; long-lived hosts (e.g. a Python interpreter dropping a node during
2600/// GC) inherit only the bounded delay instead of a permanent hang.
2601pub(crate) fn teardown_with_timeout(
2602 label: &str,
2603 timeout: Duration,
2604 teardown: impl FnOnce() + Send + 'static,
2605) -> bool {
2606 // The closure is handed over via a channel (instead of being captured by
2607 // the spawned closure) so that it stays available for the inline
2608 // fallback when spawning fails.
2609 let (work_tx, work_rx) = std::sync::mpsc::channel();
2610 let (done_tx, done_rx) = std::sync::mpsc::channel();
2611 let thread = std::thread::Builder::new()
2612 .name(format!("dora-teardown-{label}"))
2613 .spawn(move || {
2614 if let Ok(work) = work_rx.recv() {
2615 let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(work));
2616 }
2617 let _ = done_tx.send(());
2618 });
2619 match thread {
2620 Ok(_) => {
2621 let _ = work_tx.send(teardown);
2622 done_rx.recv_timeout(timeout).is_ok()
2623 }
2624 Err(err) => {
2625 warn!("failed to spawn {label} teardown thread ({err}); running it inline");
2626 let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(teardown));
2627 true
2628 }
2629 }
2630}
2631
2632impl Drop for DoraNode {
2633 fn drop(&mut self) {
2634 // The startup handshake's marker thread holds an `Arc` clone of the
2635 // publishers, so it must be stopped and joined before the publishers
2636 // are dropped for the undeclare below to see the last reference. Do
2637 // that join *inside* the bounded teardown: `shutdown()` sets the stop
2638 // flag but cannot interrupt an in-progress `publisher.put().wait()`, so
2639 // on a wedged zenoh net runtime the join could otherwise hang node
2640 // shutdown (and with it the daemon, which waits for `InputClosed`) —
2641 // the exact hang `teardown_with_timeout` exists to bound (#2425). The
2642 // consumer side already joins its acker thread under the same deadline.
2643 let startup_handshake = self.startup_handshake.take();
2644 // Tear down zenoh before notifying the daemon below, so that
2645 // daemon-signaled `InputClosed` cannot overtake in-flight zenoh data.
2646 let publishers = std::mem::take(&mut self.zenoh_publishers);
2647 let schema_publishers = std::mem::take(&mut self.zenoh_schema_publishers);
2648 let shm_provider = self.sample_allocator.shm_provider.take();
2649 let session = self.zenoh_session.take();
2650 let runtime = self._owned_runtime.take();
2651 if session.is_none() && shm_provider.is_none() && publishers.is_empty() {
2652 // no zenoh state (interactive/testing mode): drop inline. A node
2653 // without a zenoh session never has a handshake, but drop it here
2654 // too so this branch stays self-contained.
2655 drop(startup_handshake);
2656 drop(runtime);
2657 } else {
2658 // A wedged zenoh net runtime stalls `Session` close beyond its
2659 // 10s timeout and `Publisher` undeclare indefinitely, which would
2660 // hang node shutdown (and with it the daemon, which waits for
2661 // `InputClosed`). Bound the teardown with a deadline instead.
2662 let completed = teardown_with_timeout("zenoh", ZENOH_TEARDOWN_TIMEOUT, move || {
2663 // Stop + join the marker thread first (bounded by this
2664 // deadline), which releases its publishers `Arc` clone so the
2665 // `drop(publishers)` below holds the last reference and
2666 // undeclares them. Then the documented drop order: subscribers
2667 // (dropped with the handshake) and publishers (data + schema)
2668 // before the session, owned runtime last so async cleanup can
2669 // still run.
2670 if let Some(mut handshake) = startup_handshake {
2671 handshake.shutdown();
2672 // Undeclare the ack subscribers before the session below.
2673 drop(std::mem::take(&mut handshake.ack_subscribers));
2674 }
2675 drop(publishers);
2676 drop(schema_publishers);
2677 drop(shm_provider);
2678 drop(session);
2679 drop(runtime);
2680 });
2681 if !completed {
2682 warn!(
2683 "zenoh teardown timed out after {}s; continuing node shutdown",
2684 ZENOH_TEARDOWN_TIMEOUT.as_secs()
2685 );
2686 }
2687 }
2688
2689 // close all outputs first to notify subscribers as early as possible
2690 //
2691 // Testing mode (dora-rs/dora#2855): signal shutdown *before* the
2692 // CloseOutputs handshake so a daemon thread sleeping inside
2693 // `next_event` wakes up, replies, and can then process Drop's requests.
2694 if let Some(shutdown) = &self.testing_shutdown {
2695 shutdown.store(true, Ordering::Relaxed);
2696 }
2697 if let Err(err) = self
2698 .control_channel
2699 .report_closed_outputs(
2700 std::mem::take(&mut self.node_config.outputs)
2701 .into_iter()
2702 .collect(),
2703 )
2704 .context("failed to close outputs on drop")
2705 {
2706 tracing::warn!("{err:?}")
2707 }
2708
2709 if let Err(err) = self.control_channel.report_outputs_done() {
2710 tracing::warn!("{err:?}")
2711 }
2712
2713 // Drop our channel sender and join the testing daemon. The daemon loop
2714 // exits after OutputsDone under shutdown even when EventStream still
2715 // holds a sender clone (dora-rs/dora#2855).
2716 if let Some(handle) = self.testing_daemon.take() {
2717 self.control_channel.close_channel();
2718 if handle.join().is_err() {
2719 tracing::warn!("testing daemon thread panicked");
2720 }
2721 }
2722 self.testing_shutdown = None;
2723 }
2724}
2725
2726/// A payload already encoded as an Arrow IPC stream in a dora-owned sample,
2727/// together with the Arrow type it was encoded from.
2728///
2729/// The two travel together so a consumer can still type-check the output after
2730/// the source array is gone — see [`DoraNode::send_output_encoded`].
2731#[derive(Debug)]
2732pub struct EncodedSample {
2733 sample: DataSample,
2734 data_type: arrow_schema::DataType,
2735}
2736
2737impl EncodedSample {
2738 /// A human-readable name for the Arrow type the payload was encoded from,
2739 /// e.g. `"UInt8"`.
2740 ///
2741 /// Returned as a `String` rather than an `arrow_schema::DataType` because
2742 /// `arrow-schema` is the one non-umbrella Arrow crate dora's public API
2743 /// used to name, and naming it would pin 1.x to a single Arrow major.
2744 /// For the real type, enable `arrow-v59` and use
2745 /// [`data_type`](Self::data_type).
2746 pub fn type_name(&self) -> String {
2747 format!("{:?}", self.data_type)
2748 }
2749
2750 /// The Arrow type the payload was encoded from.
2751 ///
2752 /// Gated on `arrow-v59` — dora's current internal Arrow major — because
2753 /// `arrow_schema::DataType` is an Arrow type. It is not returned as a
2754 /// dora-owned type-URN (`dora_core::types::TypeRegistry`) because the URN
2755 /// catalog only covers the standard scalar/struct types: nested lists,
2756 /// dictionaries, timestamps-with-timezone and unions have no URN, so a
2757 /// URN-returning accessor would be lossy for exactly the outputs whose
2758 /// type a caller most needs to inspect.
2759 #[cfg(feature = "arrow-v59")]
2760 pub fn data_type(&self) -> &arrow_schema::DataType {
2761 &self.data_type
2762 }
2763
2764 /// The encoded Arrow IPC stream.
2765 pub fn as_bytes(&self) -> &[u8] {
2766 &self.sample
2767 }
2768}
2769
2770/// Builds dora-owned output samples without borrowing the node.
2771///
2772/// ## Why this exists (dora-rs/dora#2742)
2773///
2774/// An operator runs on its own thread and hands its outputs to the runtime's
2775/// event loop. If what crosses that boundary is an Arrow array whose buffers
2776/// belong to the operator's language runtime — a `pyarrow` array wrapping a
2777/// numpy buffer, say — then the *runtime* ends up freeing them. Releasing a
2778/// numpy-backed buffer acquires the Python GIL (pyarrow's `NumPyBuffer`
2779/// destructor does `PyAcquireGIL`), so the runtime's event loop blocks for as
2780/// long as the operator holds the GIL. That made a node unable to observe
2781/// `Stop`, and the daemon force-killed it at the grace period.
2782///
2783/// Handing the operator thread an allocator instead lets it encode into memory
2784/// dora owns and release its own payload while it still holds the GIL. It is
2785/// not an extra copy: the IPC encode is the same single copy the node would
2786/// otherwise have made, just performed on the other side of the channel.
2787#[derive(Clone)]
2788pub struct SampleAllocator {
2789 shm_provider: Option<Arc<zenoh::shm::ShmProvider<zenoh::shm::PosixShmProviderBackend>>>,
2790 zero_copy_threshold: usize,
2791}
2792
2793impl SampleAllocator {
2794 /// Allocates a [`DataSample`] of the specified size.
2795 ///
2796 /// For payloads at or above the zero-copy threshold the buffer is allocated
2797 /// directly from the zenoh SHM provider (when available), so the producer
2798 /// writes straight into shared memory and publishing moves the buffer into
2799 /// zenoh's `put` without a further copy. Smaller payloads — or the case
2800 /// where no SHM provider exists (interactive/testing mode) — use a
2801 /// heap-allocated, 128-byte-aligned buffer; the SHM provider is
2802 /// page-aligned, so dedicating a full page to a small message is pure waste.
2803 pub fn allocate(&self, data_len: usize) -> NodeResult<DataSample> {
2804 if data_len >= self.zero_copy_threshold
2805 && let Some(provider) = &self.shm_provider
2806 {
2807 use zenoh::Wait;
2808 use zenoh::shm::GarbageCollect;
2809 // Non-blocking (see `zenoh_publish`): GC and allocate, but fall back
2810 // to a heap buffer rather than `BlockOn`-sleeping 1 ms when the pool
2811 // is momentarily full under a large-message burst. The heap buffer
2812 // costs one extra copy on publish but keeps the producer from
2813 // stalling, which is what regressed sustained throughput (PR #2366).
2814 match provider
2815 .alloc(data_len)
2816 .with_policy::<GarbageCollect>()
2817 .wait()
2818 {
2819 Ok(sbuf) => {
2820 // Use the SHM buffer only when it is exactly the requested
2821 // size — zenoh 1.8 guarantees this (the logical length
2822 // matches the request even when the backing chunk is
2823 // larger). If a future provider ever over-allocates, fall
2824 // back to heap rather than expose or publish an oversized
2825 // slice (`DataSample` has no length cap of its own).
2826 if sbuf.as_ref().len() == data_len {
2827 return Ok(DataSample {
2828 storage: SampleStorage::Shm(sbuf),
2829 });
2830 }
2831 tracing::debug!(
2832 "zenoh SHM alloc returned {} bytes for a {data_len}-byte \
2833 request; using heap",
2834 sbuf.as_ref().len()
2835 );
2836 }
2837 Err(e) => {
2838 tracing::debug!("SHM alloc failed ({e}), using heap buffer");
2839 }
2840 }
2841 }
2842
2843 let avec: AVec<u8, ConstAlign<128>> = AVec::__from_elem(128, 0, data_len);
2844 Ok(avec.into())
2845 }
2846
2847 /// Encodes `array` as a complete Arrow IPC stream into a freshly allocated
2848 /// sample. Uses the hand-rolled 1-copy fast path when the array type is
2849 /// eligible, falling back to the official writer (one extra copy) otherwise.
2850 ///
2851 /// The returned sample shares no memory with `array`, so the caller may —
2852 /// and, when the payload is owned by a foreign runtime, **must** — drop
2853 /// `array` on its own thread rather than let it travel to the node.
2854 pub fn encode_arrow(&self, array: &DoraArray) -> NodeResult<EncodedSample> {
2855 self.encode_arrow_data(&dora_arrow_convert::internal::array_ref(array).to_data())
2856 }
2857
2858 /// Same, for dora-internal callers that already hold an [`ArrayData`].
2859 pub(crate) fn encode_arrow_data(&self, array: &ArrayData) -> NodeResult<EncodedSample> {
2860 let sample = match ipc_encode::PreparedIpc::from_data(array) {
2861 Some(prepared) => {
2862 // Prepare once: size the sample from the prepared layout, then
2863 // encode into it — avoids rebuilding the layout + IPC headers.
2864 let mut sample = self.allocate(prepared.byte_len())?;
2865 prepared
2866 .encode_into(&mut sample)
2867 .map_err(|e| NodeError::Output(format!("Arrow IPC encode: {e}")))?;
2868 sample
2869 }
2870 None => {
2871 let bytes = ipc_encode::encode_ipc_to_vec_data(array)
2872 .map_err(|e| NodeError::Output(format!("Arrow IPC encode: {e}")))?;
2873 let mut sample = self.allocate(bytes.len())?;
2874 sample.copy_from_slice(&bytes);
2875 sample
2876 }
2877 };
2878 Ok(EncodedSample {
2879 sample,
2880 data_type: array.data_type().clone(),
2881 })
2882 }
2883
2884 /// An allocator with no shared memory, so every sample is heap-backed.
2885 ///
2886 /// This is what a node without a zenoh session (interactive/testing mode)
2887 /// uses; it also lets callers that only need the encoding — tests, most
2888 /// obviously — build one without a live node.
2889 pub fn heap() -> Self {
2890 Self {
2891 shm_provider: None,
2892 zero_copy_threshold: ZERO_COPY_THRESHOLD,
2893 }
2894 }
2895}
2896
2897impl std::fmt::Debug for SampleAllocator {
2898 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2899 f.debug_struct("SampleAllocator")
2900 .field("shm", &self.shm_provider.is_some())
2901 .field("zero_copy_threshold", &self.zero_copy_threshold)
2902 .finish()
2903 }
2904}
2905
2906/// A data region suitable for sending as an output message.
2907///
2908/// `DataSample` implements the [`Deref`](std::ops::Deref) and
2909/// [`DerefMut`](std::ops::DerefMut) traits to read and write the mapped data.
2910///
2911/// The backing storage is either a heap buffer or — for payloads at or above
2912/// the zero-copy threshold when a zenoh SHM provider is available — a
2913/// zenoh-shared-memory buffer. Writing into an SHM-backed sample lets the
2914/// producer construct the message straight in shared memory, so publishing it
2915/// needs no further copy (the SHM buffer is moved directly into zenoh's `put`).
2916pub struct DataSample {
2917 storage: SampleStorage,
2918}
2919
2920/// Backing storage for a [`DataSample`]. Kept private so the public API never
2921/// exposes a zenoh SHM type; callers only ever see the `[u8]` view via
2922/// `Deref`/`DerefMut`.
2923enum SampleStorage {
2924 /// Heap-allocated, 128-byte-aligned buffer (used below the zero-copy
2925 /// threshold or when no SHM provider is available).
2926 Heap(AVec<u8, ConstAlign<128>>),
2927 /// Zenoh shared-memory buffer. The producer writes the payload directly
2928 /// into it and the buffer is later moved into the zenoh `put` without
2929 /// copying.
2930 Shm(zenoh::shm::ZShmMut),
2931}
2932
2933impl DataSample {
2934 /// Consume the sample into a [`FinalizedSample`] ready for transport.
2935 fn finalize(self) -> FinalizedSample {
2936 match self.storage {
2937 SampleStorage::Heap(buffer) => FinalizedSample::Vec(buffer),
2938 SampleStorage::Shm(sbuf) => FinalizedSample::Shm(sbuf),
2939 }
2940 }
2941}
2942
2943impl std::ops::Deref for DataSample {
2944 type Target = [u8];
2945
2946 fn deref(&self) -> &Self::Target {
2947 match &self.storage {
2948 SampleStorage::Heap(buffer) => buffer,
2949 SampleStorage::Shm(sbuf) => sbuf.as_ref(),
2950 }
2951 }
2952}
2953
2954impl std::ops::DerefMut for DataSample {
2955 fn deref_mut(&mut self) -> &mut Self::Target {
2956 match &mut self.storage {
2957 SampleStorage::Heap(buffer) => buffer,
2958 SampleStorage::Shm(sbuf) => sbuf.as_mut(),
2959 }
2960 }
2961}
2962
2963impl From<AVec<u8, ConstAlign<128>>> for DataSample {
2964 fn from(value: AVec<u8, ConstAlign<128>>) -> Self {
2965 Self {
2966 storage: SampleStorage::Heap(value),
2967 }
2968 }
2969}
2970
2971impl std::fmt::Debug for DataSample {
2972 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2973 f.debug_struct("DataSample")
2974 .field("len", &self.len())
2975 .finish_non_exhaustive()
2976 }
2977}
2978
2979/// A finalized output payload ready for transport.
2980///
2981/// Kept separate from [`DataMessage`] so SHM buffers stay out of the
2982/// `Serialize`/`Deserialize` TCP path: the zenoh data plane moves an `Shm`
2983/// buffer straight into `put` (zero extra copy), while the daemon fallback
2984/// converts to [`DataMessage::Vec`], copying out of shared memory only when the
2985/// zenoh path could not deliver.
2986enum FinalizedSample {
2987 Vec(AVec<u8, ConstAlign<128>>),
2988 Shm(zenoh::shm::ZShmMut),
2989}
2990
2991impl FinalizedSample {
2992 fn byte_len(&self) -> usize {
2993 match self {
2994 FinalizedSample::Vec(v) => v.len(),
2995 FinalizedSample::Shm(sbuf) => sbuf.as_ref().len(),
2996 }
2997 }
2998
2999 /// Convert into a TCP-transportable [`DataMessage`]. For the `Shm` arm this
3000 /// copies the payload out of shared memory into a heap buffer; it runs only
3001 /// on the daemon fallback (no matching zenoh subscriber / no session).
3002 fn into_data_message(self) -> DataMessage {
3003 match self {
3004 FinalizedSample::Vec(v) => DataMessage::Vec(v),
3005 FinalizedSample::Shm(sbuf) => {
3006 let bytes = sbuf.as_ref();
3007 let mut avec: AVec<u8, ConstAlign<128>> = AVec::__from_elem(128, 0, bytes.len());
3008 avec.copy_from_slice(bytes);
3009 DataMessage::Vec(avec)
3010 }
3011 }
3012 }
3013}
3014
3015/// Outcome of a zenoh publish attempt.
3016enum PublishOutcome {
3017 /// The payload was delivered to zenoh (or, on a rare SHM put error,
3018 /// consumed and lost — see [`DoraNode::zenoh_publish`]).
3019 Published,
3020 /// No matching subscriber, or a transport error before the payload was
3021 /// consumed. The sample is returned so the caller can fall back to the
3022 /// daemon path.
3023 NotPublished(FinalizedSample),
3024}
3025
3026/// FNV-1a hash of `bytes` with a fixed seed (cross-process deterministic).
3027/// Delegates to [`dora_message::metadata::fnv1a`] — the single source of truth
3028/// shared with the daemon's `dora topic` debug path, so schema hashes match.
3029pub(crate) fn fnv1a(bytes: &[u8]) -> u64 {
3030 dora_message::metadata::fnv1a(bytes)
3031}
3032
3033/// How often a schema-once output re-sends a full self-describing stream on the
3034/// data topic. Full streams prime receivers in-band, so this bounds how long a
3035/// consumer that missed the single `@schema` emission (e.g. a failed zenoh-ext
3036/// history query) drops schema-less batches: it re-primes at the next refresh
3037/// instead of losing the input permanently. ~400 B of extra framing per output
3038/// per interval — negligible.
3039pub(crate) const SCHEMA_ONCE_REFRESH_INTERVAL: Duration = Duration::from_secs(5);
3040
3041/// Producer-side schema-once state for one output.
3042struct SchemaOnceState {
3043 /// Hash of the schema confirmed published on the `@schema` subtopic.
3044 published_hash: u64,
3045 /// When the last full self-describing stream was sent on the data topic.
3046 last_full_stream: Instant,
3047}
3048
3049/// What `publish_schema_once` should do for the current message.
3050#[derive(Debug)]
3051enum SchemaOnceDecision {
3052 /// The schema for this hash is not confirmed published (first message,
3053 /// schema change, or an earlier `@schema` publish failed): publish it and
3054 /// send this message as a full self-describing stream. The full stream
3055 /// decodes standalone and primes receivers in-band — in data-plane order —
3056 /// so the first message of an output cannot be lost to the express batch
3057 /// racing ahead of the schema on the separate `@schema` plane, and a failed
3058 /// schema publish degrades to "full stream every message" (decodable)
3059 /// instead of "hash-tagged but undecodable" (dora-rs/dora#2366 review).
3060 PublishSchemaAndSendFullStream,
3061 /// The periodic full-stream refresh is due (see
3062 /// [`SCHEMA_ONCE_REFRESH_INTERVAL`]).
3063 SendFullStreamRefresh,
3064 /// Schema confirmed published and fresh: send only the schema-less batch,
3065 /// tagged with the schema hash.
3066 SendSchemaLessBatch,
3067}
3068
3069fn schema_once_decision(
3070 state: Option<&SchemaOnceState>,
3071 hash: u64,
3072 now: Instant,
3073) -> SchemaOnceDecision {
3074 match state {
3075 Some(state) if state.published_hash == hash => {
3076 if now.duration_since(state.last_full_stream) >= SCHEMA_ONCE_REFRESH_INTERVAL {
3077 SchemaOnceDecision::SendFullStreamRefresh
3078 } else {
3079 SchemaOnceDecision::SendSchemaLessBatch
3080 }
3081 }
3082 _ => SchemaOnceDecision::PublishSchemaAndSendFullStream,
3083 }
3084}
3085
3086/// Publish the Arrow IPC schema for `output_id` on its `@schema` subtopic when
3087/// it changes, and return the attachment metadata (carrying the schema hash)
3088/// for the schema-less batch the caller sends on the data topic. Returns `None`
3089/// when the caller must send the full self-describing stream instead: on the
3090/// message that (re)publishes the schema, when the `@schema` publish failed,
3091/// for the periodic full-stream refresh, or if `full_stream` is not a parseable
3092/// IPC stream (see [`SchemaOnceDecision`]).
3093///
3094/// Takes the maps by `&mut` (not `&mut self`) so it can run while an immutable
3095/// borrow of `self.zenoh_publishers` (the data publisher) is live.
3096#[allow(clippy::too_many_arguments)]
3097fn publish_schema_once(
3098 schema_publishers: &mut HashMap<DataId, zenoh_ext::AdvancedPublisher<'static>>,
3099 schema_state: &mut HashMap<DataId, SchemaOnceState>,
3100 session: &zenoh::Session,
3101 dataflow_id: DataflowId,
3102 node_id: &NodeId,
3103 output_id: &DataId,
3104 full_stream: &[u8],
3105 base_metadata: &Metadata,
3106) -> Option<Vec<u8>> {
3107 let (hash, schema_bytes) = arrow_utils::ipc_encode::schema_block_and_hash(full_stream)?;
3108
3109 let now = Instant::now();
3110 let decision = schema_once_decision(schema_state.get(output_id), hash, now);
3111 tracing::debug!(output = %output_id, decision = ?decision, "schema-once decision");
3112
3113 match decision {
3114 SchemaOnceDecision::PublishSchemaAndSendFullStream => {
3115 if let Some(publisher) =
3116 schema_publisher(schema_publishers, session, dataflow_id, node_id, output_id)
3117 {
3118 use zenoh::Wait;
3119 match publisher.put(schema_bytes).wait() {
3120 // Record the hash only on a successful publish, so a failed
3121 // emission is retried on the next message rather than
3122 // silently skipped.
3123 Ok(()) => {
3124 tracing::debug!(output = %output_id, hash, "schema published on @schema subtopic");
3125 schema_state.insert(
3126 output_id.clone(),
3127 SchemaOnceState {
3128 published_hash: hash,
3129 last_full_stream: now,
3130 },
3131 );
3132 }
3133 Err(e) => {
3134 tracing::warn!(output = %output_id, "failed to publish schema on @schema subtopic ({e})");
3135 }
3136 }
3137 }
3138 None
3139 }
3140 SchemaOnceDecision::SendFullStreamRefresh => {
3141 tracing::debug!(output = %output_id, hash, "sending full-stream refresh");
3142 if let Some(state) = schema_state.get_mut(output_id) {
3143 state.last_full_stream = now;
3144 }
3145 None
3146 }
3147 SchemaOnceDecision::SendSchemaLessBatch => {
3148 tracing::debug!(output = %output_id, hash, "sending schema-less batch with SCHEMA_HASH");
3149 // Every batch carries the schema hash so the receiver can match it
3150 // to the primed decoder (and detect a schema change).
3151 let mut metadata = base_metadata.clone();
3152 metadata
3153 .parameters
3154 .insert(SCHEMA_HASH.to_string(), Parameter::Integer(hash as i64));
3155 dora_message::encode(&metadata).ok()
3156 }
3157 }
3158}
3159
3160/// Get or lazily declare the schema `AdvancedPublisher` for `output_id` on its
3161/// `@schema` subtopic. The cache (depth 1) retains the last schema so a
3162/// late-joining subscriber's history query can fetch it; `publisher_detection`
3163/// lets a subscriber that started first discover this publisher and query its
3164/// cache. `CongestionControl::Block` keeps the single live schema emission from
3165/// being dropped under congestion.
3166fn schema_publisher<'a>(
3167 schema_publishers: &'a mut HashMap<DataId, zenoh_ext::AdvancedPublisher<'static>>,
3168 session: &zenoh::Session,
3169 dataflow_id: DataflowId,
3170 node_id: &NodeId,
3171 output_id: &DataId,
3172) -> Option<&'a zenoh_ext::AdvancedPublisher<'static>> {
3173 if !schema_publishers.contains_key(output_id) {
3174 use zenoh::Wait;
3175 use zenoh::qos::CongestionControl;
3176 use zenoh_ext::{AdvancedPublisherBuilderExt, CacheConfig, MissDetectionConfig};
3177
3178 let topic = dora_core::topics::zenoh_output_schema_topic(dataflow_id, node_id, output_id);
3179 let key = zenoh::key_expr::KeyExpr::new(topic).ok()?.into_owned();
3180 let publisher = match session
3181 .declare_publisher(key)
3182 .congestion_control(CongestionControl::Block)
3183 // `sample_miss_detection` selects SequenceNumber sequencing instead of
3184 // the cache's default Timestamp sequencing, so the schema publisher
3185 // doesn't require session-wide timestamping (which would otherwise add
3186 // an HLC timestamp to every data-plane message too). Its default
3187 // config adds no heartbeat, so there's no extra periodic traffic.
3188 .sample_miss_detection(MissDetectionConfig::default())
3189 .cache(CacheConfig::default())
3190 .publisher_detection()
3191 .wait()
3192 {
3193 Ok(p) => p,
3194 Err(e) => {
3195 tracing::warn!(output = %output_id, "failed to declare schema publisher ({e})");
3196 return None;
3197 }
3198 };
3199 schema_publishers.insert(output_id.clone(), publisher);
3200 }
3201 schema_publishers.get(output_id)
3202}
3203
3204pub(crate) use dora_message::metadata::carries_pattern_correlation;
3205
3206/// Whether the schema-once optimization may be applied to a data-plane message.
3207///
3208/// Eligible only when the payload is below the zero-copy threshold *and* the
3209/// output does not interleave multiple Arrow schemas. Service/action
3210/// request-reply messages (`request_id`/`goal_id`/`goal_status`) multiplex
3211/// response schemas per request and must travel as full self-describing streams
3212/// so each decodes standalone; streaming chunks share one schema and stay
3213/// eligible. See the rationale at the call site in `zenoh_publish`.
3214fn schema_once_eligible(
3215 payload_len: usize,
3216 zero_copy_threshold: usize,
3217 params: &MetadataParameters,
3218) -> bool {
3219 payload_len < zero_copy_threshold && !carries_pattern_correlation(params)
3220}
3221
3222/// Init Opentelemetry Tracing
3223///
3224/// This requires a tokio runtime spawning this function to be functional
3225#[cfg(feature = "tracing")]
3226pub fn init_tracing(
3227 node_id: &NodeId,
3228 dataflow_id: &DataflowId,
3229) -> NodeResult<Arc<Mutex<Option<OtelGuard>>>> {
3230 let node_id_str = node_id.to_string();
3231 let guard: Arc<Mutex<Option<OtelGuard>>> = Arc::new(Mutex::new(None));
3232 let clone = guard.clone();
3233 let tracing_monitor = async move {
3234 let mut builder = TracingBuilder::new(node_id_str.clone());
3235 // Only enable OTLP if environment variable is set
3236 if std::env::var("DORA_OTLP_ENDPOINT").is_ok()
3237 || std::env::var("DORA_JAEGER_TRACING").is_ok()
3238 {
3239 match builder.with_otlp_tracing() {
3240 Ok(b) => {
3241 builder = b.with_stdout("info", true);
3242 if let Ok(mut guard) = clone.lock() {
3243 *guard = builder.guard.take();
3244 }
3245 }
3246 Err(e) => {
3247 eprintln!("warning: failed to set up OTLP tracing: {e:?}");
3248 // Rebuild without OTLP — with_otlp_tracing consumed builder
3249 builder = TracingBuilder::new(node_id_str).with_stdout("info", true);
3250 }
3251 }
3252 } else {
3253 builder = builder.with_stdout("info", true);
3254 }
3255
3256 if let Err(e) = builder.build() {
3257 eprintln!("warning: failed to set up tracing subscriber: {e:?}");
3258 }
3259 };
3260
3261 let rt = Handle::try_current().context("failed to get tokio runtime handle")?;
3262 rt.spawn(tracing_monitor);
3263
3264 // dataflow_id is only used when metrics feature is enabled
3265 let _ = &dataflow_id;
3266
3267 // Only start the OTLP metrics exporter when an endpoint is configured.
3268 // The exporter schedules via `tokio::time::interval` and would otherwise
3269 // panic on callers whose runtime lacks the time driver, and would also
3270 // attempt to connect to `localhost:4317` on every node startup. Mirrors
3271 // the gating applied to tracing above.
3272 #[cfg(feature = "metrics")]
3273 if let Ok(endpoint) = std::env::var("DORA_OTLP_ENDPOINT") {
3274 let id = format!("{dataflow_id}/{node_id}");
3275 let monitor_task = async move {
3276 use dora_metrics::run_metrics_monitor;
3277
3278 if let Err(e) = run_metrics_monitor(id.clone(), &endpoint)
3279 .await
3280 .wrap_err("metrics monitor exited unexpectedly")
3281 {
3282 warn!("metrics monitor failed: {:#?}", e);
3283 }
3284 };
3285 let rt = Handle::try_current().context("failed to get tokio runtime handle")?;
3286 rt.spawn(monitor_task);
3287 }
3288 Ok(guard)
3289}
3290
3291/// Builder for streaming segment metadata.
3292///
3293/// Manages session/segment IDs and auto-incrementing sequence numbers
3294/// for real-time streaming patterns (voice, video, sensor streams).
3295///
3296/// The state transitions are easy to get subtly wrong, so they are worth
3297/// spelling out: [`chunk`](Self::chunk) stamps the current `(segment_id, seq)`
3298/// and then auto-increments `seq`; [`next_segment`](Self::next_segment) bumps
3299/// `segment_id` and resets `seq` to 0; and [`flush`](Self::flush) advances to a
3300/// new segment and emits a chunk marked `flush = true`, `fin = false` (the
3301/// prior segment is discarded, not completed, so it intentionally never gets a
3302/// `fin = true`).
3303///
3304/// # Example
3305///
3306/// ```
3307/// use dora_node_api::{
3308/// StreamSegment,
3309/// metadata::{FIN, FLUSH, SEGMENT_ID, SEQ, get_bool_param, get_integer_param},
3310/// };
3311///
3312/// let mut seg = StreamSegment::with_session_id("session-1".to_string());
3313///
3314/// // `chunk` stamps the current (segment, seq), then advances seq.
3315/// let first = seg.chunk(false);
3316/// assert_eq!(get_integer_param(&first, SEGMENT_ID), Some(0));
3317/// assert_eq!(get_integer_param(&first, SEQ), Some(0));
3318/// assert_eq!(get_bool_param(&first, FIN), Some(false));
3319///
3320/// let second = seg.chunk(true); // mark this chunk as the end of the segment
3321/// assert_eq!(get_integer_param(&second, SEQ), Some(1)); // seq auto-incremented
3322/// assert_eq!(get_bool_param(&second, FIN), Some(true));
3323///
3324/// // `flush` starts a new segment (seq reset to 0) and marks flush=true,
3325/// // fin=false: the old queued data is discarded, not completed.
3326/// let flushed = seg.flush();
3327/// assert_eq!(get_integer_param(&flushed, SEGMENT_ID), Some(1));
3328/// assert_eq!(get_integer_param(&flushed, SEQ), Some(0));
3329/// assert_eq!(get_bool_param(&flushed, FLUSH), Some(true));
3330/// assert_eq!(get_bool_param(&flushed, FIN), Some(false));
3331/// ```
3332pub struct StreamSegment {
3333 session_id: String,
3334 segment_id: i64,
3335 seq: i64,
3336}
3337
3338impl StreamSegment {
3339 /// Start a new session with a generated session ID and segment 0.
3340 pub fn new() -> Self {
3341 Self {
3342 session_id: DoraNode::new_request_id(),
3343 segment_id: 0,
3344 seq: 0,
3345 }
3346 }
3347
3348 /// Start a new session with an explicit session ID.
3349 pub fn with_session_id(session_id: String) -> Self {
3350 Self {
3351 session_id,
3352 segment_id: 0,
3353 seq: 0,
3354 }
3355 }
3356
3357 /// Advance to a new segment (resets seq to 0). Returns the new segment_id.
3358 pub fn next_segment(&mut self) -> i64 {
3359 self.segment_id += 1;
3360 self.seq = 0;
3361 self.segment_id
3362 }
3363
3364 /// Build metadata parameters for a chunk. Auto-increments seq.
3365 pub fn chunk(&mut self, fin: bool) -> MetadataParameters {
3366 let mut params = MetadataParameters::new();
3367 params.insert(
3368 SESSION_ID.into(),
3369 Parameter::String(self.session_id.clone()),
3370 );
3371 params.insert(SEGMENT_ID.into(), Parameter::Integer(self.segment_id));
3372 params.insert(SEQ.into(), Parameter::Integer(self.seq));
3373 params.insert(FIN.into(), Parameter::Bool(fin));
3374 self.seq += 1;
3375 params
3376 }
3377
3378 /// Build metadata for a flush message (new segment, discards older queued data).
3379 ///
3380 /// Advances to a new segment, then emits a chunk with `flush=true` and
3381 /// `fin=false`. The prior segment ends without a `fin=true` signal -- this
3382 /// is intentional for interruption semantics (the old data is being
3383 /// discarded, not completed).
3384 ///
3385 /// **Note**: flush discards *all* queued messages on the receiver's input
3386 /// regardless of `session_id`. Do not multiplex independent sessions on a
3387 /// single `DataId` when using flush.
3388 pub fn flush(&mut self) -> MetadataParameters {
3389 self.next_segment();
3390 let mut params = self.chunk(false);
3391 params.insert(FLUSH.into(), Parameter::Bool(true));
3392 params
3393 }
3394
3395 /// Returns the session ID.
3396 pub fn session_id(&self) -> &str {
3397 &self.session_id
3398 }
3399
3400 /// Returns the current segment ID.
3401 pub fn segment_id(&self) -> i64 {
3402 self.segment_id
3403 }
3404
3405 /// Returns the sequence number that will be used by the next `chunk()` call.
3406 pub fn seq(&self) -> i64 {
3407 self.seq
3408 }
3409}
3410
3411impl Default for StreamSegment {
3412 fn default() -> Self {
3413 Self::new()
3414 }
3415}
3416
3417#[cfg(test)]
3418mod tests {
3419 use super::*;
3420 use crate::integration_testing::{
3421 IntegrationTestInput, TestingInput, TestingOptions, TestingOutput,
3422 integration_testing_format::{IncomingEvent, TimedIncomingEvent},
3423 };
3424
3425 fn required_acker(node: &str, input: &str) -> dora_message::daemon_to_node::RequiredAcker {
3426 dora_message::daemon_to_node::RequiredAcker {
3427 node_id: NodeId::from(node.to_string()),
3428 input_id: DataId::from(input.to_string()),
3429 }
3430 }
3431
3432 /// A not-yet-ready `AckState` for `output` awaiting a single `acker`.
3433 fn test_ack_state(output: &str, acker: (&str, &str)) -> Arc<AckState> {
3434 Arc::new(AckState::new(
3435 DataId::from(output.to_string()),
3436 &BTreeSet::from([required_acker(acker.0, acker.1)]),
3437 Arc::new(AtomicBool::new(false)),
3438 ))
3439 }
3440
3441 #[test]
3442 fn log_fields_budget_measures_serialized_json_not_raw_bytes() {
3443 use std::collections::BTreeMap;
3444 let limit = DoraNode::MAX_LOG_FIELDS_BYTES;
3445
3446 // A small map fits.
3447 let mut small = BTreeMap::new();
3448 small.insert("k".to_string(), "v".to_string());
3449 assert!(log_fields_within_budget(&small, limit).is_some());
3450
3451 // A value that is 20 KB of raw bytes — comfortably under the 60 KB
3452 // budget by the old raw-sum measure — but made entirely of control
3453 // characters, each of which JSON-escapes to `` (6 bytes). Its
3454 // serialized form is ~120 KB, over the budget, so it must be dropped.
3455 // The pre-fix raw-byte check would have let it through and blown the
3456 // downstream parse limit.
3457 let mut big = BTreeMap::new();
3458 big.insert("k".to_string(), "\u{1}".repeat(20 * 1024));
3459 assert!(big.values().map(String::len).sum::<usize>() < limit);
3460 assert!(log_fields_within_budget(&big, limit).is_none());
3461 }
3462
3463 #[test]
3464 fn ack_state_completes_only_when_required_set_is_covered() {
3465 let ready = Arc::new(AtomicBool::new(false));
3466 let required = BTreeSet::from([
3467 required_acker("sink-a", "camera"),
3468 required_acker("sink-b", "cam"),
3469 ]);
3470 let state = AckState::new(DataId::from("image".to_string()), &required, ready.clone());
3471
3472 // An acker outside the required set (dynamic or debug consumer) must
3473 // never count toward completion.
3474 state.record("stranger", "camera");
3475 assert!(!ready.load(Ordering::Relaxed));
3476
3477 // Duplicate acks of one required identity don't complete the set.
3478 state.record("sink-a", "camera");
3479 state.record("sink-a", "camera");
3480 assert!(!ready.load(Ordering::Relaxed));
3481 assert_eq!(state.missing(), vec!["sink-b/cam".to_string()]);
3482
3483 // The last required identity completes it.
3484 state.record("sink-b", "cam");
3485 assert!(ready.load(Ordering::Relaxed));
3486 assert!(state.missing().is_empty());
3487 }
3488
3489 #[test]
3490 fn ack_state_requires_exact_identity_match() {
3491 // (node, input) is one identity: the right node acking the wrong input
3492 // (or vice versa) must not count.
3493 let ready = Arc::new(AtomicBool::new(false));
3494 let required = BTreeSet::from([required_acker("sink", "camera")]);
3495 let state = AckState::new(DataId::from("image".to_string()), &required, ready.clone());
3496
3497 state.record("sink", "other-input");
3498 state.record("other-node", "camera");
3499 assert!(!ready.load(Ordering::Relaxed));
3500
3501 state.record("sink", "camera");
3502 assert!(ready.load(Ordering::Relaxed));
3503 }
3504
3505 // Regression guard for dora-rs/dora#2891: a frozen output must never
3506 // upgrade. Before the fix an ack arriving after the grace (but before the
3507 // old 10s deadline) flipped `ready` while user code was already sending,
3508 // so a message on the shorter direct-zenoh path could overtake an earlier
3509 // one still relaying through the daemon.
3510 #[test]
3511 fn ack_state_freeze_blocks_a_late_upgrade() {
3512 let state = test_ack_state("image", ("sink", "camera"));
3513
3514 assert!(state.freeze(), "an un-acked output is frozen");
3515 assert!(state.is_frozen());
3516
3517 // The consumer's ack arrives late — it must not move the output onto
3518 // the direct path mid-stream.
3519 state.record("sink", "camera");
3520 assert!(
3521 !state.ready.load(Ordering::Relaxed),
3522 "a frozen output must stay on the daemon path for the rest of the run"
3523 );
3524 assert_eq!(state.missing(), vec!["sink/camera".to_string()]);
3525 }
3526
3527 #[test]
3528 fn ack_state_freeze_spares_an_output_that_acked_in_time() {
3529 let state = test_ack_state("image", ("sink", "camera"));
3530
3531 state.record("sink", "camera");
3532 assert!(!state.freeze(), "a ready output is not frozen");
3533 assert!(!state.is_frozen());
3534 assert!(
3535 state.ready.load(Ordering::Relaxed),
3536 "an output that proved its routes keeps the direct zenoh path"
3537 );
3538 }
3539
3540 // dora-rs/dora#2891: whatever has not proven its routes when the grace
3541 // expires is pinned to the daemon path, so every output's transport is
3542 // decided before user code sends its first message.
3543 #[test]
3544 fn grace_boundary_freezes_unacked_outputs_only() {
3545 let acked = test_ack_state("image", ("sink", "camera"));
3546 let unacked = test_ack_state("status", ("sink", "state"));
3547 acked.record("sink", "camera");
3548
3549 wait_for_grace(&[acked.clone(), unacked.clone()], Duration::from_millis(20));
3550
3551 assert!(acked.ready.load(Ordering::Relaxed));
3552 assert!(!acked.is_frozen());
3553 assert!(unacked.is_frozen());
3554
3555 // The straggler's ack lands after the boundary and is ignored.
3556 unacked.record("sink", "state");
3557 assert!(!unacked.ready.load(Ordering::Relaxed));
3558 }
3559
3560 #[test]
3561 fn grace_returns_early_once_every_output_is_acked() {
3562 // Also covers the no-awaited-outputs case (no consumers, or every ack
3563 // subscriber failed to declare): `all` is vacuously true on an empty
3564 // slice, so there is nothing to wait for and nothing to freeze.
3565 let state = test_ack_state("image", ("sink", "camera"));
3566 state.record("sink", "camera");
3567
3568 let start = Instant::now();
3569 wait_for_grace(std::slice::from_ref(&state), Duration::from_secs(30));
3570 wait_for_grace(&[], Duration::from_secs(30));
3571
3572 assert!(!state.is_frozen());
3573 assert!(state.ready.load(Ordering::Relaxed));
3574 assert!(
3575 start.elapsed() < Duration::from_secs(5),
3576 "a completed handshake must not wait out the grace, took {:?}",
3577 start.elapsed()
3578 );
3579 }
3580
3581 #[test]
3582 fn missing_output_routing_pins_every_output_to_the_daemon_path() {
3583 // `None` means an older daemon spawned this node: without
3584 // required-acker sets no route can be proven, so the safe result is
3585 // daemon-only for every declared output.
3586 let outputs = BTreeSet::from([
3587 DataId::from("image".to_string()),
3588 DataId::from("status".to_string()),
3589 ]);
3590 let routing = normalize_output_routing(None, &outputs);
3591 assert_eq!(routing.len(), 2);
3592 for output_id in &outputs {
3593 let entry = routing.get(output_id).expect("entry per output");
3594 assert!(entry.daemon_only);
3595 assert!(entry.required_ackers.is_empty());
3596 }
3597
3598 // No outputs → nothing to pin (interactive mode).
3599 assert!(normalize_output_routing(None, &BTreeSet::new()).is_empty());
3600 }
3601
3602 #[test]
3603 fn provided_output_routing_is_passed_through() {
3604 let outputs = BTreeSet::from([DataId::from("image".to_string())]);
3605 let provided = BTreeMap::from([(
3606 DataId::from("image".to_string()),
3607 OutputRouting {
3608 daemon_only: false,
3609 required_ackers: BTreeSet::from([required_acker("sink", "camera")]),
3610 },
3611 )]);
3612 let routing = normalize_output_routing(Some(provided.clone()), &outputs);
3613 assert_eq!(routing, provided);
3614 }
3615
3616 #[test]
3617 fn new_request_id_returns_valid_uuid() {
3618 let id = DoraNode::new_request_id();
3619 uuid::Uuid::parse_str(&id).expect("should be valid UUID");
3620 }
3621
3622 #[test]
3623 fn new_request_id_is_unique() {
3624 let ids: Vec<String> = (0..100).map(|_| DoraNode::new_request_id()).collect();
3625 let unique: std::collections::HashSet<_> = ids.iter().collect();
3626 assert_eq!(ids.len(), unique.len(), "all IDs should be unique");
3627 }
3628
3629 #[test]
3630 fn new_goal_id_returns_valid_uuid() {
3631 let id = DoraNode::new_goal_id();
3632 uuid::Uuid::parse_str(&id).expect("should be valid UUID");
3633 }
3634
3635 /// `DoraNode::timestamp()` must read from the SAME HLC the node
3636 /// uses to stamp outgoing messages. If a refactor accidentally
3637 /// gives `timestamp()` its own clock, the latency-measurement use
3638 /// case in the docstring silently breaks (subtracting against an
3639 /// `event.metadata.timestamp` from the data plane would mix two
3640 /// unrelated HLCs). Guard by asserting two calls share an HLC ID
3641 /// and that the second reads strictly later than the first.
3642 ///
3643 /// The strict `t2 > t1` assertion holds by HLC construction: if
3644 /// the wall clock advanced between calls, the physical component
3645 /// strictly increases; if not, the logical counter bumps. The
3646 /// lexicographic ordering on `uhlc::Timestamp` puts `t2` strictly
3647 /// after `t1` in either case, so this assertion does not flake on
3648 /// fast machines whose OS clock rounds both calls to the same tick.
3649 #[test]
3650 fn timestamp_uses_node_clock_and_is_monotonic() {
3651 let (node, events, _rx) = test_node();
3652 let t1 = node.timestamp();
3653 let t2 = node.timestamp();
3654 assert_eq!(
3655 t1.get_id(),
3656 t2.get_id(),
3657 "two timestamp() calls must come from the same HLC instance",
3658 );
3659 assert!(
3660 t2 > t1,
3661 "HLC timestamps must be strictly monotonic: {t1:?} >= {t2:?}"
3662 );
3663 drop(node);
3664 drop(events);
3665 }
3666
3667 use crate::integration_testing::{OutputReceiver, drain_outputs};
3668
3669 /// Helper: create a minimal test node with a channel output.
3670 fn test_node() -> (DoraNode, crate::EventStream, OutputReceiver) {
3671 let events = vec![TimedIncomingEvent {
3672 time_offset_secs: 0.1,
3673 event: IncomingEvent::Stop,
3674 }];
3675 let inputs = TestingInput::Input(IntegrationTestInput::new(
3676 "test-node".parse().unwrap(),
3677 events,
3678 ));
3679 let (tx, rx) = crate::integration_testing::output_channel();
3680 let outputs = TestingOutput::ToChannel(tx);
3681 let options = TestingOptions {
3682 skip_output_time_offsets: true,
3683 };
3684 let (node, event_stream) = DoraNode::init_testing(inputs, outputs, options).unwrap();
3685 (node, event_stream, rx)
3686 }
3687
3688 /// Comfortably below any multi-second join/sleep budget so node-first Drop
3689 /// regressions surface without waiting on an internal timeout boundary.
3690 const INIT_TESTING_DROP_BUDGET: Duration = Duration::from_millis(500);
3691
3692 fn init_testing_node_mid_scheduled_wait() -> (DoraNode, crate::EventStream) {
3693 let events = vec![TimedIncomingEvent {
3694 // Long enough that a hang is obvious; the shutdown flag must
3695 // interrupt well before this elapses.
3696 time_offset_secs: 30.0,
3697 event: IncomingEvent::Stop,
3698 }];
3699 let inputs = TestingInput::Input(IntegrationTestInput::new(
3700 "drop-hang-node".parse().unwrap(),
3701 events,
3702 ));
3703 let (tx, _rx) = crate::integration_testing::output_channel();
3704 let outputs = TestingOutput::ToChannel(tx);
3705 let (node, event_stream) =
3706 DoraNode::init_testing(inputs, outputs, TestingOptions::default()).unwrap();
3707
3708 // Give the testing daemon a moment to enter next_event's sleep.
3709 std::thread::sleep(Duration::from_millis(50));
3710 (node, event_stream)
3711 }
3712
3713 /// Regression for dora-rs/dora#2855: events-then-node Drop while the daemon
3714 /// is inside a scheduled `next_event` wait must not hang.
3715 #[test]
3716 fn init_testing_drop_events_then_node_during_scheduled_wait_does_not_hang() {
3717 let (node, event_stream) = init_testing_node_mid_scheduled_wait();
3718
3719 let start = Instant::now();
3720 drop(event_stream);
3721 drop(node);
3722 let elapsed = start.elapsed();
3723 assert!(
3724 elapsed < INIT_TESTING_DROP_BUDGET,
3725 "events-then-node Drop hung for {elapsed:?}; expected interruptible testing-daemon shutdown"
3726 );
3727 }
3728
3729 /// Regression for dora-rs/dora#2855: node-then-events Drop must exit the
3730 /// testing daemon after OutputsDone under shutdown even while EventStream
3731 /// still holds a channel sender clone.
3732 #[test]
3733 fn init_testing_drop_node_then_events_during_scheduled_wait_does_not_hang() {
3734 let (node, event_stream) = init_testing_node_mid_scheduled_wait();
3735
3736 let start = Instant::now();
3737 drop(node);
3738 drop(event_stream);
3739 let elapsed = start.elapsed();
3740 assert!(
3741 elapsed < INIT_TESTING_DROP_BUDGET,
3742 "node-then-events Drop hung for {elapsed:?}; expected OutputsDone under shutdown to exit the testing daemon"
3743 );
3744 }
3745
3746 #[test]
3747 fn send_service_request_returns_valid_id_and_sends_output() {
3748 let (mut node, events, mut rx) = test_node();
3749
3750 let request_id = node
3751 .send_service_request("request".into(), Default::default(), ())
3752 .unwrap();
3753
3754 // Returned ID should be a valid UUID
3755 uuid::Uuid::parse_str(&request_id).expect("returned request_id should be valid UUID");
3756
3757 // Output should have been sent to the channel
3758 drop(node);
3759 drop(events);
3760 let outputs = drain_outputs(&mut rx);
3761 assert_eq!(outputs.len(), 1);
3762 assert_eq!(outputs[0]["id"], "request");
3763 }
3764
3765 #[test]
3766 fn send_service_request_returns_unique_ids() {
3767 let (mut node, events, _rx) = test_node();
3768
3769 let id1 = node
3770 .send_service_request("out".into(), Default::default(), ())
3771 .unwrap();
3772 let id2 = node
3773 .send_service_request("out".into(), Default::default(), ())
3774 .unwrap();
3775
3776 assert_ne!(id1, id2, "successive request IDs should differ");
3777
3778 drop(node);
3779 drop(events);
3780 }
3781
3782 #[test]
3783 fn send_service_response_sends_output() {
3784 let (mut node, events, mut rx) = test_node();
3785
3786 // Simulate passing through a request_id from the incoming request
3787 let mut params = MetadataParameters::default();
3788 params.insert(
3789 dora_message::metadata::REQUEST_ID.to_string(),
3790 dora_message::metadata::Parameter::String("test-req-id".into()),
3791 );
3792 node.send_service_response("response".into(), params, ())
3793 .unwrap();
3794
3795 drop(node);
3796 drop(events);
3797 let outputs = drain_outputs(&mut rx);
3798 assert_eq!(outputs.len(), 1);
3799 assert_eq!(outputs[0]["id"], "response");
3800 }
3801
3802 /// `send_output_bytes` must reject a `data_len` that disagrees with
3803 /// `data.len()` with a clear error instead of panicking inside
3804 /// `copy_from_slice` deep in `send_output_raw`.
3805 #[test]
3806 fn send_output_bytes_rejects_len_mismatch() {
3807 let (mut node, events, _rx) = test_node();
3808
3809 let result = node.send_output_bytes("out".into(), Default::default(), 8, &[1, 2, 3, 4]);
3810
3811 let err = result.expect_err("mismatched data_len must error, not panic");
3812 assert!(
3813 err.to_string().contains("does not match"),
3814 "unexpected error message: {err}"
3815 );
3816
3817 drop(node);
3818 drop(events);
3819 }
3820
3821 /// A heap-backed `DataSample` is writable through `DerefMut`, readable
3822 /// through `Deref`, and `finalize().into_data_message()` preserves the bytes
3823 /// as the `DataMessage::Vec` daemon-path payload. (The SHM-backed arm needs
3824 /// a live zenoh provider and is covered by the copy-count harness/smoke.)
3825 #[test]
3826 fn data_sample_heap_roundtrip() {
3827 let avec: AVec<u8, ConstAlign<128>> = AVec::__from_elem(128, 0, 8);
3828 let mut sample: DataSample = avec.into();
3829 sample.copy_from_slice(&[1, 2, 3, 4, 5, 6, 7, 8]);
3830
3831 assert_eq!(&sample[..], &[1, 2, 3, 4, 5, 6, 7, 8]);
3832 assert_eq!(sample.len(), 8);
3833
3834 match sample.finalize().into_data_message() {
3835 DataMessage::Vec(v) => assert_eq!(v.as_slice(), &[1, 2, 3, 4, 5, 6, 7, 8]),
3836 }
3837 }
3838
3839 /// End-to-end wire contract: a few representative arrays IPC-encoded (fast
3840 /// path) into a sample and decoded back via `decode_arrow_ipc_zero_copy`
3841 /// must equal the input. This is the send->receive round-trip the data
3842 /// plane relies on (zenoh can't be smoke-tested here, so this stands in).
3843 #[test]
3844 fn send_output_ipc_roundtrip() {
3845 use crate::arrow_utils::decode_arrow_ipc_zero_copy_raw;
3846 use crate::arrow_utils::ipc_encode::{encode_ipc_into_data, ipc_fast_path_len_data};
3847 use arrow::array::{ArrayRef, Float32Array, StringArray, StructArray, UInt64Array};
3848 use arrow_schema::{DataType, Field};
3849 use std::ptr::NonNull;
3850
3851 fn roundtrip(data: ArrayData) {
3852 let len = ipc_fast_path_len_data(&data).expect("array should be fast-path eligible");
3853 let mut buf: AVec<u8, ConstAlign<128>> = AVec::__from_elem(128, 0, len);
3854 encode_ipc_into_data(&data, &mut buf).expect("fast-path IPC encode");
3855
3856 // Wrap the aligned sample as an Arrow Buffer (no copy), mirroring the
3857 // receive path, then decode.
3858 let ptr = NonNull::new(buf.as_ptr() as *mut u8).unwrap();
3859 let blen = buf.len();
3860 // SAFETY: ptr/len describe `buf`; the Arc keeps it alive.
3861 let buffer =
3862 unsafe { arrow::buffer::Buffer::from_custom_allocation(ptr, blen, Arc::new(buf)) };
3863 let decoded = decode_arrow_ipc_zero_copy_raw(buffer).expect("zero-copy IPC decode");
3864 assert_eq!(
3865 data, decoded,
3866 "IPC send->receive round-trip must preserve the array"
3867 );
3868 }
3869
3870 roundtrip(Float32Array::from(vec![1.0, 2.5, -3.0, 4.0]).into_data());
3871 roundtrip(UInt64Array::from(vec![Some(1), None, Some(3)]).into_data());
3872 roundtrip(StringArray::from(vec![Some("hello"), None, Some("world")]).into_data());
3873 roundtrip(
3874 StructArray::from(vec![
3875 (
3876 Arc::new(Field::new("v", DataType::UInt64, true)),
3877 Arc::new(UInt64Array::from(vec![Some(1), None, Some(3)])) as ArrayRef,
3878 ),
3879 (
3880 Arc::new(Field::new("s", DataType::Utf8, true)),
3881 Arc::new(StringArray::from(vec![Some("a"), Some("bb"), None])) as ArrayRef,
3882 ),
3883 ])
3884 .into_data(),
3885 );
3886 }
3887
3888 /// `close_outputs` must be atomic: if any id in the batch is unknown, the
3889 /// call fails *without* removing the valid ids from the local output set.
3890 /// Otherwise the daemon (never notified, because `report_closed_outputs` is
3891 /// skipped on error) and the node disagree about which outputs are open, and
3892 /// the node would silently drop subsequent sends to a still-open output.
3893 #[test]
3894 fn close_outputs_is_atomic_on_unknown_id() {
3895 let (mut node, events, _rx) = test_node();
3896 let valid: DataId = "valid".into();
3897 node.node_config.outputs.insert(valid.clone());
3898
3899 let result = node.close_outputs(vec![valid.clone(), "unknown".into()]);
3900
3901 assert!(
3902 result.is_err(),
3903 "closing a batch containing an unknown output must fail"
3904 );
3905 assert!(
3906 node.node_config.outputs.contains(&valid),
3907 "a failed close_outputs must not remove the valid output from local state"
3908 );
3909
3910 drop(node);
3911 drop(events);
3912 }
3913
3914 // ---- dora-rs/adora#150: pattern polymorphism exemption ----
3915
3916 #[test]
3917 fn carries_pattern_correlation_detects_request_id() {
3918 let mut params = MetadataParameters::default();
3919 params.insert(
3920 dora_message::metadata::REQUEST_ID.to_string(),
3921 dora_message::metadata::Parameter::String("req-1".into()),
3922 );
3923 assert!(carries_pattern_correlation(¶ms));
3924 }
3925
3926 #[test]
3927 fn carries_pattern_correlation_detects_goal_id() {
3928 let mut params = MetadataParameters::default();
3929 params.insert(
3930 dora_message::metadata::GOAL_ID.to_string(),
3931 dora_message::metadata::Parameter::String("goal-1".into()),
3932 );
3933 assert!(carries_pattern_correlation(¶ms));
3934 }
3935
3936 #[test]
3937 fn carries_pattern_correlation_detects_goal_status() {
3938 let mut params = MetadataParameters::default();
3939 params.insert(
3940 dora_message::metadata::GOAL_STATUS.to_string(),
3941 dora_message::metadata::Parameter::String("succeeded".into()),
3942 );
3943 assert!(carries_pattern_correlation(¶ms));
3944 }
3945
3946 #[test]
3947 fn carries_pattern_correlation_empty_is_not_a_pattern() {
3948 let params = MetadataParameters::default();
3949 assert!(!carries_pattern_correlation(¶ms));
3950 }
3951
3952 #[test]
3953 fn carries_pattern_correlation_ignores_non_pattern_keys() {
3954 let mut params = MetadataParameters::default();
3955 params.insert(
3956 "custom_key".to_string(),
3957 dora_message::metadata::Parameter::String("value".into()),
3958 );
3959 assert!(!carries_pattern_correlation(¶ms));
3960 }
3961
3962 #[test]
3963 fn schema_once_excludes_pattern_correlation_outputs() {
3964 // Regression (dora-rs/dora#2366 review): a small service/action
3965 // request-reply message — which multiplexes response schemas per request
3966 // — must NOT use schema-once, or a schema-less batch could reach a
3967 // consumer primed for a different schema and be silently dropped. It must
3968 // travel as a full self-describing stream instead.
3969 const THRESHOLD: usize = 4096;
3970
3971 let plain = MetadataParameters::default();
3972 assert!(
3973 schema_once_eligible(100, THRESHOLD, &plain),
3974 "small message on a stable-schema output is eligible"
3975 );
3976 assert!(
3977 !schema_once_eligible(THRESHOLD, THRESHOLD, &plain),
3978 "a message at/above the threshold is not eligible (goes via SHM/full stream)"
3979 );
3980
3981 for key in [
3982 dora_message::metadata::REQUEST_ID,
3983 dora_message::metadata::GOAL_ID,
3984 dora_message::metadata::GOAL_STATUS,
3985 ] {
3986 let mut params = MetadataParameters::default();
3987 params.insert(
3988 key.to_string(),
3989 dora_message::metadata::Parameter::String("x".into()),
3990 );
3991 assert!(
3992 !schema_once_eligible(100, THRESHOLD, ¶ms),
3993 "small pattern-correlation message ({key}) must bypass schema-once"
3994 );
3995 }
3996
3997 // Streaming is deliberately NOT excluded: every chunk of a stream shares
3998 // one schema, so streaming stays the high-rate beneficiary of
3999 // schema-once. Locking this in guards against a well-meaning "also
4000 // exclude streaming" change that would defeat the optimization.
4001 let mut stream = MetadataParameters::default();
4002 stream.insert(
4003 dora_message::metadata::SESSION_ID.to_string(),
4004 dora_message::metadata::Parameter::String("s1".into()),
4005 );
4006 stream.insert(
4007 dora_message::metadata::SEGMENT_ID.to_string(),
4008 dora_message::metadata::Parameter::Integer(0),
4009 );
4010 assert!(
4011 schema_once_eligible(100, THRESHOLD, &stream),
4012 "small streaming chunk (stable schema) stays eligible for schema-once"
4013 );
4014 }
4015
4016 #[test]
4017 fn schema_once_decision_covers_publish_refresh_and_schema_less() {
4018 let start = Instant::now();
4019 let later = start + SCHEMA_ONCE_REFRESH_INTERVAL;
4020 let state = SchemaOnceState {
4021 published_hash: 7,
4022 last_full_stream: start,
4023 };
4024
4025 // No state yet (first message of this output) → publish the schema and
4026 // send THIS message as a full stream: it decodes standalone and primes
4027 // receivers in-band, so the first message can never be lost to the
4028 // batch racing ahead of the schema on the separate `@schema` plane
4029 // (dora-rs/dora#2366 review).
4030 assert!(matches!(
4031 schema_once_decision(None, 7, start),
4032 SchemaOnceDecision::PublishSchemaAndSendFullStream
4033 ));
4034 // Schema changed (or an earlier `@schema` publish failed, which leaves
4035 // the recorded hash stale) → same: publish + full stream.
4036 assert!(matches!(
4037 schema_once_decision(Some(&state), 8, start),
4038 SchemaOnceDecision::PublishSchemaAndSendFullStream
4039 ));
4040 // Schema confirmed published and refresh not due → schema-less batch.
4041 assert!(matches!(
4042 schema_once_decision(Some(&state), 7, start),
4043 SchemaOnceDecision::SendSchemaLessBatch
4044 ));
4045 // Refresh due → send a full stream so any consumer that missed the
4046 // single `@schema` emission re-primes in-band within the interval
4047 // instead of losing the input permanently.
4048 assert!(matches!(
4049 schema_once_decision(Some(&state), 7, later),
4050 SchemaOnceDecision::SendFullStreamRefresh
4051 ));
4052 }
4053
4054 #[test]
4055 fn stream_segment_new_generates_valid_session_id() {
4056 let seg = StreamSegment::new();
4057 uuid::Uuid::parse_str(seg.session_id()).expect("session_id should be valid UUID");
4058 assert_eq!(seg.segment_id(), 0);
4059 }
4060
4061 #[test]
4062 fn stream_segment_with_session_id() {
4063 let seg = StreamSegment::with_session_id("my-session".into());
4064 assert_eq!(seg.session_id(), "my-session");
4065 assert_eq!(seg.segment_id(), 0);
4066 assert_eq!(seg.seq(), 0);
4067 }
4068
4069 #[test]
4070 fn stream_segment_seq_accessor_tracks_next_seq() {
4071 let mut seg = StreamSegment::with_session_id("s1".into());
4072 assert_eq!(seg.seq(), 0);
4073 seg.chunk(false);
4074 assert_eq!(seg.seq(), 1);
4075 seg.chunk(false);
4076 assert_eq!(seg.seq(), 2);
4077 seg.next_segment();
4078 assert_eq!(seg.seq(), 0);
4079 }
4080
4081 #[test]
4082 fn stream_segment_chunk_auto_increments_seq() {
4083 let mut seg = StreamSegment::with_session_id("s1".into());
4084 let p0 = seg.chunk(false);
4085 let p1 = seg.chunk(false);
4086 let p2 = seg.chunk(true);
4087
4088 assert_eq!(p0.get(SEQ), Some(&Parameter::Integer(0)));
4089 assert_eq!(p1.get(SEQ), Some(&Parameter::Integer(1)));
4090 assert_eq!(p2.get(SEQ), Some(&Parameter::Integer(2)));
4091 assert_eq!(p0.get(FIN), Some(&Parameter::Bool(false)));
4092 assert_eq!(p2.get(FIN), Some(&Parameter::Bool(true)));
4093 assert_eq!(p0.get(SESSION_ID), Some(&Parameter::String("s1".into())));
4094 assert_eq!(p0.get(SEGMENT_ID), Some(&Parameter::Integer(0)));
4095 }
4096
4097 #[test]
4098 fn stream_segment_next_segment_resets_seq() {
4099 let mut seg = StreamSegment::with_session_id("s1".into());
4100 seg.chunk(false); // seq=0
4101 seg.chunk(false); // seq=1
4102 let new_id = seg.next_segment();
4103 assert_eq!(new_id, 1);
4104 assert_eq!(seg.segment_id(), 1);
4105
4106 let p = seg.chunk(false);
4107 assert_eq!(p.get(SEQ), Some(&Parameter::Integer(0)));
4108 assert_eq!(p.get(SEGMENT_ID), Some(&Parameter::Integer(1)));
4109 }
4110
4111 #[test]
4112 fn stream_segment_flush_advances_segment_and_sets_flush() {
4113 let mut seg = StreamSegment::with_session_id("s1".into());
4114 seg.chunk(false);
4115 let p = seg.flush();
4116 assert_eq!(seg.segment_id(), 1);
4117 assert_eq!(p.get(FLUSH), Some(&Parameter::Bool(true)));
4118 assert_eq!(p.get(SEGMENT_ID), Some(&Parameter::Integer(1)));
4119 // flush resets seq, then chunk increments it to 1
4120 assert_eq!(p.get(SEQ), Some(&Parameter::Integer(0)));
4121 }
4122
4123 #[test]
4124 fn send_stream_chunk_sends_output() {
4125 let (mut node, events, mut rx) = test_node();
4126 let mut seg = StreamSegment::with_session_id("s1".into());
4127
4128 node.send_stream_chunk("audio".into(), &mut seg, false, ())
4129 .unwrap();
4130
4131 drop(node);
4132 drop(events);
4133 let outputs = drain_outputs(&mut rx);
4134 assert_eq!(outputs.len(), 1);
4135 assert_eq!(outputs[0]["id"], "audio");
4136 }
4137
4138 #[test]
4139 fn teardown_with_timeout_completes_fast_closure() {
4140 let start = Instant::now();
4141 let completed = teardown_with_timeout("fast", Duration::from_secs(5), || {});
4142 assert!(completed, "fast teardown should report completion");
4143 assert!(
4144 start.elapsed() < Duration::from_secs(5),
4145 "fast teardown should not wait for the full timeout"
4146 );
4147 }
4148
4149 #[test]
4150 fn teardown_with_timeout_gives_up_on_wedged_closure() {
4151 let start = Instant::now();
4152 let completed = teardown_with_timeout("wedged", Duration::from_millis(300), || {
4153 std::thread::sleep(Duration::from_secs(60))
4154 });
4155 let elapsed = start.elapsed();
4156 assert!(!completed, "wedged teardown should report a timeout");
4157 assert!(
4158 elapsed >= Duration::from_millis(300),
4159 "should wait the full deadline, returned after {elapsed:?}"
4160 );
4161 assert!(
4162 elapsed < Duration::from_secs(5),
4163 "should give up shortly after the deadline, took {elapsed:?}"
4164 );
4165 }
4166
4167 #[test]
4168 fn teardown_with_timeout_contains_panics() {
4169 let completed = teardown_with_timeout("panicking", Duration::from_secs(5), || {
4170 panic!("teardown panicked")
4171 });
4172 assert!(completed, "panicking teardown still counts as completed");
4173 }
4174
4175 // Regression guard for dora-rs/dora#2742: the node's worst-case zenoh teardown must
4176 // stay under the daemon's force-kill grace (full rationale on `ZENOH_TEARDOWN_TIMEOUT`).
4177 //
4178 // Teardown runs in sequential bounded phases (two in `EventStream::drop`, one in
4179 // `DoraNode::drop`), so the worst case is `TEARDOWN_PHASES * ZENOH_TEARDOWN_TIMEOUT` —
4180 // bump `TEARDOWN_PHASES` if you add another. The grace is `DEFAULT_STOP_GRACE +
4181 // DEFAULT_STOP_GRACE/2 = 15s` (`binaries/daemon/src/running_dataflow.rs`); it lives in
4182 // a binary crate that can't be imported here, hence the hard-coded copy, which the
4183 // daemon const back-references so the two can't silently drift apart.
4184 #[test]
4185 fn zenoh_teardown_fits_within_daemon_force_kill_grace() {
4186 const DAEMON_FORCE_KILL_GRACE: Duration = Duration::from_secs(15);
4187 const TEARDOWN_PHASES: u32 = 3;
4188 let worst_case = ZENOH_TEARDOWN_TIMEOUT * TEARDOWN_PHASES;
4189 assert!(
4190 worst_case < DAEMON_FORCE_KILL_GRACE,
4191 "worst-case zenoh teardown ({worst_case:?}) must stay under the daemon \
4192 force-kill grace ({DAEMON_FORCE_KILL_GRACE:?}); raising ZENOH_TEARDOWN_TIMEOUT \
4193 reintroduces dora-rs/dora#2742"
4194 );
4195 }
4196}
4197
4198/// Ownership invariant at the operator -> runtime boundary (dora-rs/dora#2742).
4199///
4200/// A [`SampleAllocator`] lets an operator thread encode its payload into a
4201/// dora-owned [`EncodedSample`] itself, so the runtime never holds a reference
4202/// to memory whose owner lives in another language runtime. The tests below pin
4203/// the properties that make the result safe to hand across a thread boundary.
4204#[cfg(test)]
4205mod operator_boundary_tests {
4206 use super::*;
4207 use arrow::buffer::Buffer;
4208 use std::ptr::NonNull;
4209 use std::sync::atomic::{AtomicBool, Ordering};
4210
4211 /// Stands in for a foreign owner of an Arrow payload buffer — a numpy array
4212 /// held alive by pyarrow, or a buffer owned by an operator's `.so`. Flips
4213 /// `released` when the last Arrow reference to the buffer goes away.
4214 struct ForeignOwner {
4215 released: Arc<AtomicBool>,
4216 _backing: Vec<u8>,
4217 }
4218
4219 impl Drop for ForeignOwner {
4220 fn drop(&mut self) {
4221 self.released.store(true, Ordering::SeqCst);
4222 }
4223 }
4224
4225 /// A `UInt8` array whose payload buffer is owned by `ForeignOwner`.
4226 fn foreign_owned_array(len: usize) -> (ArrayData, Arc<AtomicBool>) {
4227 let backing = vec![0xABu8; len];
4228 // A `Vec`'s heap allocation does not move when the `Vec` itself is
4229 // moved into `ForeignOwner` below, so this pointer stays valid for as
4230 // long as the owner is alive — which is exactly what the `Allocation`
4231 // contract requires.
4232 let ptr = NonNull::new(backing.as_ptr() as *mut u8).expect("non-null");
4233 let released = Arc::new(AtomicBool::new(false));
4234 let owner = Arc::new(ForeignOwner {
4235 released: released.clone(),
4236 _backing: backing,
4237 });
4238 let buffer = unsafe { Buffer::from_custom_allocation(ptr, len, owner) };
4239 let array = ArrayData::builder(arrow::datatypes::DataType::UInt8)
4240 .len(len)
4241 .add_buffer(buffer)
4242 .build()
4243 .expect("valid UInt8 array");
4244 (array, released)
4245 }
4246
4247 /// The heart of the #2742 fix: the sample the operator hands to the runtime
4248 /// must be an independent, dora-owned copy. If it kept the source buffer
4249 /// alive, the runtime would be the one releasing foreign memory — and for a
4250 /// Python operator that release takes the GIL (pyarrow's `NumPyBuffer`
4251 /// destructor), which stalls the runtime's event loop for as long as the
4252 /// operator holds it.
4253 #[test]
4254 fn encoded_sample_does_not_retain_the_source_payload() {
4255 let allocator = SampleAllocator::heap();
4256 let (array, released) = foreign_owned_array(8192);
4257
4258 let sample = allocator
4259 .encode_arrow_data(&array)
4260 .expect("encoding a UInt8 array must succeed");
4261
4262 assert!(
4263 !released.load(Ordering::SeqCst),
4264 "sanity: the source buffer is still alive while the array is"
4265 );
4266 drop(array);
4267 assert!(
4268 released.load(Ordering::SeqCst),
4269 "the encoded sample must not keep the operator's payload alive; \
4270 otherwise the runtime frees foreign memory (dora-rs/dora#2742)"
4271 );
4272 drop(sample);
4273 }
4274
4275 /// The encode must be lossless: the sample is the same Arrow IPC stream the
4276 /// node would have produced when it did the encoding itself.
4277 #[test]
4278 fn encoded_sample_round_trips_to_the_source_array() {
4279 let allocator = SampleAllocator::heap();
4280 let (array, _released) = foreign_owned_array(1024);
4281
4282 let encoded = allocator.encode_arrow_data(&array).expect("encode");
4283 assert_eq!(encoded.type_name(), format!("{:?}", array.data_type()));
4284 let decoded = crate::node::arrow_utils::decode_arrow_ipc_data(encoded.as_bytes())
4285 .expect("the sample must be a well-formed Arrow IPC stream");
4286
4287 assert_eq!(decoded, array);
4288 }
4289
4290 /// The allocator is handed to operator threads, so it has to cross thread
4291 /// boundaries — and so does the sample it produces.
4292 #[test]
4293 fn allocator_and_sample_cross_thread_boundaries() {
4294 const fn assert_send<T: Send>() {}
4295 const fn assert_send_sync_clone<T: Send + Sync + Clone>() {}
4296 assert_send::<DataSample>();
4297 assert_send::<EncodedSample>();
4298 assert_send_sync_clone::<SampleAllocator>();
4299 }
4300}