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(¶ms, "key"), Some("value"));
284 assert_eq!(get_string_param(¶ms, "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(¶ms, "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(¶ms, "key"), Some(42));
299 assert_eq!(get_integer_param(¶ms, "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(¶ms, "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(¶ms, "key"), Some(true));
314 assert_eq!(get_bool_param(¶ms, "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(¶ms, "n"), None);
322 }
323}