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