Skip to main content

dora_message/
cli_to_coordinator.rs

1use std::{collections::BTreeMap, path::PathBuf, time::Duration};
2
3use uuid::Uuid;
4
5use crate::{
6    BuildId, SessionId,
7    common::GitSource,
8    descriptor::Descriptor,
9    id::{DataId, NodeId, OperatorId},
10};
11
12#[allow(clippy::large_enum_variant)]
13#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
14pub enum ControlRequest {
15    Build {
16        session_id: SessionId,
17        dataflow: Descriptor,
18        git_sources: BTreeMap<NodeId, GitSource>,
19        prev_git_sources: BTreeMap<NodeId, GitSource>,
20        /// Allows overwriting the base working dir when CLI and daemon are
21        /// running on the same machine.
22        ///
23        /// Must not be used for multi-machine dataflows.
24        ///
25        /// Note that nodes with git sources still use a subdirectory of
26        /// the base working dir.
27        local_working_dir: Option<PathBuf>,
28        uv: bool,
29    },
30    WaitForBuild {
31        build_id: BuildId,
32    },
33    Start {
34        build_id: Option<BuildId>,
35        session_id: SessionId,
36        dataflow: Descriptor,
37        name: Option<String>,
38        /// Allows overwriting the base working dir when CLI and daemon are
39        /// running on the same machine.
40        ///
41        /// Must not be used for multi-machine dataflows.
42        ///
43        /// Note that nodes with git sources still use a subdirectory of
44        /// the base working dir.
45        local_working_dir: Option<PathBuf>,
46        uv: bool,
47        write_events_to: Option<PathBuf>,
48    },
49    WaitForSpawn {
50        dataflow_id: Uuid,
51    },
52    Reload {
53        dataflow_id: Uuid,
54        node_id: NodeId,
55        operator_id: Option<OperatorId>,
56    },
57    Check {
58        dataflow_uuid: Uuid,
59    },
60    Stop {
61        dataflow_uuid: Uuid,
62        grace_duration: Option<Duration>,
63        #[serde(default)]
64        force: bool,
65    },
66    StopByName {
67        name: String,
68        grace_duration: Option<Duration>,
69        #[serde(default)]
70        force: bool,
71    },
72    Restart {
73        dataflow_uuid: Uuid,
74        grace_duration: Option<Duration>,
75        #[serde(default)]
76        force: bool,
77    },
78    RestartByName {
79        name: String,
80        grace_duration: Option<Duration>,
81        #[serde(default)]
82        force: bool,
83    },
84    Logs {
85        uuid: Option<Uuid>,
86        name: Option<String>,
87        node: String,
88        tail: Option<usize>,
89    },
90    Destroy,
91    List,
92    /// Remove fully-completed dataflows from the coordinator's state.
93    ///
94    /// A dataflow is considered fully completed when no daemon is still
95    /// running it (i.e. it's no longer in `running_dataflows`). Multi-daemon
96    /// dataflows that are still finishing — where some daemons have reported
97    /// results but others haven't — are intentionally skipped so their final
98    /// status is computed correctly when the last daemon completes.
99    ///
100    /// Candidates are enumerated from BOTH the in-memory
101    /// `dataflow_results` map AND the persisted store
102    /// (`Succeeded` / `Failed` records). This lets a restarted
103    /// coordinator still reap historical rows that exist only on
104    /// disk — the recovery loop intentionally does not reload them
105    /// into memory, so without the persisted-store pass they would
106    /// otherwise sit in redb forever and never become reachable for
107    /// `dora clean`. If the persisted-store enumeration itself
108    /// errors, the entire request fails with
109    /// [`ControlRequestReply::Error`](crate::coordinator_to_cli::ControlRequestReply::Error) and no in-memory state is
110    /// mutated — degrading silently to in-memory-only would let the
111    /// CLI claim "nothing to clean" while historical rows are still
112    /// sitting on disk.
113    ///
114    /// For each cleaned dataflow the coordinator removes its persisted
115    /// record first and only then mutates in-memory state, so the reply
116    /// reflects what was actually persisted. The response is
117    /// [`ControlRequestReply::CleanResult`](crate::coordinator_to_cli::ControlRequestReply::CleanResult) carrying two separate
118    /// lists: `cleaned` for dataflows whose redb row (and every
119    /// `dora param` row owned by it — the persisted-store delete
120    /// cascades) is gone, and `failed` for dataflows whose
121    /// persisted-store delete errored. In-memory entries for failed
122    /// candidates are preserved so a later `dora clean` can retry;
123    /// they show up in `failed`, not `cleaned`, so the CLI can tell
124    /// "nothing eligible" apart from "all candidates failed to
125    /// clean". Logs and archived descriptors for successfully cleaned
126    /// dataflows are no longer available afterward. Cached build
127    /// results (`finished_builds`) are intentionally not touched —
128    /// clearing them would break concurrent `dora build` calls with
129    /// "unknown build id" errors.
130    Clean,
131    Info {
132        dataflow_uuid: Uuid,
133    },
134    DaemonConnected,
135    ConnectedMachines,
136    LogSubscribe {
137        dataflow_id: Uuid,
138        level: log::LevelFilter,
139    },
140    BuildLogSubscribe {
141        build_id: BuildId,
142        level: log::LevelFilter,
143    },
144    CliAndDefaultDaemonOnSameMachine,
145    GetNodeInfo,
146    TopicSubscribe {
147        dataflow_id: Uuid,
148        topics: Vec<(NodeId, DataId)>,
149        /// Binary-frame encoding the client speaks, see
150        /// [`TOPIC_DATA_PROTOCOL_VERSION`](crate::TOPIC_DATA_PROTOCOL_VERSION).
151        ///
152        /// `None` means the client predates the handshake and therefore speaks
153        /// the bincode encoding; the coordinator rejects it rather than let it
154        /// misparse postcard frames.
155        #[serde(default)]
156        protocol_version: Option<u16>,
157    },
158    TopicUnsubscribe {
159        subscription_id: Uuid,
160    },
161    GetTraces,
162    GetTraceSpans {
163        trace_id: String,
164    },
165    /// Restart a specific node without stopping the entire dataflow.
166    RestartNode {
167        dataflow_id: Uuid,
168        node_id: NodeId,
169        grace_duration: Option<Duration>,
170    },
171    /// Stop a specific node without stopping the entire dataflow.
172    StopNode {
173        dataflow_id: Uuid,
174        node_id: NodeId,
175        grace_duration: Option<Duration>,
176    },
177    /// Publish a message to a topic (for debugging/testing).
178    ///
179    /// The coordinator serializes the JSON data into Arrow format and
180    /// publishes it to Zenoh on the appropriate topic key.
181    TopicPublish {
182        dataflow_id: Uuid,
183        node_id: NodeId,
184        output_id: DataId,
185        /// JSON data to publish (will be converted to Arrow UInt8 array)
186        data_json: String,
187    },
188    /// List runtime parameters for a node.
189    GetParams {
190        dataflow_id: Uuid,
191        node_id: NodeId,
192    },
193    /// Get a single runtime parameter value.
194    GetParam {
195        dataflow_id: Uuid,
196        node_id: NodeId,
197        key: String,
198    },
199    /// Set a runtime parameter on a node.
200    SetParam {
201        dataflow_id: Uuid,
202        node_id: NodeId,
203        key: String,
204        value: serde_json::Value,
205    },
206    /// Delete a runtime parameter from a node.
207    DeleteParam {
208        dataflow_id: Uuid,
209        node_id: NodeId,
210        key: String,
211    },
212    // --- Dynamic Topology ---
213    /// Add a node to a running dataflow.
214    AddNode {
215        dataflow_id: Uuid,
216        node: crate::descriptor::Node,
217    },
218    /// Remove a node from a running dataflow.
219    RemoveNode {
220        dataflow_id: Uuid,
221        node_id: NodeId,
222        grace_duration: Option<std::time::Duration>,
223    },
224    /// Atomically replace a running node with a new definition under the
225    /// same id (dora-rs/dora#2927). The replacement must keep the node's
226    /// edges (same input mappings, outputs covering every mapped output);
227    /// a spawn failure leaves the current incarnation running.
228    ReplaceNode {
229        dataflow_id: Uuid,
230        node: crate::descriptor::Node,
231        grace_duration: Option<std::time::Duration>,
232    },
233    /// Add a mapping (connection) between two nodes in a running dataflow.
234    AddMapping {
235        dataflow_id: Uuid,
236        source_node: NodeId,
237        source_output: DataId,
238        target_node: NodeId,
239        target_input: DataId,
240    },
241    /// Remove a mapping (connection) between two nodes in a running dataflow.
242    RemoveMapping {
243        dataflow_id: Uuid,
244        source_node: NodeId,
245        source_output: DataId,
246        target_node: NodeId,
247        target_input: DataId,
248    },
249    /// Protocol version handshake. Sent by the CLI as its first request
250    /// after connecting so the coordinator can reject version-mismatched
251    /// clients before they exchange incompatible messages
252    /// (dora-rs/adora#151).
253    ///
254    /// The coordinator replies with either
255    /// [`ControlRequestReply::HelloOk`](crate::coordinator_to_cli::ControlRequestReply::HelloOk) carrying its own crate version,
256    /// or [`ControlRequestReply::Error`](crate::coordinator_to_cli::ControlRequestReply::Error) with a human-readable mismatch
257    /// message.
258    Hello {
259        dora_version: semver::Version,
260    },
261}
262
263impl ControlRequest {
264    /// Build a Hello request stamped with the current crate version of
265    /// `dora-message` (the wire-protocol version).
266    pub fn hello() -> Self {
267        Self::Hello {
268            dora_version: crate::current_crate_version(),
269        }
270    }
271}
272
273/// Check whether a CLI-reported dora version is compatible with this
274/// coordinator's crate version. Returns `Ok(())` on success or a
275/// human-readable error describing the mismatch.
276pub fn check_cli_version(cli_version: &semver::Version) -> Result<(), String> {
277    let crate_version = crate::current_crate_version();
278    if crate::versions_compatible(&crate_version, cli_version)? {
279        Ok(())
280    } else {
281        Err(format!(
282            "dora version mismatch: CLI v{cli_version} is not compatible \
283             with coordinator v{crate_version}. Upgrade the component that \
284             is behind (usually the CLI) so both sides share a semver-compatible \
285             version."
286        ))
287    }
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293
294    // ---- dora-rs/adora#151: CLI ↔ coordinator protocol version handshake ----
295
296    #[test]
297    fn hello_stamps_current_crate_version() {
298        let req = ControlRequest::hello();
299        match req {
300            ControlRequest::Hello { dora_version } => {
301                assert_eq!(dora_version, crate::current_crate_version());
302            }
303            other => panic!("expected Hello, got {other:?}"),
304        }
305    }
306
307    #[test]
308    fn check_cli_version_accepts_matching_version() {
309        let same = crate::current_crate_version();
310        assert!(check_cli_version(&same).is_ok());
311    }
312
313    #[test]
314    fn check_cli_version_rejects_incompatible_major_bump() {
315        // Semver allows same-major patch bumps but not major jumps.
316        let current = crate::current_crate_version();
317        let incompatible = semver::Version::new(current.major + 1, 0, 0);
318        let err = check_cli_version(&incompatible).expect_err("major bump should reject");
319        assert!(
320            err.contains("version mismatch"),
321            "error must mention mismatch: {err}"
322        );
323        assert!(err.contains("CLI v"), "error must include CLI version");
324    }
325
326    #[test]
327    fn check_cli_version_accepts_compatible_patch_bump() {
328        // A patch bump on the same major is always semver-compatible.
329        let current = crate::current_crate_version();
330        let patched = semver::Version::new(current.major, current.minor, current.patch + 1);
331        assert!(check_cli_version(&patched).is_ok());
332    }
333
334    #[test]
335    fn hello_roundtrips_through_json() {
336        // The handshake is sent as JSON over the WS control channel,
337        // so the enum variant must survive a roundtrip with preserved
338        // version fidelity.
339        let req = ControlRequest::hello();
340        let json = serde_json::to_string(&req).expect("serialize");
341        let decoded: ControlRequest = serde_json::from_str(&json).expect("deserialize");
342        match decoded {
343            ControlRequest::Hello { dora_version } => {
344                assert_eq!(dora_version, crate::current_crate_version());
345            }
346            other => panic!("expected Hello, got {other:?}"),
347        }
348    }
349}