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}