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}