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