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/// Unique identifier for a CLI/coordinator session.
173///
174/// A session groups the CLI commands issued against one coordinator connection.
175/// The id is a time-ordered UUIDv7, so sessions sort by creation time.
176///
177/// ```
178/// use dora_message::SessionId;
179///
180/// let a = SessionId::generate();
181/// let b = SessionId::generate();
182/// assert_ne!(a, b);
183/// assert_eq!(a.uuid().get_version_num(), 7);
184/// ```
185#[derive(
186    Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash,
187)]
188pub struct SessionId(uuid::Uuid);
189
190impl SessionId {
191    /// Generate a fresh, unique session id (a time-ordered UUIDv7).
192    pub fn generate() -> Self {
193        Self(Uuid::new_v7(Timestamp::now(uuid::NoContext)))
194    }
195
196    /// The underlying UUID.
197    pub fn uuid(&self) -> uuid::Uuid {
198        self.0
199    }
200}
201
202/// Unique identifier for one `dora build` of a dataflow.
203///
204/// Assigned when a build starts and carried through to the run so a dataflow
205/// can be matched to the artifacts it was built from. Like [`SessionId`], it is
206/// a time-ordered UUIDv7. Its [`Display`](std::fmt::Display) form is
207/// `BuildId(<uuid>)`; use [`from_display_str`](BuildId::from_display_str) to
208/// recover a value from that form.
209#[derive(
210    Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash,
211)]
212pub struct BuildId(uuid::Uuid);
213
214impl BuildId {
215    /// Generate a fresh, unique build id (a time-ordered UUIDv7).
216    pub fn generate() -> Self {
217        Self(Uuid::new_v7(Timestamp::now(uuid::NoContext)))
218    }
219
220    /// Parse a `BuildId` from its [`Display`](std::fmt::Display) form,
221    /// `BuildId(<uuid>)`, so a value logged with `%build_id` round-trips.
222    ///
223    /// A bare UUID is also accepted for backward compatibility.
224    ///
225    /// ```
226    /// use dora_message::BuildId;
227    ///
228    /// let id = BuildId::generate();
229    /// // The `Display` form recovers the original id...
230    /// assert_eq!(BuildId::from_display_str(&id.to_string()), Some(id));
231    /// // ...as does a bare UUID.
232    /// assert_eq!(BuildId::from_display_str(&id.uuid().to_string()), Some(id));
233    /// // Garbage does not parse to a bogus id.
234    /// assert_eq!(BuildId::from_display_str("not-a-build-id"), None);
235    /// ```
236    pub fn from_display_str(s: &str) -> Option<Self> {
237        let inner = s
238            .strip_prefix("BuildId(")
239            .and_then(|rest| rest.strip_suffix(')'))
240            .unwrap_or(s);
241        Uuid::parse_str(inner).ok().map(BuildId)
242    }
243
244    /// The underlying UUID.
245    pub fn uuid(&self) -> uuid::Uuid {
246        self.0
247    }
248}
249
250impl std::fmt::Display for BuildId {
251    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
252        write!(f, "BuildId({})", self.0)
253    }
254}
255
256pub fn current_crate_version() -> semver::Version {
257    let crate_version_raw = env!("CARGO_PKG_VERSION");
258
259    semver::Version::parse(crate_version_raw).unwrap()
260}
261
262pub(crate) fn versions_compatible(
263    crate_version: &semver::Version,
264    specified_version: &semver::Version,
265) -> Result<bool, String> {
266    let req = semver::VersionReq::parse(&crate_version.to_string()).map_err(|error| {
267        format!("failed to parse crate version `{crate_version}` as `VersionReq`: {error}")
268    })?;
269    let specified_dora_req = semver::VersionReq::parse(&specified_version.to_string())
270        .map_err(|error| {
271            format!(
272                "failed to parse specified dora version `{specified_version}` as `VersionReq`: {error}",
273            )
274        })?;
275    let matches = req.matches(specified_version) || specified_dora_req.matches(crate_version);
276    Ok(matches)
277}
278
279#[cfg(test)]
280mod encoding_tests {
281    use crate::metadata::Metadata;
282
283    /// The property the zenoh attachment filters rely on: a buffer that merely
284    /// *starts* with a valid value is not a valid message.
285    #[test]
286    fn decode_rejects_trailing_bytes() {
287        let mut bytes =
288            crate::encode(&Metadata::new(uhlc::HLC::default().new_timestamp())).expect("serialize");
289        bytes.extend_from_slice(b"foreign publisher trailer");
290
291        let err = crate::decode::<Metadata>(&bytes).expect_err("trailing bytes must be rejected");
292        assert!(
293            format!("{err:#}").contains("trailing bytes"),
294            "error should name the cause, got: {err:#}"
295        );
296    }
297}
298
299#[cfg(test)]
300mod topic_protocol_tests {
301    use super::*;
302
303    /// The message is the only thing an operator sees when a subscription is
304    /// refused, so it has to name the peer's version (or say it has none), name
305    /// ours, and explain why a mismatch cannot simply be tolerated.
306    #[test]
307    fn mismatch_message_names_both_sides_and_the_reason() {
308        let msg = topic_protocol_mismatch_message("client", Some(1));
309        assert!(msg.contains("client speaks version 1"), "got: {msg}");
310        assert!(
311            msg.contains(&TOPIC_DATA_PROTOCOL_VERSION.to_string()),
312            "should name our own version, got: {msg}"
313        );
314        assert!(
315            msg.contains("misparse"),
316            "should say why a mismatch is fatal rather than merely different, got: {msg}"
317        );
318    }
319
320    /// An absent version is a peer from before the handshake existed, which is a
321    /// different diagnosis from "speaks a number we don't like".
322    #[test]
323    fn mismatch_message_distinguishes_a_pre_handshake_peer() {
324        let msg = topic_protocol_mismatch_message("coordinator", None);
325        assert!(msg.contains("predates"), "got: {msg}");
326        assert!(
327            !msg.contains("version None"),
328            "should not leak a Debug-formatted Option, got: {msg}"
329        );
330    }
331
332    /// Both directions of the handshake share one message, so the peer label has
333    /// to be what varies — not two divergent copies of the wording.
334    #[test]
335    fn peer_label_is_what_varies_between_directions() {
336        let from_coordinator = topic_protocol_mismatch_message("client", Some(1));
337        let from_cli = topic_protocol_mismatch_message("coordinator", Some(1));
338        assert_ne!(from_coordinator, from_cli);
339        assert!(from_coordinator.contains("client speaks"));
340        assert!(from_cli.contains("coordinator speaks"));
341    }
342}