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    /// Constructed through [`ControlRequest::topic_subscribe`], not by
147    /// literal: the variant is `#[non_exhaustive]` so that the *next* field
148    /// added here is a minor change rather than a 2.0 (see
149    /// [`RegisterResult::Ok`](crate::coordinator_to_daemon::RegisterResult::Ok)).
150    #[non_exhaustive]
151    TopicSubscribe {
152        dataflow_id: Uuid,
153        topics: Vec<(NodeId, DataId)>,
154        /// Binary-frame encoding the client speaks, see
155        /// [`TOPIC_DATA_PROTOCOL_VERSION`](crate::TOPIC_DATA_PROTOCOL_VERSION).
156        ///
157        /// `None` means the client predates the handshake and therefore speaks
158        /// the bincode encoding; the coordinator rejects it rather than let it
159        /// misparse postcard frames.
160        #[serde(default)]
161        protocol_version: Option<u16>,
162    },
163    TopicUnsubscribe {
164        subscription_id: Uuid,
165    },
166    GetTraces,
167    GetTraceSpans {
168        trace_id: String,
169    },
170    /// Restart a specific node without stopping the entire dataflow.
171    RestartNode {
172        dataflow_id: Uuid,
173        node_id: NodeId,
174        grace_duration: Option<Duration>,
175    },
176    /// Stop a specific node without stopping the entire dataflow.
177    StopNode {
178        dataflow_id: Uuid,
179        node_id: NodeId,
180        grace_duration: Option<Duration>,
181    },
182    /// Publish a message to a topic (for debugging/testing).
183    ///
184    /// The coordinator serializes the JSON data into Arrow format and
185    /// publishes it to Zenoh on the appropriate topic key.
186    TopicPublish {
187        dataflow_id: Uuid,
188        node_id: NodeId,
189        output_id: DataId,
190        /// JSON data to publish (will be converted to Arrow UInt8 array)
191        data_json: String,
192    },
193    /// List runtime parameters for a node.
194    GetParams {
195        dataflow_id: Uuid,
196        node_id: NodeId,
197    },
198    /// Get a single runtime parameter value.
199    GetParam {
200        dataflow_id: Uuid,
201        node_id: NodeId,
202        key: String,
203    },
204    /// Set a runtime parameter on a node.
205    SetParam {
206        dataflow_id: Uuid,
207        node_id: NodeId,
208        key: String,
209        value: serde_json::Value,
210    },
211    /// Delete a runtime parameter from a node.
212    DeleteParam {
213        dataflow_id: Uuid,
214        node_id: NodeId,
215        key: String,
216    },
217    // --- Dynamic Topology ---
218    /// Add a node to a running dataflow.
219    AddNode {
220        dataflow_id: Uuid,
221        node: crate::descriptor::Node,
222    },
223    /// Remove a node from a running dataflow.
224    RemoveNode {
225        dataflow_id: Uuid,
226        node_id: NodeId,
227        grace_duration: Option<std::time::Duration>,
228    },
229    /// Atomically replace a running node with a new definition under the
230    /// same id (dora-rs/dora#2927). The replacement must keep the node's
231    /// edges (same input mappings, outputs covering every mapped output);
232    /// a spawn failure leaves the current incarnation running.
233    ReplaceNode {
234        dataflow_id: Uuid,
235        node: crate::descriptor::Node,
236        grace_duration: Option<std::time::Duration>,
237    },
238    /// Add a mapping (connection) between two nodes in a running dataflow.
239    AddMapping {
240        dataflow_id: Uuid,
241        source_node: NodeId,
242        source_output: DataId,
243        target_node: NodeId,
244        target_input: DataId,
245    },
246    /// Remove a mapping (connection) between two nodes in a running dataflow.
247    RemoveMapping {
248        dataflow_id: Uuid,
249        source_node: NodeId,
250        source_output: DataId,
251        target_node: NodeId,
252        target_input: DataId,
253    },
254    /// Protocol version handshake. Sent by the CLI as its first request
255    /// after connecting so the coordinator can reject version-mismatched
256    /// clients before they exchange incompatible messages
257    /// (dora-rs/adora#151).
258    ///
259    /// The coordinator replies with either
260    /// [`ControlRequestReply::HelloOk`](crate::coordinator_to_cli::ControlRequestReply::HelloOk) carrying its own crate version,
261    /// or [`ControlRequestReply::Error`](crate::coordinator_to_cli::ControlRequestReply::Error) with a human-readable mismatch
262    /// message.
263    Hello {
264        dora_version: semver::Version,
265    },
266}
267
268impl ControlRequest {
269    /// Build a Hello request stamped with the current crate version of
270    /// `dora-message` (the wire-protocol version).
271    pub fn hello() -> Self {
272        Self::Hello {
273            dora_version: crate::current_crate_version(),
274        }
275    }
276
277    /// Subscribe to `topics` of a running dataflow, stamped with the
278    /// binary-frame encoding this crate speaks
279    /// ([`TOPIC_DATA_PROTOCOL_VERSION`](crate::TOPIC_DATA_PROTOCOL_VERSION)).
280    pub fn topic_subscribe(dataflow_id: Uuid, topics: Vec<(NodeId, DataId)>) -> Self {
281        Self::TopicSubscribe {
282            dataflow_id,
283            topics,
284            protocol_version: Some(crate::TOPIC_DATA_PROTOCOL_VERSION),
285        }
286    }
287}
288
289/// Check whether a CLI-reported dora version is compatible with this
290/// coordinator's crate version. Returns `Ok(())` on success or a
291/// human-readable error describing the mismatch.
292pub fn check_cli_version(cli_version: &semver::Version) -> Result<(), String> {
293    let crate_version = crate::current_crate_version();
294    if crate::versions_compatible(&crate_version, cli_version)? {
295        Ok(())
296    } else {
297        Err(format!(
298            "dora version mismatch: CLI v{cli_version} is not compatible \
299             with coordinator v{crate_version}. Upgrade the component that \
300             is behind (usually the CLI) so both sides share a semver-compatible \
301             version."
302        ))
303    }
304}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309
310    // ---- dora-rs/adora#151: CLI ↔ coordinator protocol version handshake ----
311
312    #[test]
313    fn hello_stamps_current_crate_version() {
314        let req = ControlRequest::hello();
315        match req {
316            ControlRequest::Hello { dora_version } => {
317                assert_eq!(dora_version, crate::current_crate_version());
318            }
319            other => panic!("expected Hello, got {other:?}"),
320        }
321    }
322
323    #[test]
324    fn topic_subscribe_stamps_current_protocol_version() {
325        assert!(matches!(
326            ControlRequest::topic_subscribe(Uuid::nil(), vec![]),
327            ControlRequest::TopicSubscribe {
328                protocol_version: Some(crate::TOPIC_DATA_PROTOCOL_VERSION),
329                ..
330            }
331        ));
332    }
333
334    /// The wire property the `#[non_exhaustive]` on `TopicSubscribe` is there
335    /// to let us rely on: a request from a *newer* CLI carrying a field this
336    /// coordinator does not know still decodes, and one from an *older* CLI
337    /// missing a `#[serde(default)]` field decodes too. Together they are what
338    /// make appending such a field a minor change.
339    #[test]
340    fn topic_subscribe_tolerates_unknown_and_missing_fields() {
341        let newer = r#"{"TopicSubscribe":{"dataflow_id":"00000000-0000-0000-0000-000000000000","topics":[],"protocol_version":2,"mode":"metadata-only"}}"#;
342        let older = r#"{"TopicSubscribe":{"dataflow_id":"00000000-0000-0000-0000-000000000000","topics":[]}}"#;
343        for (label, json, expected_version) in [("newer", newer, Some(2)), ("older", older, None)] {
344            let req: ControlRequest = serde_json::from_str(json)
345                .unwrap_or_else(|e| panic!("{label} peer's request must decode: {e}"));
346            match req {
347                ControlRequest::TopicSubscribe {
348                    protocol_version, ..
349                } => assert_eq!(protocol_version, expected_version, "{label}"),
350                other => panic!("expected TopicSubscribe, got {other:?}"),
351            }
352        }
353    }
354
355    #[test]
356    fn check_cli_version_accepts_matching_version() {
357        let same = crate::current_crate_version();
358        assert!(check_cli_version(&same).is_ok());
359    }
360
361    #[test]
362    fn check_cli_version_rejects_incompatible_major_bump() {
363        // Semver allows same-major patch bumps but not major jumps.
364        let current = crate::current_crate_version();
365        let incompatible = semver::Version::new(current.major + 1, 0, 0);
366        let err = check_cli_version(&incompatible).expect_err("major bump should reject");
367        assert!(
368            err.contains("version mismatch"),
369            "error must mention mismatch: {err}"
370        );
371        assert!(err.contains("CLI v"), "error must include CLI version");
372    }
373
374    #[test]
375    fn check_cli_version_accepts_compatible_patch_bump() {
376        // A patch bump on the same major is always semver-compatible.
377        let current = crate::current_crate_version();
378        let patched = semver::Version::new(current.major, current.minor, current.patch + 1);
379        assert!(check_cli_version(&patched).is_ok());
380    }
381
382    #[test]
383    fn hello_roundtrips_through_json() {
384        // The handshake is sent as JSON over the WS control channel,
385        // so the enum variant must survive a roundtrip with preserved
386        // version fidelity.
387        let req = ControlRequest::hello();
388        let json = serde_json::to_string(&req).expect("serialize");
389        let decoded: ControlRequest = serde_json::from_str(&json).expect("deserialize");
390        match decoded {
391            ControlRequest::Hello { dora_version } => {
392                assert_eq!(dora_version, crate::current_crate_version());
393            }
394            other => panic!("expected Hello, got {other:?}"),
395        }
396    }
397}