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///
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(&params, "frame"), Some(7));
222/// // Wrong requested type -> None (not a panic, not a coercion).
223/// assert_eq!(get_string_param(&params, "frame"), None);
224/// // Missing key -> None.
225/// assert_eq!(get_integer_param(&params, "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(&params, "key"), Some("value"));
648        assert_eq!(get_string_param(&params, "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(&params, "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(&params, "key"), Some(42));
663        assert_eq!(get_integer_param(&params, "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(&params, "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(&params, "key"), Some(true));
678        assert_eq!(get_bool_param(&params, "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(&params, "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(&params, 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(&params, 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(&params, 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}