Skip to main content

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}