Skip to main content

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(), &parameters)?;
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, &parameters)?;
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(&params));
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(&params));
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(&params));
4248    }
4249
4250    #[test]
4251    fn carries_pattern_correlation_empty_is_not_a_pattern() {
4252        let params = MetadataParameters::default();
4253        assert!(!carries_pattern_correlation(&params));
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(&params));
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, &params),
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}