Skip to main content

magnetar_proto/
types.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Shared sans-io types.
4//!
5//! Public identifier and handle types used throughout the state-machine layer. These types are
6//! intentionally `Copy + Eq + Hash` so they can be threaded through slabs and hash maps without
7//! cloning.
8//!
9//! # References
10//!
11//! - `ClientCnx.java:117` (id allocation), `ProducerImpl.java:419` (producer id),
12//!   `ConsumerImpl.java:143` (consumer id).
13//! - `MessageIdImpl.java` (logical message id structure).
14
15use core::fmt;
16
17use crate::pb;
18
19/// A protocol-level request id, monotonically increasing per connection.
20///
21/// Mirrors `request_id` in `CommandSubscribe`, `CommandProducer`, `CommandSeek`, etc.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
23pub struct RequestId(pub u64);
24
25impl fmt::Display for RequestId {
26    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
27        self.0.fmt(f)
28    }
29}
30
31/// A producer id, allocated by the [`Connection`](crate::Connection) when a producer opens.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
33pub struct ProducerHandle(pub u64);
34
35impl fmt::Display for ProducerHandle {
36    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
37        self.0.fmt(f)
38    }
39}
40
41/// A consumer id, allocated by the [`Connection`](crate::Connection) when a subscription opens.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
43pub struct ConsumerHandle(pub u64);
44
45impl fmt::Display for ConsumerHandle {
46    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
47        self.0.fmt(f)
48    }
49}
50
51/// A monotonic per-producer publish sequence id.
52///
53/// Mirrors `sequenceId` in `MessageMetadata` / `CommandSend` / `CommandSendReceipt`. Reused on
54/// resend (per `ProducerImpl.java:745-753`) so dedup at the broker remains correct.
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
56pub struct SequenceId(pub u64);
57
58impl fmt::Display for SequenceId {
59    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
60        self.0.fmt(f)
61    }
62}
63
64/// PIP-460 segment identifier — unique within a scalable topic's segment DAG.
65///
66/// **Experimental** (PIP-460, ADR-0093). Only meaningful under
67/// `feature = "scalable-topics"`; carried on [`MessageId::segment_id`] for
68/// messages read from a scalable topic.
69#[cfg(feature = "scalable-topics")]
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
71pub struct SegmentId(pub u64);
72
73#[cfg(feature = "scalable-topics")]
74impl fmt::Display for SegmentId {
75    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
76        self.0.fmt(f)
77    }
78}
79
80/// PIP-460 hash key range `[start, end)` a segment is responsible for.
81///
82/// **Experimental** (PIP-460, ADR-0093). Surfaces the key range for
83/// observation only — segment-aware sticky-key dispatch (Key_Shared across
84/// the full DAG) is out of scope (future work).
85#[cfg(feature = "scalable-topics")]
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
87pub struct KeyRange {
88    /// Inclusive start of the hash range.
89    pub start: u32,
90    /// Exclusive end of the hash range.
91    pub end: u32,
92}
93
94/// PIP-460 segment lifecycle state.
95///
96/// **Experimental** (PIP-460, ADR-0093). Mirrors the upstream wire enum
97/// [`pb::SegmentState`], which has exactly two members — a segment is either
98/// serving writes or sealed. `#[non_exhaustive]` so a future broker enum value
99/// cannot break a `match` on this type downstream.
100///
101/// A split or merge is **not** a segment state upstream: it is a DAG-topology
102/// change, read off the `parent_ids` / `child_ids` edges of a new layout and
103/// stamped by a fresh [`ScalableTopicDag`](crate::pb::ScalableTopicDag) epoch.
104#[cfg(feature = "scalable-topics")]
105#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
106#[non_exhaustive]
107pub enum SegmentState {
108    /// Segment is live and serving reads/writes.
109    #[default]
110    Active,
111    /// Segment is sealed (no more writes); reads drain then it is removed.
112    Sealed,
113}
114
115#[cfg(feature = "scalable-topics")]
116impl SegmentState {
117    /// Convert from the wire enum integer, saturating an unrecognised value to
118    /// [`Self::Active`] (forward-compatibility with a future broker enum).
119    #[must_use]
120    pub fn from_pb_i32(value: i32) -> Self {
121        match crate::pb::SegmentState::try_from(value) {
122            Ok(crate::pb::SegmentState::Sealed) => Self::Sealed,
123            Ok(crate::pb::SegmentState::Active) | Err(_) => Self::Active,
124        }
125    }
126
127    /// Convert to the wire enum integer.
128    #[must_use]
129    pub fn to_pb_i32(self) -> i32 {
130        match self {
131            Self::Sealed => crate::pb::SegmentState::Sealed as i32,
132            Self::Active => crate::pb::SegmentState::Active as i32,
133        }
134    }
135}
136
137/// PIP-460 segment descriptor — one node of a scalable topic's segment DAG.
138///
139/// **Experimental** (PIP-460, ADR-0093). Assembled from the upstream wire pair
140/// [`pb::SegmentInfoProto`] (topology + lifecycle) and [`pb::SegmentBrokerAddress`]
141/// (placement), which [`pb::ScalableTopicDag`] carries as two parallel lists keyed
142/// by `segment_id`. Placement is therefore **optional**: a sealed segment the
143/// broker no longer serves has no address entry, and `broker_url` is `None` for it.
144///
145/// `parent_ids` / `child_ids` are the DAG edges. They are what identifies a split
146/// (one parent, several children) or a merge (several parents, one child) — see
147/// [`DagDelta`](crate::dag_watch::DagDelta).
148#[cfg(feature = "scalable-topics")]
149#[derive(Debug, Clone, PartialEq, Eq)]
150pub struct SegmentDescriptor {
151    /// Segment id, unique within the topic DAG.
152    pub segment_id: SegmentId,
153    /// Hash key range this segment serves.
154    pub key_range: KeyRange,
155    /// Plaintext broker URL serving this segment, when the DAG carries a
156    /// placement entry for it.
157    pub broker_url: Option<String>,
158    /// TLS broker URL serving this segment, when advertised.
159    pub broker_url_tls: Option<String>,
160    /// Lifecycle state.
161    pub state: SegmentState,
162    /// Ids of the segments this one descends from (empty for an original segment).
163    pub parent_ids: Vec<SegmentId>,
164    /// Ids of the segments that descend from this one (empty for a leaf).
165    pub child_ids: Vec<SegmentId>,
166    /// DAG generation at which the segment was created. This is a layout epoch,
167    /// not a clock.
168    pub created_at_epoch: u64,
169    /// DAG generation at which the segment was sealed, when it is sealed.
170    pub sealed_at_epoch: Option<u64>,
171    /// Legacy-segment marker. When set, the segment is not managed by the
172    /// scalable-topic controller and wraps this externally-managed
173    /// `persistent://...` topic instead of a `segment://...` one. The broker sets
174    /// it on the synthetic single-segment layout it returns for a regular topic
175    /// that has not been migrated to a scalable topic.
176    pub legacy_topic_name: Option<String>,
177}
178
179#[cfg(feature = "scalable-topics")]
180impl SegmentDescriptor {
181    /// Assemble from the wire pair. `broker` is the [`pb::SegmentBrokerAddress`]
182    /// whose `segment_id` matches `info`, when the DAG carries one.
183    #[must_use]
184    pub fn from_pb(
185        info: &crate::pb::SegmentInfoProto,
186        broker: Option<&crate::pb::SegmentBrokerAddress>,
187    ) -> Self {
188        Self {
189            segment_id: SegmentId(info.segment_id),
190            key_range: KeyRange {
191                start: info.hash_start,
192                end: info.hash_end,
193            },
194            broker_url: broker.map(|b| b.broker_url.clone()),
195            broker_url_tls: broker.and_then(|b| b.broker_url_tls.clone()),
196            state: SegmentState::from_pb_i32(info.state),
197            parent_ids: info.parent_ids.iter().copied().map(SegmentId).collect(),
198            child_ids: info.child_ids.iter().copied().map(SegmentId).collect(),
199            created_at_epoch: info.created_at_epoch,
200            sealed_at_epoch: info.sealed_at_epoch,
201            legacy_topic_name: info.legacy_topic_name.clone(),
202        }
203    }
204
205    /// Split back into the wire pair. The address half is `None` when the
206    /// descriptor carries no placement.
207    ///
208    /// `created_at_ms` / `sealed_at_ms` are broker-authored wall-clock stamps that
209    /// this client only ever reads, so the encode side emits `0` / `None` for them
210    /// rather than inventing a clock — `magnetar-proto` holds no clock at all
211    /// (ADR-0011). Round-tripping a decoded descriptor therefore does not preserve
212    /// them; nothing in the client reads them back.
213    #[must_use]
214    pub fn to_pb(
215        &self,
216    ) -> (
217        crate::pb::SegmentInfoProto,
218        Option<crate::pb::SegmentBrokerAddress>,
219    ) {
220        let info = crate::pb::SegmentInfoProto {
221            segment_id: self.segment_id.0,
222            hash_start: self.key_range.start,
223            hash_end: self.key_range.end,
224            state: self.state.to_pb_i32(),
225            parent_ids: self.parent_ids.iter().map(|s| s.0).collect(),
226            child_ids: self.child_ids.iter().map(|s| s.0).collect(),
227            created_at_epoch: self.created_at_epoch,
228            sealed_at_epoch: self.sealed_at_epoch,
229            created_at_ms: 0,
230            sealed_at_ms: None,
231            legacy_topic_name: self.legacy_topic_name.clone(),
232        };
233        let address = self
234            .broker_url
235            .as_ref()
236            .map(|url| crate::pb::SegmentBrokerAddress {
237                segment_id: self.segment_id.0,
238                broker_url: url.clone(),
239                broker_url_tls: self.broker_url_tls.clone(),
240            });
241        (info, address)
242    }
243
244    /// `true` when this descriptor is the broker's synthetic wrapper around a
245    /// regular, unmigrated topic rather than a controller-managed segment.
246    #[must_use]
247    pub fn is_legacy(&self) -> bool {
248        self.legacy_topic_name.is_some()
249    }
250}
251
252/// PIP-473 transaction-coordinator assignment — which broker serves one
253/// coordinator partition.
254///
255/// **Experimental** (PIP-460 / PIP-473, ADR-0093). Delivered by the
256/// metadata-driven coordinator-discovery watch, which replaces resolving the
257/// coordinator topic through an ordinary lookup.
258#[cfg(feature = "scalable-topics")]
259#[derive(Debug, Clone, PartialEq, Eq)]
260pub struct TcAssignment {
261    /// Transaction-coordinator partition id.
262    pub tc_id: u64,
263    /// Plaintext broker URL serving this coordinator, when advertised.
264    pub broker_service_url: Option<String>,
265    /// TLS broker URL serving this coordinator, when advertised.
266    pub broker_service_url_tls: Option<String>,
267}
268
269/// A logical message identifier (ledger / entry / batch / partition).
270///
271/// Mirrors the Java `MessageId` interface. `partition` defaults to `-1` for non-partitioned
272/// topics; `batch_index` defaults to `-1` for non-batched messages.
273///
274/// # Structural equality (PIP-180)
275///
276/// Two `MessageId`s compare equal iff every structural field matches —
277/// `(ledger_id, entry_id, partition, batch_index, batch_size)`. On a shadow topic
278/// (PIP-180, ADR-0033) the broker presents messages with the **source** `MessageId`
279/// (same ledger/entry pointers as the original write), so a shadow-side reader
280/// observes ids that compare equal to the source-side reader's ids — "same
281/// message" is structurally evident and needs no out-of-band correlation key.
282///
283/// # PIP-460 scalable-topic segment (experimental)
284///
285/// Under `feature = "scalable-topics"` the id carries an optional
286/// `segment_id` field. The derived `PartialEq` / `Ord` / `Hash`
287/// give exactly the cross-mode contract ADR-0093 specifies: two v4 ids both
288/// carry `None`, so the segment field is a tie and the v4 invariant is
289/// preserved bit-for-bit; a scalable id (`Some(_)`) never compares equal to a
290/// v4 id (`None`) — so callers can't accidentally deduplicate across the
291/// scalable / partitioned mode boundary.
292#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
293pub struct MessageId {
294    /// Bookkeeper ledger id where the entry lives.
295    pub ledger_id: u64,
296    /// Entry id within the ledger.
297    pub entry_id: u64,
298    /// Partition index, `-1` if non-partitioned.
299    pub partition: i32,
300    /// Index within a batched entry, `-1` if not batched.
301    pub batch_index: i32,
302    /// Size of the batch the message came from, `-1` if not batched.
303    pub batch_size: i32,
304    /// PIP-460 segment id (experimental). `None` for v4 partitioned /
305    /// non-partitioned topics — this preserves the v4 wire layout and
306    /// structural-equality contract. `Some(_)` only for messages read from a
307    /// scalable topic.
308    #[cfg(feature = "scalable-topics")]
309    pub segment_id: Option<SegmentId>,
310}
311
312impl MessageId {
313    /// A sentinel "earliest" position. Mirrors `MessageId.earliest`.
314    pub const EARLIEST: Self = Self {
315        ledger_id: u64::MAX,
316        entry_id: u64::MAX,
317        partition: -1,
318        batch_index: -1,
319        batch_size: 0,
320        #[cfg(feature = "scalable-topics")]
321        segment_id: None,
322    };
323
324    /// A sentinel "latest" position. Mirrors `MessageId.latest`.
325    pub const LATEST: Self = Self {
326        ledger_id: i64::MAX as u64,
327        entry_id: i64::MAX as u64,
328        partition: -1,
329        batch_index: -1,
330        batch_size: 0,
331        #[cfg(feature = "scalable-topics")]
332        segment_id: None,
333    };
334
335    /// Construct a message id from the wire protobuf representation.
336    pub fn from_pb(pb: &pb::MessageIdData) -> Self {
337        Self {
338            ledger_id: pb.ledger_id,
339            entry_id: pb.entry_id,
340            partition: pb.partition.unwrap_or(-1),
341            batch_index: pb.batch_index.unwrap_or(-1),
342            batch_size: pb.batch_size.unwrap_or(-1),
343            #[cfg(feature = "scalable-topics")]
344            segment_id: None,
345        }
346    }
347
348    /// Encode this message id back into its protobuf form.
349    pub fn to_pb(self) -> pb::MessageIdData {
350        pb::MessageIdData {
351            ledger_id: self.ledger_id,
352            entry_id: self.entry_id,
353            partition: Some(self.partition),
354            batch_index: Some(self.batch_index),
355            ack_set: Vec::new(),
356            batch_size: Some(self.batch_size),
357            first_chunk_message_id: None,
358        }
359    }
360
361    /// Serialise this message id to a portable byte string. Mirrors Java
362    /// `MessageId#toByteArray` — encodes a `MessageIdData` protobuf message. Callers can
363    /// stash the result anywhere (Kafka header, DB column, log line) and reconstruct via
364    /// [`Self::from_bytes`] later.
365    pub fn to_bytes(self) -> Vec<u8> {
366        use prost::Message as _;
367        // `encode_to_vec` is the idiomatic prost infallible encode: it sizes the `Vec`
368        // via `encoded_len()` (so `BufMut::remaining_mut() == usize::MAX` on a `Vec`
369        // never trips the EncodeError-on-short-buffer path). Invariant #6: no panics
370        // in magnetar-proto outside `#[cfg(test)]`.
371        self.to_pb().encode_to_vec()
372    }
373
374    /// Reconstruct a message id from the byte string produced by [`Self::to_bytes`].
375    /// Mirrors Java `MessageId#fromByteArray`. Returns `None` if `bytes` is not a valid
376    /// protobuf `MessageIdData`.
377    #[must_use]
378    pub fn from_bytes(bytes: &[u8]) -> Option<Self> {
379        use prost::Message as _;
380        let pb = pb::MessageIdData::decode(bytes).ok()?;
381        Some(Self::from_pb(&pb))
382    }
383
384    /// PIP-460: attach a [`SegmentId`] to this message id, marking it as read
385    /// from a scalable topic's segment. The runtime scalable layer calls this
386    /// when surfacing a message delivered on a per-segment v4 consumer so the
387    /// caller can correlate the id back to its DAG node.
388    #[cfg(feature = "scalable-topics")]
389    #[must_use]
390    pub fn with_segment(mut self, segment_id: SegmentId) -> Self {
391        self.segment_id = Some(segment_id);
392        self
393    }
394
395    /// PIP-460: the segment this message was read from, if any. `None` for v4
396    /// partitioned / non-partitioned topics.
397    #[cfg(feature = "scalable-topics")]
398    #[must_use]
399    pub fn segment(&self) -> Option<SegmentId> {
400        self.segment_id
401    }
402}
403
404impl fmt::Display for MessageId {
405    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
406        write!(
407            f,
408            "{}:{}:{}:{}",
409            self.ledger_id, self.entry_id, self.partition, self.batch_index
410        )
411    }
412}
413
414/// The transport-layer compression codec selected for a producer.
415///
416/// Maps 1:1 to `pb::CompressionType`. The state machine carries this enum so callers do not have
417/// to deal with the protobuf i32 directly. Re-encoded onto the wire by the producer.
418#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
419pub enum CompressionKind {
420    /// No compression.
421    #[default]
422    None,
423    /// LZ4 block compression.
424    Lz4,
425    /// Zlib deflate.
426    Zlib,
427    /// Zstandard.
428    Zstd,
429    /// Snappy.
430    Snappy,
431}
432
433impl CompressionKind {
434    /// Convert to the wire-format `pb::CompressionType`.
435    pub fn to_pb(self) -> pb::CompressionType {
436        match self {
437            Self::None => pb::CompressionType::None,
438            Self::Lz4 => pb::CompressionType::Lz4,
439            Self::Zlib => pb::CompressionType::Zlib,
440            Self::Zstd => pb::CompressionType::Zstd,
441            Self::Snappy => pb::CompressionType::Snappy,
442        }
443    }
444
445    /// Decode from the wire-format `pb::CompressionType` integer.
446    pub fn from_pb_i32(value: i32) -> Self {
447        match pb::CompressionType::try_from(value).unwrap_or(pb::CompressionType::None) {
448            pb::CompressionType::None => Self::None,
449            pb::CompressionType::Lz4 => Self::Lz4,
450            pb::CompressionType::Zlib => Self::Zlib,
451            pb::CompressionType::Zstd => Self::Zstd,
452            pb::CompressionType::Snappy => Self::Snappy,
453        }
454    }
455}
456
457#[cfg(test)]
458mod tests {
459    use super::*;
460
461    /// Helper: build a non-batched MessageId mirroring Java `MessageIdImpl(ledger, entry,
462    /// partition)`. `batch_index = -1` marks "not batched" (Java semantics).
463    fn mid(ledger: u64, entry: u64, partition: i32) -> MessageId {
464        MessageId {
465            ledger_id: ledger,
466            entry_id: entry,
467            partition,
468            batch_index: -1,
469            batch_size: 0,
470            #[cfg(feature = "scalable-topics")]
471            segment_id: None,
472        }
473    }
474
475    /// Helper: build a batched MessageId mirroring Java `BatchMessageIdImpl(ledger, entry,
476    /// partition, batch_index)`.
477    fn bmid(ledger: u64, entry: u64, partition: i32, batch_index: i32) -> MessageId {
478        MessageId {
479            ledger_id: ledger,
480            entry_id: entry,
481            partition,
482            batch_index,
483            batch_size: 0,
484            #[cfg(feature = "scalable-topics")]
485            segment_id: None,
486        }
487    }
488
489    /// Layer (a) test #4: `MessageId { segment_id: Some(..) }` round-trips
490    /// through [`MessageId::with_segment`] / [`MessageId::segment`], and the
491    /// v4-shape (`segment_id: None`) `to_bytes` is **byte-identical** to a
492    /// plain v4 id — the wire `MessageIdData` carries no segment field, so a
493    /// legacy producer / consumer round-trips bit-for-bit (ADR-0093 §2.1).
494    #[cfg(feature = "scalable-topics")]
495    #[test]
496    fn message_id_with_segment_roundtrip() {
497        let v4 = mid(10, 20, 1);
498        assert_eq!(v4.segment(), None, "v4 id has no segment");
499
500        // In-process segment attach / read.
501        let scaled = v4.with_segment(SegmentId(7));
502        assert_eq!(scaled.segment(), Some(SegmentId(7)));
503
504        // Cross-mode equality contract: scalable (`Some`) != v4 (`None`).
505        assert_ne!(scaled, v4, "Some(_) segment never equals None segment");
506        // Same segment + same coords == equal.
507        let scaled2 = mid(10, 20, 1).with_segment(SegmentId(7));
508        assert_eq!(scaled, scaled2);
509        // Different segment, same coords != equal.
510        let other_seg = mid(10, 20, 1).with_segment(SegmentId(8));
511        assert_ne!(scaled, other_seg);
512
513        // v4 invariant preserved: two `None`-segment ids compare exactly as
514        // the 5-field v4 contract did.
515        let v4b = mid(10, 20, 1);
516        assert_eq!(v4, v4b);
517
518        // Byte-identical guard: the wire `MessageIdData` has no segment field,
519        // so `to_bytes` for a `None`-segment id matches a freshly-built v4 id
520        // with the same coords. (The segment rides the lookup / DAG, not the
521        // per-message wire id, until the Pulsar 5.0 RC vendor bump.)
522        let none_seg = mid(10, 20, 1);
523        assert_eq!(
524            none_seg.to_bytes(),
525            v4.to_bytes(),
526            "None-segment wire encoding is byte-identical to v4"
527        );
528        // A `Some`-segment id encodes the same wire bytes (segment is dropped
529        // on the scaffold wire) — documented scaffold behaviour.
530        assert_eq!(
531            scaled.to_bytes(),
532            v4.to_bytes(),
533            "segment is in-process only on the scaffold wire"
534        );
535    }
536
537    #[test]
538    fn message_id_byte_roundtrip() {
539        let id = MessageId {
540            ledger_id: 1234,
541            entry_id: 5678,
542            partition: 2,
543            batch_index: 7,
544            batch_size: 16,
545            #[cfg(feature = "scalable-topics")]
546            segment_id: None,
547        };
548        let bytes = id.to_bytes();
549        let back = MessageId::from_bytes(&bytes).expect("decode");
550        assert_eq!(back, id);
551    }
552
553    #[test]
554    fn message_id_from_bytes_rejects_garbage() {
555        let garbage = &[0xFF, 0xFE, 0xFD][..];
556        assert!(MessageId::from_bytes(garbage).is_none());
557    }
558
559    /// V6: `MessageId::to_bytes` previously used `.expect("encoding MessageIdData into
560    /// a fresh Vec cannot fail")` — a panic-shaped invariant-#6 violation. The fix
561    /// switches to `prost::Message::encode_to_vec`, which is infallible by contract
562    /// (writes to an internally-sized `Vec` via `BufMut::remaining_mut() == usize::MAX`).
563    /// Smoke-test every documented edge case: `EARLIEST` / `LATEST` sentinels, batched
564    /// ids with negative `batch_index`, ids with `partition == -1`, and round-trip
565    /// each through `from_bytes` so we know the encoder didn't silently truncate.
566    #[test]
567    fn to_bytes_never_panics_on_edge_cases() {
568        for id in [
569            MessageId::EARLIEST,
570            MessageId::LATEST,
571            MessageId {
572                ledger_id: 0,
573                entry_id: 0,
574                partition: -1,
575                batch_index: -1,
576                batch_size: 0,
577                #[cfg(feature = "scalable-topics")]
578                segment_id: None,
579            },
580            MessageId {
581                ledger_id: u64::MAX,
582                entry_id: u64::MAX,
583                partition: i32::MAX,
584                batch_index: i32::MIN,
585                batch_size: i32::MIN,
586                #[cfg(feature = "scalable-topics")]
587                segment_id: None,
588            },
589        ] {
590            // No panic on encode + round-trip — the previous `.expect(...)` path is gone.
591            let bytes = id.to_bytes();
592            let back = MessageId::from_bytes(&bytes).expect("round-trip decode");
593            assert_eq!(back, id);
594        }
595    }
596
597    /// Ported from Java `MessageIdCompareToTest#testEqual` (non-batched + batched variants).
598    /// Two MessageIds with identical fields must compare equal.
599    #[test]
600    fn message_id_compare_to_equal() {
601        // Non-batched
602        let a = mid(123, 345, 567);
603        let b = mid(123, 345, 567);
604        assert_eq!(a.cmp(&b), core::cmp::Ordering::Equal);
605
606        // Batched
607        let c = bmid(234, 345, 456, 567);
608        let d = bmid(234, 345, 456, 567);
609        assert_eq!(c.cmp(&d), core::cmp::Ordering::Equal);
610    }
611
612    /// Ported from Java `MessageIdCompareToTest#testGreaterThan` and `testLessThan`.
613    /// Verifies the (ledger, entry, partition, batch_index) lexicographic ordering and its
614    /// antisymmetry — for every `a > b`, `b < a` must hold.
615    #[test]
616    fn message_id_compare_to_greater_and_less_than() {
617        // Non-batched: walk one axis at a time.
618        let m1 = mid(124, 345, 567);
619        let m2 = mid(123, 345, 567);
620        let m3 = mid(123, 344, 567);
621        let m4 = mid(123, 344, 566);
622        assert!(m1 > m2, "ledger axis: m1>m2");
623        assert!(m1 > m3, "ledger then entry: m1>m3");
624        assert!(m1 > m4, "ledger axis dominates: m1>m4");
625        assert!(m2 > m3, "entry axis: m2>m3");
626        assert!(m2 > m4, "entry then partition: m2>m4");
627        assert!(m3 > m4, "partition axis: m3>m4");
628        // Antisymmetry — every `>` above must have a `<` counterpart.
629        assert!(m2 < m1);
630        assert!(m4 < m3);
631
632        // Batched: same axes plus a batch_index tiebreaker.
633        let b1 = bmid(235, 345, 456, 567);
634        let b2 = bmid(234, 346, 456, 567);
635        let b3 = bmid(234, 345, 456, 568);
636        let b4 = bmid(234, 345, 457, 567);
637        let b5 = bmid(234, 345, 456, 567);
638        assert!(b1 > b2, "ledger dominates entry");
639        assert!(b1 > b3, "ledger dominates batch_index");
640        assert!(b1 > b4, "ledger dominates partition");
641        assert!(b1 > b5);
642        assert!(b2 > b3, "entry axis: b2>b3");
643        assert!(b2 > b4, "entry dominates partition");
644        assert!(b2 > b5, "entry axis: b2>b5");
645        assert!(b4 > b3, "partition dominates batch_index");
646        assert!(b3 > b5, "batch_index axis: b3>b5");
647        assert!(b4 > b5, "partition axis: b4>b5");
648        // Antisymmetric checks.
649        assert!(b2 < b1);
650        assert!(b5 < b3);
651    }
652
653    /// Ported from Java `MessageIdCompareToTest#compareToSymmetricTest`. The key invariant: a
654    /// "non-batched" message id (`batch_index == -1`) and a "batched" one with the same
655    /// `(ledger, entry, partition)` but `batch_index == -1` compare equal — Java treats a
656    /// `MessageIdImpl` as equivalent to a `BatchMessageIdImpl(..., -1)`. The single Rust
657    /// `MessageId` struct unifies both: this test pins down that the derived `Ord` still puts
658    /// `batch_index = -1` before any non-negative `batch_index`.
659    #[test]
660    fn message_id_compare_to_batched_versus_non_batched_symmetric() {
661        let plain = mid(123, 345, 567);
662        let b1 = bmid(123, 345, 567, -1); // identical
663        let b2 = bmid(123, 345, 567, 1); // batched, same (l, e, p)
664        let b3 = bmid(123, 345, 566, 1); // batched, smaller partition
665        let b4 = bmid(123, 345, 566, -1); // non-batched, smaller partition
666
667        // batch_index = -1 with identical (l, e, p) is the "same" id.
668        assert_eq!(plain.cmp(&b1), core::cmp::Ordering::Equal);
669        assert_eq!(b1.cmp(&plain), core::cmp::Ordering::Equal);
670
671        // Any positive batch_index orders strictly after batch_index = -1 for identical (l, e, p).
672        assert!(b2 > plain, "b2 (batch_index=1) > plain (batch_index=-1)");
673        assert!(plain < b2);
674
675        // Smaller partition dominates batch_index tiebreaker.
676        assert!(plain > b3);
677        assert!(b3 < plain);
678        assert!(plain > b4);
679        assert!(b4 < plain);
680    }
681
682    /// Ported from Java `MessageIdSerializationTest#testProtobufSerialization2`.
683    /// `partition = -1` (non-partitioned topic) must survive the byte round-trip.
684    #[test]
685    fn message_id_byte_roundtrip_non_partitioned() {
686        let id = MessageId {
687            ledger_id: 1,
688            entry_id: 2,
689            partition: -1,
690            batch_index: -1,
691            batch_size: 0,
692            #[cfg(feature = "scalable-topics")]
693            segment_id: None,
694        };
695        let bytes = id.to_bytes();
696        let back = MessageId::from_bytes(&bytes).expect("decode non-partitioned id");
697        assert_eq!(back, id);
698        assert_eq!(back.partition, -1);
699        assert_eq!(back.batch_index, -1);
700    }
701
702    /// Ported from Java `MessageIdSerializationTest#testBatchSizeNotSet`. The wire format
703    /// distinguishes "batch_size absent" from "batch_size = 0"; in Rust we collapse the
704    /// "absent" case to `-1` so callers can always reason about the value as an `i32`.
705    /// Round-tripping through `to_bytes` / `from_bytes` must preserve `batch_size = -1`.
706    #[test]
707    fn message_id_byte_roundtrip_batch_size_absent() {
708        let id = MessageId {
709            ledger_id: 1,
710            entry_id: 2,
711            partition: 3,
712            batch_index: 4,
713            batch_size: -1,
714            #[cfg(feature = "scalable-topics")]
715            segment_id: None,
716        };
717        let bytes = id.to_bytes();
718        let back = MessageId::from_bytes(&bytes).expect("decode batched id w/o batch_size");
719        assert_eq!(back, id);
720        assert_eq!(back.batch_size, -1);
721    }
722
723    /// Ported (with a documented divergence) from Java
724    /// `MessageIdSerializationTest#testProtobufSerializationEmpty`. Java throws
725    /// `IOException` on empty bytes because its `required` fields are enforced at decode.
726    /// `prost` accepts empty input and fills the `required` fields with their wire-format
727    /// defaults (zero). We document the divergence here: an empty buffer decodes to a
728    /// "default" `MessageId` with `ledger_id = 0, entry_id = 0, partition = -1,
729    /// batch_index = -1, batch_size = -1`. Callers that need Java-style strictness should
730    /// reject empty buffers themselves before calling `from_bytes`.
731    #[test]
732    fn message_id_from_bytes_empty_decodes_to_zero() {
733        let decoded = MessageId::from_bytes(&[]).expect("prost accepts empty buffer");
734        assert_eq!(
735            decoded,
736            MessageId {
737                ledger_id: 0,
738                entry_id: 0,
739                partition: -1,
740                batch_index: -1,
741                batch_size: -1,
742                #[cfg(feature = "scalable-topics")]
743                segment_id: None,
744            },
745            "empty buffer decodes to wire-format defaults"
746        );
747    }
748
749    /// `MessageId` derives `Hash` so it can key hash maps (e.g. `pending_acks`). Two
750    /// MessageIds with identical fields must hash identically. Pinned because the field order
751    /// — and therefore the `Hash` impl shape — is part of the public surface.
752    #[test]
753    fn message_id_hash_consistent_with_eq() {
754        use std::collections::HashSet;
755        let a = MessageId {
756            ledger_id: 7,
757            entry_id: 8,
758            partition: 9,
759            batch_index: 10,
760            batch_size: 11,
761            #[cfg(feature = "scalable-topics")]
762            segment_id: None,
763        };
764        let b = a;
765        let mut set = HashSet::new();
766        set.insert(a);
767        assert!(set.contains(&b));
768        assert_eq!(set.len(), 1);
769    }
770
771    /// Sanity-check the sentinel ordering: `EARLIEST` is the largest possible position by
772    /// virtue of `ledger_id = u64::MAX`, while `LATEST` uses `i64::MAX as u64`. They must
773    /// compare unequal and respect the derived `Ord`.
774    #[test]
775    fn message_id_earliest_and_latest_sentinels_distinct() {
776        assert_ne!(MessageId::EARLIEST, MessageId::LATEST);
777        // `u64::MAX` > `i64::MAX as u64`, so EARLIEST is "larger" under derived `Ord`.
778        // This is an arbitrary but stable encoding; mirror what we promise to callers.
779        assert!(MessageId::EARLIEST > MessageId::LATEST);
780        // Sentinels round-trip through the byte format like any other id.
781        let earliest_bytes = MessageId::EARLIEST.to_bytes();
782        assert_eq!(
783            MessageId::from_bytes(&earliest_bytes),
784            Some(MessageId::EARLIEST)
785        );
786    }
787
788    /// PIP-180 / ADR-0033: pins the documented structural-equality contract on
789    /// `MessageId`. The broker on a shadow topic presents messages with the **source**
790    /// `(ledger_id, entry_id, batch_index, partition)`; a structurally identical id
791    /// constructed on the source-side reader must compare `==` and hash to the same
792    /// bucket. Without this, callers cannot use `MessageId` as a deduplication key
793    /// across the source ⇄ shadow split.
794    #[test]
795    fn message_id_equality_shadow_vs_source() {
796        use std::collections::HashSet;
797        // Same physical entry observed on both sides — ledger/entry/partition/batch_index
798        // all match. PIP-180's "same message" contract.
799        let source_side = MessageId {
800            ledger_id: 42,
801            entry_id: 7,
802            partition: 0,
803            batch_index: -1,
804            batch_size: 0,
805            #[cfg(feature = "scalable-topics")]
806            segment_id: None,
807        };
808        let shadow_side = MessageId {
809            ledger_id: 42,
810            entry_id: 7,
811            partition: 0,
812            batch_index: -1,
813            batch_size: 0,
814            #[cfg(feature = "scalable-topics")]
815            segment_id: None,
816        };
817        assert_eq!(source_side, shadow_side, "PIP-180 structural equality");
818        // Hash consistency — must collide so callers can use the id as a HashSet/HashMap key
819        // across the source ⇄ shadow boundary.
820        let mut set = HashSet::new();
821        set.insert(source_side);
822        assert!(set.contains(&shadow_side));
823        // A different ledger or entry breaks equality (sanity).
824        let other = MessageId {
825            ledger_id: 42,
826            entry_id: 8,
827            partition: 0,
828            batch_index: -1,
829            batch_size: 0,
830            #[cfg(feature = "scalable-topics")]
831            segment_id: None,
832        };
833        assert_ne!(source_side, other);
834    }
835
836    /// `CompressionKind::from_pb_i32` accepts unknown protobuf integers by falling through to
837    /// `None`. Mirrors the Java `Commands#getCompressionType` fall-back so a future broker
838    /// (with an enum we have not yet bumped) cannot crash decode.
839    #[test]
840    fn compression_kind_unknown_variant_falls_back_to_none() {
841        let unknown = CompressionKind::from_pb_i32(9999);
842        assert_eq!(unknown, CompressionKind::None);
843    }
844
845    /// Every `CompressionKind` round-trips through `to_pb` -> `from_pb_i32`.
846    #[test]
847    fn compression_kind_round_trips_through_pb() {
848        for kind in [
849            CompressionKind::None,
850            CompressionKind::Lz4,
851            CompressionKind::Zlib,
852            CompressionKind::Zstd,
853            CompressionKind::Snappy,
854        ] {
855            let pb = kind.to_pb();
856            assert_eq!(
857                CompressionKind::from_pb_i32(pb as i32),
858                kind,
859                "round-trip for {kind:?}"
860            );
861        }
862    }
863}