dora_message/descriptor.rs
1#![warn(missing_docs)]
2
3use crate::{
4 config::{ByteSize, CommunicationConfig, Input, NodeRunConfig},
5 id::{DataId, NodeId, OperatorId},
6};
7use schemars::JsonSchema;
8use serde::{Deserialize, Serialize};
9use serde_with_expand_env::with_expand_envs;
10use std::{
11 collections::{BTreeMap, BTreeSet},
12 fmt,
13 path::PathBuf,
14};
15
16/// Wire framing mode for an output.
17#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
18#[serde(rename_all = "kebab-case")]
19pub enum OutputFraming {
20 /// Raw Arrow buffer layout (default, current behavior).
21 #[default]
22 Raw,
23 /// Arrow IPC stream format — self-describing, schema + record batches.
24 ArrowIpc,
25}
26
27/// Source identifier for shell-based nodes.
28pub const SHELL_SOURCE: &str = "shell";
29/// Set the [`Node::path`] field to this value to treat the node as a
30/// [_dynamic node_](https://docs.rs/dora-node-api/latest/dora_node_api/).
31pub const DYNAMIC_SOURCE: &str = "dynamic";
32
33/// # Dataflow Specification
34///
35/// The main configuration structure for defining a Dora dataflow. Dataflows are
36/// specified through YAML files that describe the nodes, their connections, and
37/// execution parameters.
38///
39/// ## Structure
40///
41/// A dataflow consists of:
42/// - **Nodes**: The computational units that process data
43/// - **Communication**: Optional communication configuration
44/// - **Deployment**: Optional deployment configuration (unstable)
45/// - **Debug options**: Optional development and debugging settings (unstable)
46///
47/// ## Example
48///
49/// ```yaml
50/// nodes:
51/// - id: webcam
52/// operator:
53/// python: webcam.py
54/// inputs:
55/// tick: dora/timer/millis/100
56/// outputs:
57/// - image
58/// - id: plot
59/// operator:
60/// python: plot.py
61/// inputs:
62/// image: webcam/image
63/// ```
64#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
65#[serde(deny_unknown_fields)]
66#[schemars(title = "dora-rs specification")]
67pub struct Descriptor {
68 /// List of nodes in the dataflow
69 ///
70 /// This is the most important field of the dataflow specification.
71 /// Each node must be identified by a unique `id`:
72 ///
73 /// ## Example
74 ///
75 /// ```yaml
76 /// nodes:
77 /// - id: foo
78 /// path: path/to/the/executable
79 /// # ... (see below)
80 /// - id: bar
81 /// path: path/to/another/executable
82 /// # ... (see below)
83 /// ```
84 ///
85 /// For each node, you need to specify the `path` of the executable or script that Dora should run when starting the node.
86 /// Most of the other node fields are optional, but you typically want to specify at least some `inputs` and/or `outputs`.
87 pub nodes: Vec<Node>,
88
89 /// Communication configuration (optional, uses defaults)
90 #[schemars(skip)]
91 #[serde(default)]
92 pub communication: CommunicationConfig,
93
94 /// Deployment configuration (optional, unstable)
95 #[schemars(skip)]
96 #[serde(rename = "_unstable_deploy")]
97 pub deploy: Option<Deploy>,
98
99 /// Debug options (optional, unstable)
100 #[schemars(skip)]
101 #[serde(default, rename = "_unstable_debug")]
102 pub debug: Debug,
103
104 /// How often the daemon checks node health (in seconds).
105 ///
106 /// Defaults to 5.0 seconds if not specified. Lower values detect hung nodes
107 /// faster but add more overhead.
108 #[serde(default)]
109 pub health_check_interval: Option<f64>,
110
111 /// Enable strict type checking: type warnings become errors during build.
112 ///
113 /// Can also be enabled via `--strict-types` CLI flag on `dora build`.
114 #[serde(default, skip_serializing_if = "Option::is_none")]
115 pub strict_types: Option<bool>,
116
117 /// Custom type compatibility rules.
118 ///
119 /// Each rule declares that a source type can be implicitly converted to
120 /// a target type. These supplement the built-in widening rules.
121 ///
122 /// ## Example
123 ///
124 /// ```yaml
125 /// type_rules:
126 /// - from: myproject/SensorV1
127 /// to: myproject/SensorV2
128 /// ```
129 #[serde(default, skip_serializing_if = "Vec::is_empty")]
130 pub type_rules: Vec<TypeRuleDef>,
131
132 /// Global environment variables inherited by every node.
133 ///
134 /// Each node's own `env` map takes precedence on key conflicts, so nodes
135 /// can override a global default without repeating shared values like
136 /// `RUST_LOG`, `OTEL_EXPORTER_OTLP_ENDPOINT`, or `CUDA_VISIBLE_DEVICES`.
137 ///
138 /// ## Example
139 ///
140 /// ```yaml
141 /// env:
142 /// RUST_LOG: info
143 /// OTEL_EXPORTER_OTLP_ENDPOINT: http://collector:4317
144 /// nodes:
145 /// - id: verbose-node
146 /// path: path/to/node
147 /// env:
148 /// RUST_LOG: debug # overrides the global RUST_LOG for this node
149 /// ```
150 #[serde(default, skip_serializing_if = "Option::is_none")]
151 pub env: Option<BTreeMap<String, EnvValue>>,
152}
153
154/// A type compatibility rule declared in the dataflow YAML.
155#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
156#[serde(deny_unknown_fields)]
157pub struct TypeRuleDef {
158 /// Source type URN
159 pub from: String,
160 /// Target type URN
161 pub to: String,
162}
163
164/// Specifies when a node should be restarted.
165#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema)]
166#[serde(rename_all = "kebab-case")]
167pub enum RestartPolicy {
168 /// Never restart the node (default)
169 #[default]
170 Never,
171 /// Restart the node if it exits with a non-zero exit code.
172 OnFailure,
173 /// Always restart the node when it exits, regardless of exit code.
174 ///
175 /// The node will not be restarted on the following conditions:
176 ///
177 /// - The node was stopped by the user (e.g., via `dora stop`).
178 /// - All inputs to the node have been closed and the node finished with a non-zero exit code.
179 Always,
180}
181
182/// Deployment configuration for distributing nodes across machines.
183#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
184#[serde(deny_unknown_fields)]
185pub struct Deploy {
186 /// Target machine for deployment
187 pub machine: Option<String>,
188 /// Working directory for the deployment
189 pub working_dir: Option<PathBuf>,
190 /// Labels for label-based scheduling (e.g. `gpu: "true"`, `arch: arm64`).
191 /// The coordinator matches these against daemon labels reported at registration.
192 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
193 pub labels: BTreeMap<String, String>,
194 /// How built binaries are distributed to remote daemons.
195 #[serde(default)]
196 pub distribute: DistributeStrategy,
197}
198
199/// Strategy for distributing built binaries to daemons.
200#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
201#[serde(rename_all = "lowercase")]
202pub enum DistributeStrategy {
203 /// Each daemon builds from source (current/default behavior).
204 #[default]
205 Local,
206 /// CLI pushes built binary via SSH/SCP before spawn.
207 Scp,
208 /// Daemon pulls binary from coordinator HTTP artifact store before spawn.
209 Http,
210}
211
212/// Debug options for dataflow development and troubleshooting.
213#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
214pub struct Debug {
215 /// When true, daemons mirror every node output to the coordinator WebSocket
216 /// so that `dora topic echo`, `dora topic hz`, and `dora topic info` can
217 /// inspect runtime messages.
218 ///
219 /// The field was previously named `publish_all_messages_to_zenoh` (from
220 /// before the CLI inspection path moved off zenoh in PR #238). Serde still
221 /// accepts the old name as an alias for backward compatibility with
222 /// existing dataflow YAML; the alias will be removed in a future release.
223 #[serde(default, alias = "publish_all_messages_to_zenoh")]
224 pub enable_debug_inspection: bool,
225}
226
227/// # Dora Node Configuration
228///
229/// A node represents a computational unit in a Dora dataflow. Each node runs as a
230/// separate process and can communicate with other nodes through inputs and outputs.
231#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
232#[serde(deny_unknown_fields)]
233pub struct Node {
234 /// Unique node identifier. Must not contain `/` characters.
235 ///
236 /// Node IDs can be arbitrary strings with the following limitations:
237 ///
238 /// - They must not contain any `/` characters (slashes).
239 /// - We do not recommend using whitespace characters (e.g. spaces) in IDs
240 ///
241 /// Each node must have an ID field.
242 ///
243 /// ## Example
244 ///
245 /// ```yaml
246 /// nodes:
247 /// - id: camera_node
248 /// - id: some_other_node
249 /// ```
250 pub id: NodeId,
251
252 /// Human-readable node name for documentation.
253 ///
254 /// This optional field can be used to define a more descriptive name in addition to a short
255 /// [`id`](Self::id).
256 ///
257 /// ## Example
258 ///
259 /// ```yaml
260 /// nodes:
261 /// - id: camera_node
262 /// name: "Camera Input Handler"
263 pub name: Option<String>,
264
265 /// Detailed description of the node's functionality.
266 ///
267 /// ## Example
268 ///
269 /// ```yaml
270 /// nodes:
271 /// - id: camera_node
272 /// description: "Captures video frames from webcam"
273 /// ```
274 pub description: Option<String>,
275
276 /// Path to executable or script that should be run.
277 ///
278 /// Specifies the path of the executable or script that Dora should run when starting the
279 /// dataflow.
280 /// This can point to a normal executable (e.g. when using a compiled language such as Rust) or
281 /// a Python script.
282 ///
283 /// Dora will automatically append a `.exe` extension on Windows systems when the specified
284 /// file name has no extension.
285 ///
286 /// ## Example
287 ///
288 /// ```yaml
289 /// nodes:
290 /// - id: rust-example
291 /// path: target/release/rust-node
292 /// - id: python-example
293 /// path: ./receive_data.py
294 /// ```
295 ///
296 /// ## URL as Path
297 ///
298 /// The `path` field can also point to a URL instead of a local path.
299 /// In this case, Dora will download the given file when starting the dataflow.
300 ///
301 /// Note that this is quite an old feature and using this functionality is **not recommended**
302 /// anymore. Instead, we recommend using a [`git`][Self::git] and/or [`build`](Self::build)
303 /// key.
304 #[serde(default, skip_serializing_if = "Option::is_none")]
305 pub path: Option<String>,
306
307 /// SHA-256 checksum the `path` download must match, verified after fetch
308 /// and on cache reuse (spec §8.2/§8.4). Set internally when a `hub:`
309 /// reference resolves to a prebuilt binary artifact; rarely set by hand.
310 #[serde(default, skip_serializing_if = "Option::is_none")]
311 pub path_sha256: Option<String>,
312
313 /// Command-line arguments passed to the executable.
314 ///
315 /// The command-line arguments that should be passed to the executable/script specified in `path`.
316 /// The arguments should be separated by space.
317 /// This field is optional and defaults to an empty argument list.
318 ///
319 /// ## Example
320 /// ```yaml
321 /// nodes:
322 /// - id: example
323 /// path: example-node
324 /// args: -v --some-flag foo
325 /// ```
326 #[serde(default, skip_serializing_if = "Option::is_none")]
327 pub args: Option<String>,
328
329 /// Environment variables for node builds and execution.
330 ///
331 /// Key-value map of environment variables that should be set for both the
332 /// [`build`](Self::build) operation and the node execution (i.e. when the node is spawned
333 /// through [`path`](Self::path)).
334 ///
335 /// Supports strings, numbers, and booleans.
336 ///
337 /// ## Example
338 ///
339 /// ```yaml
340 /// nodes:
341 /// - id: example-node
342 /// path: path/to/node
343 /// env:
344 /// DEBUG: true
345 /// PORT: 8080
346 /// API_KEY: "secret-key"
347 /// ```
348 pub env: Option<BTreeMap<String, EnvValue>>,
349
350 /// Multiple operators running in a shared runtime process.
351 ///
352 /// Operators are an experimental, lightweight alternative to nodes.
353 /// Instead of running as a separate process, operators are linked into a runtime process.
354 /// This allows running multiple operators to share a single address space (not supported for
355 /// Python currently).
356 ///
357 /// Operators are defined as part of the node list, as children of a runtime node.
358 /// A runtime node is a special node that specifies no [`path`](Self::path) field, but contains
359 /// an `operators` field instead.
360 ///
361 /// ## Example
362 ///
363 /// ```yaml
364 /// nodes:
365 /// - id: runtime-node
366 /// operators:
367 /// - id: processor
368 /// python: process.py
369 /// ```
370 #[serde(default, skip_serializing_if = "Option::is_none")]
371 pub operators: Option<RuntimeNode>,
372
373 /// Single operator configuration.
374 ///
375 /// This is a convenience field for defining runtime nodes that contain only a single operator.
376 /// This field is an alternative to the [`operators`](Self::operators) field, which can be used
377 /// if there is only a single operator defined for the runtime node.
378 ///
379 /// ## Example
380 ///
381 /// ```yaml
382 /// nodes:
383 /// - id: runtime-node
384 /// operator:
385 /// id: processor
386 /// python: script.py
387 /// outputs: [data]
388 /// ```
389 #[serde(default, skip_serializing_if = "Option::is_none")]
390 pub operator: Option<SingleOperatorDefinition>,
391
392 /// ROS2 bridge configuration (unstable).
393 ///
394 /// Declares this node as a ROS2 bridge that automatically subscribes to or
395 /// publishes on ROS2 topics. No custom code is needed -- the framework spawns
396 /// a bridge binary that converts between ROS2 DDS messages and Dora's Arrow
397 /// format.
398 ///
399 /// ## Example
400 ///
401 /// ```yaml
402 /// nodes:
403 /// - id: camera_bridge
404 /// ros2:
405 /// topic: /camera/image_raw
406 /// message_type: sensor_msgs/Image
407 /// direction: subscribe
408 /// outputs:
409 /// - image
410 /// ```
411 #[serde(default, skip_serializing_if = "Option::is_none")]
412 pub ros2: Option<Ros2BridgeConfig>,
413
414 /// Legacy node configuration (deprecated).
415 ///
416 /// Please use the top-level [`path`](Self::path), [`args`](Self::args), etc. fields instead.
417 #[serde(default, skip_serializing_if = "Option::is_none")]
418 pub custom: Option<CustomNode>,
419
420 /// Output data identifiers produced by this node.
421 ///
422 /// List of output identifiers that the node sends.
423 /// Must contain all `output_id` values that the node uses when sending output, e.g. through the
424 /// [`send_output`](https://docs.rs/dora-node-api/latest/dora_node_api/struct.DoraNode.html#method.send_output)
425 /// function.
426 ///
427 /// ## Example
428 ///
429 /// ```yaml
430 /// nodes:
431 /// - id: example-node
432 /// outputs:
433 /// - processed_image
434 /// - metadata
435 /// ```
436 #[serde(default)]
437 pub outputs: BTreeSet<DataId>,
438
439 /// Optional type annotations for outputs.
440 ///
441 /// Maps output identifiers to type URNs (e.g. `std/media/v1/Image`).
442 /// Only annotated outputs are type-checked; unannotated outputs remain dynamic.
443 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
444 pub output_types: BTreeMap<DataId, String>,
445
446 /// Per-output framing overrides (default: Raw for all).
447 ///
448 /// Maps output identifiers to their wire framing mode.
449 /// Outputs not listed here use the default `Raw` framing.
450 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
451 pub output_framing: BTreeMap<DataId, OutputFraming>,
452
453 /// Input data connections from other nodes.
454 ///
455 /// Defines the inputs that this node is subscribing to.
456 ///
457 /// The `inputs` field should be a key-value map of the following format:
458 ///
459 /// `input_id: source_node_id/source_node_output_id`
460 ///
461 /// The components are defined as follows:
462 ///
463 /// - `input_id` is the local identifier that should be used for this input.
464 ///
465 /// This will map to the `id` field of
466 /// [`Event::Input`](https://docs.rs/dora-node-api/latest/dora_node_api/enum.Event.html#variant.Input)
467 /// events sent to the node event loop.
468 /// - `source_node_id` should be the `id` field of the node that sends the output that we want
469 /// to subscribe to
470 /// - `source_node_output_id` should be the identifier of the output that that we want
471 /// to subscribe to
472 ///
473 /// ## Example
474 ///
475 /// ```yaml
476 /// nodes:
477 /// - id: example-node
478 /// outputs:
479 /// - one
480 /// - two
481 /// - id: receiver
482 /// inputs:
483 /// my_input: example-node/two
484 /// ```
485 #[serde(default)]
486 pub inputs: BTreeMap<DataId, Input>,
487
488 /// Optional type annotations for inputs.
489 ///
490 /// Maps input identifiers to expected type URNs. Used by `dora validate`
491 /// to check that upstream output types match expectations.
492 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
493 pub input_types: BTreeMap<DataId, String>,
494
495 /// Required metadata keys per output.
496 ///
497 /// Maps output identifiers to lists of required metadata key names.
498 /// These are checked at build/validate time.
499 ///
500 /// ## Example
501 ///
502 /// ```yaml
503 /// output_metadata:
504 /// response: [request_id]
505 /// ```
506 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
507 pub output_metadata: BTreeMap<DataId, Vec<String>>,
508
509 /// Communication pattern shorthand (e.g. `service-server`).
510 ///
511 /// Automatically implies required metadata keys on all outputs.
512 /// See `pattern_metadata_keys()` for supported patterns.
513 #[serde(default, skip_serializing_if = "Option::is_none")]
514 pub pattern: Option<String>,
515
516 /// Redirect stdout/stderr to a data output.
517 ///
518 /// This field can be used to send all stdout and stderr output of the node as a Dora output.
519 /// Each output line is sent as a separate message.
520 ///
521 ///
522 /// ## Example
523 ///
524 /// ```yaml
525 /// nodes:
526 /// - id: example
527 /// send_stdout_as: stdout_output
528 /// - id: logger
529 /// inputs:
530 /// example_output: example/stdout_output
531 /// ```
532 #[serde(skip_serializing_if = "Option::is_none")]
533 pub send_stdout_as: Option<String>,
534
535 /// Redirect structured log entries to a data output as JSON strings.
536 ///
537 /// Unlike `send_stdout_as` which sends raw stdout lines, this sends only
538 /// parsed structured log entries (with level, timestamp, message, fields).
539 ///
540 /// ## Example
541 ///
542 /// ```yaml
543 /// nodes:
544 /// - id: sensor
545 /// path: ./sensor
546 /// send_logs_as: logs
547 /// outputs:
548 /// - data
549 /// - logs
550 /// ```
551 #[serde(skip_serializing_if = "Option::is_none")]
552 pub send_logs_as: Option<String>,
553
554 /// Minimum log level for this node (error, warn, info, debug, trace, stdout).
555 ///
556 /// Logs below this level are suppressed from file output, coordinator
557 /// forwarding, and `send_logs_as` routing.
558 ///
559 /// ## Example
560 ///
561 /// ```yaml
562 /// nodes:
563 /// - id: noisy_sensor
564 /// path: ./sensor
565 /// min_log_level: info
566 /// ```
567 #[serde(skip_serializing_if = "Option::is_none")]
568 pub min_log_level: Option<String>,
569
570 /// Maximum log file size before rotation (e.g. "50MB", "1GB").
571 ///
572 /// When the JSONL log file exceeds this size, it is rotated. Old files
573 /// are renamed with numeric suffixes (`.1.jsonl`, `.2.jsonl`, etc.) and
574 /// the oldest are deleted once 5 rotated files exist.
575 ///
576 /// ## Example
577 ///
578 /// ```yaml
579 /// nodes:
580 /// - id: sensor
581 /// path: ./sensor
582 /// max_log_size: "100MB"
583 /// ```
584 #[serde(skip_serializing_if = "Option::is_none")]
585 pub max_log_size: Option<String>,
586 /// Maximum number of rotated log files to keep (default: 5)
587 #[serde(default, skip_serializing_if = "Option::is_none")]
588 pub max_rotated_files: Option<u32>,
589
590 /// Build commands executed during `dora build`. Each line runs separately.
591 ///
592 /// The `build` key specifies the command that should be invoked for building the node.
593 /// The key expects a single- or multi-line string.
594 ///
595 /// Each line is run as a separate command.
596 /// Spaces are used to separate arguments.
597 ///
598 /// Note that all the environment variables specified in the [`env`](Self::env) field are also
599 /// applied to the build commands.
600 ///
601 /// ## Special treatment of `pip`
602 ///
603 /// Build lines that start with `pip` or `pip3` are treated in a special way:
604 /// If the `--uv` argument is passed to the `dora build` command, all `pip`/`pip3` commands are
605 /// run through the [`uv` package manager](https://docs.astral.sh/uv/).
606 ///
607 /// ## Example
608 ///
609 /// ```yaml
610 /// nodes:
611 /// - id: build-example
612 /// build: cargo build -p receive_data --release
613 /// path: target/release/receive_data
614 /// - id: multi-line-example
615 /// build: |
616 /// pip install requirements.txt
617 /// pip install -e some/local/package
618 /// path: package
619 /// ```
620 ///
621 /// In the above example, the `pip` commands will be replaced by `uv pip` when run through
622 /// `dora build --uv`.
623 #[serde(default, skip_serializing_if = "Option::is_none")]
624 pub build: Option<String>,
625
626 /// Git repository URL for downloading nodes.
627 ///
628 /// The `git` key allows downloading nodes (i.e. their source code) from git repositories.
629 /// This can be especially useful for distributed dataflows.
630 ///
631 /// When a `git` key is specified, `dora build` automatically clones the specified repository
632 /// (or reuse an existing clone).
633 /// Then it checks out the specified [`branch`](Self::branch), [`tag`](Self::tag), or
634 /// [`rev`](Self::rev), or the default branch if none of them are specified.
635 /// Afterwards it runs the [`build`](Self::build) command if specified.
636 ///
637 /// Note that the git clone directory is set as working directory for both the
638 /// [`build`](Self::build) command and the specified [`path`](Self::path).
639 ///
640 /// ## Example
641 ///
642 /// ```yaml
643 /// nodes:
644 /// - id: rust-node
645 /// git: https://github.com/dora-rs/dora.git
646 /// build: cargo build -p rust-dataflow-example-node
647 /// path: target/debug/rust-dataflow-example-node
648 /// ```
649 ///
650 /// In the above example, `dora build` will first clone the specified `git` repository and then
651 /// run the specified `build` inside the local clone directory.
652 /// When `dora run` or `dora start` is invoked, the working directory will be the git clone
653 /// directory too. So a relative `path` will start from the clone directory.
654 #[serde(default, skip_serializing_if = "Option::is_none")]
655 pub git: Option<String>,
656
657 /// Hub package reference (unstable).
658 ///
659 /// References a node published in the Dora Hub index:
660 /// `[<namespace>/]<name>@<semver-requirement>`. A bare name is shorthand
661 /// for the official `dora-rs/` namespace.
662 ///
663 /// `dora build` resolves the reference against the index to a pinned
664 /// commit and the node is fetched/built through the same machinery as a
665 /// [`git`](Self::git) node; the package manifest supplies the
666 /// entrypoint, build command, and typed contracts. Mutually exclusive
667 /// with `path`, `git`, and `build`.
668 ///
669 /// ## Example
670 ///
671 /// ```yaml
672 /// nodes:
673 /// - id: detector
674 /// hub: dora-yolo@^0.5
675 /// inputs:
676 /// image: camera/image
677 /// outputs:
678 /// - bbox
679 /// ```
680 #[serde(default, skip_serializing_if = "Option::is_none")]
681 pub hub: Option<String>,
682
683 /// Git branch to checkout after cloning.
684 ///
685 /// The `branch` field is only allowed in combination with the [`git`](#git) field.
686 /// It specifies the branch that should be checked out after cloning.
687 /// Only one of `branch`, `tag`, or `rev` can be specified.
688 ///
689 /// ## Example
690 ///
691 /// ```yaml
692 /// nodes:
693 /// - id: rust-node
694 /// git: https://github.com/dora-rs/dora.git
695 /// branch: some-branch-name
696 /// ```
697 #[serde(default, skip_serializing_if = "Option::is_none")]
698 pub branch: Option<String>,
699
700 /// Git tag to checkout after cloning.
701 ///
702 /// The `tag` field is only allowed in combination with the [`git`](#git) field.
703 /// It specifies the git tag that should be checked out after cloning.
704 /// Only one of `branch`, `tag`, or `rev` can be specified.
705 ///
706 /// ## Example
707 ///
708 /// ```yaml
709 /// nodes:
710 /// - id: rust-node
711 /// git: https://github.com/dora-rs/dora.git
712 /// tag: v0.1.0
713 /// ```
714 #[serde(default, skip_serializing_if = "Option::is_none")]
715 pub tag: Option<String>,
716
717 /// Git revision (e.g. commit hash) to checkout after cloning.
718 ///
719 /// The `rev` field is only allowed in combination with the [`git`](#git) field.
720 /// It specifies the git revision (e.g. a commit hash) that should be checked out after cloning.
721 /// Only one of `branch`, `tag`, or `rev` can be specified.
722 ///
723 /// ## Example
724 ///
725 /// ```yaml
726 /// nodes:
727 /// - id: rust-node
728 /// git: https://github.com/dora-rs/dora.git
729 /// rev: 64ab0d7c
730 /// ```
731 #[serde(default, skip_serializing_if = "Option::is_none")]
732 pub rev: Option<String>,
733
734 /// Whether this node should be restarted on exit or error.
735 ///
736 /// Defaults to `RestartPolicy::Never`.
737 #[serde(default)]
738 pub restart_policy: RestartPolicy,
739
740 /// Size of the zenoh shared memory pool for zero-copy output publishing.
741 ///
742 /// Accepts an integer (raw bytes) or a string with a unit suffix
743 /// (`KB`, `MB`, `GB`, case-insensitive). If unset, the
744 /// `DORA_NODE_SHM_POOL_SIZE` env var is used, falling back to a
745 /// built-in default.
746 ///
747 /// ## Example
748 ///
749 /// ```yaml
750 /// nodes:
751 /// - id: camera-node
752 /// shared_memory_pool_size: 128MB
753 /// ```
754 #[serde(default, skip_serializing_if = "Option::is_none")]
755 pub shared_memory_pool_size: Option<ByteSize>,
756
757 /// Maximum number of restart attempts. 0 means unlimited.
758 ///
759 /// When combined with `restart_window`, this limits restarts within the window period.
760 /// For example, `max_restarts: 5` with `restart_window: 300` means "5 restarts per 5 minutes".
761 #[serde(default)]
762 pub max_restarts: u32,
763
764 /// Initial delay in seconds before restarting. Doubles each attempt (exponential backoff).
765 ///
766 /// For example, with `restart_delay: 1.0`, delays will be 1s, 2s, 4s, 8s, ...
767 /// Use `max_restart_delay` to cap the backoff.
768 #[serde(default, skip_serializing_if = "Option::is_none")]
769 pub restart_delay: Option<f64>,
770
771 /// Maximum delay in seconds for exponential backoff.
772 ///
773 /// Caps the exponentially growing `restart_delay`. For example, with
774 /// `restart_delay: 1.0` and `max_restart_delay: 30.0`, delays grow as
775 /// 1s, 2s, 4s, 8s, 16s, 30s, 30s, ...
776 #[serde(default, skip_serializing_if = "Option::is_none")]
777 pub max_restart_delay: Option<f64>,
778
779 /// Time window in seconds for counting restarts.
780 ///
781 /// When set, the restart counter resets after this period of time elapses since the
782 /// first restart in the current window. This enables "N restarts within M seconds" semantics.
783 #[serde(default, skip_serializing_if = "Option::is_none")]
784 pub restart_window: Option<f64>,
785
786 /// Health check timeout in seconds.
787 ///
788 /// When set, the daemon monitors this node for activity. If the node does not
789 /// communicate with the daemon within this timeout, it is killed and the restart
790 /// policy is evaluated.
791 #[serde(default, skip_serializing_if = "Option::is_none")]
792 pub health_check_timeout: Option<f64>,
793
794 /// Per-node finish-drain grace period in seconds.
795 ///
796 /// Overrides the global `DORA_FINISH_DRAIN_GRACE_SECS` for this node only.
797 /// When all other nodes in a dataflow have finished, the daemon waits this
798 /// long after the node's last input closes before force-stopping it.
799 ///
800 /// Set to a large value (e.g. `3600.0`) for nodes that need significant
801 /// post-input compute time (ML training, large-batch inference, checkpoint
802 /// writes) to prevent premature SIGKILL while the computation is in progress.
803 ///
804 /// When unset, the global grace period applies (default 120s, controlled
805 /// by `DORA_FINISH_DRAIN_GRACE_SECS`).
806 #[serde(default, skip_serializing_if = "Option::is_none")]
807 pub finish_grace_secs: Option<f64>,
808
809 /// Path to a module definition file (e.g. `nav_module.yml`).
810 ///
811 /// A module is a reusable sub-dataflow: a group of nodes with declared
812 /// inputs and outputs. At build time the module is expanded inline —
813 /// internal node IDs are prefixed with `{module_id}.` and all wiring is
814 /// rewritten so the runtime sees only flat nodes.
815 ///
816 /// Mutually exclusive with `path`, `operators`, `operator`, `custom`,
817 /// and `ros2`.
818 ///
819 /// ## Example
820 ///
821 /// ```yaml
822 /// nodes:
823 /// - id: nav_stack
824 /// module: modules/navigation_module.yml
825 /// inputs:
826 /// goal_pose: localization/goal
827 /// ```
828 #[serde(default, skip_serializing_if = "Option::is_none")]
829 pub module: Option<String>,
830
831 /// Parameters passed to a module for compile-time substitution.
832 ///
833 /// Only meaningful when `module` is set. Values are substituted into
834 /// inner node `args` fields (using `${_param.name}` syntax) and can be
835 /// injected into inner node `env` maps.
836 ///
837 /// ## Example
838 ///
839 /// ```yaml
840 /// nodes:
841 /// - id: nav_stack
842 /// module: modules/navigation_module.yml
843 /// params:
844 /// speed: "2.0"
845 /// mode: turbo
846 /// ```
847 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
848 pub params: BTreeMap<String, String>,
849
850 /// CPU cores to pin this node's process to (Linux only, ignored on other platforms).
851 ///
852 /// ## Example
853 ///
854 /// ```yaml
855 /// nodes:
856 /// - id: fast_node
857 /// path: ./fast_node
858 /// cpu_affinity: [0, 1]
859 /// ```
860 #[serde(default, skip_serializing_if = "Option::is_none")]
861 pub cpu_affinity: Option<Vec<usize>>,
862
863 /// Unstable machine deployment configuration
864 #[schemars(skip)]
865 #[serde(rename = "_unstable_deploy")]
866 pub deploy: Option<Deploy>,
867}
868
869#[allow(missing_docs)]
870#[derive(Debug, Clone, Serialize, Deserialize)]
871pub struct ResolvedNode {
872 pub id: NodeId,
873 pub name: Option<String>,
874 pub description: Option<String>,
875 pub env: Option<BTreeMap<String, EnvValue>>,
876
877 #[serde(default)]
878 pub cpu_affinity: Option<Vec<usize>>,
879
880 #[serde(default)]
881 pub deploy: Option<Deploy>,
882
883 #[serde(flatten)]
884 pub kind: CoreNodeKind,
885}
886
887#[allow(missing_docs)]
888impl ResolvedNode {
889 pub fn has_git_source(&self) -> bool {
890 self.kind
891 .as_custom()
892 .map(|n| n.source.is_git())
893 .unwrap_or_default()
894 }
895}
896
897#[allow(missing_docs)]
898#[derive(Debug, Clone, Serialize, Deserialize)]
899#[serde(rename_all = "lowercase")]
900#[allow(clippy::large_enum_variant)]
901pub enum CoreNodeKind {
902 /// Dora runtime node
903 #[serde(rename = "operators")]
904 Runtime(RuntimeNode),
905 Custom(CustomNode),
906}
907
908#[allow(missing_docs)]
909impl CoreNodeKind {
910 pub fn as_custom(&self) -> Option<&CustomNode> {
911 match self {
912 CoreNodeKind::Runtime(_) => None,
913 CoreNodeKind::Custom(custom_node) => Some(custom_node),
914 }
915 }
916}
917
918#[allow(missing_docs)]
919#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
920#[serde(transparent)]
921pub struct RuntimeNode {
922 /// List of operators running in this runtime
923 pub operators: Vec<OperatorDefinition>,
924}
925
926#[allow(missing_docs)]
927#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone)]
928pub struct OperatorDefinition {
929 /// Unique operator identifier within the runtime
930 pub id: OperatorId,
931 #[serde(flatten)]
932 pub config: OperatorConfig,
933}
934
935#[allow(missing_docs)]
936#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone)]
937pub struct SingleOperatorDefinition {
938 /// Operator identifier (optional for single operators)
939 pub id: Option<OperatorId>,
940 #[serde(flatten)]
941 pub config: OperatorConfig,
942}
943
944#[allow(missing_docs)]
945#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone)]
946pub struct OperatorConfig {
947 /// Human-readable operator name
948 pub name: Option<String>,
949 /// Detailed description of the operator
950 pub description: Option<String>,
951
952 /// Input data connections
953 #[serde(default)]
954 pub inputs: BTreeMap<DataId, Input>,
955 /// Output data identifiers
956 #[serde(default)]
957 pub outputs: BTreeSet<DataId>,
958 /// Optional type annotations for outputs
959 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
960 pub output_types: BTreeMap<DataId, String>,
961
962 /// Per-output framing overrides (default: Raw for all).
963 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
964 pub output_framing: BTreeMap<DataId, OutputFraming>,
965
966 /// Optional type annotations for inputs
967 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
968 pub input_types: BTreeMap<DataId, String>,
969
970 /// Required metadata keys per output
971 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
972 pub output_metadata: BTreeMap<DataId, Vec<String>>,
973
974 /// Communication pattern shorthand (e.g. `service-server`)
975 #[serde(default, skip_serializing_if = "Option::is_none")]
976 pub pattern: Option<String>,
977
978 /// Operator source configuration (Python, shared library, etc.)
979 #[serde(flatten)]
980 pub source: OperatorSource,
981
982 /// Build commands for this operator
983 #[serde(default, skip_serializing_if = "Option::is_none")]
984 pub build: Option<String>,
985 /// Redirect stdout to data output
986 #[serde(skip_serializing_if = "Option::is_none")]
987 pub send_stdout_as: Option<String>,
988 /// Redirect structured log entries to a data output as JSON strings
989 #[serde(skip_serializing_if = "Option::is_none")]
990 pub send_logs_as: Option<String>,
991 /// Minimum log level for this operator
992 #[serde(skip_serializing_if = "Option::is_none")]
993 pub min_log_level: Option<String>,
994 /// Maximum log file size before rotation (e.g. "50MB", "1GB")
995 #[serde(skip_serializing_if = "Option::is_none")]
996 pub max_log_size: Option<String>,
997 /// Maximum number of rotated log files to keep (default: 5)
998 #[serde(default, skip_serializing_if = "Option::is_none")]
999 pub max_rotated_files: Option<u32>,
1000}
1001
1002#[allow(missing_docs)]
1003#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone)]
1004#[serde(rename_all = "kebab-case")]
1005pub enum OperatorSource {
1006 SharedLibrary(String),
1007 Python(PythonSource),
1008 #[schemars(skip)]
1009 Wasm(String),
1010}
1011#[allow(missing_docs)]
1012#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1013#[serde(from = "PythonSourceDef", into = "PythonSourceDef")]
1014pub struct PythonSource {
1015 pub source: String,
1016 pub conda_env: Option<String>,
1017}
1018
1019#[allow(missing_docs)]
1020#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1021#[serde(untagged)]
1022pub enum PythonSourceDef {
1023 SourceOnly(String),
1024 WithOptions {
1025 source: String,
1026 conda_env: Option<String>,
1027 },
1028}
1029
1030impl From<PythonSource> for PythonSourceDef {
1031 fn from(input: PythonSource) -> Self {
1032 match input {
1033 PythonSource {
1034 source,
1035 conda_env: None,
1036 } => Self::SourceOnly(source),
1037 PythonSource { source, conda_env } => Self::WithOptions { source, conda_env },
1038 }
1039 }
1040}
1041
1042impl From<PythonSourceDef> for PythonSource {
1043 fn from(value: PythonSourceDef) -> Self {
1044 match value {
1045 PythonSourceDef::SourceOnly(source) => Self {
1046 source,
1047 conda_env: None,
1048 },
1049 PythonSourceDef::WithOptions { source, conda_env } => Self { source, conda_env },
1050 }
1051 }
1052}
1053
1054#[allow(missing_docs)]
1055#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1056pub struct CustomNode {
1057 /// Path of the source code
1058 ///
1059 /// If you want to use a specific `conda` environment.
1060 /// Provide the python path within the source.
1061 ///
1062 /// source: /home/peter/miniconda3/bin/python
1063 ///
1064 /// args: some_node.py
1065 ///
1066 /// Source can match any executable in PATH.
1067 pub path: String,
1068 pub source: NodeSource,
1069 /// SHA-256 the `path` download must match (set for hub binary artifacts,
1070 /// spec §8.2). When present the daemon fetches `path` as a verified URL
1071 /// download regardless of confinement — the checksum is the trust anchor.
1072 #[serde(default, skip_serializing_if = "Option::is_none")]
1073 pub path_sha256: Option<String>,
1074 /// Args for the executable.
1075 #[serde(default, skip_serializing_if = "Option::is_none")]
1076 pub args: Option<String>,
1077 /// Environment variables for the custom nodes
1078 ///
1079 /// Deprecated, use outer-level `env` field instead.
1080 pub envs: Option<BTreeMap<String, EnvValue>>,
1081 #[serde(default, skip_serializing_if = "Option::is_none")]
1082 pub build: Option<String>,
1083 /// Send stdout and stderr to another node
1084 #[serde(skip_serializing_if = "Option::is_none")]
1085 pub send_stdout_as: Option<String>,
1086 /// Redirect structured log entries to a data output as JSON strings
1087 #[serde(skip_serializing_if = "Option::is_none")]
1088 pub send_logs_as: Option<String>,
1089 /// Minimum log level for this node
1090 #[serde(skip_serializing_if = "Option::is_none")]
1091 pub min_log_level: Option<String>,
1092 /// Maximum log file size before rotation (e.g. "50MB", "1GB")
1093 #[serde(skip_serializing_if = "Option::is_none")]
1094 pub max_log_size: Option<String>,
1095 /// Maximum number of rotated log files to keep (default: 5)
1096 #[serde(default, skip_serializing_if = "Option::is_none")]
1097 pub max_rotated_files: Option<u32>,
1098
1099 #[serde(default)]
1100 pub restart_policy: RestartPolicy,
1101
1102 /// Maximum number of restart attempts. 0 means unlimited.
1103 #[serde(default)]
1104 pub max_restarts: u32,
1105
1106 /// Initial delay in seconds before restarting (exponential backoff).
1107 #[serde(default, skip_serializing_if = "Option::is_none")]
1108 pub restart_delay: Option<f64>,
1109
1110 /// Maximum delay in seconds for exponential backoff.
1111 #[serde(default, skip_serializing_if = "Option::is_none")]
1112 pub max_restart_delay: Option<f64>,
1113
1114 /// Time window in seconds for counting restarts.
1115 #[serde(default, skip_serializing_if = "Option::is_none")]
1116 pub restart_window: Option<f64>,
1117
1118 /// Health check timeout in seconds.
1119 ///
1120 /// When set, the daemon monitors this node for activity. If the node does not
1121 /// communicate with the daemon within this timeout, it is killed and the restart
1122 /// policy is evaluated.
1123 #[serde(default, skip_serializing_if = "Option::is_none")]
1124 pub health_check_timeout: Option<f64>,
1125
1126 /// Per-node finish-drain grace period in seconds.
1127 ///
1128 /// Overrides the global `DORA_FINISH_DRAIN_GRACE_SECS` for this node only.
1129 #[serde(default, skip_serializing_if = "Option::is_none")]
1130 pub finish_grace_secs: Option<f64>,
1131
1132 #[serde(flatten)]
1133 pub run_config: NodeRunConfig,
1134}
1135
1136#[allow(missing_docs)]
1137#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1138pub enum NodeSource {
1139 Local,
1140 GitBranch {
1141 repo: String,
1142 rev: Option<GitRepoRev>,
1143 },
1144}
1145
1146#[allow(missing_docs)]
1147impl NodeSource {
1148 pub fn is_git(&self) -> bool {
1149 matches!(self, Self::GitBranch { .. })
1150 }
1151}
1152
1153#[allow(missing_docs)]
1154#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1155pub enum GitRepoRev {
1156 Branch(String),
1157 Tag(String),
1158 Rev(String),
1159}
1160
1161#[allow(missing_docs)]
1162#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1163#[serde(untagged)]
1164pub enum EnvValue {
1165 #[serde(deserialize_with = "with_expand_envs")]
1166 Bool(bool),
1167 #[serde(deserialize_with = "with_expand_envs")]
1168 Integer(i64),
1169 #[serde(deserialize_with = "with_expand_envs")]
1170 Float(f64),
1171 #[serde(deserialize_with = "with_expand_envs")]
1172 String(String),
1173}
1174
1175impl fmt::Display for EnvValue {
1176 fn fmt(&self, fmt: &mut fmt::Formatter) -> fmt::Result {
1177 match self {
1178 EnvValue::Bool(bool) => fmt.write_str(&bool.to_string()),
1179 EnvValue::Integer(i64) => fmt.write_str(&i64.to_string()),
1180 EnvValue::Float(f64) => fmt.write_str(&f64.to_string()),
1181 EnvValue::String(str) => fmt.write_str(str),
1182 }
1183 }
1184}
1185
1186/// ROS2 bridge configuration for declarative ROS2 bridging.
1187///
1188/// This allows nodes to interact with ROS2 topics, services, and actions
1189/// without writing any custom code. The framework spawns a bridge binary that
1190/// handles the ROS2 DDS communication and Arrow data conversion.
1191///
1192/// Exactly one of `topic`, `topics`, `service`, or `action` must be set.
1193#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1194#[serde(deny_unknown_fields)]
1195pub struct Ros2BridgeConfig {
1196 /// ROS2 topic name (e.g. "/camera/image_raw").
1197 /// Mutually exclusive with `topics`, `service`, `action`.
1198 #[serde(default, skip_serializing_if = "Option::is_none")]
1199 pub topic: Option<String>,
1200
1201 /// ROS2 message type (e.g. "sensor_msgs/Image").
1202 /// Required when `topic` is set.
1203 #[serde(default, skip_serializing_if = "Option::is_none")]
1204 pub message_type: Option<String>,
1205
1206 /// Direction: subscribe (ROS2 -> Dora) or publish (Dora -> ROS2).
1207 /// Defaults to subscribe. Only used with `topic`/`topics`.
1208 #[serde(default)]
1209 pub direction: Ros2Direction,
1210
1211 /// Multiple topics on a single ROS2 node context.
1212 /// Mutually exclusive with `topic`, `service`, `action`.
1213 #[serde(default, skip_serializing_if = "Option::is_none")]
1214 pub topics: Option<Vec<Ros2TopicConfig>>,
1215
1216 /// ROS2 service name (e.g. "/add_two_ints").
1217 /// Mutually exclusive with `topic`, `topics`, `action`.
1218 #[serde(default, skip_serializing_if = "Option::is_none")]
1219 pub service: Option<String>,
1220
1221 /// ROS2 service type (e.g. "example_interfaces/AddTwoInts").
1222 /// Required when `service` is set.
1223 #[serde(default, skip_serializing_if = "Option::is_none")]
1224 pub service_type: Option<String>,
1225
1226 /// ROS2 action name (e.g. "/navigate").
1227 /// Mutually exclusive with `topic`, `topics`, `service`.
1228 #[serde(default, skip_serializing_if = "Option::is_none")]
1229 pub action: Option<String>,
1230
1231 /// ROS2 action type (e.g. "nav2_msgs/NavigateToPose").
1232 /// Required when `action` is set.
1233 #[serde(default, skip_serializing_if = "Option::is_none")]
1234 pub action_type: Option<String>,
1235
1236 /// Role: client or server. Required for `service` and `action`.
1237 #[serde(default, skip_serializing_if = "Option::is_none")]
1238 pub role: Option<Ros2Role>,
1239
1240 /// QoS policies applied to all topics (can be overridden per-topic).
1241 #[serde(default)]
1242 pub qos: Ros2QosConfig,
1243
1244 /// ROS2 namespace (default: "/").
1245 #[serde(default = "default_ros2_namespace")]
1246 pub namespace: String,
1247
1248 /// ROS2 node name. Defaults to the dora node id.
1249 #[serde(default, skip_serializing_if = "Option::is_none")]
1250 pub node_name: Option<String>,
1251}
1252
1253impl Default for Ros2BridgeConfig {
1254 fn default() -> Self {
1255 Self {
1256 topic: None,
1257 message_type: None,
1258 direction: Ros2Direction::default(),
1259 topics: None,
1260 service: None,
1261 service_type: None,
1262 action: None,
1263 action_type: None,
1264 role: None,
1265 qos: Ros2QosConfig::default(),
1266 namespace: default_ros2_namespace(),
1267 node_name: None,
1268 }
1269 }
1270}
1271
1272/// Role of a ROS2 service or action bridge node.
1273#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1274#[serde(rename_all = "snake_case")]
1275pub enum Ros2Role {
1276 /// Client: sends requests/goals, receives responses/results.
1277 Client,
1278 /// Server: receives requests, sends responses.
1279 Server,
1280}
1281
1282fn default_ros2_namespace() -> String {
1283 "/".to_string()
1284}
1285
1286/// Configuration for a single ROS2 topic in multi-topic mode.
1287#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1288#[serde(deny_unknown_fields)]
1289pub struct Ros2TopicConfig {
1290 /// ROS2 topic name.
1291 pub topic: String,
1292
1293 /// ROS2 message type (e.g. "geometry_msgs/Twist").
1294 pub message_type: String,
1295
1296 /// Direction: subscribe or publish.
1297 #[serde(default)]
1298 pub direction: Ros2Direction,
1299
1300 /// Maps to an dora output id (for subscribe direction).
1301 #[serde(default, skip_serializing_if = "Option::is_none")]
1302 pub output: Option<String>,
1303
1304 /// Maps to an dora input id (for publish direction).
1305 #[serde(default, skip_serializing_if = "Option::is_none")]
1306 pub input: Option<String>,
1307
1308 /// Per-topic QoS override.
1309 #[serde(default, skip_serializing_if = "Option::is_none")]
1310 pub qos: Option<Ros2QosConfig>,
1311}
1312
1313/// Direction of ROS2 bridge communication.
1314#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
1315#[serde(rename_all = "snake_case")]
1316pub enum Ros2Direction {
1317 /// Subscribe: receive from ROS2, forward to dora outputs.
1318 #[default]
1319 Subscribe,
1320 /// Publish: receive from dora inputs, publish to ROS2.
1321 Publish,
1322}
1323
1324/// ROS2 Quality of Service configuration.
1325#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
1326#[serde(deny_unknown_fields)]
1327pub struct Ros2QosConfig {
1328 /// Use reliable transport (default: false = best effort).
1329 #[serde(default)]
1330 pub reliable: bool,
1331
1332 /// Durability: "volatile" (default), "transient_local".
1333 #[serde(default, skip_serializing_if = "Option::is_none")]
1334 pub durability: Option<String>,
1335
1336 /// Liveliness: "automatic" (default), "manual_by_participant", "manual_by_topic".
1337 #[serde(default, skip_serializing_if = "Option::is_none")]
1338 pub liveliness: Option<String>,
1339
1340 /// Lease duration in seconds (default: infinity).
1341 #[serde(default, skip_serializing_if = "Option::is_none")]
1342 pub lease_duration: Option<f64>,
1343
1344 /// Max blocking time in seconds for reliable transport.
1345 #[serde(default, skip_serializing_if = "Option::is_none")]
1346 pub max_blocking_time: Option<f64>,
1347
1348 /// History depth for KeepLast policy (default: 1).
1349 #[serde(default, skip_serializing_if = "Option::is_none")]
1350 pub keep_last: Option<i32>,
1351
1352 /// Use KeepAll history policy instead of KeepLast.
1353 #[serde(default)]
1354 pub keep_all: bool,
1355}
1356
1357#[cfg(test)]
1358mod tests {
1359 use super::*;
1360
1361 #[test]
1362 fn output_framing_defaults_to_raw() {
1363 let yaml = r#"
1364nodes:
1365 - id: test
1366 path: test.py
1367 outputs:
1368 - data
1369"#;
1370 let desc: Descriptor = serde_yaml::from_str(yaml).unwrap();
1371 assert!(desc.nodes[0].output_framing.is_empty());
1372 }
1373
1374 #[test]
1375 fn output_framing_parses_arrow_ipc() {
1376 let yaml = r#"
1377nodes:
1378 - id: test
1379 path: test.py
1380 outputs:
1381 - data
1382 output_framing:
1383 data: arrow-ipc
1384"#;
1385 let desc: Descriptor = serde_yaml::from_str(yaml).unwrap();
1386 assert_eq!(
1387 desc.nodes[0].output_framing.get::<DataId>(&"data".into()),
1388 Some(&OutputFraming::ArrowIpc)
1389 );
1390 }
1391
1392 #[test]
1393 fn cpu_affinity_parses() {
1394 let yaml = r#"
1395nodes:
1396 - id: test
1397 path: test.py
1398 cpu_affinity: [0, 2, 4]
1399"#;
1400 let desc: Descriptor = serde_yaml::from_str(yaml).unwrap();
1401 assert_eq!(desc.nodes[0].cpu_affinity, Some(vec![0, 2, 4]));
1402 }
1403
1404 #[test]
1405 fn cpu_affinity_defaults_to_none() {
1406 let yaml = r#"
1407nodes:
1408 - id: test
1409 path: test.py
1410"#;
1411 let desc: Descriptor = serde_yaml::from_str(yaml).unwrap();
1412 assert_eq!(desc.nodes[0].cpu_affinity, None);
1413 }
1414
1415 #[test]
1416 fn debug_flag_accepts_new_name() {
1417 let yaml = r#"
1418nodes:
1419 - id: test
1420 path: test.py
1421_unstable_debug:
1422 enable_debug_inspection: true
1423"#;
1424 let desc: Descriptor = serde_yaml::from_str(yaml).unwrap();
1425 assert!(desc.debug.enable_debug_inspection);
1426 }
1427
1428 #[test]
1429 fn debug_flag_accepts_legacy_alias() {
1430 // Backward-compat regression guard (#240): dataflow YAML in the wild
1431 // still uses `publish_all_messages_to_zenoh`. The serde alias must
1432 // keep deserializing that into the renamed field.
1433 let yaml = r#"
1434nodes:
1435 - id: test
1436 path: test.py
1437_unstable_debug:
1438 publish_all_messages_to_zenoh: true
1439"#;
1440 let desc: Descriptor = serde_yaml::from_str(yaml).unwrap();
1441 assert!(desc.debug.enable_debug_inspection);
1442 }
1443}