Skip to main content

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}