Skip to main content

dora_message/
daemon_to_node.rs

1use std::{
2    collections::{BTreeMap, BTreeSet},
3    net::SocketAddr,
4    path::PathBuf,
5};
6
7use std::sync::Arc;
8
9use crate::{
10    DataflowId,
11    config::NodeRunConfig,
12    descriptor::OperatorDefinition,
13    id::{DataId, NodeId, OperatorId},
14    metadata::Metadata,
15};
16
17pub use crate::common::{DataMessage, SharedMemoryId, Timestamped};
18
19// Passed via env variable
20#[derive(Debug, serde::Serialize, serde::Deserialize)]
21pub struct RuntimeConfig {
22    pub node: NodeConfig,
23    pub operators: Vec<OperatorDefinition>,
24}
25
26#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
27pub struct NodeConfig {
28    pub dataflow_id: DataflowId,
29    pub node_id: NodeId,
30    pub run_config: NodeRunConfig,
31    pub daemon_communication: Option<DaemonCommunication>,
32    pub dataflow_descriptor: serde_yaml::Value,
33    pub dynamic: bool,
34    pub write_events_to: Option<PathBuf>,
35    /// Number of times this node has been restarted. 0 on first run.
36    #[serde(default)]
37    pub restart_count: u32,
38    /// Per-output data-plane routing for the startup handshake, computed by the
39    /// daemon from the **actual** placement of the dataflow's nodes at spawn
40    /// time (the descriptor's `deploy` section is intent, not placement — label
41    /// scheduling can resolve differently).
42    ///
43    /// `None` means the node was spawned by an older daemon that doesn't
44    /// provide routing; the node then keeps every output on the reliable daemon
45    /// path (correct, just without the direct-zenoh fast path).
46    #[serde(default)]
47    pub output_routing: Option<BTreeMap<DataId, OutputRouting>>,
48}
49
50/// Data-plane routing for one node output — see
51/// [`NodeConfig::output_routing`].
52#[derive(Debug, Default, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
53pub struct OutputRouting {
54    /// This output must stay on the reliable daemon path for the node's
55    /// lifetime; it gets no direct zenoh publisher. The daemon sets it when
56    /// some consumer runs under another daemon (only this node's daemon can
57    /// feed inter-daemon forwarding, dora #2738 — the direct node-to-node
58    /// zenoh mesh is same-machine only), when a remote static consumer has no
59    /// dialable endpoint or watches `input_timeout`, or when any consumer
60    /// declares `queue_policy: backpressure` (the direct zenoh ingress can
61    /// drop before the per-input policy applies). The full policy lives in
62    /// the daemon's `output_routing` module.
63    #[serde(default)]
64    pub daemon_only: bool,
65    /// The static same-daemon consumers whose startup acks the producer must
66    /// collect before switching this output from the reliable daemon path to
67    /// the direct node-to-node zenoh path. Dynamic consumers are never
68    /// required: they join at arbitrary times (or never), and nothing may wait
69    /// on them.
70    ///
71    /// The producer only collects these during a bounded startup window; an
72    /// acker that is slow to answer costs this output the fast path for the
73    /// whole run rather than upgrading it mid-stream (dora-rs/dora#2891).
74    #[serde(default)]
75    pub required_ackers: BTreeSet<RequiredAcker>,
76}
77
78/// Identity of one consumer input that must ack a producer's startup markers
79/// before the producer may switch the corresponding output to the direct zenoh
80/// path — see [`OutputRouting::required_ackers`].
81#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize)]
82pub struct RequiredAcker {
83    pub node_id: NodeId,
84    pub input_id: DataId,
85}
86
87#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
88pub enum DaemonCommunication {
89    Tcp { socket_addr: SocketAddr },
90    Interactive,
91}
92
93#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
94#[must_use]
95#[allow(clippy::large_enum_variant)]
96#[non_exhaustive]
97pub enum DaemonReply {
98    Result(Result<(), String>),
99    NextEvents(Vec<Timestamped<NodeEvent>>),
100    NodeConfig {
101        result: Result<NodeConfig, String>,
102    },
103    /// Reply to [`DaemonRequest::ExtensionLoad`]. `None` means the key is not
104    /// in the table — either never stored, or already dropped.
105    ExtensionValue {
106        value: Option<Vec<u8>>,
107    },
108    Empty,
109    /// Opaque reply to [`crate::node_to_daemon::DaemonRequest::ExtensionRequest`],
110    /// produced by the extension's daemon half. dora does not interpret it.
111    ///
112    /// Appended last so existing variants keep their postcard indices: the
113    /// Python node API ships separately (PyPI) from the daemon, so a
114    /// mixed-version pair must not misdecode older replies.
115    ExtensionReply {
116        #[serde(with = "crate::bulk_bytes::vec")]
117        payload: Vec<u8>,
118    },
119}
120
121impl DaemonReply {
122    /// Bulk bytes this reply will contribute to its encoding, for
123    /// [`crate::encode_presized`].
124    ///
125    /// `NextEvents` is a batch, so this sums the per-event hints rather than
126    /// relying on the single flat envelope allowance `encode_presized` adds:
127    /// the daemon drains up to `NODE_EVENT_CHANNEL_CAPACITY` events into one
128    /// reply, and a batch of a few dozen would otherwise realloc several times
129    /// on envelopes alone.
130    pub fn encode_size_hint(&self) -> usize {
131        match self {
132            DaemonReply::NextEvents(events) => {
133                events.iter().map(|e| e.inner.encode_size_hint()).sum()
134            }
135            // The extension value is opaque bytes handed straight back, so
136            // its own length is the whole hint.
137            DaemonReply::ExtensionValue { value } => value.as_ref().map_or(0, |bytes| bytes.len()),
138            DaemonReply::Result(_) | DaemonReply::NodeConfig { .. } | DaemonReply::Empty => 0,
139            DaemonReply::ExtensionReply { payload } => payload.len(),
140        }
141    }
142}
143
144#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
145#[non_exhaustive]
146#[allow(clippy::large_enum_variant)]
147pub enum NodeEvent {
148    Stop,
149    Reload {
150        operator_id: Option<OperatorId>,
151    },
152    Input {
153        id: DataId,
154        metadata: Arc<Metadata>,
155        data: Option<Arc<DataMessage>>,
156    },
157    InputClosed {
158        id: DataId,
159    },
160    /// Notifies a node that a previously closed input has recovered and will receive data again.
161    InputRecovered {
162        id: DataId,
163    },
164    /// Notifies a node that an upstream node has restarted.
165    ///
166    /// Sent to downstream nodes when a node with a restart policy successfully
167    /// restarts after a failure.
168    NodeRestarted {
169        id: NodeId,
170    },
171    /// Notifies a node that all its inputs have been closed.
172    ///
173    /// This event is only sent to nodes that have at least one input.
174    AllInputsClosed,
175    /// A runtime parameter has been updated.
176    ///
177    /// Sent when `dora param set` changes a parameter for this node.
178    ///
179    /// `value_json` carries JSON-encoded bytes rather than `serde_json::Value`:
180    /// this message is serialized with postcard on the daemon↔node TCP channel,
181    /// and `serde_json::Value::deserialize` uses `deserialize_any`, which
182    /// postcard (like any non-self-describing format) does not support.
183    ParamUpdate {
184        key: String,
185        value_json: Vec<u8>,
186    },
187    /// A runtime parameter has been deleted.
188    ///
189    /// Sent when `dora param delete` removes a parameter for this node.
190    ParamDeleted {
191        key: String,
192    },
193    /// An upstream node has failed.
194    ///
195    /// Sent to downstream nodes when an upstream node exits with a
196    /// non-zero exit code.
197    NodeFailed {
198        affected_input_ids: Vec<DataId>,
199        error: String,
200        source_node_id: NodeId,
201    },
202    /// An extension key this node stored or loaded has been dropped, by
203    /// another node or by the daemon reclaiming it.
204    ///
205    /// Delivered out of band: the event stream consumes it rather than
206    /// surfacing it to user code, so a language binding polls
207    /// `drain_dropped_extension_keys()` instead.
208    ExtensionDropped {
209        namespace: String,
210        key: String,
211    },
212}
213
214impl NodeEvent {
215    /// Bulk bytes this event will contribute to the encoding of the
216    /// [`DaemonReply`] that wraps it.
217    ///
218    /// Includes a flat per-event allowance for the `Timestamped` wrapper,
219    /// `Metadata` and ids, because these are batched: see
220    /// [`DaemonReply::encode_size_hint`].
221    pub fn encode_size_hint(&self) -> usize {
222        /// Measured at ~72 bytes for a typical `Input` (timestamp 25 +
223        /// `Metadata` 27 + tags and ids); rounded up so a batch of small events
224        /// still lands in one allocation.
225        const PER_EVENT_ENVELOPE: usize = 128;
226
227        let payload = match self {
228            NodeEvent::Input { data, .. } => data.as_ref().map_or(0, |d| d.len()),
229            NodeEvent::Stop
230            | NodeEvent::Reload { .. }
231            | NodeEvent::InputClosed { .. }
232            | NodeEvent::InputRecovered { .. }
233            | NodeEvent::NodeRestarted { .. }
234            | NodeEvent::AllInputsClosed
235            | NodeEvent::ParamDeleted { .. } => 0,
236            // Payload-carrying variants: count the variable-length field so the
237            // pre-sized buffer does not realloc on encode (matches the sibling
238            // `DaemonRequest::encode_size_hint`, which counts its own payloads).
239            NodeEvent::ParamUpdate { value_json, .. } => value_json.len(),
240            NodeEvent::NodeFailed { error, .. } => error.len(),
241            NodeEvent::ExtensionDropped { namespace, key } => namespace.len() + key.len(),
242        };
243        payload.saturating_add(PER_EVENT_ENVELOPE)
244    }
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250
251    #[test]
252    fn encode_size_hint_counts_param_update_payload() {
253        let value_json = vec![0u8; 10_000];
254        let event = NodeEvent::ParamUpdate {
255            key: "some_param".to_string(),
256            value_json: value_json.clone(),
257        };
258        // The hint feeds `encode_presized`, so it must cover the payload or the
259        // pre-sized buffer reallocates while encoding — the exact cost the hint
260        // exists to avoid. Before the fix, ParamUpdate reported a flat envelope.
261        assert!(
262            event.encode_size_hint() >= value_json.len(),
263            "ParamUpdate hint ({}) must include its {}-byte value_json",
264            event.encode_size_hint(),
265            value_json.len()
266        );
267    }
268
269    #[test]
270    fn encode_size_hint_counts_node_failed_error() {
271        let error = "e".repeat(4096);
272        let event = NodeEvent::NodeFailed {
273            affected_input_ids: Vec::new(),
274            error: error.clone(),
275            source_node_id: NodeId::from("upstream".to_string()),
276        };
277        assert!(
278            event.encode_size_hint() >= error.len(),
279            "NodeFailed hint ({}) must include its {}-byte error string",
280            event.encode_size_hint(),
281            error.len()
282        );
283    }
284}