dora_message/daemon_to_coordinator.rs
1use std::collections::BTreeMap;
2
3pub use crate::common::{
4 DataMessage, LogLevel, LogMessage, NodeError, NodeErrorCause, NodeExitStatus, Timestamped,
5};
6use crate::{
7 BuildId, DataflowId, common::DaemonId, current_crate_version, id::NodeId, metadata::Metadata,
8 versions_compatible,
9};
10
11/// Per-dataflow status reported by a daemon after (re-)registration.
12#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
13pub struct DataflowStatusEntry {
14 pub dataflow_id: uuid::Uuid,
15 pub running_nodes: Vec<NodeId>,
16}
17
18#[allow(clippy::large_enum_variant)]
19#[derive(Debug, serde::Serialize, serde::Deserialize)]
20#[non_exhaustive]
21pub enum CoordinatorRequest {
22 Register(DaemonRegisterRequest),
23 Event {
24 daemon_id: DaemonId,
25 event: DaemonEvent,
26 },
27 /// Resolve a machine id to a registered daemon (cross-machine pools).
28 ResolveMachine {
29 machine_id: String,
30 },
31}
32
33#[derive(Debug, serde::Serialize, serde::Deserialize)]
34pub struct DaemonRegisterRequest {
35 dora_version: semver::Version,
36 pub machine_id: Option<String>,
37 #[serde(default)]
38 pub labels: BTreeMap<String, String>,
39 /// Whether this daemon understands hub-sourced git nodes — the `subdir`
40 /// and `hub` provenance fields on a `GitSource` (spec §10.2, P2.10).
41 ///
42 /// `#[serde(default)]` makes this `false` for a daemon built before the
43 /// field existed, so the coordinator can refuse to route a hub node to it
44 /// with a clear error. This is the capability signal the `dora_version`
45 /// gate cannot provide: during the `1.0.0-rc` window a pre-hub daemon and a
46 /// hub-aware coordinator report the *same* version, so version alone can't
47 /// distinguish them — an explicit flag can.
48 #[serde(default)]
49 supports_hub_sources: bool,
50
51 /// Layout version of [`Metadata`], which every `InterDaemonEvent::Output`
52 /// carries between daemons over zenoh.
53 ///
54 /// The coordinator is the only chokepoint that sees every daemon: there is
55 /// no daemon-to-daemon connection to handshake on, because that path is
56 /// zenoh pub/sub. Gating registration therefore gates the daemon-to-daemon
57 /// wire transitively — any two daemons routing a dataflow have both passed
58 /// this check, so they agree on the `Metadata` layout.
59 ///
60 /// `dora_version` alone does not cover this: #2366 dropped a `Metadata`
61 /// field without changing the version, and the resulting mid-stream desync
62 /// was #2742. The node-to-daemon path already gates this
63 /// ([`crate::node_to_daemon::NodeRegisterRequest::check_version`]); this is
64 /// the same gate one hop out.
65 ///
66 /// `#[serde(default)]` makes a pre-field daemon report 0 and fail the check
67 /// with a legible message. That works because this frame is JSON over the
68 /// coordinator WebSocket — a non-self-describing encoding would fail while
69 /// decoding the frame that carries the field, never reaching the check.
70 #[serde(default)]
71 metadata_version: u16,
72
73 /// The zenoh endpoint this daemon is about to bind, so the coordinator can
74 /// hand it to daemons that register after this one.
75 ///
76 /// Carried *in the registration* rather than reported once the session is
77 /// open, and that timing is the whole point. The coordinator handles
78 /// registrations one at a time on its event loop, so an endpoint that
79 /// arrives with the registration is already on record before the next
80 /// daemon's reply is built — two daemons starting simultaneously are
81 /// ordered by that loop and the later one always learns about the earlier.
82 /// Reporting after the session opened instead left a window (register →
83 /// listener bound) in which both daemons could register, each be handed a
84 /// list without the other, and stay partitioned: zenoh reads
85 /// `connect/endpoints` once at session open, so neither could act on the
86 /// other's later report.
87 ///
88 /// The port is reserved before registering, so this is the endpoint the
89 /// daemon *will* bind rather than one it has bound. A bind that then fails
90 /// verification is withdrawn with
91 /// [`DaemonEvent::ZenohListenEndpoint`]`(None)`, so the coordinator never
92 /// keeps handing out an endpoint with nothing behind it for long.
93 ///
94 /// `None` for a daemon with no dialable listener — a single-machine
95 /// deployment (loopback, which would point a remote peer at its own host)
96 /// or a failed reservation.
97 #[serde(default)]
98 pub zenoh_listen_endpoint: Option<String>,
99}
100
101impl DaemonRegisterRequest {
102 pub fn new(machine_id: Option<String>, labels: BTreeMap<String, String>) -> Self {
103 Self::with_zenoh_endpoint(machine_id, labels, None)
104 }
105
106 /// [`Self::new`] plus the zenoh endpoint this daemon will bind; see
107 /// [`Self::zenoh_listen_endpoint`].
108 pub fn with_zenoh_endpoint(
109 machine_id: Option<String>,
110 labels: BTreeMap<String, String>,
111 zenoh_listen_endpoint: Option<String>,
112 ) -> Self {
113 Self {
114 dora_version: current_crate_version(),
115 machine_id,
116 labels,
117 // a daemon built from this crate understands hub git sources
118 supports_hub_sources: true,
119 metadata_version: Metadata::CURRENT_VERSION,
120 zenoh_listen_endpoint,
121 }
122 }
123
124 /// Whether the registering daemon can build/spawn hub-sourced git nodes
125 /// (carrying `subdir` / `hub` provenance).
126 pub fn supports_hub_sources(&self) -> bool {
127 self.supports_hub_sources
128 }
129
130 pub fn check_version(&self) -> Result<(), String> {
131 let crate_version = current_crate_version();
132 let specified_version = &self.dora_version;
133
134 if versions_compatible(&crate_version, specified_version)? {
135 // Even when semver matches, the payload layout can differ within a
136 // release series (#2366). Reject here so the failure is a legible
137 // registration error rather than a mid-stream desync between
138 // daemons routing the same dataflow (#2742).
139 if self.metadata_version != Metadata::CURRENT_VERSION {
140 return Err(format!(
141 "message wire-format mismatch: this daemon speaks metadata format v{} \
142 but the coordinator speaks v{}. The daemon and coordinator were built \
143 from dora revisions with incompatible message formats; rebuild both \
144 from the same revision.",
145 self.metadata_version,
146 Metadata::CURRENT_VERSION
147 ));
148 }
149 Ok(())
150 } else {
151 // Direction-aware remediation: `versions_compatible` rejects both
152 // older and newer daemons, so the fix differs. Upgrade whichever
153 // side is older.
154 let remedy = if *specified_version < crate_version {
155 format!(
156 "upgrade the daemon to match the coordinator (e.g. \
157 `cargo install dora-cli --version {crate_version}`) — an older daemon \
158 also lacks newer wire features such as hub `subdir`/`hub:` node sources"
159 )
160 } else {
161 format!(
162 "upgrade the coordinator to dora v{specified_version} (or run an older \
163 daemon) so both sides match"
164 )
165 };
166 Err(format!(
167 "version mismatch: this daemon runs dora v{specified_version} but the \
168 coordinator expects v{crate_version} — these dora versions are incompatible. \
169 {remedy}.",
170 ))
171 }
172 }
173}
174
175#[cfg(test)]
176mod register_version_tests {
177 use super::*;
178
179 #[test]
180 fn current_version_is_compatible() {
181 assert!(
182 DaemonRegisterRequest::new(None, Default::default())
183 .check_version()
184 .is_ok()
185 );
186 }
187
188 fn request_with_version(dora_version: semver::Version) -> DaemonRegisterRequest {
189 DaemonRegisterRequest {
190 dora_version,
191 machine_id: None,
192 labels: Default::default(),
193 supports_hub_sources: true,
194 metadata_version: Metadata::CURRENT_VERSION,
195 zenoh_listen_endpoint: None,
196 }
197 }
198
199 #[test]
200 fn same_version_daemon_with_a_different_metadata_layout_is_rejected() {
201 // The gap this closes: semver alone let #2366 through, where a
202 // `Metadata` field was dropped without a version bump. Two daemons
203 // routing one dataflow exchange `InterDaemonEvent::Output` over zenoh,
204 // which carries `Metadata` — and zenoh pub/sub has no connection to
205 // handshake on, so the coordinator is the only place this can be
206 // caught. A same-version daemon with a stale layout must be rejected
207 // here rather than desyncing mid-stream (#2742).
208 let mut req = DaemonRegisterRequest::new(None, Default::default());
209 req.metadata_version = Metadata::CURRENT_VERSION.wrapping_add(1);
210
211 let err = req
212 .check_version()
213 .expect_err("a metadata layout mismatch must be rejected");
214 assert!(err.contains("wire-format mismatch"), "{err}");
215 assert!(
216 err.contains("rebuild both"),
217 "the error should say how to fix it: {err}"
218 );
219 }
220
221 #[test]
222 fn a_daemon_predating_the_field_is_rejected_with_a_legible_error() {
223 // The frame is JSON over the coordinator WebSocket, so a daemon built
224 // before `metadata_version` existed simply omits it and `serde(default)`
225 // yields 0. That must fail the gate with a real message rather than
226 // being silently treated as compatible.
227 let json = serde_json::to_string(&DaemonRegisterRequest::new(None, Default::default()))
228 .expect("serialize");
229 let stripped: serde_json::Value = {
230 let mut v: serde_json::Value = serde_json::from_str(&json).unwrap();
231 v.as_object_mut().unwrap().remove("metadata_version");
232 v
233 };
234 let old: DaemonRegisterRequest =
235 serde_json::from_value(stripped).expect("a pre-field daemon must still deserialize");
236 assert_eq!(old.metadata_version, 0);
237 assert!(
238 old.check_version().is_err(),
239 "a pre-field daemon must not be treated as compatible"
240 );
241 }
242
243 #[test]
244 fn incompatible_daemon_gets_direction_aware_upgrade_advice() {
245 // `versions_compatible` rejects both older and newer daemons, so the
246 // remediation must name the right side. (Cross-version only — a
247 // *same-version* pre-hub daemon passes this gate, which is why hub
248 // capability is signalled explicitly via `supports_hub_sources`.)
249 let current = current_crate_version();
250
251 // A NEWER daemon than the coordinator → upgrade the *coordinator*.
252 let err = request_with_version(semver::Version::new(current.major + 1, 0, 0))
253 .check_version()
254 .expect_err("newer-major daemon must be rejected");
255 assert!(err.contains("version mismatch"), "{err}");
256 assert!(
257 err.contains("upgrade the coordinator"),
258 "newer daemon should advise upgrading the coordinator: {err}"
259 );
260
261 // An OLDER daemon than the coordinator → upgrade the *daemon*.
262 let err = request_with_version(semver::Version::new(0, 1, 0))
263 .check_version()
264 .expect_err("older daemon must be rejected");
265 assert!(
266 err.contains("upgrade the daemon"),
267 "older daemon should advise upgrading the daemon: {err}"
268 );
269 }
270
271 #[test]
272 fn hub_capability_is_advertised_by_current_daemons_and_defaults_off() {
273 // A daemon built from this crate advertises hub support.
274 assert!(DaemonRegisterRequest::new(None, Default::default()).supports_hub_sources());
275
276 // A daemon built before the field existed sends a request without it;
277 // `#[serde(default)]` must decode that as "no hub support" so the
278 // coordinator refuses to route hub nodes to it. This is the same-version
279 // gap the version check cannot catch.
280 let legacy = r#"{"dora_version":"1.0.0-rc1","machine_id":null,"labels":{}}"#;
281 let decoded: DaemonRegisterRequest = serde_json::from_str(legacy).unwrap();
282 assert!(!decoded.supports_hub_sources());
283 }
284}
285
286#[derive(Debug, serde::Serialize, serde::Deserialize)]
287#[non_exhaustive]
288pub enum DaemonEvent {
289 BuildResult {
290 build_id: BuildId,
291 result: Result<(), String>,
292 },
293 SpawnResult {
294 dataflow_id: DataflowId,
295 result: Result<(), String>,
296 },
297 AllNodesReady {
298 dataflow_id: DataflowId,
299 exited_before_subscribe: Vec<NodeId>,
300 },
301 AllNodesFinished {
302 dataflow_id: DataflowId,
303 result: DataflowDaemonResult,
304 },
305 Heartbeat {
306 #[serde(default)]
307 ft_stats: Option<FaultToleranceSnapshot>,
308 },
309 /// The zenoh endpoint this daemon actually bound and is reachable at,
310 /// sent once its session is open.
311 ///
312 /// The coordinator records it and hands it to daemons that register later
313 /// (see `RegisterResult::Ok::peer_zenoh_endpoints`), which is what lets a
314 /// multi-machine deployment wire itself without every daemon being told
315 /// every other daemon's address.
316 ///
317 /// Confirms or withdraws the endpoint this daemon advertised in its
318 /// registration, once its zenoh session is open and the listener has been
319 /// verified against `info().locators()`.
320 ///
321 /// `Some(endpoint)` confirms (and would correct a differing one);
322 /// `None` withdraws, which is what a daemon whose listener did not bind
323 /// must do so the coordinator stops handing out a dead endpoint.
324 ///
325 /// The registration carries the endpoint in the first place — see
326 /// [`DaemonRegisterRequest::zenoh_listen_endpoint`] for why it cannot wait
327 /// until here. This is the correction, not the announcement.
328 ZenohListenEndpoint {
329 endpoint: Option<String>,
330 },
331 /// Sent by the daemon after registration to report its current state.
332 /// Enables coordinator-daemon reconciliation on reconnect.
333 StatusReport {
334 running_dataflows: Vec<DataflowStatusEntry>,
335 },
336 Log(LogMessage),
337 Exit,
338 NodeMetrics {
339 dataflow_id: DataflowId,
340 metrics: BTreeMap<NodeId, NodeMetrics>,
341 #[serde(default)]
342 network: Option<NetworkMetrics>,
343 },
344 /// Topic debug payload destined for one or more active CLI subscriptions.
345 ///
346 /// Daemon and coordinator are co-deployed from the same build, so this
347 /// multi-subscriber shape is safe to evolve within the repository.
348 TopicDebugData {
349 dataflow_id: DataflowId,
350 subscription_ids: Vec<uuid::Uuid>,
351 payload: Vec<u8>,
352 },
353 /// Daemon acknowledges state catch-up through a given sequence number.
354 StateCatchUpAck {
355 dataflow_id: DataflowId,
356 ack_sequence: u64,
357 },
358 /// Sent by the daemon when a node has exited and the daemon will NOT
359 /// restart it (e.g. `dora node stop`, a node exiting under
360 /// `restart_policy: Never`, or a final-failure cascade). The
361 /// coordinator uses this to invalidate its cached `node_metrics`
362 /// entry so `dora node list` reflects the actual state instead of
363 /// the last-reported "Running" snapshot. Without this signal the
364 /// daemon's metrics-snapshot loop simply stops including the dead
365 /// node and the coordinator's cache is frozen at the last
366 /// pre-exit values forever.
367 NodeStopped {
368 dataflow_id: DataflowId,
369 node_id: NodeId,
370 /// `true` if the daemon called `disable_restart()` before the
371 /// exit (i.e. the `stop_single_node` / `restart_single_node`
372 /// path triggered by `dora node stop`/`restart`). `false` for
373 /// a final-failure exit under `restart_policy: Never` or a
374 /// `max_restarts` exhaustion. The coordinator uses this to
375 /// pick `NodeStatus::Stopped` vs `NodeStatus::Failed`, so a
376 /// crash is not silently reported as a clean teardown (which
377 /// would hide it from `dora doctor`).
378 #[serde(default)]
379 clean_stop: bool,
380 },
381}
382
383/// Health status of a node
384#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
385#[serde(rename_all = "kebab-case")]
386pub enum NodeStatus {
387 #[default]
388 Running,
389 Restarting,
390 /// One or more inputs have timed out (circuit breaker open)
391 Degraded,
392 Failed,
393 /// Node was cleanly stopped (e.g. via `dora node stop`) and the
394 /// process has exited. Distinguishes a deliberate teardown from a
395 /// crash failure. Coordinator-side entries with this status are
396 /// removed after `NODE_STOPPED_GRACE_PERIOD` so `dora node list`
397 /// eventually stops showing zombies.
398 Stopped,
399}
400
401impl std::fmt::Display for NodeStatus {
402 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
403 match self {
404 NodeStatus::Running => write!(f, "Running"),
405 NodeStatus::Restarting => write!(f, "Restarting"),
406 NodeStatus::Degraded => write!(f, "Degraded"),
407 NodeStatus::Failed => write!(f, "Failed"),
408 NodeStatus::Stopped => write!(f, "Stopped"),
409 }
410 }
411}
412
413/// Snapshot of daemon-level fault tolerance counters
414#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
415pub struct FaultToleranceSnapshot {
416 pub restarts: u64,
417 pub health_check_kills: u64,
418 pub input_timeouts: u64,
419 pub circuit_breaker_recoveries: u64,
420}
421
422/// Resource metrics for a node process
423#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
424pub struct NodeMetrics {
425 /// Process ID
426 pub pid: u32,
427 /// CPU usage percentage (0-100 per core)
428 pub cpu_usage: f32,
429 /// Memory usage in bytes
430 pub memory_bytes: u64,
431 /// Disk read bytes per second (if available)
432 pub disk_read_bytes: Option<u64>,
433 /// Disk write bytes per second (if available)
434 pub disk_write_bytes: Option<u64>,
435 /// Number of times this node has been restarted
436 #[serde(default)]
437 pub restart_count: u32,
438 /// Input IDs that have timed out (circuit breaker open)
439 #[serde(default)]
440 pub broken_inputs: Vec<String>,
441 /// Current health status
442 #[serde(default)]
443 pub status: NodeStatus,
444 /// Number of pending messages in the node's input queue
445 #[serde(default)]
446 pub pending_messages: u64,
447}
448
449/// Per-dataflow network I/O counters for cross-daemon Zenoh traffic.
450#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
451pub struct NetworkMetrics {
452 pub bytes_sent: u64,
453 pub bytes_received: u64,
454 pub messages_sent: u64,
455 pub messages_received: u64,
456 #[serde(default)]
457 pub publish_failures: u64,
458}
459
460#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
461pub struct DataflowDaemonResult {
462 pub timestamp: uhlc::Timestamp,
463 pub node_results: BTreeMap<NodeId, Result<(), NodeError>>,
464}
465
466impl DataflowDaemonResult {
467 pub fn is_ok(&self) -> bool {
468 self.node_results.values().all(|r| r.is_ok())
469 }
470}
471
472#[derive(Debug, serde::Deserialize, serde::Serialize)]
473pub enum DaemonCoordinatorReply {
474 TriggerBuildResult(Result<(), String>),
475 TriggerSpawnResult(Result<(), String>),
476 ReloadResult(Result<(), String>),
477 StopResult(Result<(), String>),
478 DestroyResult {
479 result: Result<(), String>,
480 #[serde(skip)]
481 notify: Option<tokio::sync::oneshot::Sender<()>>,
482 },
483 Logs(Result<Vec<u8>, String>),
484 /// Reply for `DaemonCoordinatorEvent::AddNode`. Previously the daemon
485 /// returned `None` and the coordinator accepted any successful TCP
486 /// response as proof that AddNode applied, even a `SetParamResult` or
487 /// other unrelated reply — committing state for a node the daemon
488 /// may have rejected (#1682). This variant lets the coordinator
489 /// pattern-match a specific reply and forward daemon errors to the
490 /// CLI instead of corrupting the dataflow state. Rescue of #1757.
491 AddNodeResult(Result<(), String>),
492 RestartNodeResult(Result<(), String>),
493 StopNodeResult(Result<(), String>),
494 RemoveNodeResult(Result<(), String>),
495 /// Reply for `DaemonCoordinatorEvent::ReplaceNode`. Same
496 /// specific-reply contract as `AddNodeResult` (#1682): the
497 /// coordinator only commits its descriptor update after matching
498 /// this exact variant.
499 ReplaceNodeResult(Result<(), String>),
500 /// Reply for `DaemonCoordinatorEvent::AddMapping`. Previously the daemon
501 /// returned `None`, which the coordinator's WS layer skipped instead
502 /// of forwarding as a reply, causing `send_and_receive` to time out
503 /// after 30s with `daemon dispatch failed: timeout waiting for daemon
504 /// WS reply`. Same bug class as #1682's AddNode silent-reply hole;
505 /// applied to mappings here.
506 AddMappingResult(Result<(), String>),
507 /// Reply for `DaemonCoordinatorEvent::RemoveMapping`. See
508 /// `AddMappingResult` doc for the silent-reply bug class.
509 RemoveMappingResult(Result<(), String>),
510 SetParamResult(Result<(), String>),
511 DeleteParamResult(Result<(), String>),
512 StartTopicDebugStreamResult(Result<(), String>),
513 StopTopicDebugStreamResult(Result<(), String>),
514}