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