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