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