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