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///
12/// A node receives a `Metadata` alongside every input. The [`timestamp`] and
13/// [`metadata_version`] are read through accessors, while [`parameters`] — the
14/// user-provided key/value pairs — is a public field read and written directly.
15///
16/// [`timestamp`]: Self::timestamp
17/// [`metadata_version`]: Self::metadata_version
18/// [`parameters`]: Self::parameters
19///
20/// # Examples
21///
22/// ```
23/// use dora_message::metadata::{get_integer_param, Metadata, Parameter};
24///
25/// let timestamp = uhlc::HLC::default().new_timestamp();
26/// let mut metadata = Metadata::new(timestamp);
27///
28/// // A fresh `Metadata` carries the current wire-format version and no
29/// // user parameters.
30/// assert_eq!(metadata.metadata_version(), Metadata::CURRENT_VERSION);
31/// assert!(metadata.parameters.is_empty());
32///
33/// // Attach a typed parameter and read it back with the matching getter.
34/// metadata
35/// .parameters
36/// .insert("frame".to_string(), Parameter::Integer(7));
37/// assert_eq!(get_integer_param(&metadata.parameters, "frame"), Some(7));
38/// ```
39#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
40pub struct Metadata {
41 metadata_version: u16,
42 timestamp: uhlc::Timestamp,
43 /// User-provided key/value parameters carried alongside the payload.
44 ///
45 /// A [`BTreeMap`] of [`Parameter`] values keyed by name. Read the common
46 /// scalar cases through the type-checked [`get_string_param`],
47 /// [`get_integer_param`], and [`get_bool_param`] helpers rather than
48 /// matching on the [`Parameter`] variant directly.
49 pub parameters: MetadataParameters,
50}
51
52impl Metadata {
53 /// Current metadata wire-format version, stamped on every outgoing message.
54 ///
55 /// Bumped from 0 to 1 when the `ArrowTypeInfo` sidecar was dropped and the
56 /// wire format became Arrow-IPC-only, and from 1 to 2 when the binary
57 /// encoding moved from bincode to postcard (varint integers and length
58 /// prefixes, so the byte layout differs even though the field order does
59 /// not). A receiver can compare
60 /// [`metadata_version`](Self::metadata_version) against this to detect a peer
61 /// speaking an incompatible format and report it clearly instead of failing
62 /// with a cryptic positional-deserialization error.
63 pub const CURRENT_VERSION: u16 = 2;
64
65 /// Create metadata with the given timestamp and no user parameters.
66 pub fn new(timestamp: uhlc::Timestamp) -> Self {
67 Self::from_parameters(timestamp, Default::default())
68 }
69
70 /// Metadata for a startup route-probe marker (see [`STARTUP_MARKER_PARAM`]).
71 pub fn startup_marker(timestamp: uhlc::Timestamp) -> Self {
72 Self::from_parameters(
73 timestamp,
74 BTreeMap::from([(STARTUP_MARKER_PARAM.to_owned(), Parameter::Bool(true))]),
75 )
76 }
77
78 /// Whether this message is a startup route-probe marker rather than node
79 /// data. Markers are consumed by the receiving node's startup barrier and
80 /// must never be decoded or surfaced to user code — see
81 /// [`STARTUP_MARKER_PARAM`].
82 pub fn is_startup_marker(&self) -> bool {
83 get_bool_param(&self.parameters, STARTUP_MARKER_PARAM).unwrap_or(false)
84 }
85
86 /// Metadata for a startup route-probe **ack**: the consumer-side reply to a
87 /// startup marker, identifying which consumer input received it (see
88 /// [`STARTUP_ACK_PARAM`]).
89 pub fn startup_ack(timestamp: uhlc::Timestamp, consumer_node: &str, input_id: &str) -> Self {
90 Self::from_parameters(
91 timestamp,
92 BTreeMap::from([
93 (STARTUP_ACK_PARAM.to_owned(), Parameter::Bool(true)),
94 (
95 STARTUP_ACK_CONSUMER_PARAM.to_owned(),
96 Parameter::String(consumer_node.to_owned()),
97 ),
98 (
99 STARTUP_ACK_INPUT_PARAM.to_owned(),
100 Parameter::String(input_id.to_owned()),
101 ),
102 ]),
103 )
104 }
105
106 /// `Some((consumer_node, input_id))` iff this message is a well-formed
107 /// startup route-probe ack — see [`STARTUP_ACK_PARAM`]. Malformed acks
108 /// (missing or wrongly-typed identity parameters) return `None` and are
109 /// ignored by producers, which keeps the affected output on the reliable
110 /// daemon path instead of switching on bad evidence.
111 pub fn startup_ack_identity(&self) -> Option<(&str, &str)> {
112 if !get_bool_param(&self.parameters, STARTUP_ACK_PARAM).unwrap_or(false) {
113 return None;
114 }
115 let consumer = get_string_param(&self.parameters, STARTUP_ACK_CONSUMER_PARAM)?;
116 let input = get_string_param(&self.parameters, STARTUP_ACK_INPUT_PARAM)?;
117 Some((consumer, input))
118 }
119
120 /// Create metadata with the given timestamp and user parameters, stamping
121 /// the current wire-format version ([`CURRENT_VERSION`](Self::CURRENT_VERSION)).
122 pub fn from_parameters(timestamp: uhlc::Timestamp, parameters: MetadataParameters) -> Self {
123 Self {
124 metadata_version: Self::CURRENT_VERSION,
125 timestamp,
126 parameters,
127 }
128 }
129
130 /// The wire-format version stamped on this metadata. Compare against
131 /// [`CURRENT_VERSION`](Self::CURRENT_VERSION) on receive to reject peers
132 /// using an incompatible format.
133 pub fn metadata_version(&self) -> u16 {
134 self.metadata_version
135 }
136
137 /// The hybrid-logical-clock timestamp assigned when this message was sent.
138 pub fn timestamp(&self) -> uhlc::Timestamp {
139 self.timestamp
140 }
141
142 /// The serialized OpenTelemetry propagation context carried in the
143 /// `open_telemetry_context` parameter, or an empty string if absent.
144 pub fn open_telemetry_context(&self) -> String {
145 get_string_param(&self.parameters, OPEN_TELEMETRY_CONTEXT)
146 .unwrap_or("")
147 .to_string()
148 }
149}
150
151/// Reserved [`MetadataParameters`] key marking a message as a **startup
152/// route-probe marker** rather than node data.
153///
154/// The zenoh data plane is direct node-to-node pub/sub, so a producer that
155/// publishes before a consumer's subscription has propagated would drop those
156/// early samples. Rather than infer route-readiness from zenoh declarations,
157/// each producer publishes markers on its real output topic while an output is
158/// still on the reliable daemon path, and each consumer answers every received
159/// marker with an ack (see [`STARTUP_ACK_PARAM`]): an ack arriving back at the
160/// producer is end-to-end proof that the route pair carries data. The producer
161/// stops marking an output — and switches it to the direct zenoh path — once
162/// all its required consumers have acked, but only within a bounded startup
163/// window: an output still un-acked when the window closes is pinned to the
164/// daemon path for the rest of the run, so a topic's messages never straddle a
165/// path switch (dora-rs/dora#2891). A route that never proves itself just keeps
166/// the output on the daemon path.
167///
168/// The `__dora_` prefix is reserved; user parameters must not use it. Receivers
169/// filter markers before decoding the payload, so they never reach user code.
170pub const STARTUP_MARKER_PARAM: &str = "__dora_startup_marker";
171
172/// Reserved [`MetadataParameters`] key marking a message as a **startup
173/// route-probe ack**: the consumer-side half of the startup handshake.
174///
175/// When a consumer's data subscriber receives a startup marker (see
176/// [`STARTUP_MARKER_PARAM`]) for an input, it replies with an ack on the
177/// output's dedicated `@ack` topic. An ack arriving back at the producer is
178/// end-to-end proof that the route works in *both* directions; once every
179/// required consumer of an output has acked, the producer switches that output
180/// from the reliable daemon path to the direct node-to-node zenoh path. Ack
181/// timing is load-bearing: the switch can only happen inside the producer's
182/// bounded startup window (see [`STARTUP_MARKER_PARAM`]), so a consumer that
183/// acks late costs that output the fast path for the whole run. A missing or
184/// late ack never fails anything — the output simply stays on the daemon path.
185///
186/// Acks travel as an empty-payload message whose attachment carries this flag
187/// plus the acking consumer's identity under [`STARTUP_ACK_CONSUMER_PARAM`] and
188/// [`STARTUP_ACK_INPUT_PARAM`] — the identity rides in the attachment rather
189/// than the zenoh key so consumer/input ids never need key escaping.
190pub const STARTUP_ACK_PARAM: &str = "__dora_startup_ack";
191
192/// Reserved key carrying the acking consumer's node id as a
193/// [`Parameter::String`] — see [`STARTUP_ACK_PARAM`].
194pub const STARTUP_ACK_CONSUMER_PARAM: &str = "__dora_startup_ack_consumer";
195
196/// Reserved key carrying the acking consumer's input id as a
197/// [`Parameter::String`] — see [`STARTUP_ACK_PARAM`].
198pub const STARTUP_ACK_INPUT_PARAM: &str = "__dora_startup_ack_input";
199
200/// Additional metadata that can be sent as part of output messages.
201pub type MetadataParameters = BTreeMap<String, Parameter>;
202
203/// A typed metadata parameter sent as part of output messages.
204///
205/// Parameters are stored by key in [`MetadataParameters`]. The `get_*_param`
206/// helpers ([`get_string_param`], [`get_integer_param`], [`get_bool_param`])
207/// are type-checked: they return the value only when the stored variant matches
208/// the requested type, and `None` both for a missing key **and** for a key whose
209/// stored value has a different type. Callers therefore never need to match on
210/// the variant themselves for the common scalar cases.
211///
212/// ```
213/// use dora_message::metadata::{
214/// get_integer_param, get_string_param, MetadataParameters, Parameter,
215/// };
216///
217/// let mut params = MetadataParameters::new();
218/// params.insert("frame".to_string(), Parameter::Integer(7));
219///
220/// // Matching type -> the value.
221/// assert_eq!(get_integer_param(¶ms, "frame"), Some(7));
222/// // Wrong requested type -> None (not a panic, not a coercion).
223/// assert_eq!(get_string_param(¶ms, "frame"), None);
224/// // Missing key -> None.
225/// assert_eq!(get_integer_param(¶ms, "absent"), None);
226/// ```
227#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
228pub enum Parameter {
229 /// A boolean value.
230 Bool(bool),
231 /// A signed 64-bit integer value.
232 Integer(i64),
233 /// A UTF-8 string value.
234 String(String),
235 /// A list of signed 64-bit integers.
236 ListInt(Vec<i64>),
237 /// A 64-bit floating-point value.
238 Float(f64),
239 /// A list of 64-bit floating-point values.
240 ListFloat(Vec<f64>),
241 /// A list of UTF-8 strings.
242 ListString(Vec<String>),
243 /// A UTC timestamp.
244 Timestamp(DateTime<Utc>),
245}
246
247/// Extract a string parameter from metadata, returning `None` if missing or
248/// not a `Parameter::String`.
249pub fn get_string_param<'a>(params: &'a MetadataParameters, key: &str) -> Option<&'a str> {
250 params.get(key).and_then(|p| match p {
251 Parameter::String(s) => Some(s.as_str()),
252 _ => None,
253 })
254}
255
256/// Extract an integer parameter from metadata, returning `None` if missing or
257/// not a `Parameter::Integer`.
258pub fn get_integer_param(params: &MetadataParameters, key: &str) -> Option<i64> {
259 params.get(key).and_then(|p| match p {
260 Parameter::Integer(n) => Some(*n),
261 _ => None,
262 })
263}
264
265/// Extract a bool parameter from metadata, returning `None` if missing or
266/// not a `Parameter::Bool`.
267pub fn get_bool_param(params: &MetadataParameters, key: &str) -> Option<bool> {
268 params.get(key).and_then(|p| match p {
269 Parameter::Bool(b) => Some(*b),
270 _ => None,
271 })
272}
273
274// ---------------------------------------------------------------------------
275// Well-known metadata parameter keys for service and action patterns
276// ---------------------------------------------------------------------------
277
278/// Metadata key for correlating a service request with its response.
279pub const REQUEST_ID: &str = "request_id";
280
281/// Metadata key for identifying an action goal across feedback/result messages.
282pub const GOAL_ID: &str = "goal_id";
283
284/// Metadata key for the completion status of an action goal.
285pub const GOAL_STATUS: &str = "goal_status";
286
287/// Goal completed successfully.
288pub const GOAL_STATUS_SUCCEEDED: &str = "succeeded";
289
290/// Goal was aborted by the server.
291pub const GOAL_STATUS_ABORTED: &str = "aborted";
292
293/// Goal was canceled by the client.
294pub const GOAL_STATUS_CANCELED: &str = "canceled";
295
296// ---------------------------------------------------------------------------
297// Well-known metadata parameter key for distributed tracing
298// ---------------------------------------------------------------------------
299
300/// Metadata key carrying the serialized OpenTelemetry propagation context, so a
301/// trace can be continued across a dora message hop. Read via
302/// [`Metadata::open_telemetry_context`]; stamped by the node and runtime send
303/// paths when tracing is enabled. Keep this the single source of truth so the
304/// write and read sides can never drift.
305pub const OPEN_TELEMETRY_CONTEXT: &str = "open_telemetry_context";
306
307// ---------------------------------------------------------------------------
308// Well-known metadata parameter keys for the streaming pattern
309// ---------------------------------------------------------------------------
310
311/// Metadata key identifying the conversation/session.
312pub const SESSION_ID: &str = "session_id";
313
314/// Metadata key for the logical segment within a session (e.g. one utterance).
315pub const SEGMENT_ID: &str = "segment_id";
316
317/// Metadata key for chunk sequence number within a segment.
318pub const SEQ: &str = "seq";
319
320/// Metadata key marking the last chunk of a segment (`true` on final chunk).
321pub const FIN: &str = "fin";
322
323/// Metadata key to discard older queued messages on this input (`true` to flush).
324pub const FLUSH: &str = "flush";
325
326/// Metadata key indicating the wire framing of the data payload.
327/// When set to `"arrow-ipc"`, the payload is an Arrow IPC stream.
328pub const FRAMING: &str = "_framing";
329
330/// Value for [`FRAMING`] indicating Arrow IPC stream framing.
331pub const FRAMING_ARROW_IPC: &str = "arrow-ipc";
332
333/// Metadata key carrying the FNV-1a hash (as an `i64`) of the Arrow IPC schema
334/// for a zenoh data message. Present on schema-once messages so a receiver can
335/// tell which primed decoder a schema-less batch belongs to and detect schema
336/// changes. Absent on messages a receiver should decode as a standalone full
337/// stream (large/SHM and daemon-path payloads).
338pub const SCHEMA_HASH: &str = "_schema_hash";
339
340/// Metadata key carrying the true on-wire byte size (as an `i64`) of a
341/// `dora topic` debug frame's data sample.
342///
343/// The daemon rebuilds a self-describing Arrow IPC stream for inspection by
344/// prepending the retained schema block to each schema-once batch, so the
345/// rebuilt stream the CLI receives is larger than what actually travelled on
346/// the wire (the schema-less batch — the schema is published only once on the
347/// `@schema` subtopic). To keep `dora topic info`'s bandwidth accounting
348/// accurate, the daemon stamps the original data-sample length here; the CLI
349/// measures this instead of the rebuilt stream length (dora-rs/dora#2584).
350///
351/// Debug/inspection path only — never set on real node→node outputs.
352pub const WIRE_SIZE: &str = "_wire_size";
353
354/// Byte size to charge for a `dora topic` debug frame when accounting bandwidth.
355///
356/// Prefers the daemon-stamped [`WIRE_SIZE`] (the real on-wire data-sample
357/// length): the `data` the CLI receives is a rebuilt self-describing stream
358/// whose schema was re-prepended for inspection, so for a schema-once output
359/// `data.len()` over-reports what actually travelled. Falls back to the buffer
360/// length when the key is absent — an older daemon, or a non-debug frame that
361/// never carried it (dora-rs/dora#2584). A present-but-out-of-range stamp (a
362/// negative `i64` that can't be a byte count) also falls back rather than
363/// silently counting zero. Keep this the single reader of [`WIRE_SIZE`] so the
364/// daemon stamp and the CLI accounting can never disagree on the fallback rule.
365pub fn debug_frame_wire_size(params: &MetadataParameters, data: Option<&[u8]>) -> usize {
366 get_integer_param(params, WIRE_SIZE)
367 .and_then(|n| usize::try_from(n).ok())
368 .or_else(|| data.map(|d| d.len()))
369 .unwrap_or(0)
370}
371
372/// Returns `true` if the given parameters carry any pattern-correlation key
373/// ([`REQUEST_ID`], [`GOAL_ID`], or [`GOAL_STATUS`]).
374///
375/// Messages marked with these keys belong to a service or action pattern where
376/// multiple Arrow schemas can legitimately flow through a single output/input,
377/// distinguished by metadata rather than a fixed Arrow type. Runtime type
378/// checks skip such messages (dora-rs/adora#150), and the schema-once zenoh
379/// optimization excludes them — on the send side (they always travel as full
380/// self-describing streams), on the node receive side (their schemas must not
381/// churn the per-input decoder), and on the daemon's `dora topic` debug path
382/// (same, for its schema cache). Keep this the single definition so those
383/// layers can never disagree on what "pattern-correlated" means.
384pub fn carries_pattern_correlation(params: &MetadataParameters) -> bool {
385 params.contains_key(REQUEST_ID)
386 || params.contains_key(GOAL_ID)
387 || params.contains_key(GOAL_STATUS)
388}
389
390/// Remove internal wire-protocol keys ([`SCHEMA_HASH`], [`FRAMING`]) from a
391/// parameter map. Call this at every wire→user boundary: the keys are
392/// meaningless after decode, and a stale [`SCHEMA_HASH`] forwarded from an
393/// input's metadata into `send_output` parameters (a standard pattern, e.g.
394/// replay) would ride onto outputs that don't overwrite it, making receivers
395/// hash-mismatch and silently drop them (dora-rs/dora#2366 review).
396pub fn strip_internal_parameters(params: &mut MetadataParameters) {
397 params.remove(SCHEMA_HASH);
398 params.remove(FRAMING);
399}
400
401/// FNV-1a-64 hash with a fixed seed (cross-process deterministic). Used to
402/// fingerprint an Arrow IPC schema block so a schema-less batch can be matched
403/// to the schema it was encoded against (see [`SCHEMA_HASH`]). The producer
404/// node, the consumer node, and the daemon's `dora topic` debug path all hash
405/// the same schema-block bytes with this function, so the value must stay
406/// identical across crates — keep this the single source of truth.
407pub fn fnv1a(bytes: &[u8]) -> u64 {
408 let mut hash: u64 = 0xcbf29ce484222325;
409 for b in bytes {
410 hash ^= *b as u64;
411 hash = hash.wrapping_mul(0x100000001b3);
412 }
413 hash
414}
415
416#[cfg(test)]
417mod tests {
418 use super::*;
419
420 #[test]
421 fn fnv1a_matches_standard_vectors() {
422 // Canonical FNV-1a-64 vectors — pin the algorithm so the producer and
423 // consumers (across crates/processes) never disagree on a schema hash.
424 assert_eq!(fnv1a(b""), 0xcbf29ce484222325);
425 assert_eq!(fnv1a(b"a"), 0xaf63dc4c8601ec8c);
426 }
427
428 fn test_timestamp() -> uhlc::Timestamp {
429 uhlc::HLC::default().new_timestamp()
430 }
431
432 #[test]
433 fn startup_marker_is_detected_and_survives_the_wire() {
434 // A marker is recognized only via the reserved parameter, and the flag
435 // must survive postcard round-tripping — it travels as the zenoh
436 // attachment, and a receiver that failed to recognize it would decode
437 // the marker as node data and surface it to user code.
438 let marker = Metadata::startup_marker(test_timestamp());
439 assert!(marker.is_startup_marker());
440
441 let bytes = crate::encode(&marker).expect("serialize");
442 let decoded: Metadata = crate::decode(&bytes).expect("deserialize");
443 assert!(decoded.is_startup_marker());
444 }
445
446 #[test]
447 fn ordinary_metadata_is_not_a_startup_marker() {
448 // Guard the other direction: real data must never be mistaken for a
449 // marker (it would be silently dropped instead of delivered).
450 assert!(!Metadata::new(test_timestamp()).is_startup_marker());
451
452 // Wrong type under the reserved key must not count as a marker.
453 let wrong_type = Metadata::from_parameters(
454 test_timestamp(),
455 BTreeMap::from([(
456 STARTUP_MARKER_PARAM.to_owned(),
457 Parameter::String("true".into()),
458 )]),
459 );
460 assert!(!wrong_type.is_startup_marker());
461
462 // Explicit `false` is not a marker either.
463 let explicit_false = Metadata::from_parameters(
464 test_timestamp(),
465 BTreeMap::from([(STARTUP_MARKER_PARAM.to_owned(), Parameter::Bool(false))]),
466 );
467 assert!(!explicit_false.is_startup_marker());
468 }
469
470 #[test]
471 fn startup_marker_key_is_reserved_and_distinct() {
472 // The `__dora_` prefix keeps it out of the user parameter namespace, and
473 // it must not collide with any well-known protocol key.
474 assert_eq!(STARTUP_MARKER_PARAM, "__dora_startup_marker");
475 assert!(STARTUP_MARKER_PARAM.starts_with("__dora_"));
476 for key in [
477 REQUEST_ID,
478 GOAL_ID,
479 GOAL_STATUS,
480 SESSION_ID,
481 SEGMENT_ID,
482 SEQ,
483 FIN,
484 FLUSH,
485 ] {
486 assert_ne!(STARTUP_MARKER_PARAM, key);
487 }
488 }
489
490 #[test]
491 fn startup_ack_round_trips_and_extracts_identity() {
492 // The ack travels as a postcard attachment on the `@ack` topic; the
493 // producer must recover exactly the (consumer, input) identity it needs
494 // to tick off a required acker.
495 let ack = Metadata::startup_ack(test_timestamp(), "camera-consumer", "image/depth");
496 assert_eq!(
497 ack.startup_ack_identity(),
498 Some(("camera-consumer", "image/depth"))
499 );
500 // An ack is not a marker (and vice versa, checked below): the two
501 // travel on different topics but share the filtering code path.
502 assert!(!ack.is_startup_marker());
503
504 let bytes = crate::encode(&ack).expect("serialize");
505 let decoded: Metadata = crate::decode(&bytes).expect("deserialize");
506 assert_eq!(
507 decoded.startup_ack_identity(),
508 Some(("camera-consumer", "image/depth"))
509 );
510 }
511
512 #[test]
513 fn malformed_startup_acks_are_rejected() {
514 // Ordinary metadata and markers are not acks.
515 assert_eq!(Metadata::new(test_timestamp()).startup_ack_identity(), None);
516 assert_eq!(
517 Metadata::startup_marker(test_timestamp()).startup_ack_identity(),
518 None
519 );
520
521 // Flag present but identity missing → not a valid ack: a producer must
522 // never count an acker it cannot identify.
523 let flag_only = Metadata::from_parameters(
524 test_timestamp(),
525 BTreeMap::from([(STARTUP_ACK_PARAM.to_owned(), Parameter::Bool(true))]),
526 );
527 assert_eq!(flag_only.startup_ack_identity(), None);
528
529 // Wrongly-typed identity parameters are rejected too.
530 let wrong_types = Metadata::from_parameters(
531 test_timestamp(),
532 BTreeMap::from([
533 (STARTUP_ACK_PARAM.to_owned(), Parameter::Bool(true)),
534 (STARTUP_ACK_CONSUMER_PARAM.to_owned(), Parameter::Integer(1)),
535 (STARTUP_ACK_INPUT_PARAM.to_owned(), Parameter::Integer(2)),
536 ]),
537 );
538 assert_eq!(wrong_types.startup_ack_identity(), None);
539
540 // `Bool(false)` under the flag key is not an ack.
541 let explicit_false = Metadata::from_parameters(
542 test_timestamp(),
543 BTreeMap::from([
544 (STARTUP_ACK_PARAM.to_owned(), Parameter::Bool(false)),
545 (
546 STARTUP_ACK_CONSUMER_PARAM.to_owned(),
547 Parameter::String("c".into()),
548 ),
549 (
550 STARTUP_ACK_INPUT_PARAM.to_owned(),
551 Parameter::String("i".into()),
552 ),
553 ]),
554 );
555 assert_eq!(explicit_false.startup_ack_identity(), None);
556 }
557
558 #[test]
559 fn startup_ack_keys_are_reserved_and_distinct() {
560 let ack_keys = [
561 STARTUP_ACK_PARAM,
562 STARTUP_ACK_CONSUMER_PARAM,
563 STARTUP_ACK_INPUT_PARAM,
564 ];
565 for key in ack_keys {
566 assert!(key.starts_with("__dora_"));
567 assert_ne!(key, STARTUP_MARKER_PARAM);
568 }
569 for (i, a) in ack_keys.iter().enumerate() {
570 for b in &ack_keys[i + 1..] {
571 assert_ne!(a, b);
572 }
573 }
574 }
575
576 #[test]
577 fn well_known_keys_have_stable_values() {
578 // These string values are part of the cross-language protocol
579 // (Python nodes use the same literal strings). Do not change.
580 assert_eq!(REQUEST_ID, "request_id");
581 assert_eq!(GOAL_ID, "goal_id");
582 assert_eq!(GOAL_STATUS, "goal_status");
583 assert_eq!(GOAL_STATUS_SUCCEEDED, "succeeded");
584 assert_eq!(GOAL_STATUS_ABORTED, "aborted");
585 assert_eq!(GOAL_STATUS_CANCELED, "canceled");
586 assert_eq!(SESSION_ID, "session_id");
587 assert_eq!(SEGMENT_ID, "segment_id");
588 assert_eq!(SEQ, "seq");
589 assert_eq!(FIN, "fin");
590 assert_eq!(FLUSH, "flush");
591 }
592
593 #[test]
594 fn well_known_keys_are_distinct() {
595 let keys = [
596 REQUEST_ID,
597 GOAL_ID,
598 GOAL_STATUS,
599 SESSION_ID,
600 SEGMENT_ID,
601 SEQ,
602 FIN,
603 FLUSH,
604 ];
605 for (i, a) in keys.iter().enumerate() {
606 for b in &keys[i + 1..] {
607 assert_ne!(a, b);
608 }
609 }
610 }
611
612 #[test]
613 fn goal_status_values_are_distinct() {
614 let vals = [
615 GOAL_STATUS_SUCCEEDED,
616 GOAL_STATUS_ABORTED,
617 GOAL_STATUS_CANCELED,
618 ];
619 for (i, a) in vals.iter().enumerate() {
620 for b in &vals[i + 1..] {
621 assert_ne!(a, b);
622 }
623 }
624 }
625
626 #[test]
627 fn outgoing_metadata_is_stamped_with_current_version() {
628 // The wire format is positional (postcard) and carries no separate type
629 // descriptor, so `metadata_version` is the only in-band signal of an
630 // incompatible layout. Every constructor must stamp `CURRENT_VERSION`.
631 assert_eq!(Metadata::CURRENT_VERSION, 2);
632 let ts = uhlc::HLC::default().new_timestamp();
633 assert_eq!(
634 Metadata::new(ts).metadata_version(),
635 Metadata::CURRENT_VERSION
636 );
637 assert_eq!(
638 Metadata::from_parameters(ts, Default::default()).metadata_version(),
639 Metadata::CURRENT_VERSION
640 );
641 }
642
643 #[test]
644 fn get_string_param_extracts_string() {
645 let mut params = MetadataParameters::default();
646 params.insert("key".to_string(), Parameter::String("value".to_string()));
647 assert_eq!(get_string_param(¶ms, "key"), Some("value"));
648 assert_eq!(get_string_param(¶ms, "missing"), None);
649 }
650
651 #[test]
652 fn get_string_param_returns_none_for_non_string() {
653 let mut params = MetadataParameters::default();
654 params.insert("num".to_string(), Parameter::Integer(42));
655 assert_eq!(get_string_param(¶ms, "num"), None);
656 }
657
658 #[test]
659 fn get_integer_param_extracts_integer() {
660 let mut params = MetadataParameters::default();
661 params.insert("key".to_string(), Parameter::Integer(42));
662 assert_eq!(get_integer_param(¶ms, "key"), Some(42));
663 assert_eq!(get_integer_param(¶ms, "missing"), None);
664 }
665
666 #[test]
667 fn get_integer_param_returns_none_for_non_integer() {
668 let mut params = MetadataParameters::default();
669 params.insert("s".to_string(), Parameter::String("hello".to_string()));
670 assert_eq!(get_integer_param(¶ms, "s"), None);
671 }
672
673 #[test]
674 fn get_bool_param_extracts_bool() {
675 let mut params = MetadataParameters::default();
676 params.insert("key".to_string(), Parameter::Bool(true));
677 assert_eq!(get_bool_param(¶ms, "key"), Some(true));
678 assert_eq!(get_bool_param(¶ms, "missing"), None);
679 }
680
681 #[test]
682 fn get_bool_param_returns_none_for_non_bool() {
683 let mut params = MetadataParameters::default();
684 params.insert("n".to_string(), Parameter::Integer(1));
685 assert_eq!(get_bool_param(¶ms, "n"), None);
686 }
687
688 #[test]
689 fn debug_frame_wire_size_prefers_stamped_value() {
690 // The stamped size wins over the (larger, schema-inflated) buffer: a
691 // schema-once frame's rebuilt `data` is bigger than what travelled.
692 let mut params = MetadataParameters::default();
693 params.insert(WIRE_SIZE.to_string(), Parameter::Integer(17));
694 assert_eq!(debug_frame_wire_size(¶ms, Some(&[0u8; 42])), 17);
695 }
696
697 #[test]
698 fn debug_frame_wire_size_falls_back_to_buffer_len() {
699 // No stamp (older daemon / non-debug frame) ⇒ use the buffer length.
700 let params = MetadataParameters::default();
701 assert_eq!(debug_frame_wire_size(¶ms, Some(&[0u8; 42])), 42);
702 }
703
704 #[test]
705 fn debug_frame_wire_size_falls_back_on_out_of_range_stamp() {
706 // A negative i64 can't be a byte count; fall back rather than count 0.
707 let mut params = MetadataParameters::default();
708 params.insert(WIRE_SIZE.to_string(), Parameter::Integer(-1));
709 assert_eq!(debug_frame_wire_size(¶ms, Some(&[0u8; 42])), 42);
710 }
711
712 #[test]
713 fn debug_frame_wire_size_zero_without_stamp_or_buffer() {
714 assert_eq!(
715 debug_frame_wire_size(&MetadataParameters::default(), None),
716 0
717 );
718 }
719}