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}