dora_cli/command/run.rs
1//! The `dora run` command runs a dataflow locally in isolation: it
2//! spawns nodes in-process via `dora_daemon::Daemon::run_dataflow`
3//! without binding any coordinator port. As a result, `dora run` does
4//! NOT integrate with the CLI monitoring commands (`dora list`,
5//! `dora stop`, `dora logs`, `dora top`, ...). Use `dora up` + `dora
6//! start` instead when you need to attach those tools.
7//!
8//! Running isolated means:
9//! - `dora run` can be invoked while `dora up` is already running
10//! (the coordinator port is not contested).
11//! - Multiple `dora run` calls can execute in parallel.
12
13use super::Executable;
14use crate::common::parse_duration;
15use crate::{
16 BuildConfig, build as build_dataflow,
17 common::{handle_dataflow_result, resolve_dataflow, write_events_to},
18 output::{
19 LogFormat, LogOutputConfig, parse_log_filter, parse_log_level_str, print_log_message,
20 },
21 session::DataflowSession,
22};
23use dora_core::build::LogLevelOrStdout;
24use dora_core::descriptor::{Descriptor, DescriptorExt};
25use dora_daemon::{Daemon, LogDestination, RunDataflowOptions, flume};
26use eyre::Context;
27use std::{path::PathBuf, time::Duration};
28use tokio::runtime::Builder;
29
30#[derive(Debug, clap::Args)]
31/// Run a dataflow locally in isolation.
32///
33/// Spawns nodes in-process without a coordinator. CLI monitoring
34/// commands (`dora list`, `dora stop`, `dora logs`, ...) do NOT attach
35/// to a `dora run` execution. Use `dora up` + `dora start` instead when
36/// you need to attach those tools or run multiple coordinated
37/// dataflows.
38pub struct Run {
39 /// Path to the dataflow descriptor file
40 #[clap(value_name = "PATH")]
41 pub dataflow: String,
42 // Use UV to run nodes.
43 #[clap(long, action)]
44 pub uv: bool,
45 /// Automatically stop the dataflow after the given duration
46 ///
47 /// The command will send a stop message after the specified time has elapsed,
48 /// similar to pressing Ctrl-C. This gracefully stops all nodes in the dataflow.
49 ///
50 /// Examples:
51 /// --stop-after 30 # 30 seconds (a bare number is seconds)
52 /// --stop-after 10s # 10 seconds
53 /// --stop-after 5m # 5 minutes
54 /// --stop-after 1h30m # 1 hour 30 minutes
55 /// --stop-after 500ms # 500 milliseconds
56 #[clap(long, value_name = "DURATION", verbatim_doc_comment)]
57 #[arg(value_parser = parse_duration)]
58 pub stop_after: Option<Duration>,
59 /// Minimum log level to display
60 ///
61 /// Levels: error, warn, info, debug, trace, stdout (default).
62 /// "stdout" shows everything including raw stdout from nodes.
63 #[clap(long, default_value = "stdout", env = "DORA_LOG_LEVEL")]
64 #[arg(value_parser = parse_log_level_str)]
65 pub log_level: LogLevelOrStdout,
66 /// Output format for log messages
67 ///
68 /// `json` emits JSON Lines (one object per log message).
69 #[clap(long, default_value = "pretty", env = "DORA_LOG_FORMAT")]
70 pub log_format: LogFormat,
71 /// Per-node log level filter
72 ///
73 /// Format: "node1=level,node2=level". Overrides --log-level for matched nodes.
74 ///
75 /// Examples:
76 /// --log-filter "sensor=debug,processor=warn"
77 #[clap(
78 long,
79 value_name = "FILTER",
80 env = "DORA_LOG_FILTER",
81 verbatim_doc_comment
82 )]
83 pub log_filter: Option<String>,
84 /// Allow shell nodes to execute arbitrary commands.
85 ///
86 /// Shell nodes are disabled by default for security reasons. This flag
87 /// sets the DORA_ALLOW_SHELL_NODES environment variable.
88 #[clap(long)]
89 pub allow_shell_nodes: bool,
90 /// Enable debug mode (publishes all messages to Zenoh for topic echo/hz/info)
91 #[clap(long, action)]
92 pub debug: bool,
93 /// Use pinned git source commits from a lockfile during pre-run build.
94 #[clap(long, action, conflicts_with = "write_lockfile")]
95 pub locked: bool,
96 /// Write resolved git source commits to a lockfile during pre-run build.
97 #[clap(long, action)]
98 pub write_lockfile: bool,
99 /// Path to build lockfile (defaults to `<dataflow-stem>.dora-lock.yaml`).
100 #[clap(long, value_name = "PATH")]
101 pub lockfile: Option<PathBuf>,
102 /// Decouples the working dir used for descriptor-relative paths
103 /// from the dataflow file's parent. Needed when the path passed in
104 /// is a rewritten copy (e.g. `dora record`'s tempfile) whose parent
105 /// can't resolve the original `build:` / relative-path references.
106 #[clap(skip)]
107 pub working_dir: Option<PathBuf>,
108 /// Substitute a local checkout for a hub package (UC11 inner loop):
109 /// `--hub-override <namespace>/<name>=<path>`. Same as `dora build
110 /// --hub-override`; `dora run` is always local, so it applies directly.
111 #[clap(long = "hub-override", value_name = "PKG=PATH")]
112 pub hub_override: Vec<String>,
113 /// Set an environment variable for every node of this dataflow
114 /// (repeatable). Merges into the dataflow-level `env:` block;
115 /// node-level `env:` entries still win on conflict.
116 ///
117 /// Under `dora run` nodes also inherit this process's environment,
118 /// but `--env` is the portable spelling that behaves identically
119 /// under `dora start` (where nodes inherit the DAEMON's environment
120 /// instead). Applies at spawn time only; `build:` commands are
121 /// unaffected.
122 /// Values must survive the descriptor encoding verbatim: a literal
123 /// `$` is refused (the receiving process would expand it) and so are
124 /// numeric-looking values that would be coerced.
125 #[clap(long = "env", value_name = "KEY=VALUE")]
126 pub env: Vec<String>,
127 /// Exit once every node has finished, treating `dora/timer/...`
128 /// inputs as a clock rather than as work.
129 ///
130 /// By default a node is only told its inputs are closed when ALL of
131 /// them are, and a timer input never closes — so a graph where any
132 /// node consumes a timer cannot finish on its own, even after every
133 /// worker has done its work. With this flag a node is finished once
134 /// its DATA inputs have closed, which makes "run N items and exit"
135 /// scriptable without wrapping the command in a timeout.
136 ///
137 /// Nodes whose inputs are all timers are unaffected: they have no
138 /// data dependency that could finish, so they are treated as
139 /// sources, exactly as a node with no inputs is.
140 ///
141 /// Overrides `exit_when_nodes_finish:` in the dataflow YAML in
142 /// either direction: pass the flag to force it on, or
143 /// `--exit-when-nodes-finish=false` to force it off for a descriptor
144 /// that asks for it. Omit it entirely and the descriptor decides.
145 #[clap(
146 long,
147 num_args = 0..=1,
148 require_equals = true,
149 default_missing_value = "true",
150 value_name = "BOOL"
151 )]
152 pub exit_when_nodes_finish: Option<bool>,
153}
154
155impl Run {
156 pub fn new(dataflow: String) -> Self {
157 Self {
158 dataflow,
159 uv: false,
160 stop_after: None,
161 log_level: LogLevelOrStdout::Stdout,
162 log_format: LogFormat::Pretty,
163 log_filter: None,
164 allow_shell_nodes: false,
165 debug: false,
166 locked: false,
167 write_lockfile: false,
168 lockfile: None,
169 working_dir: None,
170 hub_override: Vec::new(),
171 env: Vec::new(),
172 exit_when_nodes_finish: None,
173 }
174 }
175
176 pub fn with_working_dir(mut self, working_dir: PathBuf) -> Self {
177 self.working_dir = Some(working_dir);
178 self
179 }
180}
181
182pub fn run(dataflow: String, uv: bool) -> eyre::Result<()> {
183 let mut run = Run::new(dataflow);
184 run.uv = uv;
185 run.execute()
186}
187
188impl Executable for Run {
189 fn execute(self) -> eyre::Result<()> {
190 if self.allow_shell_nodes {
191 // SAFETY: Called before spawning any threads (tokio runtime not yet built),
192 // so there are no concurrent reads of environment variables.
193 unsafe { std::env::set_var("DORA_ALLOW_SHELL_NODES", "true") };
194 }
195
196 let rt = Builder::new_multi_thread()
197 .enable_all()
198 .build()
199 .context("tokio runtime failed")?;
200
201 #[cfg(feature = "tracing")]
202 let _guard = {
203 let _enter = rt.enter();
204 let env_log = std::env::var("RUST_LOG").unwrap_or("info".to_string());
205 dora_tracing::init_tracing_subscriber(
206 "dora-run",
207 Some(&env_log),
208 None,
209 tracing::metadata::LevelFilter::INFO,
210 )
211 .context("failed to initialize tracing")?
212 };
213
214 let dataflow_path =
215 resolve_dataflow(self.dataflow.clone()).context("could not resolve dataflow")?;
216 // Validate `--env` BEFORE building: a typo should fail in
217 // milliseconds, not after a full dataflow build.
218 let env_overrides = crate::env_overrides::parse_env_overrides(&self.env)?;
219
220 build_dataflow(BuildConfig {
221 dataflow: dataflow_path.to_string_lossy().into_owned(),
222 uv: self.uv,
223 force_local: true,
224 locked: self.locked,
225 write_lockfile: self.write_lockfile,
226 lockfile_override: self.lockfile.clone(),
227 working_dir_override: self.working_dir.clone(),
228 hub_overrides: self.hub_override.clone(),
229 ..Default::default()
230 })
231 .context("failed to build dataflow before run")?;
232 let dataflow_session = DataflowSession::read_session(&dataflow_path)
233 .context("failed to read DataflowSession")?;
234
235 // `--env` merges into the dataflow-level `env:` of the descriptor
236 // handed to the in-process daemon. The hub-resolved descriptor
237 // (when present) is the mandatory base — the on-disk YAML still
238 // has unresolved `hub:` references. Without `--env`, pass the
239 // session state through unchanged.
240 let descriptor_override = if env_overrides.is_empty() {
241 dataflow_session.resolved_dataflow.clone()
242 } else {
243 let mut descriptor = match dataflow_session.resolved_dataflow.clone() {
244 Some(resolved) => resolved,
245 None => Descriptor::blocking_read(&dataflow_path)
246 .context("failed to read dataflow descriptor for --env")?,
247 };
248 crate::env_overrides::apply_env_overrides(&mut descriptor, env_overrides);
249 Some(descriptor)
250 };
251
252 let node_filters = match &self.log_filter {
253 Some(filter) => parse_log_filter(filter).map_err(|e| eyre::eyre!(e))?,
254 None => Default::default(),
255 };
256
257 let log_config = LogOutputConfig {
258 min_level: self.log_level,
259 format: self.log_format,
260 node_filters,
261 print_dataflow_id: false,
262 print_daemon_name: false,
263 };
264
265 let (log_tx, log_rx) = flume::bounded(100);
266 std::thread::spawn(move || {
267 for message in log_rx {
268 print_log_message(message, &log_config);
269 }
270 });
271
272 // Drive `Daemon::run_dataflow` on a tokio worker thread, not the
273 // calling main thread. On Windows the main thread's default stack
274 // is 1 MiB, which the daemon's deeply-nested async state machine
275 // (zenoh + arrow + flume + coordinator I/O) overflows in debug
276 // builds — `target\debug\examples\rust-dataflow.exe` crashed with
277 // STATUS_STACK_OVERFLOW in the 05-28 nightly (#1964). tokio worker
278 // threads default to 2 MiB, which clears the overflow. Linux and
279 // macOS were unaffected because their main-thread stacks are
280 // multi-MiB by default. Regression from #1962.
281 //
282 // `run_dataflow` takes `&Path`, so the returned future borrows
283 // non-`'static`; move an owned `PathBuf` (plus the other Run
284 // fields) into the spawned task so the future is `'static`.
285 let dataflow_path_for_daemon = dataflow_path.clone();
286 let uv = self.uv;
287 let stop_after = self.stop_after;
288 let debug = self.debug;
289 let working_dir_override = self.working_dir.clone();
290 let exit_when_nodes_finish = self.exit_when_nodes_finish;
291 let handle = rt.spawn(async move {
292 Daemon::run_dataflow_with(
293 None,
294 &dataflow_path_for_daemon,
295 dataflow_session.build_id,
296 dataflow_session.local_build,
297 dataflow_session.session_id,
298 uv,
299 LogDestination::Channel { sender: log_tx },
300 write_events_to(),
301 stop_after,
302 debug,
303 working_dir_override,
304 // hub-resolved descriptor and/or `--env` merge — see above
305 descriptor_override,
306 // Left unset the descriptor decides; given, it overrides.
307 match exit_when_nodes_finish {
308 Some(v) => RunDataflowOptions::default().exit_when_nodes_finish(v),
309 None => RunDataflowOptions::default(),
310 },
311 )
312 .await
313 });
314 let result = rt
315 .block_on(handle)
316 .context("dora-run daemon task panicked")??;
317 // Bound runtime shutdown to prevent hanging on blocking Drop impls
318 // (e.g. zenoh::Session::drop blocks tokio workers on macOS during
319 // TCP teardown). Without this, `rt` drops implicitly at end of scope
320 // and waits indefinitely for all worker threads to exit (#2287).
321 rt.shutdown_timeout(Duration::from_secs(10));
322 handle_dataflow_result(result, None)
323 }
324}