Skip to main content

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}