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_core::{
15    config::{DataId, NodeId, NodeRunConfig},
16    descriptor::{Descriptor, DescriptorExt},
17    topics::{DORA_DAEMON_LOCAL_LISTEN_PORT_DEFAULT, DORA_DAEMON_LOCAL_LISTEN_PORT_ENV, LOCALHOST},
18    types::TypeRegistry,
19    uhlc,
20};
21use dora_message::{
22    DataflowId,
23    daemon_to_node::{DaemonCommunication, DaemonReply, NodeConfig},
24    metadata::{
25        FIN, FLUSH, FRAMING, FRAMING_ARROW_IPC, Metadata, MetadataParameters, Parameter,
26        SCHEMA_HASH, SEGMENT_ID, SEQ, SESSION_ID,
27    },
28    node_to_daemon::{DaemonRequest, DataMessage, Timestamped},
29};
30use eyre::WrapErr;
31use is_terminal::IsTerminal;
32
33#[cfg(feature = "tracing")]
34use std::sync::Mutex;
35use std::{
36    collections::{BTreeSet, HashMap},
37    path::PathBuf,
38    sync::Arc,
39    time::{Duration, Instant},
40};
41#[cfg(feature = "tracing")]
42use tokio::runtime::Handle;
43
44#[cfg(feature = "tracing")]
45use dora_tracing::{OtelGuard, TracingBuilder};
46use tracing::{info, warn};
47
48pub mod arrow_utils;
49mod control_channel;
50
51/// Runtime type checking mode, controlled by `DORA_RUNTIME_TYPE_CHECK` env var.
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53enum RuntimeTypeCheck {
54    /// No runtime type checking (default).
55    Off,
56    /// Log warnings on type mismatches.
57    Warn,
58    /// Return errors on type mismatches.
59    Error,
60}
61
62impl RuntimeTypeCheck {
63    fn from_env() -> Self {
64        match std::env::var("DORA_RUNTIME_TYPE_CHECK").as_deref() {
65            Ok("error") => Self::Error,
66            Ok("1" | "warn" | "true") => Self::Warn,
67            Ok("") | Err(_) => Self::Off,
68            Ok(other) => {
69                tracing::warn!(
70                    "unknown DORA_RUNTIME_TYPE_CHECK value \"{other}\", \
71                     expected \"warn\" or \"error\"; disabling runtime type check"
72                );
73                Self::Off
74            }
75        }
76    }
77}
78
79/// The data size threshold at which we start using shared memory.
80///
81/// Shared memory works by sharing memory pages. This means that the smallest
82/// memory region that can be shared is one memory page, which is typically
83/// 4KiB.
84///
85/// Using shared memory for messages smaller than the page size still requires
86/// sharing a full page, so we have some memory overhead. We also have some
87/// performance overhead because we need to issue multiple syscalls. For small
88/// messages it is faster to send them over a traditional TCP stream (or similar).
89///
90/// This hardcoded threshold value specifies which messages are sent through
91/// shared memory. Messages that are smaller than this threshold are sent through
92/// TCP.
93pub const ZERO_COPY_THRESHOLD: usize = 4096;
94
95/// Per-output data-plane readiness for the startup no-loss barrier.
96///
97/// The zenoh data plane is direct node-to-node pub/sub, so a producer that
98/// starts publishing before a consumer's subscription has propagated drops those
99/// early samples. Until every expected subscriber has announced its subscription
100/// (via a liveliness readiness token — see
101/// [`dora_core::topics::zenoh_input_ready_liveliness_topic`]), the producer keeps
102/// delivering over the reliable daemon path and only switches this output to the
103/// fast zenoh path once all subscribers are confirmed wired. Over-counting keeps
104/// an output on the (lossless) daemon path; it never drops messages.
105struct ZenohOutputReadiness {
106    /// Number of subscriber input-links this output has in the dataflow.
107    expected: usize,
108    /// Latches `true` once all expected subscribers are confirmed wired. Never
109    /// reverts — once switched to the fast path we stay on it.
110    ready: bool,
111    /// Distinct readiness-token key expressions currently seen for this output
112    /// (deduped). Maintained by the liveliness subscriber callback.
113    alive: Arc<std::sync::Mutex<std::collections::HashSet<String>>>,
114    /// Liveliness subscriber counting readiness tokens for this output. Kept
115    /// alive so its callback keeps `alive` current; dropped with the node.
116    _liveliness_sub: zenoh::pubsub::Subscriber<()>,
117}
118
119/// How long [`DoraNode::init`] waits for the direct-zenoh routes of this node's
120/// outputs to connect before handing control to user code.
121///
122/// The daemon "all nodes ready" barrier already guarantees every subscriber is
123/// *declared* before any node runs; this only waits out the residual zenoh
124/// propagation between two already-declared endpoints, so it is normally
125/// sub-second. On timeout (e.g. a zenoh data plane that can't connect while the
126/// control plane can) the un-connected outputs simply start on the reliable
127/// daemon path and upgrade to direct zenoh once their subscribers appear — the
128/// dataflow is never blocked, and nothing is dropped.
129const ZENOH_OUTPUT_CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
130/// Poll interval while waiting for output routes to connect at startup.
131const ZENOH_OUTPUT_CONNECT_POLL_INTERVAL: Duration = Duration::from_millis(5);
132
133/// Per-phase deadline for tearing down zenoh state on node shutdown (subscribers,
134/// liveliness tokens, and the session/publishers — see [`DoraNode::drop`] and
135/// `EventStream::drop`). Each `undeclare`/close blocks indefinitely when zenoh's net
136/// runtime is wedged (e.g. retrying an unreachable scouted peer on a headless CI
137/// runner), so it is bounded here and abandoned on timeout.
138///
139/// This MUST stay comfortably below the daemon's force-kill grace
140/// (`DEFAULT_STOP_GRACE (10s) + DEFAULT_STOP_GRACE/2 = 15s`, see
141/// `binaries/daemon/src/running_dataflow.rs`), including the worst case where all
142/// three phases wedge sequentially (`3 *` this value). Otherwise a node with a wedged
143/// net runtime is still tearing down when the daemon `TerminateProcess`es it, which on
144/// Windows surfaces as `ExitCode(1)` and reddens the nightly (dora-rs/dora#2742). The
145/// `zenoh_teardown_fits_within_daemon_force_kill_grace` test guards the invariant.
146///
147/// This deliberately no longer exceeds zenoh's internal 10s session-close timeout: a
148/// semi-wedged close that would settle at ~10s is abandoned instead, and peers fall
149/// back to liveliness expiry. That is the right trade for a *shutting-down* node —
150/// waiting out the 10s only to be force-killed anyway yields a worse (unclean) exit.
151pub(crate) const ZENOH_TEARDOWN_TIMEOUT: Duration = Duration::from_secs(3);
152
153/// Allows sending outputs and retrieving node information.
154///
155/// The main purpose of this struct is to send outputs via Dora. There are also functions available
156/// for retrieving the node configuration.
157pub struct DoraNode {
158    id: NodeId,
159    dataflow_id: DataflowId,
160    node_config: NodeRunConfig,
161    control_channel: ControlChannel,
162    clock: Arc<uhlc::HLC>,
163
164    /// Zenoh session for direct node-to-node pub/sub (data plane).
165    /// `None` in interactive/testing mode.
166    zenoh_session: Option<zenoh::Session>,
167    /// Zenoh shared memory provider for zero-copy publishing.
168    zenoh_shm_provider: Option<zenoh::shm::ShmProvider<zenoh::shm::PosixShmProviderBackend>>,
169    /// Per-output zenoh publishers (lazily created on first send).
170    /// `'static` is sound because zenoh `Publisher` internally holds `Arc<Session>`,
171    /// so it doesn't borrow from the session field on this struct.
172    /// Publishers must be dropped BEFORE the session (enforced in Drop impl).
173    zenoh_publishers: HashMap<DataId, zenoh::pubsub::Publisher<'static>>,
174    /// Per-output schema publishers on the `@schema` subtopic (lazily created on
175    /// the first small message). Their cache retains the last schema so a
176    /// late-joining subscriber fetches it via a history query, letting the data
177    /// topic carry only schema-less batches. Dropped before the session, like
178    /// [`zenoh_publishers`](Self::zenoh_publishers).
179    zenoh_schema_publishers: HashMap<DataId, zenoh_ext::AdvancedPublisher<'static>>,
180    /// Per-output schema-once state: the confirmed-published schema hash (so
181    /// the schema is only re-published when it changes or a publish failed) and
182    /// the time of the last full-stream send (for the periodic in-band refresh).
183    zenoh_schema_state: HashMap<DataId, SchemaOnceState>,
184    /// Threshold for using zenoh SHM vs inline bytes (default 4096).
185    zenoh_zero_copy_threshold: usize,
186    /// Per-output startup readiness for the direct zenoh data plane (lazily
187    /// created on first send). See [`ZenohOutputReadiness`].
188    zenoh_output_readiness: HashMap<DataId, ZenohOutputReadiness>,
189
190    dataflow_descriptor: serde_yaml::Result<Descriptor>,
191    warned_unknown_output: BTreeSet<DataId>,
192    interactive: bool,
193    restart_count: u32,
194
195    /// Runtime type checking state. `None` when off (zero overhead).
196    /// When `Some`, holds the mode (Warn/Error) and a map of output DataId -> expected Arrow DataType.
197    runtime_type_checks: Option<(RuntimeTypeCheck, HashMap<DataId, arrow_schema::DataType>)>,
198
199    /// Tokio runtime owned by the node. Populated only when no ambient
200    /// runtime was available at init. Must drop after the zenoh session
201    /// (which is drained explicitly at the top of [`Drop`]) so that any
202    /// async cleanup triggered by session shutdown can still run.
203    _owned_runtime: Option<tokio::runtime::Runtime>,
204}
205
206impl DoraNode {
207    /// Initiate a node from environment variables set by the Dora daemon or fall back to
208    /// interactive mode.
209    ///
210    /// This is the recommended initialization function for Dora nodes, which are spawned by
211    /// Dora daemon instances. The daemon will set a `DORA_NODE_CONFIG` environment variable to
212    /// configure the node.
213    ///
214    /// When the node is started manually without the `DORA_NODE_CONFIG` environment variable set,
215    /// the initialization will fall back to [`init_interactive`](Self::init_interactive) if `stdin`
216    /// is a terminal (detected through
217    /// [`isatty`](https://www.man7.org/linux/man-pages/man3/isatty.3.html)).
218    ///
219    /// If the `DORA_NODE_CONFIG` environment variable is not set and `DORA_TEST_WITH_INPUTS` is
220    /// set, the node will be initialized in integration test mode. See the
221    /// [integration testing](crate::integration_testing) module for details.
222    ///
223    /// This function will also initialize the node in integration test mode when the
224    /// [`setup_integration_testing`](crate::integration_testing::setup_integration_testing)
225    /// function was called before. This takes precedence over all environment variables.
226    ///
227    /// ```no_run
228    /// use dora_node_api::DoraNode;
229    ///
230    /// let (mut node, mut events) = DoraNode::init_from_env().expect("Could not init node.");
231    /// ```
232    pub fn init_from_env() -> NodeResult<(Self, EventStream)> {
233        Self::init_from_env_inner(true)
234    }
235
236    /// Initialize the node from environment variables set by the Dora daemon; error if not set.
237    ///
238    /// This function behaves the same as [`init_from_env`](Self::init_from_env), but it does _not_
239    /// fall back to [`init_interactive`](Self::init_interactive). Instead, an error is returned
240    /// when the `DORA_NODE_CONFIG` environment variable is missing.
241    pub fn init_from_env_force() -> NodeResult<(Self, EventStream)> {
242        Self::init_from_env_inner(false)
243    }
244
245    fn init_from_env_inner(fallback_to_interactive: bool) -> NodeResult<(Self, EventStream)> {
246        if let Some(testing_comm) = take_testing_communication() {
247            let TestingCommunication {
248                input,
249                output,
250                options,
251            } = *testing_comm;
252            return Self::init_testing(input, output, options);
253        }
254
255        // normal execution (started by dora daemon)
256        match std::env::var("DORA_NODE_CONFIG") {
257            Ok(raw) => {
258                let node_config: NodeConfig =
259                    serde_yaml::from_str(&raw).context("failed to deserialize node config")?;
260                return Self::init(node_config);
261            }
262            Err(std::env::VarError::NotUnicode(_)) => {
263                return Err(NodeError::Init(
264                    "DORA_NODE_CONFIG env variable is not valid unicode".into(),
265                ));
266            }
267            Err(std::env::VarError::NotPresent) => {} // continue trying other init methods
268        };
269
270        // node integration test mode
271        match std::env::var("DORA_TEST_WITH_INPUTS") {
272            Ok(raw) => {
273                let input_file = PathBuf::from(raw);
274                let output_file = match std::env::var("DORA_TEST_WRITE_OUTPUTS_TO") {
275                    Ok(raw) => PathBuf::from(raw),
276                    Err(std::env::VarError::NotUnicode(_)) => {
277                        return Err(NodeError::Init(
278                            "DORA_TEST_WRITE_OUTPUTS_TO env variable is not valid unicode".into(),
279                        ));
280                    }
281                    Err(std::env::VarError::NotPresent) => {
282                        input_file.with_file_name("outputs.jsonl")
283                    }
284                };
285                let skip_output_time_offsets =
286                    std::env::var_os("DORA_TEST_NO_OUTPUT_TIME_OFFSET").is_some();
287
288                let input = TestingInput::FromJsonFile(input_file);
289                let output = TestingOutput::ToFile(output_file);
290                let options = TestingOptions {
291                    skip_output_time_offsets,
292                };
293
294                return Self::init_testing(input, output, options);
295            }
296            Err(std::env::VarError::NotUnicode(_)) => {
297                return Err(NodeError::Init(
298                    "DORA_TEST_WITH_INPUTS env variable is not valid unicode".into(),
299                ));
300            }
301            Err(std::env::VarError::NotPresent) => {} // continue trying other init methods
302        }
303
304        // interactive mode
305        if fallback_to_interactive && std::io::stdin().is_terminal() {
306            println!(
307                "{}",
308                "Starting node in interactive mode as DORA_NODE_CONFIG env variable is not set"
309                    .green()
310            );
311            return Self::init_interactive();
312        }
313
314        // no run mode applicable
315        Err(NodeError::Init(
316            "DORA_NODE_CONFIG env variable is not set".into(),
317        ))
318    }
319
320    /// Create a builder for configuring a node connection.
321    ///
322    /// Setting a `node_id` selects the dynamic-node path; without one, `build()`
323    /// falls back to [`init_from_env`](Self::init_from_env). Use this builder
324    /// when you need a custom daemon port — the other init functions cover the
325    /// common cases. Source-compatible with upstream dora 0.5.x: `.dynamic()`
326    /// is accepted (no-op) so code written against upstream still compiles.
327    ///
328    /// ```no_run
329    /// use dora_node_api::DoraNode;
330    /// use dora_node_api::dora_core::config::NodeId;
331    ///
332    /// let (mut node, mut events) = DoraNode::builder()
333    ///     .node_id(NodeId::from("plot".to_string()))
334    ///     .daemon_port(6789)
335    ///     .build()
336    ///     .expect("Could not init node");
337    /// ```
338    pub fn builder() -> DoraNodeBuilder {
339        DoraNodeBuilder::default()
340    }
341
342    /// Initiate a node from a dataflow id and a node id.
343    ///
344    /// This initialization function should be used for [_dynamic nodes_](index.html#dynamic-nodes).
345    ///
346    /// ```no_run
347    /// use dora_node_api::DoraNode;
348    /// use dora_node_api::dora_core::config::NodeId;
349    ///
350    /// let (mut node, mut events) = DoraNode::init_from_node_id(NodeId::from("plot".to_string())).expect("Could not init node plot");
351    /// ```
352    ///
353    pub fn init_from_node_id(node_id: NodeId) -> NodeResult<(Self, EventStream)> {
354        Self::builder().node_id(node_id).build()
355    }
356
357    /// Dynamic initialization function for nodes that are sometimes used as dynamic nodes.
358    ///
359    /// This function first tries initializing the traditional way through
360    /// [`init_from_env`][Self::init_from_env]. If this fails, it falls back to
361    /// [`init_from_node_id`][Self::init_from_node_id].
362    pub fn init_flexible(node_id: NodeId) -> NodeResult<(Self, EventStream)> {
363        if std::env::var("DORA_NODE_CONFIG").is_ok() {
364            info!(
365                "Skipping {node_id} specified within the node initialization in favor of `DORA_NODE_CONFIG` specified by `dora start`"
366            );
367            Self::init_from_env()
368        } else {
369            Self::init_from_node_id(node_id)
370        }
371    }
372
373    /// Initialize the node in a standalone mode that prompts for inputs on the terminal.
374    ///
375    /// Instead of connecting to a `dora daemon`, this interactive mode prompts for node inputs
376    /// on the terminal. In this mode, the node is completely isolated from the dora daemon and
377    /// other nodes, so it cannot be part of a dataflow.
378    ///
379    /// Note that this function will hang indefinitely if no input is supplied to the interactive
380    /// prompt. So it should be only used through a terminal.
381    ///
382    /// Because of the above limitations, it is not recommended to use this function directly.
383    /// Use [**`init_from_env`**](Self::init_from_env) instead, which supports both normal daemon
384    /// connections and manual interactive runs.
385    ///
386    /// ## Example
387    ///
388    /// Run any node that uses `init_interactive` or [`init_from_env`](Self::init_from_env) directly
389    /// from a terminal. The node will then start in "interactive mode" and prompt you for the next
390    /// input:
391    ///
392    /// ```bash
393    /// > cargo build -p rust-dataflow-example-node
394    /// > target/debug/rust-dataflow-example-node
395    /// hello
396    /// Starting node in interactive mode as DORA_NODE_CONFIG env variable is not set
397    /// Node asks for next input
398    /// ? Input ID
399    /// [empty input ID to stop]
400    /// ```
401    ///
402    /// The `rust-dataflow-example-node` expects a `tick` input, so let's set the input ID to
403    /// `tick`. Tick messages don't have any data, so we leave the "Data" empty when prompted:
404    ///
405    /// ```bash
406    /// Node asks for next input
407    /// > Input ID tick
408    /// > Data
409    /// tick 0, sending 0x943ed1be20c711a4
410    /// node sends output random with data: PrimitiveArray<UInt64>
411    /// [
412    ///   10682205980693303716,
413    /// ]
414    /// Node asks for next input
415    /// ? Input ID
416    /// [empty input ID to stop]
417    /// ```
418    ///
419    /// We see that both the `stdout` output of the node and also the output messages that it sends
420    /// are printed to the terminal. Then we get another prompt for the next input.
421    ///
422    /// If you want to send an input with data, you can either send it as text (for string data)
423    /// or as a JSON object (for struct, string, or array data). Other data types are not supported
424    /// currently.
425    ///
426    /// Empty input IDs are interpreted as stop instructions:
427    ///
428    /// ```bash
429    /// > Input ID
430    /// given input ID is empty -> stopping
431    /// Received stop
432    /// Node asks for next input
433    /// event channel was stopped -> returning empty event list
434    /// node reports EventStreamDropped
435    /// node reports closed outputs []
436    /// node reports OutputsDone
437    /// ```
438    ///
439    /// In addition to the node output, we see log messages for the different events that the node
440    /// reports. After `OutputsDone`, the node should exit.
441    ///
442    /// ### JSON data
443    ///
444    /// In addition to text input, the `Data` prompt also supports JSON objects, which will be
445    /// converted to Apache Arrow struct arrays:
446    ///
447    /// ```bash
448    /// Node asks for next input
449    /// > Input ID some_input
450    /// > Data { "field_1": 42, "field_2": { "inner": "foo" } }
451    /// ```
452    ///
453    /// This JSON data is converted to the following Arrow array:
454    ///
455    /// ```text
456    /// StructArray
457    /// -- validity: [valid, ]
458    /// [
459    ///   -- child 0: "field_1" (Int64)
460    ///      PrimitiveArray<Int64>
461    ///      [42,]
462    ///   -- child 1: "field_2" (Struct([Field { name: "inner", data_type: Utf8, nullable: true, dict_id: 0, dict_is_ordered: false, metadata: {} }]))
463    ///      StructArray
464    ///      -- validity: [valid,]
465    ///      [
466    ///        -- child 0: "inner" (Utf8)
467    ///        StringArray
468    ///        ["foo",]
469    ///      ]
470    /// ]
471    /// ```
472    pub fn init_interactive() -> NodeResult<(Self, EventStream)> {
473        #[cfg(feature = "tracing")]
474        {
475            TracingBuilder::new("node")
476                .with_stdout("debug", false)
477                .build()
478                .wrap_err("failed to set up tracing subscriber")?;
479        }
480
481        let node_config = NodeConfig {
482            dataflow_id: DataflowId::new_v4(),
483            node_id: "test-node"
484                .parse()
485                .map_err(|e| NodeError::Init(format!("{e}")))?,
486            run_config: NodeRunConfig {
487                inputs: Default::default(),
488                outputs: Default::default(),
489                output_types: Default::default(),
490                output_framing: Default::default(),
491                input_types: Default::default(),
492                shared_memory_pool_size: None,
493            },
494            daemon_communication: Some(DaemonCommunication::Interactive),
495            dataflow_descriptor: serde_yaml::Value::Null,
496            dynamic: false,
497            write_events_to: None,
498            restart_count: 0,
499        };
500        let (mut node, events) = Self::init(node_config)?;
501        node.interactive = true;
502        Ok((node, events))
503    }
504
505    /// Initializes a node in integration test mode.
506    ///
507    /// No connection to a dora daemon is made in this mode. Instead, inputs are read from the
508    /// specified `TestingInput`, and outputs are written to the specified `TestingOutput`.
509    /// Additional options for the testing mode can be specified through `TestingOptions`.
510    ///
511    /// It is recommended to use this function only within test functions.
512    pub fn init_testing(
513        input: TestingInput,
514        output: TestingOutput,
515        options: TestingOptions,
516    ) -> NodeResult<(Self, EventStream)> {
517        let node_config = NodeConfig {
518            dataflow_id: DataflowId::new_v4(),
519            node_id: "test-node"
520                .parse()
521                .map_err(|e| NodeError::Init(format!("{e}")))?,
522            run_config: NodeRunConfig {
523                inputs: Default::default(),
524                outputs: Default::default(),
525                output_types: Default::default(),
526                output_framing: Default::default(),
527                input_types: Default::default(),
528                shared_memory_pool_size: None,
529            },
530            daemon_communication: None,
531            dataflow_descriptor: serde_yaml::Value::Null,
532            dynamic: false,
533            write_events_to: None,
534            restart_count: 0,
535        };
536        let testing_comm = TestingCommunication {
537            input,
538            output,
539            options,
540        };
541        let (mut node, events) = Self::init_with_options(node_config, Some(testing_comm))?;
542        node.interactive = true;
543        Ok((node, events))
544    }
545
546    /// Internal initialization routine that should not be used outside of Dora.
547    #[doc(hidden)]
548    #[tracing::instrument]
549    pub fn init(node_config: NodeConfig) -> NodeResult<(Self, EventStream)> {
550        Self::init_with_options(node_config, None)
551    }
552
553    #[tracing::instrument(skip(testing_communication))]
554    fn init_with_options(
555        node_config: NodeConfig,
556        testing_communication: Option<TestingCommunication>,
557    ) -> NodeResult<(Self, EventStream)> {
558        let NodeConfig {
559            dataflow_id,
560            node_id,
561            run_config,
562            daemon_communication,
563            dataflow_descriptor,
564            dynamic,
565            write_events_to,
566            restart_count,
567        } = node_config;
568        let clock = Arc::new(uhlc::HLC::default());
569        let input_config = run_config.inputs.clone();
570
571        let daemon_communication = match daemon_communication {
572            Some(comm) => comm.into(),
573            None => match testing_communication {
574                Some(comm) => {
575                    let TestingCommunication {
576                        input,
577                        output,
578                        options,
579                    } = comm;
580                    let (sender, mut receiver) = tokio::sync::mpsc::channel(5);
581                    let new_communication = DaemonCommunicationWrapper::Testing { channel: sender };
582                    let mut events = IntegrationTestingEvents::new(input, output, options)?;
583                    std::thread::spawn(move || {
584                        while let Some((request, reply_sender)) = receiver.blocking_recv() {
585                            let reply = events.request(&request);
586                            if reply_sender
587                                .send(reply.unwrap_or_else(|err| {
588                                    DaemonReply::Result(Err(format!("{err:?}")))
589                                }))
590                                .is_err()
591                            {
592                                eprintln!("failed to send reply");
593                            }
594                        }
595                    });
596                    new_communication
597                }
598                None => {
599                    return Err(NodeError::Init(
600                        "no daemon communication method specified".into(),
601                    ));
602                }
603            },
604        };
605
606        // Initialize zenoh session for direct node-to-node data plane.
607        // Skip in interactive/testing mode (no daemon, no dataflow topology).
608        let is_standard_mode = matches!(
609            daemon_communication,
610            DaemonCommunicationWrapper::Standard(_)
611        );
612        // Pool size priority: per-node YAML config > env var > built-in default.
613        let shm_pool_size = run_config
614            .shared_memory_pool_size
615            .map(|bs| bs.as_bytes())
616            .or_else(|| {
617                std::env::var("DORA_NODE_SHM_POOL_SIZE")
618                    .ok()
619                    .and_then(|s| s.parse::<usize>().ok())
620            })
621            // 8 MB default — kept deliberately small so the pool fits a
622            // constrained `/dev/shm` (Docker/Kubernetes commonly cap it at
623            // 64 MB). A pool that doesn't fit backing memory was observed to
624            // break large-output delivery entirely (the segment can't be backed
625            // as it fills), so bumping this default is NOT a safe way to widen
626            // the large-message pipeline. Throughput under large-message bursts
627            // is handled instead by the non-blocking `GarbageCollect` alloc
628            // policy (see `allocate_data_sample` / `zenoh_publish`): the producer
629            // never stalls on a momentarily-full pool, it just copies via the
630            // heap path. Raise this only alongside a matching `/dev/shm` (via
631            // `shared_memory_pool_size` / `DORA_NODE_SHM_POOL_SIZE`) to keep more
632            // large outputs zero-copy.
633            .unwrap_or(8 * 1024 * 1024);
634        let zenoh_zero_copy_threshold = std::env::var("DORA_ZERO_COPY_THRESHOLD")
635            .ok()
636            .and_then(|s| s.parse::<usize>().ok())
637            .unwrap_or(ZERO_COPY_THRESHOLD);
638        let (zenoh_session, zenoh_shm_provider, owned_runtime) = if !is_standard_mode {
639            (None, None, None)
640        } else {
641            let (handle, owned_runtime) = match tokio::runtime::Handle::try_current() {
642                Ok(handle) => (handle, None),
643                Err(_) => {
644                    let rt = tokio::runtime::Builder::new_multi_thread()
645                        .enable_all()
646                        .thread_name("dora-node-runtime")
647                        .build()
648                        .map_err(|e| {
649                            NodeError::Init(format!("failed to create owned tokio runtime: {e}"))
650                        })?;
651                    let handle = rt.handle().clone();
652                    (handle, Some(rt))
653                }
654            };
655            // Use scope + spawn to avoid panicking when called from a tokio
656            // worker thread (block_on panics in that context on
657            // current-thread runtimes).
658            let session = std::thread::scope(|s| {
659                match s
660                    .spawn(|| handle.block_on(dora_core::topics::open_zenoh_session(None)))
661                    .join()
662                {
663                    Ok(Ok(session)) => Ok(session),
664                    Ok(Err(e)) => Err(NodeError::Init(format!(
665                        "failed to open zenoh session: {e:?}"
666                    ))),
667                    Err(_panic) => Err(NodeError::Init("zenoh session init panicked".into())),
668                }
669            })?;
670            // SHM provider is best-effort: if the OS rejects the segment
671            // allocation (e.g. `/dev/shm` exhausted in CI), fall back to
672            // `None`. `send_output_sample` already publishes via heap
673            // buffers when the provider is missing.
674            let provider = {
675                use zenoh::Wait;
676                use zenoh::shm::{AllocAlignment, MemoryLayout, ShmProviderBuilder};
677
678                let alignment =
679                    AllocAlignment::new(crate::arrow_utils::ARROW_BUFFER_ALIGNMENT_EXPONENT)
680                        .expect("ARROW_BUFFER_ALIGNMENT is a valid power-of-two alignment");
681                let layout = shm_pool_size
682                    .checked_next_multiple_of(crate::arrow_utils::ARROW_BUFFER_ALIGNMENT)
683                    .and_then(|aligned| MemoryLayout::new(aligned, alignment).ok());
684
685                match layout {
686                    Some(layout) => match ShmProviderBuilder::default_backend(layout).wait() {
687                        Ok(provider) => Some(provider),
688                        Err(e) => {
689                            warn!(
690                                "failed to create zenoh SHM provider ({e}); \
691                                 falling back to heap-buffered publishes"
692                            );
693                            None
694                        }
695                    },
696                    None => {
697                        warn!(
698                            "invalid zenoh SHM pool size ({shm_pool_size}); \
699                             falling back to heap-buffered publishes"
700                        );
701                        None
702                    }
703                }
704            };
705            (Some(session), provider, owned_runtime)
706        };
707
708        let event_stream = EventStream::init(
709            dataflow_id,
710            &node_id,
711            &daemon_communication,
712            input_config,
713            &run_config.input_types,
714            clock.clone(),
715            write_events_to,
716            zenoh_session.as_ref(),
717            dynamic,
718        )
719        .wrap_err("failed to init event stream")?;
720        let control_channel =
721            ControlChannel::init(dataflow_id, &node_id, &daemon_communication, clock.clone())
722                .wrap_err("failed to init control channel")?;
723        let runtime_type_checks = match RuntimeTypeCheck::from_env() {
724            RuntimeTypeCheck::Off => None,
725            mode => {
726                let registry = TypeRegistry::new();
727                let mut checks = HashMap::new();
728                for (id, urn) in &run_config.output_types {
729                    match registry.resolve_arrow_type(urn) {
730                        Some(dt) => {
731                            checks.insert(id.clone(), dt);
732                        }
733                        None => {
734                            if registry.resolve(urn).is_some() {
735                                info!(
736                                    "runtime type check: skipping complex type \"{urn}\" on output \"{id}\""
737                                );
738                            } else {
739                                warn!(
740                                    "runtime type check: unknown type URN \"{urn}\" on output \"{id}\""
741                                );
742                            }
743                        }
744                    }
745                }
746                Some((mode, checks))
747            }
748        };
749
750        let mut node = Self {
751            id: node_id,
752            dataflow_id,
753            node_config: run_config.clone(),
754            control_channel,
755            clock,
756            zenoh_session,
757            zenoh_shm_provider,
758            zenoh_publishers: HashMap::new(),
759            zenoh_schema_publishers: HashMap::new(),
760            zenoh_schema_state: HashMap::new(),
761            zenoh_zero_copy_threshold,
762            zenoh_output_readiness: HashMap::new(),
763            dataflow_descriptor: serde_yaml::from_value(dataflow_descriptor),
764            warned_unknown_output: BTreeSet::new(),
765            interactive: false,
766            restart_count,
767            runtime_type_checks,
768            _owned_runtime: owned_runtime,
769        };
770
771        if dynamic {
772            // Env vars from the dataflow descriptor are already injected by the
773            // daemon at spawn time via `Command::env()`.  Setting them here with
774            // `std::env::set_var` would be undefined behavior because the tokio
775            // multi-threaded runtime is already running and other threads may
776            // call `std::env::var` concurrently.
777            //
778            // If the node was started outside the daemon (manual dynamic node),
779            // the user must set the required env vars before launching the
780            // process.
781            if let Ok(descriptor) = &node.dataflow_descriptor
782                && let Some(env_vars) = descriptor
783                    .nodes
784                    .iter()
785                    .find(|n| n.id == node.id)
786                    .and_then(|n| n.env.as_ref())
787            {
788                for key in env_vars.keys() {
789                    if std::env::var(key).is_err() {
790                        warn!(
791                            "env var `{key}` declared in dataflow descriptor is not set; \
792                                 it should have been injected by the daemon at spawn time"
793                        );
794                    }
795                }
796            }
797        }
798
799        // Warm up the direct-zenoh data plane before handing control to user
800        // code: declare output publishers and wait (bounded) until their
801        // subscribers are wired, so the first `send_output` on a connected output
802        // hits an established route. Fail-safe — un-connected outputs start on the
803        // daemon path and upgrade later (see `warm_up_direct_outputs`).
804        node.warm_up_direct_outputs();
805
806        Ok((node, event_stream))
807    }
808
809    /// Check whether `output_id` is declared as an output of this node.
810    ///
811    /// Returns `true` if the output is declared (or this node is `interactive`,
812    /// which has no static output declaration); `false` and emits a one-time
813    /// warning if the output is unknown. Public so callers building higher-level
814    /// send helpers (e.g. the Python `send_output_raw` zero-copy path) can
815    /// validate before allocating a buffer.
816    pub fn validate_output(&mut self, output_id: &DataId) -> bool {
817        if !self.node_config.outputs.contains(output_id) && !self.interactive {
818            if !self.warned_unknown_output.contains(output_id) {
819                warn!("Ignoring output `{output_id}` not in node's output list.");
820                self.warned_unknown_output.insert(output_id.clone());
821            }
822            false
823        } else {
824            true
825        }
826    }
827
828    /// Send raw data from the node to the other nodes.
829    ///
830    /// We take a closure as an input to enable zero copy on send.
831    ///
832    /// ```no_run
833    /// use dora_node_api::{DoraNode, MetadataParameters};
834    /// use dora_core::config::DataId;
835    ///
836    /// let (mut node, mut events) = DoraNode::init_from_env().expect("Could not init node.");
837    ///
838    /// let output = DataId::from("output_id".to_owned());
839    ///
840    /// let data: &[u8] = &[0, 1, 2, 3];
841    /// let parameters = MetadataParameters::default();
842    ///
843    /// node.send_output_raw(
844    ///    output,
845    ///    parameters,
846    ///    data.len(),
847    ///    |out| {
848    ///         out.copy_from_slice(data);
849    ///     }).expect("Could not send output");
850    /// ```
851    ///
852    /// Ignores the output if the given `output_id` is not specified as node output in the dataflow
853    /// configuration file.
854    pub fn send_output_raw<F>(
855        &mut self,
856        output_id: DataId,
857        parameters: MetadataParameters,
858        data_len: usize,
859        data: F,
860    ) -> NodeResult<()>
861    where
862        F: FnOnce(&mut [u8]),
863    {
864        if !self.validate_output(&output_id) {
865            return Ok(());
866        };
867        // The receiver expects a self-describing Arrow IPC stream. Build it in
868        // place: pre-write the UInt8 IPC header into the (shared-memory) sample,
869        // then let the caller write their bytes straight into the data region —
870        // zero payload copies (and the SHM sample is moved into zenoh's `put`).
871        let total = ipc_encode::uint8_ipc_len(data_len)
872            .map_err(|e| NodeError::Output(format!("Arrow IPC encode: {e}")))?;
873        let mut sample = self.allocate_data_sample(total)?;
874        let offset = ipc_encode::encode_uint8_ipc_header(&mut sample, data_len)
875            .map_err(|e| NodeError::Output(format!("Arrow IPC encode: {e}")))?;
876        data(&mut sample[offset..offset + data_len]);
877
878        let mut parameters = parameters;
879        parameters.insert(
880            FRAMING.to_string(),
881            Parameter::String(FRAMING_ARROW_IPC.to_string()),
882        );
883        self.send_output_sample(output_id, parameters, Some(sample))
884    }
885
886    /// Sends the give Arrow array as an output message.
887    ///
888    /// Uses shared memory for efficient data transfer if suitable.
889    ///
890    /// This method might copy the message once to move it to shared memory.
891    ///
892    /// Ignores the output if the given `output_id` is not specified as node output in the dataflow
893    /// configuration file.
894    pub fn send_output(
895        &mut self,
896        output_id: DataId,
897        parameters: MetadataParameters,
898        data: impl Array,
899    ) -> NodeResult<()> {
900        if !self.validate_output(&output_id) {
901            return Ok(());
902        };
903
904        let arrow_array = data.to_data();
905
906        // Runtime type check (only when DORA_RUNTIME_TYPE_CHECK is set).
907        //
908        // Skip the check when this message carries pattern metadata
909        // (`request_id`, `goal_id`, or `goal_status`). Service, action,
910        // and streaming patterns legitimately multiplex multiple Arrow
911        // schemas through a single output — a service server may reply
912        // with different response shapes for different request types —
913        // so a single declared Arrow type cannot cover all variants.
914        // Non-pattern messages still get full validation
915        // (dora-rs/adora#150).
916        if let Some((mode, checks)) = &self.runtime_type_checks
917            && let Some(expected) = checks.get(&output_id)
918            && !carries_pattern_correlation(&parameters)
919        {
920            let actual = arrow_array.data_type();
921            if actual != expected {
922                let msg = format!(
923                    "output \"{output_id}\": expected Arrow type {expected:?}, got {actual:?}"
924                );
925                match mode {
926                    RuntimeTypeCheck::Error => {
927                        return Err(NodeError::Output(msg));
928                    }
929                    RuntimeTypeCheck::Warn => {
930                        warn!("type mismatch: {msg}");
931                    }
932                    RuntimeTypeCheck::Off => unreachable!(),
933                }
934            }
935        }
936
937        self.send_output_array(output_id, parameters, arrow_array)
938    }
939
940    /// IPC-encode `arrow_array` into a (shared-memory) sample and send it.
941    ///
942    /// Every data-plane payload is a self-describing Arrow IPC stream. Uses the
943    /// hand-rolled 1-copy fast path when the array type is eligible, falling
944    /// back to the official writer (one extra copy) otherwise. The shared send
945    /// path for [`send_output`](Self::send_output) and
946    /// [`send_output_raw`](Self::send_output_raw).
947    fn send_output_array(
948        &mut self,
949        output_id: DataId,
950        mut parameters: MetadataParameters,
951        arrow_array: ArrayData,
952    ) -> NodeResult<()> {
953        parameters.insert(
954            FRAMING.to_string(),
955            Parameter::String(FRAMING_ARROW_IPC.to_string()),
956        );
957
958        let sample = match ipc_encode::ipc_fast_path_len(&arrow_array) {
959            Some(len) => {
960                let mut s = self.allocate_data_sample(len)?;
961                ipc_encode::encode_ipc_into(&arrow_array, &mut s)
962                    .map_err(|e| NodeError::Output(format!("Arrow IPC encode: {e}")))?;
963                s
964            }
965            None => {
966                let bytes = ipc_encode::encode_ipc_to_vec(&arrow_array)
967                    .map_err(|e| NodeError::Output(format!("Arrow IPC encode: {e}")))?;
968                let mut s = self.allocate_data_sample(bytes.len())?;
969                s.copy_from_slice(&bytes);
970                s
971            }
972        };
973
974        self.send_output_sample(output_id, parameters, Some(sample))
975            .wrap_err("failed to send output")?;
976
977        Ok(())
978    }
979
980    /// Send the given raw byte data as output.
981    ///
982    /// Might copy the data once to move it into shared memory.
983    ///
984    /// Ignores the output if the given `output_id` is not specified as node output in the dataflow
985    /// configuration file.
986    pub fn send_output_bytes(
987        &mut self,
988        output_id: DataId,
989        parameters: MetadataParameters,
990        data_len: usize,
991        data: &[u8],
992    ) -> NodeResult<()> {
993        if !self.validate_output(&output_id) {
994            return Ok(());
995        };
996        // `send_output_raw` allocates a `data_len`-byte sample and the closure
997        // below copies `data` into it. A mismatch would otherwise panic deep
998        // inside `copy_from_slice` ("source slice length .. does not match
999        // destination slice length .."); return a clear error instead.
1000        if data.len() != data_len {
1001            return Err(NodeError::Output(format!(
1002                "send_output_bytes: data_len ({data_len}) does not match data.len() ({})",
1003                data.len()
1004            )));
1005        }
1006        self.send_output_raw(output_id, parameters, data_len, |sample| {
1007            sample.copy_from_slice(data)
1008        })
1009    }
1010
1011    /// Sends the given [`DataSample`] as output.
1012    ///
1013    /// The sample must already be a self-describing Arrow IPC stream (the
1014    /// `FRAMING_ARROW_IPC` parameter should be set). It is recommended to use a
1015    /// function like [`send_output`][Self::send_output] instead, which handles
1016    /// the encoding.
1017    ///
1018    /// Ignores the output if the given `output_id` is not specified as node output in the dataflow
1019    /// configuration file.
1020    pub fn send_output_sample(
1021        &mut self,
1022        output_id: DataId,
1023        mut parameters: MetadataParameters,
1024        sample: Option<DataSample>,
1025    ) -> NodeResult<()> {
1026        // `SCHEMA_HASH` is an internal wire-protocol key that only
1027        // `publish_schema_once` may set, and only for the schema-less batch it
1028        // belongs to. A stale value forwarded from an input's metadata (the
1029        // receive path strips it, but a recorded/hand-built parameter map can
1030        // still carry one) would make receivers route this output's full
1031        // self-describing stream to the schema-once decoder, hash-mismatch, and
1032        // silently drop it (dora-rs/dora#2366 review).
1033        parameters.remove(SCHEMA_HASH);
1034        // Auto-inject OpenTelemetry trace context when telemetry is enabled.
1035        // Uses the ambient OTel context, which is populated when the tracing
1036        // subscriber has an OpenTelemetry layer (e.g., via with_otlp_tracing).
1037        // Only trace/span IDs are propagated (via W3C TraceContext propagator).
1038        // OTel Baggage is NOT propagated to avoid leaking sensitive data across
1039        // node boundaries. If a user explicitly provides this key, it wins.
1040        #[cfg(feature = "tracing")]
1041        if !parameters.contains_key("open_telemetry_context") {
1042            let cx = opentelemetry::Context::current();
1043            let serialized = dora_tracing::telemetry::serialize_context(&cx);
1044            if !serialized.is_empty() {
1045                parameters.insert(
1046                    "open_telemetry_context".to_string(),
1047                    crate::Parameter::String(serialized),
1048                );
1049            }
1050        }
1051
1052        let metadata = Metadata::from_parameters(self.clock.new_timestamp(), parameters);
1053
1054        let finalized = sample.map(|sample| sample.finalize());
1055
1056        // How a data-plane message should be delivered.
1057        enum Delivery {
1058            /// zenoh delivered the payload (or it was consumed by a failed SHM
1059            /// put); only the daemon's control-plane state needs syncing.
1060            Zenoh,
1061            /// Deliver via the daemon control channel. `None` is a metadata-only
1062            /// message with no payload.
1063            Daemon(Option<DataMessage>),
1064        }
1065
1066        // Always publish data-plane messages via zenoh when a session is
1067        // available. The daemon control channel is only used as a fallback when
1068        // the zenoh session could not be opened (e.g. interactive/testing mode)
1069        // or when no subscriber matched in time. An SHM-backed sample is moved
1070        // straight into zenoh's `put` (no extra copy); only the daemon fallback
1071        // copies it out into a `DataMessage::Vec`.
1072        let delivery = match finalized {
1073            Some(finalized) if self.zenoh_session.is_some() => {
1074                tracing::trace!(
1075                    output = %output_id,
1076                    size = finalized.byte_len(),
1077                    "publishing via zenoh"
1078                );
1079                match self.zenoh_publish(&output_id, &metadata, finalized) {
1080                    Ok(PublishOutcome::Published) => Delivery::Zenoh,
1081                    Ok(PublishOutcome::NotPublished(sample)) => {
1082                        Delivery::Daemon(Some(sample.into_data_message()))
1083                    }
1084                    Err(e) => {
1085                        tracing::warn!(
1086                            "zenoh publish failed ({e}); message dropped \
1087                             (SHM payload consumed, no daemon fallback)"
1088                        );
1089                        Delivery::Zenoh
1090                    }
1091                }
1092            }
1093            Some(finalized) => Delivery::Daemon(Some(finalized.into_data_message())),
1094            None => Delivery::Daemon(None),
1095        };
1096
1097        match delivery {
1098            Delivery::Zenoh => {
1099                // Keep the daemon's control-plane state in sync (input
1100                // deadlines, circuit-breaker recovery) without duplicating the
1101                // data payload that zenoh already delivered.
1102                self.control_channel
1103                    .report_output_sent(output_id.clone(), metadata)
1104                    .wrap_err_with(|| format!("failed to report output {output_id}"))?;
1105            }
1106            Delivery::Daemon(data) => {
1107                // The daemon/TCP path serializes the whole message; an oversized
1108                // IPC payload would otherwise fail deep in the transport with a
1109                // generic error. Reject it here with a clear, output-specific
1110                // message. Large payloads are expected to reach a zenoh
1111                // subscriber instead, which has no such limit.
1112                if let Some(DataMessage::Vec(v)) = &data
1113                    && v.len() > dora_message::MAX_MESSAGE_BYTES
1114                {
1115                    return Err(NodeError::Output(format!(
1116                        "output \"{output_id}\": IPC-encoded message is {} bytes, exceeding \
1117                         the {}-byte daemon transport limit (no matching zenoh subscriber to \
1118                         take the large payload)",
1119                        v.len(),
1120                        dora_message::MAX_MESSAGE_BYTES,
1121                    )));
1122                }
1123                self.control_channel
1124                    .send_message(output_id.clone(), metadata, data)
1125                    .wrap_err_with(|| format!("failed to send output {output_id}"))?;
1126            }
1127        }
1128
1129        Ok(())
1130    }
1131
1132    /// Report the given outputs IDs as closed.
1133    ///
1134    /// The node is not allowed to send more outputs with the closed IDs.
1135    ///
1136    /// Closing outputs early can be helpful to receivers.
1137    pub fn close_outputs(&mut self, outputs_ids: Vec<DataId>) -> NodeResult<()> {
1138        // Validate the whole batch before mutating any local state. Removing
1139        // outputs eagerly would leave the node's local output set out of sync
1140        // with the daemon if a later id is unknown: the early ones would be
1141        // gone locally, yet `report_closed_outputs` is skipped on error so the
1142        // daemon never learns about them.
1143        for output_id in &outputs_ids {
1144            if !self.node_config.outputs.contains(output_id) {
1145                return Err(NodeError::Output(format!("unknown output {output_id}")));
1146            }
1147        }
1148        for output_id in &outputs_ids {
1149            self.node_config.outputs.remove(output_id);
1150        }
1151
1152        self.control_channel
1153            .report_closed_outputs(outputs_ids)
1154            .wrap_err("failed to report closed outputs to daemon")?;
1155
1156        Ok(())
1157    }
1158
1159    /// Whether the direct zenoh data plane is safe to use for `output_id` — i.e.
1160    /// every subscriber this output has in the dataflow has announced its
1161    /// subscription. Returns `false` while any expected subscriber is still
1162    /// missing, so the caller keeps using the reliable daemon path (no message is
1163    /// dropped during the startup subscription-establishment window).
1164    ///
1165    /// Lazily declares the per-output liveliness counter on first use. Once all
1166    /// subscribers are confirmed the result latches `true` and later calls are a
1167    /// cheap map lookup. If readiness tracking cannot be set up (no zenoh session
1168    /// or an invalid key) this returns `true` so the data plane is not stalled.
1169    fn ensure_output_ready(&mut self, output_id: &DataId) -> bool {
1170        use zenoh::Wait;
1171
1172        if self
1173            .zenoh_output_readiness
1174            .get(output_id)
1175            .is_some_and(|r| r.ready)
1176        {
1177            return true;
1178        }
1179
1180        if !self.zenoh_output_readiness.contains_key(output_id) {
1181            match self.declare_output_readiness(output_id) {
1182                Some(readiness) => {
1183                    self.zenoh_output_readiness
1184                        .insert(output_id.clone(), readiness);
1185                }
1186                // Tracking unavailable — don't stall the data plane.
1187                None => return true,
1188            }
1189        }
1190
1191        let (expected, count) = {
1192            let readiness = self
1193                .zenoh_output_readiness
1194                .get(output_id)
1195                .expect("readiness entry just ensured");
1196            let count = readiness.alive.lock().map(|alive| alive.len()).unwrap_or(0);
1197            (readiness.expected, count)
1198        };
1199
1200        // No subscribers to wait for: nothing can be lost, use the fast path.
1201        if expected == 0 {
1202            self.mark_output_ready(output_id);
1203            return true;
1204        }
1205
1206        if count < expected {
1207            return false;
1208        }
1209
1210        // Every readiness token is visible (all subscribers declared their
1211        // subscriptions). Confirm zenoh has actually forwarded a matching
1212        // subscription to this publisher before switching, so the very first
1213        // zenoh sample isn't published into a not-yet-wired route.
1214        let matched = self
1215            .zenoh_publishers
1216            .get(output_id)
1217            .and_then(|publisher| publisher.matching_status().wait().ok())
1218            .is_some_and(|status| status.matching());
1219        if matched {
1220            self.mark_output_ready(output_id);
1221            return true;
1222        }
1223
1224        false
1225    }
1226
1227    /// Latch the output's data plane as ready (idempotent).
1228    fn mark_output_ready(&mut self, output_id: &DataId) {
1229        if let Some(readiness) = self.zenoh_output_readiness.get_mut(output_id) {
1230            if !readiness.ready {
1231                tracing::debug!(
1232                    output = %output_id,
1233                    subscribers = readiness.expected,
1234                    "all subscribers wired; switching output to direct zenoh data plane"
1235                );
1236            }
1237            readiness.ready = true;
1238        }
1239    }
1240
1241    /// Build the per-output readiness counter: resolve how many subscribers the
1242    /// output has (from the dataflow descriptor) and declare a liveliness
1243    /// subscriber that counts their readiness tokens. Returns `None` if there is
1244    /// no zenoh session or the counting subscription could not be declared.
1245    fn declare_output_readiness(&self, output_id: &DataId) -> Option<ZenohOutputReadiness> {
1246        use zenoh::Wait;
1247
1248        let session = self.zenoh_session.as_ref()?;
1249
1250        // Expected subscriber count from the global topology. A descriptor that
1251        // failed to parse (never the case for a daemon-spawned node) yields 0,
1252        // which just means "no barrier" — the pre-existing behaviour.
1253        let expected = match &self.dataflow_descriptor {
1254            Ok(descriptor) => descriptor
1255                .output_subscriber_count(&self.id, output_id)
1256                .unwrap_or_else(|e| {
1257                    tracing::warn!(
1258                        output = %output_id,
1259                        "failed to count subscribers ({e}); not gating the zenoh data plane"
1260                    );
1261                    0
1262                }),
1263            Err(_) => 0,
1264        };
1265
1266        let prefix = dora_core::topics::zenoh_output_ready_liveliness_prefix(
1267            self.dataflow_id,
1268            &self.id,
1269            output_id,
1270        );
1271        let key_expr = match zenoh::key_expr::KeyExpr::new(prefix) {
1272            Ok(key) => key.into_owned(),
1273            Err(e) => {
1274                tracing::warn!(output = %output_id, "invalid readiness key ({e}); not gating the zenoh data plane");
1275                return None;
1276            }
1277        };
1278
1279        let alive = Arc::new(std::sync::Mutex::new(std::collections::HashSet::new()));
1280        let alive_cb = alive.clone();
1281        // `history(true)` replays tokens that were declared before this
1282        // subscriber, so subscribers that wired up early are still counted.
1283        let subscriber = session
1284            .liveliness()
1285            .declare_subscriber(key_expr)
1286            .history(true)
1287            .callback(move |sample| {
1288                let key = sample.key_expr().as_str().to_string();
1289                if let Ok(mut alive) = alive_cb.lock() {
1290                    match sample.kind() {
1291                        zenoh::sample::SampleKind::Put => {
1292                            alive.insert(key);
1293                        }
1294                        zenoh::sample::SampleKind::Delete => {
1295                            alive.remove(&key);
1296                        }
1297                    }
1298                }
1299            })
1300            .wait();
1301
1302        match subscriber {
1303            Ok(subscriber) => Some(ZenohOutputReadiness {
1304                expected,
1305                ready: false,
1306                alive,
1307                _liveliness_sub: subscriber,
1308            }),
1309            Err(e) => {
1310                tracing::warn!(output = %output_id, "failed to declare readiness subscriber ({e}); not gating the zenoh data plane");
1311                None
1312            }
1313        }
1314    }
1315
1316    /// Ensure a direct-zenoh data publisher is declared for `output_id`.
1317    ///
1318    /// Idempotent: returns `true` if a publisher exists after the call (already
1319    /// present or freshly declared), `false` if there is no zenoh session or the
1320    /// declaration failed (the caller then falls back to the daemon path).
1321    ///
1322    /// QoS is configured at declare time so it applies to every put:
1323    /// `express(true)` bypasses zenoh's adaptive batch timer (the single biggest
1324    /// small-message latency win — without it, per-put delivery on the bare local
1325    /// config collapses to a few msg/s), `Priority::RealTime` keeps data-plane
1326    /// messages off the bulk-data queues, and `CongestionControl::Drop` prevents a
1327    /// stalled subscriber from back-pressuring the publishing node.
1328    fn ensure_zenoh_publisher(&mut self, output_id: &DataId) -> bool {
1329        use zenoh::Wait;
1330        use zenoh::qos::{CongestionControl, Priority};
1331
1332        if self.zenoh_publishers.contains_key(output_id) {
1333            return true;
1334        }
1335        let Some(session) = self.zenoh_session.as_ref() else {
1336            return false;
1337        };
1338        let topic =
1339            dora_core::topics::zenoh_output_publish_topic(self.dataflow_id, &self.id, output_id);
1340        let key_expr = match zenoh::key_expr::KeyExpr::new(topic) {
1341            Ok(key) => key.into_owned(),
1342            Err(e) => {
1343                tracing::warn!(output = %output_id, "invalid zenoh key ({e}); falling back to daemon path");
1344                return false;
1345            }
1346        };
1347        let publisher = match session
1348            .declare_publisher(key_expr)
1349            .congestion_control(CongestionControl::Drop)
1350            .express(true)
1351            .priority(Priority::RealTime)
1352            .wait()
1353        {
1354            Ok(publisher) => publisher,
1355            Err(e) => {
1356                tracing::warn!(output = %output_id, "failed to declare zenoh publisher ({e}); falling back to daemon path");
1357                return false;
1358            }
1359        };
1360        self.zenoh_publishers.insert(output_id.clone(), publisher);
1361        true
1362    }
1363
1364    /// Eagerly declare this node's direct-zenoh output publishers and readiness
1365    /// counters, then wait (bounded) until every output's subscribers are wired
1366    /// up — so the user's first `send_output` on a connected output is guaranteed
1367    /// to hit an established route rather than dropping startup samples.
1368    ///
1369    /// Runs once, at the end of [`Self::init`], *after* the daemon "all nodes
1370    /// ready" barrier. By then every subscriber in the dataflow has already
1371    /// declared its data subscriber and readiness token (the barrier releases
1372    /// only once all nodes have subscribed), so this merely waits out zenoh route
1373    /// propagation between already-declared endpoints — normally milliseconds.
1374    ///
1375    /// Fail-safe: outputs that don't connect within
1376    /// [`ZENOH_OUTPUT_CONNECT_TIMEOUT`] (an unreachable remote subscriber, or a
1377    /// zenoh data plane that can't connect while the control plane can) are left
1378    /// on the reliable daemon path and upgrade to direct zenoh on a later send
1379    /// once their subscribers appear. The dataflow is never blocked from starting
1380    /// and nothing is dropped.
1381    fn warm_up_direct_outputs(&mut self) {
1382        if self.zenoh_session.is_none() {
1383            return;
1384        }
1385        let outputs: Vec<DataId> = self.node_config.outputs.iter().cloned().collect();
1386        if outputs.is_empty() {
1387            return;
1388        }
1389
1390        // Declare publishers up front so zenoh starts wiring routes immediately
1391        // and `ensure_output_ready` (which reads `matching_status`) can observe
1392        // them. `ensure_output_ready` lazily declares the readiness counter.
1393        for output_id in &outputs {
1394            self.ensure_zenoh_publisher(output_id);
1395        }
1396
1397        let deadline = Instant::now() + ZENOH_OUTPUT_CONNECT_TIMEOUT;
1398        loop {
1399            let all_ready = outputs
1400                .iter()
1401                .all(|output_id| self.ensure_output_ready(output_id));
1402            if all_ready {
1403                break;
1404            }
1405            if Instant::now() >= deadline {
1406                let pending: Vec<&str> = outputs
1407                    .iter()
1408                    .filter(|o| !self.ensure_output_ready(o))
1409                    .map(|o| o.as_str())
1410                    .collect();
1411                tracing::debug!(
1412                    outputs = ?pending,
1413                    "direct-zenoh routes not confirmed within {}s; these outputs start on the daemon path and upgrade when their subscribers connect",
1414                    ZENOH_OUTPUT_CONNECT_TIMEOUT.as_secs()
1415                );
1416                break;
1417            }
1418            std::thread::sleep(ZENOH_OUTPUT_CONNECT_POLL_INTERVAL);
1419        }
1420    }
1421
1422    /// Publish data directly via zenoh (node-to-node, bypassing daemon for data).
1423    /// Uses SHM for zero-copy when possible, falls back to heap buffer.
1424    ///
1425    /// The publisher is declared (see [`Self::ensure_zenoh_publisher`]) with
1426    /// `express(true)` to bypass zenoh's adaptive batch timer and with
1427    /// `Priority::RealTime` so data-plane messages don't share queues with bulk
1428    /// traffic.
1429    fn zenoh_publish(
1430        &mut self,
1431        output_id: &DataId,
1432        metadata: &Metadata,
1433        finalized: FinalizedSample,
1434    ) -> eyre::Result<PublishOutcome> {
1435        use zenoh::Wait;
1436
1437        // Every failure *before* the payload is moved into `put` returns the
1438        // sample as `NotPublished` so the caller can still deliver it via the
1439        // daemon. Only a failed `put` of an SHM buffer (which consumes it)
1440        // returns `Err` — that is the single non-recoverable case.
1441        //
1442        // Get or create the publisher for this output. Normally it was already
1443        // declared eagerly at startup (see [`Self::warm_up_direct_outputs`]);
1444        // this covers outputs sent that weren't declared then. A missing
1445        // session or a declaration failure falls back to the daemon path.
1446        if !self.ensure_zenoh_publisher(output_id) {
1447            return Ok(PublishOutcome::NotPublished(finalized));
1448        }
1449
1450        // Startup no-loss barrier: until every subscriber for this output has
1451        // wired up its zenoh subscription, deliver over the reliable daemon path
1452        // rather than dropping early samples on the not-yet-established data
1453        // plane. Once all subscribers are confirmed this latches ready and every
1454        // subsequent send takes the fast zenoh path.
1455        if !self.ensure_output_ready(output_id) {
1456            return Ok(PublishOutcome::NotPublished(finalized));
1457        }
1458
1459        let Some(publisher) = self.zenoh_publishers.get(output_id) else {
1460            tracing::warn!(output = %output_id, "zenoh publisher missing; falling back to daemon path");
1461            return Ok(PublishOutcome::NotPublished(finalized));
1462        };
1463        let session = self
1464            .zenoh_session
1465            .as_ref()
1466            .expect("zenoh session presence checked above");
1467
1468        // Serialize metadata as zenoh attachment.
1469        let metadata_bytes = match bincode::serialize(metadata) {
1470            Ok(bytes) => bytes,
1471            Err(e) => {
1472                tracing::warn!(output = %output_id, "failed to serialize metadata ({e}); falling back to daemon path");
1473                return Ok(PublishOutcome::NotPublished(finalized));
1474            }
1475        };
1476
1477        match finalized {
1478            // The producer already wrote into shared memory. Move the SHM
1479            // buffer straight into `put` — no realloc, no copy. This is the
1480            // path that eliminates the former heap-to-SHM second copy.
1481            //
1482            // On a put error the buffer has been consumed and cannot be
1483            // recovered for the daemon fallback, so this returns an error
1484            // (the caller logs and drops the message). This is a deliberate
1485            // trade-off for zero-copy on the common matched-subscriber path.
1486            // Producer-constructed SHM sample: `put` *moves* (consumes) the SHM
1487            // buffer, so — unlike the borrowed-heap `Vec` arm below — there is no
1488            // intact payload left to retry on error. A put failure is therefore
1489            // best-effort: the message is dropped (the caller logs it). This is
1490            // the deliberate, accepted trade-off for the zero-copy large-output
1491            // path, not an oversight.
1492            FinalizedSample::Shm(sbuf) => {
1493                publisher
1494                    .put(sbuf)
1495                    .attachment(&metadata_bytes[..])
1496                    .wait()
1497                    .map_err(|e| eyre::eyre!("zenoh SHM publish failed: {e}"))?;
1498                Ok(PublishOutcome::Published)
1499            }
1500            // Heap payload. At or above the threshold, copy once into a fresh
1501            // SHM buffer so local subscribers still get zero-copy delivery;
1502            // below it, a heap-buffered put is cheaper than a full SHM page.
1503            // The heap buffer is only borrowed, so any put error can fall back
1504            // to the daemon path with the payload intact.
1505            FinalizedSample::Vec(avec) => {
1506                if avec.len() >= self.zenoh_zero_copy_threshold
1507                    && let Some(provider) = &self.zenoh_shm_provider
1508                {
1509                    use zenoh::shm::GarbageCollect;
1510                    // Non-blocking: garbage-collect freed chunks and allocate, but
1511                    // do NOT block waiting for the pool to drain. Under a burst of
1512                    // large messages the zero-copy receiver pins each segment for
1513                    // the whole receive pipeline, so the pool can be momentarily
1514                    // exhausted; `BlockOn` would then sleep 1 ms per retry (zenoh
1515                    // 1.8 has no alloc signalling yet), throttling throughput to
1516                    // ~1k msg/s. Falling back to a heap-buffered put instead keeps
1517                    // the producer moving (PR #2366).
1518                    match provider
1519                        .alloc(avec.len())
1520                        .with_policy::<GarbageCollect>()
1521                        .wait()
1522                    {
1523                        Ok(mut sbuf) => {
1524                            sbuf.as_mut().copy_from_slice(&avec);
1525                            return match publisher.put(sbuf).attachment(&metadata_bytes[..]).wait()
1526                            {
1527                                Ok(()) => Ok(PublishOutcome::Published),
1528                                Err(e) => {
1529                                    tracing::warn!(
1530                                        "zenoh SHM publish failed ({e}); \
1531                                         falling back to daemon path"
1532                                    );
1533                                    Ok(PublishOutcome::NotPublished(FinalizedSample::Vec(avec)))
1534                                }
1535                            };
1536                        }
1537                        Err(e) => {
1538                            tracing::debug!("SHM alloc failed ({e}), using heap buffer");
1539                        }
1540                    }
1541                }
1542
1543                // A large payload that did not make it into SHM (no provider, or
1544                // the pool was momentarily full) must NOT be published over the
1545                // zenoh data plane: a payload larger than the transport batch
1546                // size is fragmented, and the express/`Drop` data publisher
1547                // silently drops fragmented messages — `put` reports success but
1548                // the subscriber never receives them (PR #2366). Route it via the
1549                // reliable daemon path instead (TCP, up to `MAX_MESSAGE_BYTES`).
1550                // Only sub-threshold payloads, which fit a single batch and never
1551                // fragment, take the zenoh heap put below.
1552                if avec.len() >= self.zenoh_zero_copy_threshold {
1553                    return Ok(PublishOutcome::NotPublished(FinalizedSample::Vec(avec)));
1554                }
1555
1556                // Only sub-threshold (single-batch, never-fragmented) payloads
1557                // reach this point — large payloads were routed to the daemon
1558                // path above. Apply the schema-once optimization to small
1559                // messages with a stable Arrow schema: publish the schema on the
1560                // `@schema` subtopic (only on change) and send just the
1561                // schema-less batch on the data topic, tagged with the schema
1562                // hash so the receiver matches it to the decoder primed from the
1563                // subtopic.
1564                //
1565                // The message that (re)publishes the schema — the output's first,
1566                // every schema change, any message after a failed `@schema` put,
1567                // and a periodic refresh — is itself sent as a full
1568                // self-describing stream (`publish_schema_once` returns `None`
1569                // for it). It decodes standalone and primes receivers in-band,
1570                // in data-plane order, so the express batch can never outrun its
1571                // own schema (the `@schema` plane's non-express `Block` publish
1572                // otherwise loses that race) and a one-shot output cannot lose
1573                // its only message.
1574                //
1575                // Service/action request-reply messages (carrying
1576                // `request_id`/`goal_id`/`goal_status`) are excluded: a server
1577                // legitimately multiplexes multiple response schemas through one
1578                // output, interleaved per request, and each per-message schema
1579                // change would force a full stream + `@schema` publish anyway.
1580                // Sending them as full self-describing streams (the pre-PR
1581                // behavior) makes each message decode standalone regardless of
1582                // schema order, at the cost of ~400 B of framing per message —
1583                // acceptable for these request/reply-rate patterns.
1584                //
1585                // Streaming (`session_id`/`segment_id`) is deliberately NOT
1586                // excluded: every chunk of a stream shares one schema, so
1587                // schema-once primes once and each chunk reuses it — streaming is
1588                // the high-rate small-message case schema-once exists for. A
1589                // schema change at a segment boundary is just the one-time
1590                // re-prime window any schema-once output has, not the per-message
1591                // alternation that makes service/action lossy.
1592                //
1593                // `schema_once` is bound here, not inside the match, so its
1594                // attachment bytes outlive the `put` below.
1595                let schema_once = if schema_once_eligible(
1596                    avec.len(),
1597                    self.zenoh_zero_copy_threshold,
1598                    &metadata.parameters,
1599                ) {
1600                    publish_schema_once(
1601                        &mut self.zenoh_schema_publishers,
1602                        &mut self.zenoh_schema_state,
1603                        session,
1604                        self.dataflow_id,
1605                        &self.id,
1606                        output_id,
1607                        &avec,
1608                        metadata,
1609                    )
1610                } else {
1611                    None
1612                };
1613                // Fall back to a full standalone stream if the batch slice can't
1614                // be taken (a real IPC stream always can — defensive).
1615                let (payload, attachment): (&[u8], &[u8]) = match schema_once.as_ref() {
1616                    Some(att) => match arrow_utils::ipc_encode::batch_slice(&avec) {
1617                        Some(slice) => (slice, att.as_slice()),
1618                        None => (&avec[..], &metadata_bytes[..]),
1619                    },
1620                    None => (&avec[..], &metadata_bytes[..]),
1621                };
1622                match publisher.put(payload).attachment(attachment).wait() {
1623                    Ok(()) => Ok(PublishOutcome::Published),
1624                    Err(e) => {
1625                        tracing::warn!("zenoh publish failed ({e}); falling back to daemon path");
1626                        // The zenoh data plane did not deliver this message. If
1627                        // it was the one meant to prime receivers in-band (the
1628                        // first message of a schema, or a periodic refresh),
1629                        // `publish_schema_once` already recorded its state and
1630                        // the following messages would go out schema-less with
1631                        // no delivered priming stream. Forget the output's
1632                        // schema-once state so the next message sends a full
1633                        // stream and re-publishes the schema. (A congestion
1634                        // drop reports `Ok` and stays undetectable — inherent
1635                        // to `CongestionControl::Drop`; the periodic refresh
1636                        // bounds that residual window.)
1637                        self.zenoh_schema_state.remove(output_id);
1638                        Ok(PublishOutcome::NotPublished(FinalizedSample::Vec(avec)))
1639                    }
1640                }
1641            }
1642        }
1643    }
1644
1645    /// Returns the ID of the node as specified in the dataflow configuration file.
1646    pub fn id(&self) -> &NodeId {
1647        &self.id
1648    }
1649
1650    /// Returns the unique identifier for the running dataflow instance.
1651    ///
1652    /// Dora assigns each dataflow instance a random identifier when started.
1653    pub fn dataflow_id(&self) -> &DataflowId {
1654        &self.dataflow_id
1655    }
1656
1657    /// Returns the input and output configuration of this node.
1658    pub fn node_config(&self) -> &NodeRunConfig {
1659        &self.node_config
1660    }
1661
1662    /// Returns the zero-copy SHM threshold in bytes.
1663    ///
1664    /// Outputs whose raw payload is at least this many bytes are published via
1665    /// zenoh shared memory (zero-copy for local subscribers); smaller outputs
1666    /// are published via zenoh with a heap-buffered put. Configured via the
1667    /// `DORA_ZERO_COPY_THRESHOLD` env var, defaulting to
1668    /// [`ZERO_COPY_THRESHOLD`].
1669    pub fn zero_copy_threshold(&self) -> usize {
1670        self.zenoh_zero_copy_threshold
1671    }
1672
1673    /// Returns true if this node was restarted after a previous exit or failure.
1674    ///
1675    /// Nodes can use this to decide whether to restore saved state or start fresh.
1676    pub fn is_restart(&self) -> bool {
1677        self.restart_count > 0
1678    }
1679
1680    /// Returns how many times this node has been restarted.
1681    ///
1682    /// Returns 0 on the first run, 1 after the first restart, etc.
1683    pub fn restart_count(&self) -> u32 {
1684        self.restart_count
1685    }
1686
1687    /// Returns the current timestamp from the node's Hybrid Logical Clock.
1688    ///
1689    /// This generates a new HLC timestamp, which combines the physical
1690    /// wall-clock time with a logical counter to ensure uniqueness and
1691    /// monotonicity even across nodes. The HLC is the same clock dora
1692    /// stamps every outgoing message with, so this is the right value
1693    /// to subtract from an input event's `metadata.timestamp` when
1694    /// measuring per-event processing latency — using
1695    /// `std::time::SystemTime::now()` instead would mix two unrelated
1696    /// clocks and give meaningless results across daemons.
1697    pub fn timestamp(&self) -> uhlc::Timestamp {
1698        self.clock.new_timestamp()
1699    }
1700
1701    /// Send a structured log message.
1702    ///
1703    /// Outputs a JSONL line to stdout that the daemon parses automatically.
1704    /// Works with `min_log_level` filtering and `send_logs_as` routing.
1705    ///
1706    /// `level` should be one of: `"error"`, `"warn"`, `"info"`, `"debug"`, `"trace"`.
1707    /// Unknown levels default to `"info"`.
1708    pub fn log(&self, level: &str, message: &str, target: Option<&str>) {
1709        self.log_with_fields(level, message, target, None);
1710    }
1711
1712    /// Maximum total size of log fields before they are dropped (60 KB).
1713    /// Matches the downstream 64 KB parse limit with headroom for the message envelope.
1714    const MAX_LOG_FIELDS_BYTES: usize = 60 * 1024;
1715
1716    /// Send a structured log message with optional key-value fields.
1717    ///
1718    /// Like [`log`](Self::log), but accepts additional structured fields that
1719    /// are included in the JSON payload and preserved through `send_logs_as`.
1720    pub fn log_with_fields(
1721        &self,
1722        level: &str,
1723        message: &str,
1724        target: Option<&str>,
1725        fields: Option<&std::collections::BTreeMap<String, String>>,
1726    ) {
1727        let level_str = match level.to_lowercase().as_str() {
1728            "error" => "error",
1729            "warn" | "warning" => "warn",
1730            "info" => "info",
1731            "debug" => "debug",
1732            "trace" => "trace",
1733            _ => "info",
1734        };
1735        let timestamp = chrono::Utc::now().to_rfc3339();
1736        let mut entry = serde_json::json!({
1737            "timestamp": timestamp,
1738            "level": level_str,
1739            "node_id": self.id.to_string(),
1740            "message": message,
1741        });
1742        if let Some(target) = target {
1743            entry["target"] = serde_json::Value::String(target.to_string());
1744        }
1745        if let Some(fields) = fields {
1746            let total: usize = fields.iter().map(|(k, v)| k.len() + v.len()).sum();
1747            if total <= Self::MAX_LOG_FIELDS_BYTES {
1748                entry["fields"] = serde_json::json!(fields);
1749            } else {
1750                eprintln!("dora log: fields too large ({total} bytes), dropping fields");
1751                entry["fields_dropped"] = serde_json::Value::Bool(true);
1752            }
1753        }
1754        match serde_json::to_string(&entry) {
1755            Ok(json) => println!("{json}"),
1756            Err(e) => eprintln!("dora log serialization error: {e}"),
1757        }
1758    }
1759
1760    /// Log an error message.
1761    pub fn log_error(&self, message: &str) {
1762        self.log("error", message, None);
1763    }
1764
1765    /// Log a warning message.
1766    pub fn log_warn(&self, message: &str) {
1767        self.log("warn", message, None);
1768    }
1769
1770    /// Log an info message.
1771    pub fn log_info(&self, message: &str) {
1772        self.log("info", message, None);
1773    }
1774
1775    /// Log a debug message.
1776    pub fn log_debug(&self, message: &str) {
1777        self.log("debug", message, None);
1778    }
1779
1780    /// Log a trace message.
1781    pub fn log_trace(&self, message: &str) {
1782        self.log("trace", message, None);
1783    }
1784
1785    // -----------------------------------------------------------------
1786    // Service / Action helpers
1787    // -----------------------------------------------------------------
1788
1789    /// Generate a new unique request/goal ID (UUID v7, time-ordered).
1790    ///
1791    /// Uses a per-thread monotonic counter context to guarantee uniqueness
1792    /// even when multiple IDs are generated within the same clock tick.
1793    pub fn new_request_id() -> String {
1794        thread_local! {
1795            static CTX: uuid::ContextV7 = const { uuid::ContextV7::new() };
1796        }
1797        CTX.with(|ctx| uuid::Uuid::new_v7(uuid::Timestamp::now(ctx)).to_string())
1798    }
1799
1800    /// Generate a new unique goal ID (UUID v7, time-ordered).
1801    ///
1802    /// This is an alias for [`new_request_id`](Self::new_request_id) that
1803    /// reads more naturally in action (goal/feedback/result) contexts.
1804    pub fn new_goal_id() -> String {
1805        Self::new_request_id()
1806    }
1807
1808    /// Send a service request, automatically injecting a `request_id` into the
1809    /// metadata parameters. Returns the generated request ID.
1810    ///
1811    /// Any existing `request_id` key in `parameters` is replaced.
1812    pub fn send_service_request(
1813        &mut self,
1814        output_id: DataId,
1815        mut parameters: MetadataParameters,
1816        data: impl Array,
1817    ) -> NodeResult<String> {
1818        if parameters.contains_key(dora_message::metadata::REQUEST_ID) {
1819            tracing::warn!("send_service_request: caller-provided request_id will be overwritten");
1820        }
1821        let request_id = Self::new_request_id();
1822        parameters.insert(
1823            dora_message::metadata::REQUEST_ID.to_string(),
1824            dora_message::metadata::Parameter::String(request_id.clone()),
1825        );
1826        self.send_output(output_id, parameters, data)?;
1827        Ok(request_id)
1828    }
1829
1830    /// Send a service response. This is a semantic alias for [`send_output`](Self::send_output).
1831    ///
1832    /// The caller is expected to pass through the `request_id` parameter from
1833    /// the incoming request's metadata.
1834    pub fn send_service_response(
1835        &mut self,
1836        output_id: DataId,
1837        parameters: MetadataParameters,
1838        data: impl Array,
1839    ) -> NodeResult<()> {
1840        self.send_output(output_id, parameters, data)
1841    }
1842
1843    // -----------------------------------------------------------------
1844    // Streaming helpers
1845    // -----------------------------------------------------------------
1846
1847    /// Send a streaming segment chunk. Convenience wrapper around
1848    /// [`send_output`](Self::send_output) that builds metadata from the
1849    /// [`StreamSegment`] builder.
1850    pub fn send_stream_chunk(
1851        &mut self,
1852        output_id: DataId,
1853        segment: &mut StreamSegment,
1854        fin: bool,
1855        data: impl Array,
1856    ) -> NodeResult<()> {
1857        self.send_output(output_id, segment.chunk(fin), data)
1858    }
1859
1860    /// Allocates a [`DataSample`] of the specified size.
1861    ///
1862    /// For payloads at or above the zero-copy threshold the buffer is allocated
1863    /// directly from the zenoh SHM provider (when available), so the producer
1864    /// writes straight into shared memory and publishing moves the buffer into
1865    /// zenoh's `put` without a further copy. Smaller payloads — or the case
1866    /// where no SHM provider exists (interactive/testing mode) — use a
1867    /// heap-allocated, 128-byte-aligned buffer; the SHM provider is
1868    /// page-aligned, so dedicating a full page to a small message is pure waste.
1869    pub fn allocate_data_sample(&mut self, data_len: usize) -> NodeResult<DataSample> {
1870        if data_len >= self.zenoh_zero_copy_threshold
1871            && let Some(provider) = &self.zenoh_shm_provider
1872        {
1873            use zenoh::Wait;
1874            use zenoh::shm::GarbageCollect;
1875            // Non-blocking (see `zenoh_publish`): GC and allocate, but fall back
1876            // to a heap buffer rather than `BlockOn`-sleeping 1 ms when the pool
1877            // is momentarily full under a large-message burst. The heap buffer
1878            // costs one extra copy on publish but keeps the producer from
1879            // stalling, which is what regressed sustained throughput (PR #2366).
1880            match provider
1881                .alloc(data_len)
1882                .with_policy::<GarbageCollect>()
1883                .wait()
1884            {
1885                Ok(sbuf) => {
1886                    // Use the SHM buffer only when it is exactly the requested
1887                    // size — zenoh 1.8 guarantees this (the logical length
1888                    // matches the request even when the backing chunk is
1889                    // larger). If a future provider ever over-allocates, fall
1890                    // back to heap rather than expose or publish an oversized
1891                    // slice (`DataSample` has no length cap of its own).
1892                    if sbuf.as_ref().len() == data_len {
1893                        return Ok(DataSample {
1894                            storage: SampleStorage::Shm(sbuf),
1895                        });
1896                    }
1897                    tracing::debug!(
1898                        "zenoh SHM alloc returned {} bytes for a {data_len}-byte \
1899                         request; using heap",
1900                        sbuf.as_ref().len()
1901                    );
1902                }
1903                Err(e) => {
1904                    tracing::debug!("SHM alloc failed ({e}), using heap buffer");
1905                }
1906            }
1907        }
1908
1909        let avec: AVec<u8, ConstAlign<128>> = AVec::__from_elem(128, 0, data_len);
1910        Ok(avec.into())
1911    }
1912
1913    /// Returns the full dataflow descriptor that this node is part of.
1914    ///
1915    /// This method returns the parsed dataflow YAML file.
1916    pub fn dataflow_descriptor(&self) -> NodeResult<&Descriptor> {
1917        match &self.dataflow_descriptor {
1918            Ok(d) => Ok(d),
1919            Err(err) => Err(NodeError::Data(format!(
1920                "failed to parse dataflow descriptor: {err}\n\n\
1921                    This might be caused by mismatched version numbers of dora \
1922                    daemon and the dora node API"
1923            ))),
1924        }
1925    }
1926
1927    /// Register a pinned memory pool with the daemon for lifecycle tracking.
1928    ///
1929    /// Send the memory pool metadata to the daemon so it can track the pool
1930    /// and provide it to other nodes for zero-copy access.
1931    pub fn register_pinned_memory(
1932        &mut self,
1933        shared_memory_id: String,
1934        metadata: Metadata,
1935    ) -> Result<(), eyre::Error> {
1936        self.control_channel
1937            .register_pinned_memory(shared_memory_id, metadata)
1938    }
1939
1940    /// Read pinned memory metadata from the daemon.
1941    ///
1942    /// When `free` is true, the daemon also frees the pool after reading.
1943    pub fn read_pinned_memory(
1944        &mut self,
1945        shared_memory_id: String,
1946        free: bool,
1947    ) -> Result<Metadata, eyre::Error> {
1948        self.control_channel
1949            .read_pinned_memory(shared_memory_id, free)
1950    }
1951
1952    /// Free a pinned memory pool via the daemon.
1953    pub fn free_pinned_memory(&mut self, shared_memory_id: String) -> Result<(), eyre::Error> {
1954        self.control_channel.free_pinned_memory(shared_memory_id)
1955    }
1956}
1957
1958/// Builder for initializing a node with custom connection parameters.
1959///
1960/// Created via [`DoraNode::builder()`]. Callers who don't need a custom daemon
1961/// port should prefer [`DoraNode::init_from_env`] or
1962/// [`DoraNode::init_from_node_id`]. Setting [`node_id`](Self::node_id) selects
1963/// the dynamic-node path; otherwise [`build`](Self::build) falls back to
1964/// [`DoraNode::init_from_env`].
1965#[derive(Default)]
1966pub struct DoraNodeBuilder {
1967    node_id: Option<NodeId>,
1968    daemon_port: Option<u16>,
1969}
1970
1971impl DoraNodeBuilder {
1972    /// Set the node ID. Presence of a node ID selects the dynamic-node path.
1973    pub fn node_id(mut self, node_id: NodeId) -> Self {
1974        self.node_id = Some(node_id);
1975        self
1976    }
1977
1978    /// No-op kept for source compatibility with upstream dora 0.5.x
1979    /// [`#1591`](https://github.com/dora-rs/dora/pull/1591). Upstream gates the
1980    /// dynamic-node path on an explicit `.dynamic()` call; here, dynamic mode
1981    /// is selected by the presence of `node_id`, making the flag redundant.
1982    /// Kept so that `.node_id(id).dynamic().build()` written against upstream
1983    /// still compiles.
1984    #[inline]
1985    pub fn dynamic(self) -> Self {
1986        self
1987    }
1988
1989    /// Override the daemon port. When unset, the builder honours the
1990    /// `DORA_DAEMON_LOCAL_LISTEN_PORT` env var and falls back to
1991    /// `DORA_DAEMON_LOCAL_LISTEN_PORT_DEFAULT`.
1992    pub fn daemon_port(mut self, port: u16) -> Self {
1993        self.daemon_port = Some(port);
1994        self
1995    }
1996
1997    /// Build and connect the node.
1998    pub fn build(self) -> NodeResult<(DoraNode, EventStream)> {
1999        let Some(node_id) = self.node_id else {
2000            return DoraNode::init_from_env();
2001        };
2002
2003        let port = self.daemon_port.unwrap_or_else(|| {
2004            match std::env::var(DORA_DAEMON_LOCAL_LISTEN_PORT_ENV) {
2005                Ok(p) => p.parse().unwrap_or_else(|e| {
2006                    tracing::warn!(
2007                        "invalid {DORA_DAEMON_LOCAL_LISTEN_PORT_ENV}={p:?}: {e}, using default port"
2008                    );
2009                    DORA_DAEMON_LOCAL_LISTEN_PORT_DEFAULT
2010                }),
2011                Err(_) => DORA_DAEMON_LOCAL_LISTEN_PORT_DEFAULT,
2012            }
2013        });
2014        let daemon_address = (LOCALHOST, port).into();
2015
2016        let mut channel =
2017            DaemonChannel::new_tcp(daemon_address).context("Could not connect to the daemon")?;
2018        let clock = Arc::new(uhlc::HLC::default());
2019
2020        let reply = channel
2021            .request(&Timestamped {
2022                inner: DaemonRequest::NodeConfig { node_id },
2023                timestamp: clock.new_timestamp(),
2024            })
2025            .wrap_err("failed to request node config from daemon")?;
2026
2027        match reply {
2028            DaemonReply::NodeConfig {
2029                result: Ok(node_config),
2030            } => DoraNode::init(node_config),
2031            DaemonReply::NodeConfig { result: Err(error) } => {
2032                let capped: String = error.chars().take(512).collect();
2033                Err(NodeError::Init(format!(
2034                    "failed to get node config from daemon: {capped}"
2035                )))
2036            }
2037            _ => Err(NodeError::Init("unexpected reply from daemon".into())),
2038        }
2039    }
2040}
2041
2042/// Runs `teardown` on a dedicated thread, waiting at most `timeout` for it to
2043/// complete. Returns `true` if the teardown finished in time. Panics in
2044/// `teardown` are contained and count as completion. If spawning the thread
2045/// fails, the teardown runs inline without a deadline.
2046///
2047/// On timeout the thread keeps running detached: everything moved into the
2048/// closure (zenoh sockets, SHM segments, the owned tokio runtime) is leaked
2049/// until process exit. That is acceptable for nodes dropped right before
2050/// exit; long-lived hosts (e.g. a Python interpreter dropping a node during
2051/// GC) inherit only the bounded delay instead of a permanent hang.
2052pub(crate) fn teardown_with_timeout(
2053    label: &str,
2054    timeout: Duration,
2055    teardown: impl FnOnce() + Send + 'static,
2056) -> bool {
2057    // The closure is handed over via a channel (instead of being captured by
2058    // the spawned closure) so that it stays available for the inline
2059    // fallback when spawning fails.
2060    let (work_tx, work_rx) = std::sync::mpsc::channel();
2061    let (done_tx, done_rx) = std::sync::mpsc::channel();
2062    let thread = std::thread::Builder::new()
2063        .name(format!("dora-teardown-{label}"))
2064        .spawn(move || {
2065            if let Ok(work) = work_rx.recv() {
2066                let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(work));
2067            }
2068            let _ = done_tx.send(());
2069        });
2070    match thread {
2071        Ok(_) => {
2072            let _ = work_tx.send(teardown);
2073            done_rx.recv_timeout(timeout).is_ok()
2074        }
2075        Err(err) => {
2076            warn!("failed to spawn {label} teardown thread ({err}); running it inline");
2077            let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(teardown));
2078            true
2079        }
2080    }
2081}
2082
2083impl Drop for DoraNode {
2084    fn drop(&mut self) {
2085        // Tear down zenoh before notifying the daemon below, so that
2086        // daemon-signaled `InputClosed` cannot overtake in-flight zenoh data.
2087        let publishers = std::mem::take(&mut self.zenoh_publishers);
2088        let schema_publishers = std::mem::take(&mut self.zenoh_schema_publishers);
2089        // Per-output readiness counters hold liveliness subscribers on the shared
2090        // session; drop them before the session (same reasoning as publishers).
2091        let readiness = std::mem::take(&mut self.zenoh_output_readiness);
2092        let shm_provider = self.zenoh_shm_provider.take();
2093        let session = self.zenoh_session.take();
2094        let runtime = self._owned_runtime.take();
2095        if session.is_none() && shm_provider.is_none() && publishers.is_empty() {
2096            // no zenoh state (interactive/testing mode): drop inline
2097            drop(runtime);
2098        } else {
2099            // A wedged zenoh net runtime stalls `Session` close beyond its
2100            // 10s timeout and `Publisher` undeclare indefinitely, which would
2101            // hang node shutdown (and with it the daemon, which waits for
2102            // `InputClosed`). Bound the teardown with a deadline instead.
2103            let completed = teardown_with_timeout("zenoh", ZENOH_TEARDOWN_TIMEOUT, move || {
2104                // documented drop order: publishers (data + schema) and readiness
2105                // subscribers before the session, owned runtime last so async
2106                // cleanup can still run
2107                drop(publishers);
2108                drop(schema_publishers);
2109                drop(readiness);
2110                drop(shm_provider);
2111                drop(session);
2112                drop(runtime);
2113            });
2114            if !completed {
2115                warn!(
2116                    "zenoh teardown timed out after {}s; continuing node shutdown",
2117                    ZENOH_TEARDOWN_TIMEOUT.as_secs()
2118                );
2119            }
2120        }
2121
2122        // close all outputs first to notify subscribers as early as possible
2123        if let Err(err) = self
2124            .control_channel
2125            .report_closed_outputs(
2126                std::mem::take(&mut self.node_config.outputs)
2127                    .into_iter()
2128                    .collect(),
2129            )
2130            .context("failed to close outputs on drop")
2131        {
2132            tracing::warn!("{err:?}")
2133        }
2134
2135        if let Err(err) = self.control_channel.report_outputs_done() {
2136            tracing::warn!("{err:?}")
2137        }
2138    }
2139}
2140
2141/// A data region suitable for sending as an output message.
2142///
2143/// `DataSample` implements the [`Deref`](std::ops::Deref) and
2144/// [`DerefMut`](std::ops::DerefMut) traits to read and write the mapped data.
2145///
2146/// The backing storage is either a heap buffer or — for payloads at or above
2147/// the zero-copy threshold when a zenoh SHM provider is available — a
2148/// zenoh-shared-memory buffer. Writing into an SHM-backed sample lets the
2149/// producer construct the message straight in shared memory, so publishing it
2150/// needs no further copy (the SHM buffer is moved directly into zenoh's `put`).
2151pub struct DataSample {
2152    storage: SampleStorage,
2153}
2154
2155/// Backing storage for a [`DataSample`]. Kept private so the public API never
2156/// exposes a zenoh SHM type; callers only ever see the `[u8]` view via
2157/// `Deref`/`DerefMut`.
2158enum SampleStorage {
2159    /// Heap-allocated, 128-byte-aligned buffer (used below the zero-copy
2160    /// threshold or when no SHM provider is available).
2161    Heap(AVec<u8, ConstAlign<128>>),
2162    /// Zenoh shared-memory buffer. The producer writes the payload directly
2163    /// into it and the buffer is later moved into the zenoh `put` without
2164    /// copying.
2165    Shm(zenoh::shm::ZShmMut),
2166}
2167
2168impl DataSample {
2169    /// Consume the sample into a [`FinalizedSample`] ready for transport.
2170    fn finalize(self) -> FinalizedSample {
2171        match self.storage {
2172            SampleStorage::Heap(buffer) => FinalizedSample::Vec(buffer),
2173            SampleStorage::Shm(sbuf) => FinalizedSample::Shm(sbuf),
2174        }
2175    }
2176}
2177
2178impl std::ops::Deref for DataSample {
2179    type Target = [u8];
2180
2181    fn deref(&self) -> &Self::Target {
2182        match &self.storage {
2183            SampleStorage::Heap(buffer) => buffer,
2184            SampleStorage::Shm(sbuf) => sbuf.as_ref(),
2185        }
2186    }
2187}
2188
2189impl std::ops::DerefMut for DataSample {
2190    fn deref_mut(&mut self) -> &mut Self::Target {
2191        match &mut self.storage {
2192            SampleStorage::Heap(buffer) => buffer,
2193            SampleStorage::Shm(sbuf) => sbuf.as_mut(),
2194        }
2195    }
2196}
2197
2198impl From<AVec<u8, ConstAlign<128>>> for DataSample {
2199    fn from(value: AVec<u8, ConstAlign<128>>) -> Self {
2200        Self {
2201            storage: SampleStorage::Heap(value),
2202        }
2203    }
2204}
2205
2206impl std::fmt::Debug for DataSample {
2207    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2208        f.debug_struct("DataSample")
2209            .field("len", &self.len())
2210            .finish_non_exhaustive()
2211    }
2212}
2213
2214/// A finalized output payload ready for transport.
2215///
2216/// Kept separate from [`DataMessage`] so SHM buffers stay out of the
2217/// `Serialize`/`Deserialize` TCP path: the zenoh data plane moves an `Shm`
2218/// buffer straight into `put` (zero extra copy), while the daemon fallback
2219/// converts to [`DataMessage::Vec`], copying out of shared memory only when the
2220/// zenoh path could not deliver.
2221enum FinalizedSample {
2222    Vec(AVec<u8, ConstAlign<128>>),
2223    Shm(zenoh::shm::ZShmMut),
2224}
2225
2226impl FinalizedSample {
2227    fn byte_len(&self) -> usize {
2228        match self {
2229            FinalizedSample::Vec(v) => v.len(),
2230            FinalizedSample::Shm(sbuf) => sbuf.as_ref().len(),
2231        }
2232    }
2233
2234    /// Convert into a TCP-transportable [`DataMessage`]. For the `Shm` arm this
2235    /// copies the payload out of shared memory into a heap buffer; it runs only
2236    /// on the daemon fallback (no matching zenoh subscriber / no session).
2237    fn into_data_message(self) -> DataMessage {
2238        match self {
2239            FinalizedSample::Vec(v) => DataMessage::Vec(v),
2240            FinalizedSample::Shm(sbuf) => {
2241                let bytes = sbuf.as_ref();
2242                let mut avec: AVec<u8, ConstAlign<128>> = AVec::__from_elem(128, 0, bytes.len());
2243                avec.copy_from_slice(bytes);
2244                DataMessage::Vec(avec)
2245            }
2246        }
2247    }
2248}
2249
2250/// Outcome of a zenoh publish attempt.
2251enum PublishOutcome {
2252    /// The payload was delivered to zenoh (or, on a rare SHM put error,
2253    /// consumed and lost — see [`DoraNode::zenoh_publish`]).
2254    Published,
2255    /// No matching subscriber, or a transport error before the payload was
2256    /// consumed. The sample is returned so the caller can fall back to the
2257    /// daemon path.
2258    NotPublished(FinalizedSample),
2259}
2260
2261/// FNV-1a hash of `bytes` with a fixed seed (cross-process deterministic).
2262/// Delegates to [`dora_message::metadata::fnv1a`] — the single source of truth
2263/// shared with the daemon's `dora topic` debug path, so schema hashes match.
2264pub(crate) fn fnv1a(bytes: &[u8]) -> u64 {
2265    dora_message::metadata::fnv1a(bytes)
2266}
2267
2268/// How often a schema-once output re-sends a full self-describing stream on the
2269/// data topic. Full streams prime receivers in-band, so this bounds how long a
2270/// consumer that missed the single `@schema` emission (e.g. a failed zenoh-ext
2271/// history query) drops schema-less batches: it re-primes at the next refresh
2272/// instead of losing the input permanently. ~400 B of extra framing per output
2273/// per interval — negligible.
2274pub(crate) const SCHEMA_ONCE_REFRESH_INTERVAL: Duration = Duration::from_secs(5);
2275
2276/// Producer-side schema-once state for one output.
2277struct SchemaOnceState {
2278    /// Hash of the schema confirmed published on the `@schema` subtopic.
2279    published_hash: u64,
2280    /// When the last full self-describing stream was sent on the data topic.
2281    last_full_stream: Instant,
2282}
2283
2284/// What `publish_schema_once` should do for the current message.
2285#[derive(Debug)]
2286enum SchemaOnceDecision {
2287    /// The schema for this hash is not confirmed published (first message,
2288    /// schema change, or an earlier `@schema` publish failed): publish it and
2289    /// send this message as a full self-describing stream. The full stream
2290    /// decodes standalone and primes receivers in-band — in data-plane order —
2291    /// so the first message of an output cannot be lost to the express batch
2292    /// racing ahead of the schema on the separate `@schema` plane, and a failed
2293    /// schema publish degrades to "full stream every message" (decodable)
2294    /// instead of "hash-tagged but undecodable" (dora-rs/dora#2366 review).
2295    PublishSchemaAndSendFullStream,
2296    /// The periodic full-stream refresh is due (see
2297    /// [`SCHEMA_ONCE_REFRESH_INTERVAL`]).
2298    SendFullStreamRefresh,
2299    /// Schema confirmed published and fresh: send only the schema-less batch,
2300    /// tagged with the schema hash.
2301    SendSchemaLessBatch,
2302}
2303
2304fn schema_once_decision(
2305    state: Option<&SchemaOnceState>,
2306    hash: u64,
2307    now: Instant,
2308) -> SchemaOnceDecision {
2309    match state {
2310        Some(state) if state.published_hash == hash => {
2311            if now.duration_since(state.last_full_stream) >= SCHEMA_ONCE_REFRESH_INTERVAL {
2312                SchemaOnceDecision::SendFullStreamRefresh
2313            } else {
2314                SchemaOnceDecision::SendSchemaLessBatch
2315            }
2316        }
2317        _ => SchemaOnceDecision::PublishSchemaAndSendFullStream,
2318    }
2319}
2320
2321/// Publish the Arrow IPC schema for `output_id` on its `@schema` subtopic when
2322/// it changes, and return the attachment metadata (carrying the schema hash)
2323/// for the schema-less batch the caller sends on the data topic. Returns `None`
2324/// when the caller must send the full self-describing stream instead: on the
2325/// message that (re)publishes the schema, when the `@schema` publish failed,
2326/// for the periodic full-stream refresh, or if `full_stream` is not a parseable
2327/// IPC stream (see [`SchemaOnceDecision`]).
2328///
2329/// Takes the maps by `&mut` (not `&mut self`) so it can run while an immutable
2330/// borrow of `self.zenoh_publishers` (the data publisher) is live.
2331#[allow(clippy::too_many_arguments)]
2332fn publish_schema_once(
2333    schema_publishers: &mut HashMap<DataId, zenoh_ext::AdvancedPublisher<'static>>,
2334    schema_state: &mut HashMap<DataId, SchemaOnceState>,
2335    session: &zenoh::Session,
2336    dataflow_id: DataflowId,
2337    node_id: &NodeId,
2338    output_id: &DataId,
2339    full_stream: &[u8],
2340    base_metadata: &Metadata,
2341) -> Option<Vec<u8>> {
2342    let (hash, schema_bytes) = arrow_utils::ipc_encode::schema_block_and_hash(full_stream)?;
2343
2344    let now = Instant::now();
2345    let decision = schema_once_decision(schema_state.get(output_id), hash, now);
2346    tracing::debug!(output = %output_id, decision = ?decision, "schema-once decision");
2347
2348    match decision {
2349        SchemaOnceDecision::PublishSchemaAndSendFullStream => {
2350            if let Some(publisher) =
2351                schema_publisher(schema_publishers, session, dataflow_id, node_id, output_id)
2352            {
2353                use zenoh::Wait;
2354                match publisher.put(schema_bytes).wait() {
2355                    // Record the hash only on a successful publish, so a failed
2356                    // emission is retried on the next message rather than
2357                    // silently skipped.
2358                    Ok(()) => {
2359                        tracing::debug!(output = %output_id, hash, "schema published on @schema subtopic");
2360                        schema_state.insert(
2361                            output_id.clone(),
2362                            SchemaOnceState {
2363                                published_hash: hash,
2364                                last_full_stream: now,
2365                            },
2366                        );
2367                    }
2368                    Err(e) => {
2369                        tracing::warn!(output = %output_id, "failed to publish schema on @schema subtopic ({e})");
2370                    }
2371                }
2372            }
2373            None
2374        }
2375        SchemaOnceDecision::SendFullStreamRefresh => {
2376            tracing::debug!(output = %output_id, hash, "sending full-stream refresh");
2377            if let Some(state) = schema_state.get_mut(output_id) {
2378                state.last_full_stream = now;
2379            }
2380            None
2381        }
2382        SchemaOnceDecision::SendSchemaLessBatch => {
2383            tracing::debug!(output = %output_id, hash, "sending schema-less batch with SCHEMA_HASH");
2384            // Every batch carries the schema hash so the receiver can match it
2385            // to the primed decoder (and detect a schema change).
2386            let mut metadata = base_metadata.clone();
2387            metadata
2388                .parameters
2389                .insert(SCHEMA_HASH.to_string(), Parameter::Integer(hash as i64));
2390            bincode::serialize(&metadata).ok()
2391        }
2392    }
2393}
2394
2395/// Get or lazily declare the schema `AdvancedPublisher` for `output_id` on its
2396/// `@schema` subtopic. The cache (depth 1) retains the last schema so a
2397/// late-joining subscriber's history query can fetch it; `publisher_detection`
2398/// lets a subscriber that started first discover this publisher and query its
2399/// cache. `CongestionControl::Block` keeps the single live schema emission from
2400/// being dropped under congestion.
2401fn schema_publisher<'a>(
2402    schema_publishers: &'a mut HashMap<DataId, zenoh_ext::AdvancedPublisher<'static>>,
2403    session: &zenoh::Session,
2404    dataflow_id: DataflowId,
2405    node_id: &NodeId,
2406    output_id: &DataId,
2407) -> Option<&'a zenoh_ext::AdvancedPublisher<'static>> {
2408    if !schema_publishers.contains_key(output_id) {
2409        use zenoh::Wait;
2410        use zenoh::qos::CongestionControl;
2411        use zenoh_ext::{AdvancedPublisherBuilderExt, CacheConfig, MissDetectionConfig};
2412
2413        let topic = dora_core::topics::zenoh_output_schema_topic(dataflow_id, node_id, output_id);
2414        let key = zenoh::key_expr::KeyExpr::new(topic).ok()?.into_owned();
2415        let publisher = match session
2416            .declare_publisher(key)
2417            .congestion_control(CongestionControl::Block)
2418            // `sample_miss_detection` selects SequenceNumber sequencing instead of
2419            // the cache's default Timestamp sequencing, so the schema publisher
2420            // doesn't require session-wide timestamping (which would otherwise add
2421            // an HLC timestamp to every data-plane message too). Its default
2422            // config adds no heartbeat, so there's no extra periodic traffic.
2423            .sample_miss_detection(MissDetectionConfig::default())
2424            .cache(CacheConfig::default())
2425            .publisher_detection()
2426            .wait()
2427        {
2428            Ok(p) => p,
2429            Err(e) => {
2430                tracing::warn!(output = %output_id, "failed to declare schema publisher ({e})");
2431                return None;
2432            }
2433        };
2434        schema_publishers.insert(output_id.clone(), publisher);
2435    }
2436    schema_publishers.get(output_id)
2437}
2438
2439pub(crate) use dora_message::metadata::carries_pattern_correlation;
2440
2441/// Whether the schema-once optimization may be applied to a data-plane message.
2442///
2443/// Eligible only when the payload is below the zero-copy threshold *and* the
2444/// output does not interleave multiple Arrow schemas. Service/action
2445/// request-reply messages (`request_id`/`goal_id`/`goal_status`) multiplex
2446/// response schemas per request and must travel as full self-describing streams
2447/// so each decodes standalone; streaming chunks share one schema and stay
2448/// eligible. See the rationale at the call site in `zenoh_publish`.
2449fn schema_once_eligible(
2450    payload_len: usize,
2451    zero_copy_threshold: usize,
2452    params: &MetadataParameters,
2453) -> bool {
2454    payload_len < zero_copy_threshold && !carries_pattern_correlation(params)
2455}
2456
2457/// Init Opentelemetry Tracing
2458///
2459/// This requires a tokio runtime spawning this function to be functional
2460#[cfg(feature = "tracing")]
2461pub fn init_tracing(
2462    node_id: &NodeId,
2463    dataflow_id: &DataflowId,
2464) -> NodeResult<Arc<Mutex<Option<OtelGuard>>>> {
2465    let node_id_str = node_id.to_string();
2466    let guard: Arc<Mutex<Option<OtelGuard>>> = Arc::new(Mutex::new(None));
2467    let clone = guard.clone();
2468    let tracing_monitor = async move {
2469        let mut builder = TracingBuilder::new(node_id_str.clone());
2470        // Only enable OTLP if environment variable is set
2471        if std::env::var("DORA_OTLP_ENDPOINT").is_ok()
2472            || std::env::var("DORA_JAEGER_TRACING").is_ok()
2473        {
2474            match builder.with_otlp_tracing() {
2475                Ok(b) => {
2476                    builder = b.with_stdout("info", true);
2477                    if let Ok(mut guard) = clone.lock() {
2478                        *guard = builder.guard.take();
2479                    }
2480                }
2481                Err(e) => {
2482                    eprintln!("warning: failed to set up OTLP tracing: {e:?}");
2483                    // Rebuild without OTLP — with_otlp_tracing consumed builder
2484                    builder = TracingBuilder::new(node_id_str).with_stdout("info", true);
2485                }
2486            }
2487        } else {
2488            builder = builder.with_stdout("info", true);
2489        }
2490
2491        if let Err(e) = builder.build() {
2492            eprintln!("warning: failed to set up tracing subscriber: {e:?}");
2493        }
2494    };
2495
2496    let rt = Handle::try_current().context("failed to get tokio runtime handle")?;
2497    rt.spawn(tracing_monitor);
2498
2499    // dataflow_id is only used when metrics feature is enabled
2500    let _ = &dataflow_id;
2501
2502    // Only start the OTLP metrics exporter when an endpoint is configured.
2503    // The exporter schedules via `tokio::time::interval` and would otherwise
2504    // panic on callers whose runtime lacks the time driver, and would also
2505    // attempt to connect to `localhost:4317` on every node startup. Mirrors
2506    // the gating applied to tracing above.
2507    #[cfg(feature = "metrics")]
2508    if std::env::var("DORA_OTLP_ENDPOINT").is_ok() {
2509        let id = format!("{dataflow_id}/{node_id}");
2510        let monitor_task = async move {
2511            use dora_metrics::run_metrics_monitor;
2512
2513            if let Err(e) = run_metrics_monitor(id.clone())
2514                .await
2515                .wrap_err("metrics monitor exited unexpectedly")
2516            {
2517                warn!("metrics monitor failed: {:#?}", e);
2518            }
2519        };
2520        let rt = Handle::try_current().context("failed to get tokio runtime handle")?;
2521        rt.spawn(monitor_task);
2522    }
2523    Ok(guard)
2524}
2525
2526/// Builder for streaming segment metadata.
2527///
2528/// Manages session/segment IDs and auto-incrementing sequence numbers
2529/// for real-time streaming patterns (voice, video, sensor streams).
2530pub struct StreamSegment {
2531    session_id: String,
2532    segment_id: i64,
2533    seq: i64,
2534}
2535
2536impl StreamSegment {
2537    /// Start a new session with a generated session ID and segment 0.
2538    pub fn new() -> Self {
2539        Self {
2540            session_id: DoraNode::new_request_id(),
2541            segment_id: 0,
2542            seq: 0,
2543        }
2544    }
2545
2546    /// Start a new session with an explicit session ID.
2547    pub fn with_session_id(session_id: String) -> Self {
2548        Self {
2549            session_id,
2550            segment_id: 0,
2551            seq: 0,
2552        }
2553    }
2554
2555    /// Advance to a new segment (resets seq to 0). Returns the new segment_id.
2556    pub fn next_segment(&mut self) -> i64 {
2557        self.segment_id += 1;
2558        self.seq = 0;
2559        self.segment_id
2560    }
2561
2562    /// Build metadata parameters for a chunk. Auto-increments seq.
2563    pub fn chunk(&mut self, fin: bool) -> MetadataParameters {
2564        let mut params = MetadataParameters::new();
2565        params.insert(
2566            SESSION_ID.into(),
2567            Parameter::String(self.session_id.clone()),
2568        );
2569        params.insert(SEGMENT_ID.into(), Parameter::Integer(self.segment_id));
2570        params.insert(SEQ.into(), Parameter::Integer(self.seq));
2571        params.insert(FIN.into(), Parameter::Bool(fin));
2572        self.seq += 1;
2573        params
2574    }
2575
2576    /// Build metadata for a flush message (new segment, discards older queued data).
2577    ///
2578    /// Advances to a new segment, then emits a chunk with `flush=true` and
2579    /// `fin=false`. The prior segment ends without a `fin=true` signal -- this
2580    /// is intentional for interruption semantics (the old data is being
2581    /// discarded, not completed).
2582    ///
2583    /// **Note**: flush discards *all* queued messages on the receiver's input
2584    /// regardless of `session_id`. Do not multiplex independent sessions on a
2585    /// single `DataId` when using flush.
2586    pub fn flush(&mut self) -> MetadataParameters {
2587        self.next_segment();
2588        let mut params = self.chunk(false);
2589        params.insert(FLUSH.into(), Parameter::Bool(true));
2590        params
2591    }
2592
2593    /// Returns the session ID.
2594    pub fn session_id(&self) -> &str {
2595        &self.session_id
2596    }
2597
2598    /// Returns the current segment ID.
2599    pub fn segment_id(&self) -> i64 {
2600        self.segment_id
2601    }
2602
2603    /// Returns the sequence number that will be used by the next `chunk()` call.
2604    pub fn seq(&self) -> i64 {
2605        self.seq
2606    }
2607}
2608
2609impl Default for StreamSegment {
2610    fn default() -> Self {
2611        Self::new()
2612    }
2613}
2614
2615#[cfg(test)]
2616mod tests {
2617    use super::*;
2618    use crate::integration_testing::{
2619        IntegrationTestInput, TestingInput, TestingOptions, TestingOutput,
2620        integration_testing_format::{IncomingEvent, TimedIncomingEvent},
2621    };
2622    use arrow::array::NullArray;
2623
2624    #[test]
2625    fn new_request_id_returns_valid_uuid() {
2626        let id = DoraNode::new_request_id();
2627        uuid::Uuid::parse_str(&id).expect("should be valid UUID");
2628    }
2629
2630    #[test]
2631    fn new_request_id_is_unique() {
2632        let ids: Vec<String> = (0..100).map(|_| DoraNode::new_request_id()).collect();
2633        let unique: std::collections::HashSet<_> = ids.iter().collect();
2634        assert_eq!(ids.len(), unique.len(), "all IDs should be unique");
2635    }
2636
2637    #[test]
2638    fn new_goal_id_returns_valid_uuid() {
2639        let id = DoraNode::new_goal_id();
2640        uuid::Uuid::parse_str(&id).expect("should be valid UUID");
2641    }
2642
2643    /// `DoraNode::timestamp()` must read from the SAME HLC the node
2644    /// uses to stamp outgoing messages. If a refactor accidentally
2645    /// gives `timestamp()` its own clock, the latency-measurement use
2646    /// case in the docstring silently breaks (subtracting against an
2647    /// `event.metadata.timestamp` from the data plane would mix two
2648    /// unrelated HLCs). Guard by asserting two calls share an HLC ID
2649    /// and that the second reads strictly later than the first.
2650    ///
2651    /// The strict `t2 > t1` assertion holds by HLC construction: if
2652    /// the wall clock advanced between calls, the physical component
2653    /// strictly increases; if not, the logical counter bumps. The
2654    /// lexicographic ordering on `uhlc::Timestamp` puts `t2` strictly
2655    /// after `t1` in either case, so this assertion does not flake on
2656    /// fast machines whose OS clock rounds both calls to the same tick.
2657    #[test]
2658    fn timestamp_uses_node_clock_and_is_monotonic() {
2659        let (node, events, _rx) = test_node();
2660        let t1 = node.timestamp();
2661        let t2 = node.timestamp();
2662        assert_eq!(
2663            t1.get_id(),
2664            t2.get_id(),
2665            "two timestamp() calls must come from the same HLC instance",
2666        );
2667        assert!(
2668            t2 > t1,
2669            "HLC timestamps must be strictly monotonic: {t1:?} >= {t2:?}"
2670        );
2671        drop(node);
2672        drop(events);
2673    }
2674
2675    /// Helper: create a minimal test node with a channel output.
2676    fn test_node() -> (
2677        DoraNode,
2678        crate::EventStream,
2679        flume::Receiver<serde_json::Map<String, serde_json::Value>>,
2680    ) {
2681        let events = vec![TimedIncomingEvent {
2682            time_offset_secs: 0.1,
2683            event: IncomingEvent::Stop,
2684        }];
2685        let inputs = TestingInput::Input(IntegrationTestInput::new(
2686            "test-node".parse().unwrap(),
2687            events,
2688        ));
2689        let (tx, rx) = flume::unbounded();
2690        let outputs = TestingOutput::ToChannel(tx);
2691        let options = TestingOptions {
2692            skip_output_time_offsets: true,
2693        };
2694        let (node, event_stream) = DoraNode::init_testing(inputs, outputs, options).unwrap();
2695        (node, event_stream, rx)
2696    }
2697
2698    #[test]
2699    fn send_service_request_returns_valid_id_and_sends_output() {
2700        let (mut node, events, rx) = test_node();
2701
2702        let request_id = node
2703            .send_service_request("request".into(), Default::default(), NullArray::new(0))
2704            .unwrap();
2705
2706        // Returned ID should be a valid UUID
2707        uuid::Uuid::parse_str(&request_id).expect("returned request_id should be valid UUID");
2708
2709        // Output should have been sent to the channel
2710        drop(node);
2711        drop(events);
2712        let outputs: Vec<_> = rx.try_iter().collect();
2713        assert_eq!(outputs.len(), 1);
2714        assert_eq!(outputs[0]["id"], "request");
2715    }
2716
2717    #[test]
2718    fn send_service_request_returns_unique_ids() {
2719        let (mut node, events, _rx) = test_node();
2720
2721        let id1 = node
2722            .send_service_request("out".into(), Default::default(), NullArray::new(0))
2723            .unwrap();
2724        let id2 = node
2725            .send_service_request("out".into(), Default::default(), NullArray::new(0))
2726            .unwrap();
2727
2728        assert_ne!(id1, id2, "successive request IDs should differ");
2729
2730        drop(node);
2731        drop(events);
2732    }
2733
2734    #[test]
2735    fn send_service_response_sends_output() {
2736        let (mut node, events, rx) = test_node();
2737
2738        // Simulate passing through a request_id from the incoming request
2739        let mut params = MetadataParameters::default();
2740        params.insert(
2741            dora_message::metadata::REQUEST_ID.to_string(),
2742            dora_message::metadata::Parameter::String("test-req-id".into()),
2743        );
2744        node.send_service_response("response".into(), params, NullArray::new(0))
2745            .unwrap();
2746
2747        drop(node);
2748        drop(events);
2749        let outputs: Vec<_> = rx.try_iter().collect();
2750        assert_eq!(outputs.len(), 1);
2751        assert_eq!(outputs[0]["id"], "response");
2752    }
2753
2754    /// `send_output_bytes` must reject a `data_len` that disagrees with
2755    /// `data.len()` with a clear error instead of panicking inside
2756    /// `copy_from_slice` deep in `send_output_raw`.
2757    #[test]
2758    fn send_output_bytes_rejects_len_mismatch() {
2759        let (mut node, events, _rx) = test_node();
2760
2761        let result = node.send_output_bytes("out".into(), Default::default(), 8, &[1, 2, 3, 4]);
2762
2763        let err = result.expect_err("mismatched data_len must error, not panic");
2764        assert!(
2765            err.to_string().contains("does not match"),
2766            "unexpected error message: {err}"
2767        );
2768
2769        drop(node);
2770        drop(events);
2771    }
2772
2773    /// A heap-backed `DataSample` is writable through `DerefMut`, readable
2774    /// through `Deref`, and `finalize().into_data_message()` preserves the bytes
2775    /// as the `DataMessage::Vec` daemon-path payload. (The SHM-backed arm needs
2776    /// a live zenoh provider and is covered by the copy-count harness/smoke.)
2777    #[test]
2778    fn data_sample_heap_roundtrip() {
2779        let avec: AVec<u8, ConstAlign<128>> = AVec::__from_elem(128, 0, 8);
2780        let mut sample: DataSample = avec.into();
2781        sample.copy_from_slice(&[1, 2, 3, 4, 5, 6, 7, 8]);
2782
2783        assert_eq!(&sample[..], &[1, 2, 3, 4, 5, 6, 7, 8]);
2784        assert_eq!(sample.len(), 8);
2785
2786        match sample.finalize().into_data_message() {
2787            DataMessage::Vec(v) => assert_eq!(v.as_slice(), &[1, 2, 3, 4, 5, 6, 7, 8]),
2788        }
2789    }
2790
2791    /// End-to-end wire contract: a few representative arrays IPC-encoded (fast
2792    /// path) into a sample and decoded back via `decode_arrow_ipc_zero_copy`
2793    /// must equal the input. This is the send->receive round-trip the data
2794    /// plane relies on (zenoh can't be smoke-tested here, so this stands in).
2795    #[test]
2796    fn send_output_ipc_roundtrip() {
2797        use crate::arrow_utils::decode_arrow_ipc_zero_copy;
2798        use crate::arrow_utils::ipc_encode::{encode_ipc_into, ipc_fast_path_len};
2799        use arrow::array::{ArrayRef, Float32Array, StringArray, StructArray, UInt64Array};
2800        use arrow_schema::{DataType, Field};
2801        use std::ptr::NonNull;
2802
2803        fn roundtrip(data: ArrayData) {
2804            let len = ipc_fast_path_len(&data).expect("array should be fast-path eligible");
2805            let mut buf: AVec<u8, ConstAlign<128>> = AVec::__from_elem(128, 0, len);
2806            encode_ipc_into(&data, &mut buf).expect("fast-path IPC encode");
2807
2808            // Wrap the aligned sample as an Arrow Buffer (no copy), mirroring the
2809            // receive path, then decode.
2810            let ptr = NonNull::new(buf.as_ptr() as *mut u8).unwrap();
2811            let blen = buf.len();
2812            // SAFETY: ptr/len describe `buf`; the Arc keeps it alive.
2813            let buffer =
2814                unsafe { arrow::buffer::Buffer::from_custom_allocation(ptr, blen, Arc::new(buf)) };
2815            let decoded = decode_arrow_ipc_zero_copy(buffer).expect("zero-copy IPC decode");
2816            assert_eq!(
2817                data, decoded,
2818                "IPC send->receive round-trip must preserve the array"
2819            );
2820        }
2821
2822        roundtrip(Float32Array::from(vec![1.0, 2.5, -3.0, 4.0]).into_data());
2823        roundtrip(UInt64Array::from(vec![Some(1), None, Some(3)]).into_data());
2824        roundtrip(StringArray::from(vec![Some("hello"), None, Some("world")]).into_data());
2825        roundtrip(
2826            StructArray::from(vec![
2827                (
2828                    Arc::new(Field::new("v", DataType::UInt64, true)),
2829                    Arc::new(UInt64Array::from(vec![Some(1), None, Some(3)])) as ArrayRef,
2830                ),
2831                (
2832                    Arc::new(Field::new("s", DataType::Utf8, true)),
2833                    Arc::new(StringArray::from(vec![Some("a"), Some("bb"), None])) as ArrayRef,
2834                ),
2835            ])
2836            .into_data(),
2837        );
2838    }
2839
2840    /// `close_outputs` must be atomic: if any id in the batch is unknown, the
2841    /// call fails *without* removing the valid ids from the local output set.
2842    /// Otherwise the daemon (never notified, because `report_closed_outputs` is
2843    /// skipped on error) and the node disagree about which outputs are open, and
2844    /// the node would silently drop subsequent sends to a still-open output.
2845    #[test]
2846    fn close_outputs_is_atomic_on_unknown_id() {
2847        let (mut node, events, _rx) = test_node();
2848        let valid: DataId = "valid".into();
2849        node.node_config.outputs.insert(valid.clone());
2850
2851        let result = node.close_outputs(vec![valid.clone(), "unknown".into()]);
2852
2853        assert!(
2854            result.is_err(),
2855            "closing a batch containing an unknown output must fail"
2856        );
2857        assert!(
2858            node.node_config.outputs.contains(&valid),
2859            "a failed close_outputs must not remove the valid output from local state"
2860        );
2861
2862        drop(node);
2863        drop(events);
2864    }
2865
2866    // ---- dora-rs/adora#150: pattern polymorphism exemption ----
2867
2868    #[test]
2869    fn carries_pattern_correlation_detects_request_id() {
2870        let mut params = MetadataParameters::default();
2871        params.insert(
2872            dora_message::metadata::REQUEST_ID.to_string(),
2873            dora_message::metadata::Parameter::String("req-1".into()),
2874        );
2875        assert!(carries_pattern_correlation(&params));
2876    }
2877
2878    #[test]
2879    fn carries_pattern_correlation_detects_goal_id() {
2880        let mut params = MetadataParameters::default();
2881        params.insert(
2882            dora_message::metadata::GOAL_ID.to_string(),
2883            dora_message::metadata::Parameter::String("goal-1".into()),
2884        );
2885        assert!(carries_pattern_correlation(&params));
2886    }
2887
2888    #[test]
2889    fn carries_pattern_correlation_detects_goal_status() {
2890        let mut params = MetadataParameters::default();
2891        params.insert(
2892            dora_message::metadata::GOAL_STATUS.to_string(),
2893            dora_message::metadata::Parameter::String("succeeded".into()),
2894        );
2895        assert!(carries_pattern_correlation(&params));
2896    }
2897
2898    #[test]
2899    fn carries_pattern_correlation_empty_is_not_a_pattern() {
2900        let params = MetadataParameters::default();
2901        assert!(!carries_pattern_correlation(&params));
2902    }
2903
2904    #[test]
2905    fn carries_pattern_correlation_ignores_non_pattern_keys() {
2906        let mut params = MetadataParameters::default();
2907        params.insert(
2908            "custom_key".to_string(),
2909            dora_message::metadata::Parameter::String("value".into()),
2910        );
2911        assert!(!carries_pattern_correlation(&params));
2912    }
2913
2914    #[test]
2915    fn schema_once_excludes_pattern_correlation_outputs() {
2916        // Regression (dora-rs/dora#2366 review): a small service/action
2917        // request-reply message — which multiplexes response schemas per request
2918        // — must NOT use schema-once, or a schema-less batch could reach a
2919        // consumer primed for a different schema and be silently dropped. It must
2920        // travel as a full self-describing stream instead.
2921        const THRESHOLD: usize = 4096;
2922
2923        let plain = MetadataParameters::default();
2924        assert!(
2925            schema_once_eligible(100, THRESHOLD, &plain),
2926            "small message on a stable-schema output is eligible"
2927        );
2928        assert!(
2929            !schema_once_eligible(THRESHOLD, THRESHOLD, &plain),
2930            "a message at/above the threshold is not eligible (goes via SHM/full stream)"
2931        );
2932
2933        for key in [
2934            dora_message::metadata::REQUEST_ID,
2935            dora_message::metadata::GOAL_ID,
2936            dora_message::metadata::GOAL_STATUS,
2937        ] {
2938            let mut params = MetadataParameters::default();
2939            params.insert(
2940                key.to_string(),
2941                dora_message::metadata::Parameter::String("x".into()),
2942            );
2943            assert!(
2944                !schema_once_eligible(100, THRESHOLD, &params),
2945                "small pattern-correlation message ({key}) must bypass schema-once"
2946            );
2947        }
2948
2949        // Streaming is deliberately NOT excluded: every chunk of a stream shares
2950        // one schema, so streaming stays the high-rate beneficiary of
2951        // schema-once. Locking this in guards against a well-meaning "also
2952        // exclude streaming" change that would defeat the optimization.
2953        let mut stream = MetadataParameters::default();
2954        stream.insert(
2955            dora_message::metadata::SESSION_ID.to_string(),
2956            dora_message::metadata::Parameter::String("s1".into()),
2957        );
2958        stream.insert(
2959            dora_message::metadata::SEGMENT_ID.to_string(),
2960            dora_message::metadata::Parameter::Integer(0),
2961        );
2962        assert!(
2963            schema_once_eligible(100, THRESHOLD, &stream),
2964            "small streaming chunk (stable schema) stays eligible for schema-once"
2965        );
2966    }
2967
2968    #[test]
2969    fn schema_once_decision_covers_publish_refresh_and_schema_less() {
2970        let start = Instant::now();
2971        let later = start + SCHEMA_ONCE_REFRESH_INTERVAL;
2972        let state = SchemaOnceState {
2973            published_hash: 7,
2974            last_full_stream: start,
2975        };
2976
2977        // No state yet (first message of this output) → publish the schema and
2978        // send THIS message as a full stream: it decodes standalone and primes
2979        // receivers in-band, so the first message can never be lost to the
2980        // batch racing ahead of the schema on the separate `@schema` plane
2981        // (dora-rs/dora#2366 review).
2982        assert!(matches!(
2983            schema_once_decision(None, 7, start),
2984            SchemaOnceDecision::PublishSchemaAndSendFullStream
2985        ));
2986        // Schema changed (or an earlier `@schema` publish failed, which leaves
2987        // the recorded hash stale) → same: publish + full stream.
2988        assert!(matches!(
2989            schema_once_decision(Some(&state), 8, start),
2990            SchemaOnceDecision::PublishSchemaAndSendFullStream
2991        ));
2992        // Schema confirmed published and refresh not due → schema-less batch.
2993        assert!(matches!(
2994            schema_once_decision(Some(&state), 7, start),
2995            SchemaOnceDecision::SendSchemaLessBatch
2996        ));
2997        // Refresh due → send a full stream so any consumer that missed the
2998        // single `@schema` emission re-primes in-band within the interval
2999        // instead of losing the input permanently.
3000        assert!(matches!(
3001            schema_once_decision(Some(&state), 7, later),
3002            SchemaOnceDecision::SendFullStreamRefresh
3003        ));
3004    }
3005
3006    #[test]
3007    fn stream_segment_new_generates_valid_session_id() {
3008        let seg = StreamSegment::new();
3009        uuid::Uuid::parse_str(seg.session_id()).expect("session_id should be valid UUID");
3010        assert_eq!(seg.segment_id(), 0);
3011    }
3012
3013    #[test]
3014    fn stream_segment_with_session_id() {
3015        let seg = StreamSegment::with_session_id("my-session".into());
3016        assert_eq!(seg.session_id(), "my-session");
3017        assert_eq!(seg.segment_id(), 0);
3018        assert_eq!(seg.seq(), 0);
3019    }
3020
3021    #[test]
3022    fn stream_segment_seq_accessor_tracks_next_seq() {
3023        let mut seg = StreamSegment::with_session_id("s1".into());
3024        assert_eq!(seg.seq(), 0);
3025        seg.chunk(false);
3026        assert_eq!(seg.seq(), 1);
3027        seg.chunk(false);
3028        assert_eq!(seg.seq(), 2);
3029        seg.next_segment();
3030        assert_eq!(seg.seq(), 0);
3031    }
3032
3033    #[test]
3034    fn stream_segment_chunk_auto_increments_seq() {
3035        let mut seg = StreamSegment::with_session_id("s1".into());
3036        let p0 = seg.chunk(false);
3037        let p1 = seg.chunk(false);
3038        let p2 = seg.chunk(true);
3039
3040        assert_eq!(p0.get(SEQ), Some(&Parameter::Integer(0)));
3041        assert_eq!(p1.get(SEQ), Some(&Parameter::Integer(1)));
3042        assert_eq!(p2.get(SEQ), Some(&Parameter::Integer(2)));
3043        assert_eq!(p0.get(FIN), Some(&Parameter::Bool(false)));
3044        assert_eq!(p2.get(FIN), Some(&Parameter::Bool(true)));
3045        assert_eq!(p0.get(SESSION_ID), Some(&Parameter::String("s1".into())));
3046        assert_eq!(p0.get(SEGMENT_ID), Some(&Parameter::Integer(0)));
3047    }
3048
3049    #[test]
3050    fn stream_segment_next_segment_resets_seq() {
3051        let mut seg = StreamSegment::with_session_id("s1".into());
3052        seg.chunk(false); // seq=0
3053        seg.chunk(false); // seq=1
3054        let new_id = seg.next_segment();
3055        assert_eq!(new_id, 1);
3056        assert_eq!(seg.segment_id(), 1);
3057
3058        let p = seg.chunk(false);
3059        assert_eq!(p.get(SEQ), Some(&Parameter::Integer(0)));
3060        assert_eq!(p.get(SEGMENT_ID), Some(&Parameter::Integer(1)));
3061    }
3062
3063    #[test]
3064    fn stream_segment_flush_advances_segment_and_sets_flush() {
3065        let mut seg = StreamSegment::with_session_id("s1".into());
3066        seg.chunk(false);
3067        let p = seg.flush();
3068        assert_eq!(seg.segment_id(), 1);
3069        assert_eq!(p.get(FLUSH), Some(&Parameter::Bool(true)));
3070        assert_eq!(p.get(SEGMENT_ID), Some(&Parameter::Integer(1)));
3071        // flush resets seq, then chunk increments it to 1
3072        assert_eq!(p.get(SEQ), Some(&Parameter::Integer(0)));
3073    }
3074
3075    #[test]
3076    fn send_stream_chunk_sends_output() {
3077        let (mut node, events, rx) = test_node();
3078        let mut seg = StreamSegment::with_session_id("s1".into());
3079
3080        node.send_stream_chunk("audio".into(), &mut seg, false, NullArray::new(0))
3081            .unwrap();
3082
3083        drop(node);
3084        drop(events);
3085        let outputs: Vec<_> = rx.try_iter().collect();
3086        assert_eq!(outputs.len(), 1);
3087        assert_eq!(outputs[0]["id"], "audio");
3088    }
3089
3090    #[test]
3091    fn teardown_with_timeout_completes_fast_closure() {
3092        let start = Instant::now();
3093        let completed = teardown_with_timeout("fast", Duration::from_secs(5), || {});
3094        assert!(completed, "fast teardown should report completion");
3095        assert!(
3096            start.elapsed() < Duration::from_secs(5),
3097            "fast teardown should not wait for the full timeout"
3098        );
3099    }
3100
3101    #[test]
3102    fn teardown_with_timeout_gives_up_on_wedged_closure() {
3103        let start = Instant::now();
3104        let completed = teardown_with_timeout("wedged", Duration::from_millis(300), || {
3105            std::thread::sleep(Duration::from_secs(60))
3106        });
3107        let elapsed = start.elapsed();
3108        assert!(!completed, "wedged teardown should report a timeout");
3109        assert!(
3110            elapsed >= Duration::from_millis(300),
3111            "should wait the full deadline, returned after {elapsed:?}"
3112        );
3113        assert!(
3114            elapsed < Duration::from_secs(5),
3115            "should give up shortly after the deadline, took {elapsed:?}"
3116        );
3117    }
3118
3119    #[test]
3120    fn teardown_with_timeout_contains_panics() {
3121        let completed = teardown_with_timeout("panicking", Duration::from_secs(5), || {
3122            panic!("teardown panicked")
3123        });
3124        assert!(completed, "panicking teardown still counts as completed");
3125    }
3126
3127    // Regression guard for dora-rs/dora#2742: the node's worst-case zenoh teardown must
3128    // stay under the daemon's force-kill grace (full rationale on `ZENOH_TEARDOWN_TIMEOUT`).
3129    //
3130    // Teardown runs in sequential bounded phases (two in `EventStream::drop`, one in
3131    // `DoraNode::drop`), so the worst case is `TEARDOWN_PHASES * ZENOH_TEARDOWN_TIMEOUT` —
3132    // bump `TEARDOWN_PHASES` if you add another. The grace is `DEFAULT_STOP_GRACE +
3133    // DEFAULT_STOP_GRACE/2 = 15s` (`binaries/daemon/src/running_dataflow.rs`); it lives in
3134    // a binary crate that can't be imported here, hence the hard-coded copy, which the
3135    // daemon const back-references so the two can't silently drift apart.
3136    #[test]
3137    fn zenoh_teardown_fits_within_daemon_force_kill_grace() {
3138        const DAEMON_FORCE_KILL_GRACE: Duration = Duration::from_secs(15);
3139        const TEARDOWN_PHASES: u32 = 3;
3140        let worst_case = ZENOH_TEARDOWN_TIMEOUT * TEARDOWN_PHASES;
3141        assert!(
3142            worst_case < DAEMON_FORCE_KILL_GRACE,
3143            "worst-case zenoh teardown ({worst_case:?}) must stay under the daemon \
3144             force-kill grace ({DAEMON_FORCE_KILL_GRACE:?}); raising ZENOH_TEARDOWN_TIMEOUT \
3145             reintroduces dora-rs/dora#2742"
3146        );
3147    }
3148}