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}