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(¶meters)
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(¶ms));
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(¶ms));
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(¶ms));
2880 }
2881
2882 #[test]
2883 fn carries_pattern_correlation_empty_is_not_a_pattern() {
2884 let params = MetadataParameters::default();
2885 assert!(!carries_pattern_correlation(¶ms));
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(¶ms));
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, ¶ms),
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}