Skip to main content

dora_message/
metadata.rs

1use std::collections::BTreeMap;
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5
6/// Additional data that is sent as part of output messages.
7///
8/// Includes a timestamp and additional user-provided parameters. The payload is
9/// a self-describing Arrow IPC stream, so the message carries no separate type
10/// descriptor.
11#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
12pub struct Metadata {
13    metadata_version: u16,
14    timestamp: uhlc::Timestamp,
15    pub parameters: MetadataParameters,
16}
17
18impl Metadata {
19    /// Current metadata wire-format version, stamped on every outgoing message.
20    ///
21    /// Bumped from 0 to 1 when the `ArrowTypeInfo` sidecar was dropped and the
22    /// wire format became Arrow-IPC-only. A receiver can compare
23    /// [`metadata_version`](Self::metadata_version) against this to detect a peer
24    /// speaking an incompatible format and report it clearly instead of failing
25    /// with a cryptic positional-deserialization error.
26    pub const CURRENT_VERSION: u16 = 1;
27
28    pub fn new(timestamp: uhlc::Timestamp) -> Self {
29        Self::from_parameters(timestamp, Default::default())
30    }
31
32    pub fn from_parameters(timestamp: uhlc::Timestamp, parameters: MetadataParameters) -> Self {
33        Self {
34            metadata_version: Self::CURRENT_VERSION,
35            timestamp,
36            parameters,
37        }
38    }
39
40    /// The wire-format version stamped on this metadata. Compare against
41    /// [`CURRENT_VERSION`](Self::CURRENT_VERSION) on receive to reject peers
42    /// using an incompatible format.
43    pub fn metadata_version(&self) -> u16 {
44        self.metadata_version
45    }
46
47    pub fn timestamp(&self) -> uhlc::Timestamp {
48        self.timestamp
49    }
50
51    pub fn open_telemetry_context(&self) -> String {
52        get_string_param(&self.parameters, "open_telemetry_context")
53            .unwrap_or("")
54            .to_string()
55    }
56}
57
58/// Additional metadata that can be sent as part of output messages.
59pub type MetadataParameters = BTreeMap<String, Parameter>;
60
61/// A metadata parameter that can be sent as part of output messages.
62#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
63pub enum Parameter {
64    Bool(bool),
65    Integer(i64),
66    String(String),
67    ListInt(Vec<i64>),
68    Float(f64),
69    ListFloat(Vec<f64>),
70    ListString(Vec<String>),
71    Timestamp(DateTime<Utc>),
72}
73
74/// Extract a string parameter from metadata, returning `None` if missing or
75/// not a `Parameter::String`.
76pub fn get_string_param<'a>(params: &'a MetadataParameters, key: &str) -> Option<&'a str> {
77    params.get(key).and_then(|p| match p {
78        Parameter::String(s) => Some(s.as_str()),
79        _ => None,
80    })
81}
82
83/// Extract an integer parameter from metadata, returning `None` if missing or
84/// not a `Parameter::Integer`.
85pub fn get_integer_param(params: &MetadataParameters, key: &str) -> Option<i64> {
86    params.get(key).and_then(|p| match p {
87        Parameter::Integer(n) => Some(*n),
88        _ => None,
89    })
90}
91
92/// Extract a bool parameter from metadata, returning `None` if missing or
93/// not a `Parameter::Bool`.
94pub fn get_bool_param(params: &MetadataParameters, key: &str) -> Option<bool> {
95    params.get(key).and_then(|p| match p {
96        Parameter::Bool(b) => Some(*b),
97        _ => None,
98    })
99}
100
101// ---------------------------------------------------------------------------
102// Well-known metadata parameter keys for service and action patterns
103// ---------------------------------------------------------------------------
104
105/// Metadata key for correlating a service request with its response.
106pub const REQUEST_ID: &str = "request_id";
107
108/// Metadata key for identifying an action goal across feedback/result messages.
109pub const GOAL_ID: &str = "goal_id";
110
111/// Metadata key for the completion status of an action goal.
112pub const GOAL_STATUS: &str = "goal_status";
113
114/// Goal completed successfully.
115pub const GOAL_STATUS_SUCCEEDED: &str = "succeeded";
116
117/// Goal was aborted by the server.
118pub const GOAL_STATUS_ABORTED: &str = "aborted";
119
120/// Goal was canceled by the client.
121pub const GOAL_STATUS_CANCELED: &str = "canceled";
122
123// ---------------------------------------------------------------------------
124// Well-known metadata parameter keys for the streaming pattern
125// ---------------------------------------------------------------------------
126
127/// Metadata key identifying the conversation/session.
128pub const SESSION_ID: &str = "session_id";
129
130/// Metadata key for the logical segment within a session (e.g. one utterance).
131pub const SEGMENT_ID: &str = "segment_id";
132
133/// Metadata key for chunk sequence number within a segment.
134pub const SEQ: &str = "seq";
135
136/// Metadata key marking the last chunk of a segment (`true` on final chunk).
137pub const FIN: &str = "fin";
138
139/// Metadata key to discard older queued messages on this input (`true` to flush).
140pub const FLUSH: &str = "flush";
141
142/// Metadata key indicating the wire framing of the data payload.
143/// When set to `"arrow-ipc"`, the payload is an Arrow IPC stream.
144pub const FRAMING: &str = "_framing";
145
146/// Value for [`FRAMING`] indicating Arrow IPC stream framing.
147pub const FRAMING_ARROW_IPC: &str = "arrow-ipc";
148
149/// Metadata key carrying the FNV-1a hash (as an `i64`) of the Arrow IPC schema
150/// for a zenoh data message. Present on schema-once messages so a receiver can
151/// tell which primed decoder a schema-less batch belongs to and detect schema
152/// changes. Absent on messages a receiver should decode as a standalone full
153/// stream (large/SHM and daemon-path payloads).
154pub const SCHEMA_HASH: &str = "_schema_hash";
155
156/// Returns `true` if the given parameters carry any pattern-correlation key
157/// ([`REQUEST_ID`], [`GOAL_ID`], or [`GOAL_STATUS`]).
158///
159/// Messages marked with these keys belong to a service or action pattern where
160/// multiple Arrow schemas can legitimately flow through a single output/input,
161/// distinguished by metadata rather than a fixed Arrow type. Runtime type
162/// checks skip such messages (dora-rs/adora#150), and the schema-once zenoh
163/// optimization excludes them — on the send side (they always travel as full
164/// self-describing streams), on the node receive side (their schemas must not
165/// churn the per-input decoder), and on the daemon's `dora topic` debug path
166/// (same, for its schema cache). Keep this the single definition so those
167/// layers can never disagree on what "pattern-correlated" means.
168pub fn carries_pattern_correlation(params: &MetadataParameters) -> bool {
169    params.contains_key(REQUEST_ID)
170        || params.contains_key(GOAL_ID)
171        || params.contains_key(GOAL_STATUS)
172}
173
174/// Remove internal wire-protocol keys ([`SCHEMA_HASH`], [`FRAMING`]) from a
175/// parameter map. Call this at every wire→user boundary: the keys are
176/// meaningless after decode, and a stale [`SCHEMA_HASH`] forwarded from an
177/// input's metadata into `send_output` parameters (a standard pattern, e.g.
178/// replay) would ride onto outputs that don't overwrite it, making receivers
179/// hash-mismatch and silently drop them (dora-rs/dora#2366 review).
180pub fn strip_internal_parameters(params: &mut MetadataParameters) {
181    params.remove(SCHEMA_HASH);
182    params.remove(FRAMING);
183}
184
185/// FNV-1a-64 hash with a fixed seed (cross-process deterministic). Used to
186/// fingerprint an Arrow IPC schema block so a schema-less batch can be matched
187/// to the schema it was encoded against (see [`SCHEMA_HASH`]). The producer
188/// node, the consumer node, and the daemon's `dora topic` debug path all hash
189/// the same schema-block bytes with this function, so the value must stay
190/// identical across crates — keep this the single source of truth.
191pub fn fnv1a(bytes: &[u8]) -> u64 {
192    let mut hash: u64 = 0xcbf29ce484222325;
193    for b in bytes {
194        hash ^= *b as u64;
195        hash = hash.wrapping_mul(0x100000001b3);
196    }
197    hash
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203
204    #[test]
205    fn fnv1a_matches_standard_vectors() {
206        // Canonical FNV-1a-64 vectors — pin the algorithm so the producer and
207        // consumers (across crates/processes) never disagree on a schema hash.
208        assert_eq!(fnv1a(b""), 0xcbf29ce484222325);
209        assert_eq!(fnv1a(b"a"), 0xaf63dc4c8601ec8c);
210    }
211
212    #[test]
213    fn well_known_keys_have_stable_values() {
214        // These string values are part of the cross-language protocol
215        // (Python nodes use the same literal strings). Do not change.
216        assert_eq!(REQUEST_ID, "request_id");
217        assert_eq!(GOAL_ID, "goal_id");
218        assert_eq!(GOAL_STATUS, "goal_status");
219        assert_eq!(GOAL_STATUS_SUCCEEDED, "succeeded");
220        assert_eq!(GOAL_STATUS_ABORTED, "aborted");
221        assert_eq!(GOAL_STATUS_CANCELED, "canceled");
222        assert_eq!(SESSION_ID, "session_id");
223        assert_eq!(SEGMENT_ID, "segment_id");
224        assert_eq!(SEQ, "seq");
225        assert_eq!(FIN, "fin");
226        assert_eq!(FLUSH, "flush");
227    }
228
229    #[test]
230    fn well_known_keys_are_distinct() {
231        let keys = [
232            REQUEST_ID,
233            GOAL_ID,
234            GOAL_STATUS,
235            SESSION_ID,
236            SEGMENT_ID,
237            SEQ,
238            FIN,
239            FLUSH,
240        ];
241        for (i, a) in keys.iter().enumerate() {
242            for b in &keys[i + 1..] {
243                assert_ne!(a, b);
244            }
245        }
246    }
247
248    #[test]
249    fn goal_status_values_are_distinct() {
250        let vals = [
251            GOAL_STATUS_SUCCEEDED,
252            GOAL_STATUS_ABORTED,
253            GOAL_STATUS_CANCELED,
254        ];
255        for (i, a) in vals.iter().enumerate() {
256            for b in &vals[i + 1..] {
257                assert_ne!(a, b);
258            }
259        }
260    }
261
262    #[test]
263    fn outgoing_metadata_is_stamped_with_current_version() {
264        // The wire format is positional (bincode) and carries no separate type
265        // descriptor, so `metadata_version` is the only in-band signal of an
266        // incompatible layout. Every constructor must stamp `CURRENT_VERSION`.
267        assert_eq!(Metadata::CURRENT_VERSION, 1);
268        let ts = uhlc::HLC::default().new_timestamp();
269        assert_eq!(
270            Metadata::new(ts).metadata_version(),
271            Metadata::CURRENT_VERSION
272        );
273        assert_eq!(
274            Metadata::from_parameters(ts, Default::default()).metadata_version(),
275            Metadata::CURRENT_VERSION
276        );
277    }
278
279    #[test]
280    fn get_string_param_extracts_string() {
281        let mut params = MetadataParameters::default();
282        params.insert("key".to_string(), Parameter::String("value".to_string()));
283        assert_eq!(get_string_param(&params, "key"), Some("value"));
284        assert_eq!(get_string_param(&params, "missing"), None);
285    }
286
287    #[test]
288    fn get_string_param_returns_none_for_non_string() {
289        let mut params = MetadataParameters::default();
290        params.insert("num".to_string(), Parameter::Integer(42));
291        assert_eq!(get_string_param(&params, "num"), None);
292    }
293
294    #[test]
295    fn get_integer_param_extracts_integer() {
296        let mut params = MetadataParameters::default();
297        params.insert("key".to_string(), Parameter::Integer(42));
298        assert_eq!(get_integer_param(&params, "key"), Some(42));
299        assert_eq!(get_integer_param(&params, "missing"), None);
300    }
301
302    #[test]
303    fn get_integer_param_returns_none_for_non_integer() {
304        let mut params = MetadataParameters::default();
305        params.insert("s".to_string(), Parameter::String("hello".to_string()));
306        assert_eq!(get_integer_param(&params, "s"), None);
307    }
308
309    #[test]
310    fn get_bool_param_extracts_bool() {
311        let mut params = MetadataParameters::default();
312        params.insert("key".to_string(), Parameter::Bool(true));
313        assert_eq!(get_bool_param(&params, "key"), Some(true));
314        assert_eq!(get_bool_param(&params, "missing"), None);
315    }
316
317    #[test]
318    fn get_bool_param_returns_none_for_non_bool() {
319        let mut params = MetadataParameters::default();
320        params.insert("n".to_string(), Parameter::Integer(1));
321        assert_eq!(get_bool_param(&params, "n"), None);
322    }
323}