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 ///
239 /// Built through [`DaemonCoordinatorEvent::start_topic_debug_stream`];
240 /// `#[non_exhaustive]` for the reason given on [`RegisterResult::Ok`].
241 #[non_exhaustive]
242 StartTopicDebugStream {
243 dataflow_id: DataflowId,
244 outputs: Vec<(NodeId, DataId)>,
245 subscription_id: uuid::Uuid,
246 },
247 /// Stop forwarding output frames for a previously registered CLI topic
248 /// inspection subscription.
249 StopTopicDebugStream {
250 dataflow_id: DataflowId,
251 subscription_id: uuid::Uuid,
252 },
253 /// Incremental state catch-up: replays missed state mutations to a
254 /// reconnecting daemon.
255 StateCatchUp {
256 dataflow_id: DataflowId,
257 /// The entries the daemon missed, ordered by sequence number.
258 entries: Vec<StateCatchUpEntry>,
259 },
260}
261
262impl DaemonCoordinatorEvent {
263 /// Forward frames of `outputs` from `dataflow_id` to the coordinator under
264 /// `subscription_id`.
265 pub fn start_topic_debug_stream(
266 dataflow_id: DataflowId,
267 outputs: Vec<(NodeId, DataId)>,
268 subscription_id: uuid::Uuid,
269 ) -> Self {
270 Self::StartTopicDebugStream {
271 dataflow_id,
272 outputs,
273 subscription_id,
274 }
275 }
276}
277
278#[derive(Debug, serde::Deserialize, serde::Serialize)]
279pub struct BuildDataflowNodes {
280 pub build_id: BuildId,
281 pub session_id: SessionId,
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 git_sources: BTreeMap<NodeId, GitSource>,
291 pub prev_git_sources: BTreeMap<NodeId, GitSource>,
292 pub dataflow_descriptor: Descriptor,
293 pub nodes_on_machine: BTreeSet<NodeId>,
294 pub uv: bool,
295}
296
297#[derive(Debug, serde::Deserialize, serde::Serialize)]
298pub struct SpawnDataflowNodes {
299 pub build_id: Option<BuildId>,
300 pub session_id: SessionId,
301 pub dataflow_id: DataflowId,
302 /// Allows overwriting the base working dir when CLI and daemon are
303 /// running on the same machine.
304 ///
305 /// Must not be used for multi-machine dataflows.
306 ///
307 /// Note that nodes with git sources still use a subdirectory of
308 /// the base working dir.
309 pub local_working_dir: Option<PathBuf>,
310 pub nodes: BTreeMap<NodeId, ResolvedNode>,
311 pub dataflow_descriptor: Descriptor,
312 pub spawn_nodes: BTreeSet<NodeId>,
313 pub uv: bool,
314 pub write_events_to: Option<PathBuf>,
315 /// Base URL for downloading artifacts from the coordinator (HTTP distribution mode).
316 /// When set, daemons can pull binaries from `{artifact_base_url}/{build_id}/{node_id}`.
317 #[serde(default, skip_serializing_if = "Option::is_none")]
318 pub artifact_base_url: Option<String>,
319}
320
321#[cfg(test)]
322mod register_result_tests {
323 use super::*;
324
325 /// A daemon built before `peer_zenoh_endpoints` existed sends a reply
326 /// without the field. It must still decode — into "no peers to dial" —
327 /// rather than failing registration outright, which would take the whole
328 /// daemon down over a field it does not need.
329 #[test]
330 fn a_reply_without_peer_endpoints_decodes_as_no_peers() {
331 let legacy = r#"{"Ok":{"daemon_id":{"machine_id":"A","uuid":"00000000-0000-0000-0000-000000000001"}}}"#;
332 let decoded: RegisterResult =
333 serde_json::from_str(legacy).expect("legacy register reply must stay decodable");
334 let (_, peers) = decoded.into_parts().expect("legacy reply is Ok");
335 assert!(peers.is_empty());
336 }
337
338 #[test]
339 fn peer_endpoints_round_trip() {
340 let peers = vec!["tcp/10.0.2.100:5456".to_string()];
341 let encoded = serde_json::to_string(&RegisterResult::Ok {
342 daemon_id: DaemonId::new(Some("A".to_string())),
343 peer_zenoh_endpoints: peers.clone(),
344 })
345 .expect("serialize");
346 let decoded: RegisterResult = serde_json::from_str(&encoded).expect("deserialize");
347 assert_eq!(decoded.into_parts().expect("ok").1, peers);
348 }
349
350 /// `to_result` is the id-only convenience over `into_parts`; an `Err` reply
351 /// must stay an error through both.
352 #[test]
353 fn an_error_reply_is_an_error_through_both_accessors() {
354 assert!(RegisterResult::Err("nope".into()).to_result().is_err());
355 assert!(RegisterResult::Err("nope".into()).into_parts().is_err());
356 }
357}