Skip to main content

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}