dora_message/lib.rs
1//! Message types and wire protocol shared by all dora components.
2//!
3//! Binary encoding is [postcard](https://docs.rs/postcard): a compact,
4//! non-self-describing serde format with a stable, documented wire spec. The
5//! encoding is positional, so field order and enum variant order are part of
6//! the protocol — see [`metadata::Metadata::CURRENT_VERSION`] for how an
7//! incompatible peer is detected.
8//!
9
10/// The version of the dora-message crate
11pub const VERSION: &str = env!("CARGO_PKG_VERSION");
12
13pub use uhlc;
14
15/// Per-message slack when pre-sizing an encode buffer, covering the envelope
16/// around the bulk payload: enum tags, ids, [`metadata::Metadata`] and varint
17/// length prefixes. Overshooting is free (one slightly larger allocation);
18/// undershooting costs a realloc plus a memcpy of the payload.
19const ENVELOPE_SIZE_HINT: usize = 512;
20
21/// Encode `value` in dora's binary wire format.
22///
23/// This and [`encode_presized`] are the only encode entry points; call sites do
24/// not name the underlying codec, so it stays swappable from one file.
25///
26/// # Example
27///
28/// A value round-trips through [`encode`] and [`decode`]:
29///
30/// ```
31/// # fn main() -> eyre::Result<()> {
32/// use dora_message::common::NodeExitStatus;
33///
34/// let bytes = dora_message::encode(&NodeExitStatus::ExitCode(1))?;
35/// let decoded: NodeExitStatus = dora_message::decode(&bytes)?;
36/// assert!(!decoded.is_success());
37/// # Ok(())
38/// # }
39/// ```
40pub fn encode<T: serde::Serialize>(value: &T) -> postcard::Result<Vec<u8>> {
41 encode_presized(value, 0)
42}
43
44/// Exact length `value` takes in the binary wire format, computed without
45/// producing the encoding (bulk payloads are counted, not copied). Lets a
46/// sender size a frame before committing to it.
47pub fn serialized_size<T: serde::Serialize>(value: &T) -> postcard::Result<usize> {
48 postcard::experimental::serialized_size(value)
49}
50
51/// [`encode`], for a message carrying `bulk_bytes` bytes of bulk payload.
52///
53/// The encoder writes into a `Vec` that would otherwise start empty and
54/// reallocate as it grows — 10–80% of the encode cost on dora's messages, worst
55/// on the small control messages that dominate the daemon↔node TCP path. Pass
56/// `encode_size_hint()`, which the message types that carry a payload provide;
57/// the envelope slack is added here so that policy lives in one place.
58pub fn encode_presized<T: serde::Serialize>(
59 value: &T,
60 bulk_bytes: usize,
61) -> postcard::Result<Vec<u8>> {
62 postcard::to_extend(
63 value,
64 Vec::with_capacity(bulk_bytes.saturating_add(ENVELOPE_SIZE_HINT)),
65 )
66}
67
68/// Decode `bytes` in dora's binary wire format, requiring the value to consume
69/// the **entire** slice.
70///
71/// This is the only decode entry point, and the trailing-byte check is why it
72/// must stay that way: postcard's own `from_bytes` ignores trailing bytes where
73/// bincode (this protocol's previous codec) rejected them, and two call sites
74/// depend on the strict behaviour.
75///
76/// - The zenoh attachment filters read a decode failure as "not a dora message,
77/// ignore it". A foreign publisher whose attachment merely *starts* with
78/// something shaped like a [`metadata::Metadata`] would otherwise be accepted
79/// as genuine.
80/// - The length-prefixed daemon↔node frames read a decode failure as a desynced
81/// or incompatible peer. A frame longer than the value it carries is evidence
82/// of exactly that, and must not be silently truncated.
83pub fn decode<'a, T: serde::Deserialize<'a>>(bytes: &'a [u8]) -> eyre::Result<T> {
84 let (value, rest) = postcard::take_from_bytes(bytes)?;
85 if !rest.is_empty() {
86 eyre::bail!(
87 "trailing bytes after decoded value ({} of {} unconsumed) — \
88 likely a desynced or incompatible peer",
89 rest.len(),
90 bytes.len()
91 );
92 }
93 Ok(value)
94}
95
96/// Maximum allowed message size over TCP (64 MiB).
97///
98/// Large payloads should use the shared-memory transport instead,
99/// which bypasses this limit via zero-copy IPC.
100pub const MAX_MESSAGE_BYTES: usize = 64 * 1024 * 1024;
101
102/// Read timeout for TCP/socket connections (30 seconds).
103pub const TCP_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
104
105/// Encoding version of the WebSocket topic data channel's **binary** frames
106/// (see `docs/websocket-topic-data-channel.md`).
107///
108/// The subscription handshake is JSON and self-describing, but the frames that
109/// follow it are `subscription_id ++ <encoded Timestamped<InterDaemonEvent>>`
110/// with no per-frame envelope. Both the old (bincode) and current (postcard)
111/// encodings are positional, so a peer speaking the wrong one does not fail to
112/// decode — it *misparses*, yielding plausible-looking garbage.
113///
114/// Version 1 is the bincode encoding; version 2 is postcard. Peers exchange
115/// this in the `TopicSubscribe`/`TopicSubscribed` handshake and refuse the
116/// subscription on mismatch, so the failure is a clear error at subscribe time
117/// rather than corrupt data later.
118///
119/// A peer that predates versioning sends no value at all; that absence is
120/// treated as "incompatible" for the same reason.
121///
122/// # Why the `Hello` handshake is not enough
123///
124/// [`cli_to_coordinator::check_cli_version`] already rejects a CLI whose dora
125/// version is incompatible with the coordinator's, which covers the
126/// bincode-to-postcard break at the 1.0 boundary. It does not make this
127/// redundant, for two reasons:
128///
129/// - **Third-party subscribers never send `Hello`.** The channel is documented
130/// for external consumers, who reach the binary frames without any version
131/// exchange at all.
132/// - **`versions_compatible` is semver-caret.** A 1.0 peer and a 1.5 peer are
133/// "compatible", so if this encoding ever changes again inside the 1.x
134/// series, `Hello` waves it through and only this version catches it.
135pub const TOPIC_DATA_PROTOCOL_VERSION: u16 = 2;
136
137/// Rejection message for a topic-data peer whose binary-frame encoding is not
138/// [`TOPIC_DATA_PROTOCOL_VERSION`].
139///
140/// Shared by both sides of the handshake so the wording, and the explanation of
141/// why a mismatch cannot simply be tolerated, live next to the constant they
142/// describe. `peer` names the other side ("client" / "coordinator") so the
143/// message reads correctly in whichever direction it is produced.
144pub fn topic_protocol_mismatch_message(peer: &str, peer_version: Option<u16>) -> String {
145 let ours = TOPIC_DATA_PROTOCOL_VERSION;
146 let peer_state = match peer_version {
147 Some(version) => format!("{peer} speaks version {version}"),
148 None => format!("{peer} predates the topic data protocol handshake (bincode-era frames)"),
149 };
150 format!(
151 "topic data protocol mismatch: {peer_state}, this side speaks {ours}. \
152 Binary frames are positionally encoded, so subscribing would silently \
153 misparse rather than fail. Upgrade whichever side is older."
154 )
155}
156
157pub mod auth;
158/// Bulk-payload serde helpers. Public so an out-of-tree extension can give its
159/// own opaque payloads the same treatment dora gives its own, rather than
160/// duplicating the visitor — see `docs/extensions.md`.
161pub mod bulk_bytes;
162pub mod common;
163pub mod config;
164/// Dataflow descriptor types for YAML-based dataflow specifications.
165pub mod descriptor;
166pub mod id;
167pub mod metadata;
168
169pub mod coordinator_to_daemon;
170pub mod daemon_to_coordinator;
171
172pub mod daemon_to_daemon;
173
174pub mod daemon_to_node;
175pub mod dynamic_node;
176pub mod node_to_daemon;
177
178pub mod cli_to_coordinator;
179pub mod coordinator_to_cli;
180
181pub mod ws_protocol;
182pub mod zenoh;
183
184pub mod integration_testing_format;
185
186pub use aligned_vec;
187pub use arrow_data;
188pub use arrow_schema;
189use uuid::{Timestamp, Uuid};
190
191/// Unique identifier for a dataflow instance.
192///
193/// Dora assigns each dataflow instance a unique ID on start.
194pub type DataflowId = uuid::Uuid;
195
196/// Unique identifier for a CLI/coordinator session.
197///
198/// A session groups the CLI commands issued against one coordinator connection.
199/// The id is a time-ordered UUIDv7, so sessions sort by creation time.
200///
201/// ```
202/// use dora_message::SessionId;
203///
204/// let a = SessionId::generate();
205/// let b = SessionId::generate();
206/// assert_ne!(a, b);
207/// assert_eq!(a.uuid().get_version_num(), 7);
208/// ```
209#[derive(
210 Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash,
211)]
212pub struct SessionId(uuid::Uuid);
213
214impl SessionId {
215 /// Generate a fresh, unique session id (a time-ordered UUIDv7).
216 pub fn generate() -> Self {
217 Self(Uuid::new_v7(Timestamp::now(uuid::NoContext)))
218 }
219
220 /// The underlying UUID.
221 pub fn uuid(&self) -> uuid::Uuid {
222 self.0
223 }
224}
225
226/// Unique identifier for one `dora build` of a dataflow.
227///
228/// Assigned when a build starts and carried through to the run so a dataflow
229/// can be matched to the artifacts it was built from. Like [`SessionId`], it is
230/// a time-ordered UUIDv7. Its [`Display`](std::fmt::Display) form is
231/// `BuildId(<uuid>)`; use [`from_display_str`](BuildId::from_display_str) to
232/// recover a value from that form.
233#[derive(
234 Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash,
235)]
236pub struct BuildId(uuid::Uuid);
237
238impl BuildId {
239 /// Generate a fresh, unique build id (a time-ordered UUIDv7).
240 pub fn generate() -> Self {
241 Self(Uuid::new_v7(Timestamp::now(uuid::NoContext)))
242 }
243
244 /// Parse a `BuildId` from its [`Display`](std::fmt::Display) form,
245 /// `BuildId(<uuid>)`, so a value logged with `%build_id` round-trips.
246 ///
247 /// A bare UUID is also accepted for backward compatibility.
248 ///
249 /// ```
250 /// use dora_message::BuildId;
251 ///
252 /// let id = BuildId::generate();
253 /// // The `Display` form recovers the original id...
254 /// assert_eq!(BuildId::from_display_str(&id.to_string()), Some(id));
255 /// // ...as does a bare UUID.
256 /// assert_eq!(BuildId::from_display_str(&id.uuid().to_string()), Some(id));
257 /// // Garbage does not parse to a bogus id.
258 /// assert_eq!(BuildId::from_display_str("not-a-build-id"), None);
259 /// ```
260 pub fn from_display_str(s: &str) -> Option<Self> {
261 let inner = s
262 .strip_prefix("BuildId(")
263 .and_then(|rest| rest.strip_suffix(')'))
264 .unwrap_or(s);
265 Uuid::parse_str(inner).ok().map(BuildId)
266 }
267
268 /// The underlying UUID.
269 pub fn uuid(&self) -> uuid::Uuid {
270 self.0
271 }
272}
273
274impl std::fmt::Display for BuildId {
275 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
276 write!(f, "BuildId({})", self.0)
277 }
278}
279
280/// This `dora-message` build's own version, as a parsed [`semver::Version`].
281///
282/// This is the compile-time [`VERSION`] (`CARGO_PKG_VERSION`) parsed into a
283/// structured [`semver::Version`] so it can be compared with a peer's
284/// version. dora's components stamp it into their connection handshakes and
285/// compare it against the version a peer reports — `cli_to_coordinator::ControlRequest::Hello`,
286/// the daemon's `daemon_to_coordinator::DaemonRegisterRequest`, and the node's
287/// `node_to_daemon::NodeRegisterRequest` — to detect an incompatible peer.
288///
289/// # Examples
290///
291/// ```
292/// // The structured version round-trips to the crate's `VERSION` string.
293/// assert_eq!(
294/// dora_message::current_crate_version().to_string(),
295/// dora_message::VERSION,
296/// );
297/// ```
298///
299/// # Panics
300///
301/// Parses [`VERSION`], which for any released build is a valid semver string
302/// baked in by Cargo, so this never panics in practice.
303pub fn current_crate_version() -> semver::Version {
304 let crate_version_raw = env!("CARGO_PKG_VERSION");
305
306 semver::Version::parse(crate_version_raw).unwrap()
307}
308
309pub(crate) fn versions_compatible(
310 crate_version: &semver::Version,
311 specified_version: &semver::Version,
312) -> Result<bool, String> {
313 let req = semver::VersionReq::parse(&crate_version.to_string()).map_err(|error| {
314 format!("failed to parse crate version `{crate_version}` as `VersionReq`: {error}")
315 })?;
316 let specified_dora_req = semver::VersionReq::parse(&specified_version.to_string())
317 .map_err(|error| {
318 format!(
319 "failed to parse specified dora version `{specified_version}` as `VersionReq`: {error}",
320 )
321 })?;
322 let matches = req.matches(specified_version) || specified_dora_req.matches(crate_version);
323 Ok(matches)
324}
325
326#[cfg(test)]
327mod encoding_tests {
328 use crate::metadata::Metadata;
329
330 /// The property the zenoh attachment filters rely on: a buffer that merely
331 /// *starts* with a valid value is not a valid message.
332 #[test]
333 fn decode_rejects_trailing_bytes() {
334 let mut bytes =
335 crate::encode(&Metadata::new(uhlc::HLC::default().new_timestamp())).expect("serialize");
336 bytes.extend_from_slice(b"foreign publisher trailer");
337
338 let err = crate::decode::<Metadata>(&bytes).expect_err("trailing bytes must be rejected");
339 assert!(
340 format!("{err:#}").contains("trailing bytes"),
341 "error should name the cause, got: {err:#}"
342 );
343 }
344}
345
346#[cfg(test)]
347mod topic_protocol_tests {
348 use super::*;
349
350 /// The message is the only thing an operator sees when a subscription is
351 /// refused, so it has to name the peer's version (or say it has none), name
352 /// ours, and explain why a mismatch cannot simply be tolerated.
353 #[test]
354 fn mismatch_message_names_both_sides_and_the_reason() {
355 let msg = topic_protocol_mismatch_message("client", Some(1));
356 assert!(msg.contains("client speaks version 1"), "got: {msg}");
357 assert!(
358 msg.contains(&TOPIC_DATA_PROTOCOL_VERSION.to_string()),
359 "should name our own version, got: {msg}"
360 );
361 assert!(
362 msg.contains("misparse"),
363 "should say why a mismatch is fatal rather than merely different, got: {msg}"
364 );
365 }
366
367 /// An absent version is a peer from before the handshake existed, which is a
368 /// different diagnosis from "speaks a number we don't like".
369 #[test]
370 fn mismatch_message_distinguishes_a_pre_handshake_peer() {
371 let msg = topic_protocol_mismatch_message("coordinator", None);
372 assert!(msg.contains("predates"), "got: {msg}");
373 assert!(
374 !msg.contains("version None"),
375 "should not leak a Debug-formatted Option, got: {msg}"
376 );
377 }
378
379 /// Both directions of the handshake share one message, so the peer label has
380 /// to be what varies — not two divergent copies of the wording.
381 #[test]
382 fn peer_label_is_what_varies_between_directions() {
383 let from_coordinator = topic_protocol_mismatch_message("client", Some(1));
384 let from_cli = topic_protocol_mismatch_message("coordinator", Some(1));
385 assert_ne!(from_coordinator, from_cli);
386 assert!(from_coordinator.contains("client speaks"));
387 assert!(from_cli.contains("coordinator speaks"));
388 }
389}