dora_message/coordinator_to_daemon.rs
1use std::{
2 collections::{BTreeMap, BTreeSet},
3 path::PathBuf,
4 time::Duration,
5};
6
7use crate::{
8 BuildId, DataflowId, SessionId,
9 common::{DaemonId, GitSource},
10 descriptor::{Descriptor, ResolvedNode},
11 id::{DataId, NodeId, OperatorId},
12};
13
14// ---------------------------------------------------------------------------
15// State catch-up types (incremental replay for reconnecting daemons)
16// ---------------------------------------------------------------------------
17
18/// A single state mutation that a reconnecting daemon may have missed.
19#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
20pub struct StateCatchUpEntry {
21 pub sequence: u64,
22 pub operation: StateCatchUpOperation,
23}
24
25/// The kind of state mutation recorded in the replication log.
26#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
27pub enum StateCatchUpOperation {
28 SetParam {
29 node_id: NodeId,
30 key: String,
31 value: serde_json::Value,
32 },
33 DeleteParam {
34 node_id: NodeId,
35 key: String,
36 },
37}
38
39pub use crate::common::Timestamped;
40
41#[derive(Debug, serde::Serialize, serde::Deserialize)]
42#[non_exhaustive]
43pub enum RegisterResult {
44 /// Constructed through [`RegisterResult::ok`], not by literal: the variant
45 /// is `#[non_exhaustive]` so that the *next* field added here is a minor
46 /// change rather than a 2.0. Adding `peer_zenoh_endpoints` to an exhaustive
47 /// variant was itself a major break (`enum_struct_variant_field_added`);
48 /// paying it once, with the attribute, is what keeps it from recurring.
49 #[non_exhaustive]
50 Ok {
51 /// unique ID assigned by the coordinator
52 daemon_id: DaemonId,
53 /// Zenoh listen endpoints of the daemons that were already registered
54 /// when this one joined, for it to dial.
55 ///
56 /// The coordinator is the only component every daemon already talks
57 /// to, which makes it the one place a daemon can learn where its peers
58 /// are without anyone configuring an address twice. Without this a
59 /// multi-machine deployment has to name every daemon's endpoint on
60 /// every other daemon's command line (`--zenoh-connect`), or rely on
61 /// multicast — which a mesh VPN does not carry.
62 ///
63 /// Only endpoints a daemon *verified as bound* appear here (see the
64 /// `info().locators()` check in `open_zenoh_session_with_listen`), so a
65 /// dial planned from this list has a listener behind it.
66 ///
67 /// Deliberately only the *earlier* daemons: zenoh reads
68 /// `connect/endpoints` once at session open and never re-reads it, so a
69 /// daemon cannot act on an endpoint that arrives later. It does not
70 /// need to — a zenoh transport is bidirectional, so the joining
71 /// daemon's dial carries traffic in both directions. Each daemon
72 /// dialing everyone who came before it therefore builds the full
73 /// clique, with no daemon ever needing to learn about a later one.
74 ///
75 /// Daemons may start simultaneously: each advertises its endpoint in
76 /// its own registration (see
77 /// [`crate::daemon_to_coordinator::DaemonRegisterRequest::zenoh_listen_endpoint`]),
78 /// and the coordinator handles registrations one at a time, so the one
79 /// that registers second always sees the first. That ordering is what
80 /// removes the need for a daemon to ever act on a *later* report —
81 /// which it could not do anyway, zenoh having no runtime equivalent of
82 /// `connect/endpoints`.
83 ///
84 /// `#[serde(default)]` keeps a daemon built before this field existed
85 /// decodable: it sees no peers and falls back to the multicast/explicit
86 /// wiring it already had.
87 #[serde(default)]
88 peer_zenoh_endpoints: Vec<String>,
89 },
90 Err(String),
91}
92
93impl RegisterResult {
94 /// A successful registration: the assigned id and the peers to dial.
95 pub fn ok(daemon_id: DaemonId, peer_zenoh_endpoints: Vec<String>) -> Self {
96 Self::Ok {
97 daemon_id,
98 peer_zenoh_endpoints,
99 }
100 }
101
102 /// The assigned id alone, for callers that do not wire zenoh.
103 pub fn to_result(self) -> eyre::Result<DaemonId> {
104 self.into_parts().map(|(daemon_id, _)| daemon_id)
105 }
106
107 /// The assigned id plus the peer endpoints to dial; see
108 /// [`RegisterResult::Ok::peer_zenoh_endpoints`].
109 pub fn into_parts(self) -> eyre::Result<(DaemonId, Vec<String>)> {
110 match self {
111 RegisterResult::Ok {
112 daemon_id,
113 peer_zenoh_endpoints,
114 } => Ok((daemon_id, peer_zenoh_endpoints)),
115 RegisterResult::Err(err) => Err(eyre::eyre!(err)),
116 }
117 }
118}
119
120/// Reply to `CoordinatorRequest::ResolveMachine` — sent by the coordinator
121/// to the requesting daemon over the same request/response channel used for
122/// `RegisterResult`.
123#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
124pub enum ResolveMachineReply {
125 /// Reply to `CoordinatorRequest::ResolveMachine`.
126 ResolveMachineResult {
127 found: bool,
128 /// The target daemon's WS peer address as seen by the coordinator
129 /// (set at registration). Used by the memory-pool direct-TCP data
130 /// plane to reach the mirror daemon's data listener.
131 address: Option<std::net::SocketAddr>,
132 },
133}
134
135#[allow(clippy::large_enum_variant)]
136#[derive(Debug, serde::Deserialize, serde::Serialize)]
137pub enum DaemonCoordinatorEvent {
138 Build(BuildDataflowNodes),
139 Spawn(SpawnDataflowNodes),
140 AllNodesReady {
141 dataflow_id: DataflowId,
142 exited_before_subscribe: Vec<NodeId>,
143 },
144 StopDataflow {
145 dataflow_id: DataflowId,
146 grace_duration: Option<Duration>,
147 #[serde(default)]
148 force: bool,
149 },
150 ReloadDataflow {
151 dataflow_id: DataflowId,
152 node_id: NodeId,
153 operator_id: Option<OperatorId>,
154 },
155 Logs {
156 dataflow_id: DataflowId,
157 node_id: NodeId,
158 tail: Option<usize>,
159 },
160 RestartNode {
161 dataflow_id: DataflowId,
162 node_id: NodeId,
163 grace_duration: Option<Duration>,
164 },
165 StopNode {
166 dataflow_id: DataflowId,
167 node_id: NodeId,
168 grace_duration: Option<Duration>,
169 },
170 SetParam {
171 dataflow_id: DataflowId,
172 node_id: NodeId,
173 key: String,
174 value: serde_json::Value,
175 },
176 DeleteParam {
177 dataflow_id: DataflowId,
178 node_id: NodeId,
179 key: String,
180 },
181 Destroy,
182 Heartbeat,
183 PeerDaemonDisconnected {
184 daemon_id: DaemonId,
185 },
186 // --- Dynamic Topology ---
187 /// Add a node to a running dataflow on this daemon.
188 AddNode {
189 dataflow_id: DataflowId,
190 node: crate::descriptor::ResolvedNode,
191 uv: bool,
192 },
193 /// Remove a node from a running dataflow on this daemon.
194 RemoveNode {
195 dataflow_id: DataflowId,
196 node_id: NodeId,
197 grace_duration: Option<Duration>,
198 },
199 /// Atomically replace a running node on this daemon with a new
200 /// definition under the same id (dora-rs/dora#2927): spawn the
201 /// replacement first (a failure leaves the current incarnation
202 /// untouched), then swap the entry and stop the outgoing incarnation.
203 ///
204 /// `unresolved_node` is the original YAML-shape [`crate::descriptor::Node`]
205 /// (as `dora node replace` received it); the daemon assigns it wholesale
206 /// onto the stored descriptor entry so the child's `DORA_NODE_CONFIG` /
207 /// `DoraNode::dataflow_descriptor()` reflect the replacement's definition
208 /// end-to-end. The field is separate from `node` because the two shapes
209 /// differ (`Node` is the flat YAML surface; `ResolvedNode` is the nested
210 /// resolved form used for spawning), and a hand-written back-conversion
211 /// would silently drift as fields are added to either type
212 /// (dora-rs/dora#2988 review, finding 2).
213 ReplaceNode {
214 dataflow_id: DataflowId,
215 node: crate::descriptor::ResolvedNode,
216 unresolved_node: crate::descriptor::Node,
217 uv: bool,
218 grace_duration: Option<Duration>,
219 },
220 /// Add a mapping (connection) in a running dataflow.
221 AddMapping {
222 dataflow_id: DataflowId,
223 source_node: NodeId,
224 source_output: DataId,
225 target_node: NodeId,
226 target_input: DataId,
227 },
228 /// Remove a mapping (connection) in a running dataflow.
229 RemoveMapping {
230 dataflow_id: DataflowId,
231 source_node: NodeId,
232 source_output: DataId,
233 target_node: NodeId,
234 target_input: DataId,
235 },
236 /// Start forwarding matching output frames back to the coordinator over
237 /// the daemon control channel for CLI topic inspection.
238 StartTopicDebugStream {
239 dataflow_id: DataflowId,
240 outputs: Vec<(NodeId, DataId)>,
241 subscription_id: uuid::Uuid,
242 },
243 /// Stop forwarding output frames for a previously registered CLI topic
244 /// inspection subscription.
245 StopTopicDebugStream {
246 dataflow_id: DataflowId,
247 subscription_id: uuid::Uuid,
248 },
249 /// Incremental state catch-up: replays missed state mutations to a
250 /// reconnecting daemon.
251 StateCatchUp {
252 dataflow_id: DataflowId,
253 /// The entries the daemon missed, ordered by sequence number.
254 entries: Vec<StateCatchUpEntry>,
255 },
256}
257
258#[derive(Debug, serde::Deserialize, serde::Serialize)]
259pub struct BuildDataflowNodes {
260 pub build_id: BuildId,
261 pub session_id: SessionId,
262 /// Allows overwriting the base working dir when CLI and daemon are
263 /// running on the same machine.
264 ///
265 /// Must not be used for multi-machine dataflows.
266 ///
267 /// Note that nodes with git sources still use a subdirectory of
268 /// the base working dir.
269 pub local_working_dir: Option<PathBuf>,
270 pub git_sources: BTreeMap<NodeId, GitSource>,
271 pub prev_git_sources: BTreeMap<NodeId, GitSource>,
272 pub dataflow_descriptor: Descriptor,
273 pub nodes_on_machine: BTreeSet<NodeId>,
274 pub uv: bool,
275}
276
277#[derive(Debug, serde::Deserialize, serde::Serialize)]
278pub struct SpawnDataflowNodes {
279 pub build_id: Option<BuildId>,
280 pub session_id: SessionId,
281 pub dataflow_id: DataflowId,
282 /// Allows overwriting the base working dir when CLI and daemon are
283 /// running on the same machine.
284 ///
285 /// Must not be used for multi-machine dataflows.
286 ///
287 /// Note that nodes with git sources still use a subdirectory of
288 /// the base working dir.
289 pub local_working_dir: Option<PathBuf>,
290 pub nodes: BTreeMap<NodeId, ResolvedNode>,
291 pub dataflow_descriptor: Descriptor,
292 pub spawn_nodes: BTreeSet<NodeId>,
293 pub uv: bool,
294 pub write_events_to: Option<PathBuf>,
295 /// Base URL for downloading artifacts from the coordinator (HTTP distribution mode).
296 /// When set, daemons can pull binaries from `{artifact_base_url}/{build_id}/{node_id}`.
297 #[serde(default, skip_serializing_if = "Option::is_none")]
298 pub artifact_base_url: Option<String>,
299}
300
301#[cfg(test)]
302mod register_result_tests {
303 use super::*;
304
305 /// A daemon built before `peer_zenoh_endpoints` existed sends a reply
306 /// without the field. It must still decode — into "no peers to dial" —
307 /// rather than failing registration outright, which would take the whole
308 /// daemon down over a field it does not need.
309 #[test]
310 fn a_reply_without_peer_endpoints_decodes_as_no_peers() {
311 let legacy = r#"{"Ok":{"daemon_id":{"machine_id":"A","uuid":"00000000-0000-0000-0000-000000000001"}}}"#;
312 let decoded: RegisterResult =
313 serde_json::from_str(legacy).expect("legacy register reply must stay decodable");
314 let (_, peers) = decoded.into_parts().expect("legacy reply is Ok");
315 assert!(peers.is_empty());
316 }
317
318 #[test]
319 fn peer_endpoints_round_trip() {
320 let peers = vec!["tcp/10.0.2.100:5456".to_string()];
321 let encoded = serde_json::to_string(&RegisterResult::Ok {
322 daemon_id: DaemonId::new(Some("A".to_string())),
323 peer_zenoh_endpoints: peers.clone(),
324 })
325 .expect("serialize");
326 let decoded: RegisterResult = serde_json::from_str(&encoded).expect("deserialize");
327 assert_eq!(decoded.into_parts().expect("ok").1, peers);
328 }
329
330 /// `to_result` is the id-only convenience over `into_parts`; an `Err` reply
331 /// must stay an error through both.
332 #[test]
333 fn an_error_reply_is_an_error_through_both_accessors() {
334 assert!(RegisterResult::Err("nope".into()).to_result().is_err());
335 assert!(RegisterResult::Err("nope".into()).into_parts().is_err());
336 }
337}