Skip to main content

laser_sdk/stream/
transport.rs

1use crate::capabilities::{Capabilities, HelloOutcome};
2use crate::error::LaserError;
3use crate::filters::reader::{DEFAULT_IDLE_INTERVAL, Delivery, FilteredReader, MatchedRecord};
4use crate::stream::consumer_group::ConsumerGroup;
5use crate::stream::{HeaderKey, HeaderValue, Topic};
6use crate::types::MessageId;
7use bytes::Bytes;
8use futures::Stream;
9use iggy::prelude::{
10    AutoCommit, AutoCommitWhen, BackgroundConfig, ConsumerGroupClient, ConsumerOffsetClient,
11    DirectConfig, Identifier, IggyConsumer, IggyConsumerBuilder, IggyExpiry, IggyMessage,
12    IggyProducer, IggyTimestamp, MaxTopicSize, Partitioning, PollingStrategy, ReceivedMessage,
13    SendMessagesResponse,
14};
15use laser_wire::filter::{FilterRef, FilteredStart};
16use serde::de::DeserializeOwned;
17use std::collections::{BTreeMap, BTreeSet, VecDeque};
18use std::future::Future;
19use std::pin::Pin;
20use std::sync::Arc;
21use std::task::{Context, Poll};
22use std::time::Duration;
23use tokio::time::{Instant, sleep};
24
25const DEFAULT_BATCH_LENGTH: u32 = 1000;
26const DEFAULT_RETRY_INTERVAL: Duration = Duration::from_secs(1);
27
28/// Exact Apache Iggy user headers, preserving each value's wire type.
29pub type Headers = BTreeMap<HeaderKey, HeaderValue>;
30
31/// Per-send partition selection.
32#[derive(Debug, Clone, PartialEq, Eq, Default)]
33pub enum Routing {
34    /// Let Apache Iggy balance records across the topic partitions.
35    #[default]
36    Balanced,
37    /// Hash a non-empty key, preserving order for records with the same key.
38    Key(Vec<u8>),
39    /// Send directly to one partition.
40    Partition(u32),
41}
42
43impl Routing {
44    /// Route records by a stable, non-empty key.
45    pub fn key(value: impl Into<Vec<u8>>) -> Self {
46        Self::Key(value.into())
47    }
48
49    fn into_partitioning(self) -> Result<Partitioning, LaserError> {
50        match self {
51            Self::Balanced => Ok(Partitioning::balanced()),
52            Self::Key(key) => Ok(Partitioning::messages_key(&key)?),
53            Self::Partition(partition) => Ok(Partitioning::partition_id(partition)),
54        }
55    }
56}
57
58/// Polling position for a consumer.
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
60pub enum ConsumerStart {
61    /// Begin at the first available record.
62    First,
63    /// Begin at the last available record.
64    Last,
65    /// Resume after the server-stored consumer offset.
66    #[default]
67    Next,
68    /// Begin at an absolute log offset.
69    Offset(u64),
70    /// Begin at a microsecond Unix timestamp.
71    TimestampMicros(u64),
72}
73
74impl ConsumerStart {
75    const fn into_filtered(self) -> FilteredStart {
76        match self {
77            Self::First => FilteredStart::First,
78            Self::Last => FilteredStart::Last,
79            Self::Next => FilteredStart::Next,
80            Self::Offset(offset) => FilteredStart::Offset(offset),
81            Self::TimestampMicros(timestamp) => FilteredStart::Timestamp(timestamp),
82        }
83    }
84
85    fn into_polling(self) -> PollingStrategy {
86        match self {
87            Self::First => PollingStrategy::first(),
88            Self::Last => PollingStrategy::last(),
89            Self::Next => PollingStrategy::next(),
90            Self::Offset(offset) => PollingStrategy::offset(offset),
91            Self::TimestampMicros(timestamp) => {
92                PollingStrategy::timestamp(IggyTimestamp::from(timestamp))
93            }
94        }
95    }
96}
97
98/// Server offset storage policy for a live consumer.
99#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
100pub enum CommitPolicy {
101    /// Never store offsets automatically. Call [`Consumer::commit`] after a
102    /// record has been handled successfully.
103    Disabled,
104    /// Store offsets on a fixed interval.
105    Interval(Duration),
106    /// Commit the current polled batch on the server before delivery. A crash
107    /// can skip records the application has not processed. A policy-aware
108    /// group consumer stores the delivered prefix when it polls again and at
109    /// shutdown instead, so a crash redelivers the current batch and never
110    /// skips it.
111    #[default]
112    Polling,
113    /// Store on an interval or commit the current batch during polling.
114    IntervalOrPolling(Duration),
115    /// Store after consuming all records returned by a poll.
116    All,
117    /// Store on an interval or after consuming a full poll result.
118    IntervalOrAll(Duration),
119    /// Store after every yielded record.
120    Each,
121    /// Store on an interval or after every yielded record.
122    IntervalOrEach(Duration),
123    /// Store after every `n` yielded records.
124    Every(u32),
125    /// Store on an interval or after every `n` yielded records.
126    IntervalOrEvery(Duration, u32),
127}
128
129impl CommitPolicy {
130    fn into_auto_commit(self) -> Result<AutoCommit, LaserError> {
131        let every = |messages: u32| {
132            if messages == 0 {
133                Err(LaserError::Invalid(
134                    "commit frequency must be greater than zero".to_owned(),
135                ))
136            } else {
137                Ok(AutoCommitWhen::ConsumingEveryNthMessage(messages))
138            }
139        };
140        Ok(match self {
141            Self::Disabled => AutoCommit::Disabled,
142            Self::Interval(duration) => AutoCommit::Interval(positive_duration(
143                duration,
144                "commit interval must be greater than zero",
145            )?),
146            Self::Polling => AutoCommit::When(AutoCommitWhen::PollingMessages),
147            Self::IntervalOrPolling(duration) => AutoCommit::IntervalOrWhen(
148                positive_duration(duration, "commit interval must be greater than zero")?,
149                AutoCommitWhen::PollingMessages,
150            ),
151            Self::All => AutoCommit::When(AutoCommitWhen::ConsumingAllMessages),
152            Self::IntervalOrAll(duration) => AutoCommit::IntervalOrWhen(
153                positive_duration(duration, "commit interval must be greater than zero")?,
154                AutoCommitWhen::ConsumingAllMessages,
155            ),
156            Self::Each => AutoCommit::When(AutoCommitWhen::ConsumingEachMessage),
157            Self::IntervalOrEach(duration) => AutoCommit::IntervalOrWhen(
158                positive_duration(duration, "commit interval must be greater than zero")?,
159                AutoCommitWhen::ConsumingEachMessage,
160            ),
161            Self::Every(messages) => AutoCommit::When(every(messages)?),
162            Self::IntervalOrEvery(duration, messages) => AutoCommit::IntervalOrWhen(
163                positive_duration(duration, "commit interval must be greater than zero")?,
164                every(messages)?,
165            ),
166        })
167    }
168}
169
170fn positive_duration<T>(duration: Duration, message: &'static str) -> Result<T, LaserError>
171where
172    T: TryFrom<Duration>,
173{
174    if duration.is_zero() {
175        return Err(LaserError::Invalid(message.to_owned()));
176    }
177    duration
178        .try_into()
179        .map_err(|_| LaserError::Invalid(message.to_owned()))
180}
181
182/// A raw streaming record with optional exact-width user headers.
183#[derive(Debug, bon::Builder)]
184pub struct ProducerMessage {
185    #[builder(into)]
186    payload: Bytes,
187    #[builder(default)]
188    headers: Headers,
189}
190
191impl ProducerMessage {
192    /// Create a record without user headers.
193    pub fn new(payload: impl Into<Bytes>) -> Self {
194        Self {
195            payload: payload.into(),
196            headers: Headers::new(),
197        }
198    }
199
200    /// Replace all user headers.
201    #[must_use]
202    pub fn with_headers(mut self, headers: Headers) -> Self {
203        self.headers = headers;
204        self
205    }
206
207    /// Add or replace one user header.
208    #[must_use]
209    pub fn header(mut self, key: HeaderKey, value: HeaderValue) -> Self {
210        self.headers.insert(key, value);
211        self
212    }
213
214    fn into_iggy(self) -> Result<IggyMessage, LaserError> {
215        Ok(IggyMessage::builder()
216            .payload(self.payload)
217            .user_headers(self.headers)
218            .build()?)
219    }
220}
221
222/// Configures a live [`Producer`] for one topic.
223pub struct ProducerBuilder {
224    topic: Topic,
225    batch_length: u32,
226    linger: Duration,
227    retries: Option<u32>,
228    retry_interval: Option<Duration>,
229    routing: Routing,
230    create_stream: bool,
231    create_topic: bool,
232    partitions: u32,
233    expire_after: Option<Duration>,
234    never_expire: bool,
235    max_topic_bytes: Option<u64>,
236    unlimited_topic_size: bool,
237    background: Option<BackgroundConfig>,
238}
239
240impl ProducerBuilder {
241    pub(crate) fn new(topic: Topic) -> Self {
242        let publish = topic.laser.publish_options();
243        Self {
244            topic,
245            batch_length: DEFAULT_BATCH_LENGTH,
246            linger: Duration::ZERO,
247            retries: Some(publish.max_retries),
248            retry_interval: Some(publish.retry_backoff),
249            routing: Routing::Balanced,
250            // A producer never creates a stream unless asked. A governed
251            // identity has no stream-wide create right, and a stream is
252            // provisioned with its tenant.
253            create_stream: false,
254            create_topic: true,
255            partitions: 1,
256            expire_after: None,
257            never_expire: false,
258            max_topic_bytes: None,
259            unlimited_topic_size: false,
260            background: None,
261        }
262    }
263
264    #[must_use]
265    pub fn batch_length(mut self, batch_length: u32) -> Self {
266        self.batch_length = batch_length;
267        self
268    }
269
270    #[must_use]
271    pub fn linger(mut self, linger: Duration) -> Self {
272        self.linger = linger;
273        self
274    }
275
276    #[must_use]
277    pub fn retries(mut self, retries: Option<u32>, interval: Option<Duration>) -> Self {
278        self.retries = retries;
279        self.retry_interval = interval;
280        self
281    }
282
283    #[must_use]
284    pub fn retry_backoff(mut self, interval: Duration) -> Self {
285        self.retry_interval = Some(interval);
286        self
287    }
288
289    #[must_use]
290    pub fn routing(mut self, routing: Routing) -> Self {
291        self.routing = routing;
292        self
293    }
294
295    /// Create the stream on init when it does not exist. Off by default: a
296    /// stream is provisioned with its tenant, and a scoped identity may not
297    /// create one. A refused create on an existing stream still proceeds.
298    #[must_use]
299    pub fn create_stream(mut self, create: bool) -> Self {
300        self.create_stream = create;
301        self
302    }
303
304    #[must_use]
305    pub fn create_topic(mut self, create: bool) -> Self {
306        self.create_topic = create;
307        self
308    }
309
310    #[must_use]
311    pub fn partitions(mut self, partitions: u32) -> Self {
312        self.partitions = partitions;
313        self
314    }
315
316    /// Creates the topic with messages expiring after `expiry`, which must be
317    /// greater than zero. Set this or [`never_expire`](Self::never_expire), not
318    /// both, or [`build`](Self::build) fails with [`LaserError::Invalid`].
319    /// Without either the server default applies.
320    #[must_use]
321    pub fn expire_after(mut self, expiry: Duration) -> Self {
322        self.expire_after = Some(expiry);
323        self
324    }
325
326    /// Creates the topic with messages that never expire. Set this or
327    /// [`expire_after`](Self::expire_after), not both.
328    #[must_use]
329    pub fn never_expire(mut self) -> Self {
330        self.never_expire = true;
331        self
332    }
333
334    /// Creates the topic capped at `payload` bytes, which must be greater than
335    /// zero. Set this or [`unlimited_topic_size`](Self::unlimited_topic_size),
336    /// not both, or [`build`](Self::build) fails with [`LaserError::Invalid`].
337    /// Without either the server default applies.
338    #[must_use]
339    pub fn max_topic_bytes(mut self, payload: u64) -> Self {
340        self.max_topic_bytes = Some(payload);
341        self
342    }
343
344    /// Creates the topic without a size limit. Set this or
345    /// [`max_topic_bytes`](Self::max_topic_bytes), not both.
346    #[must_use]
347    pub fn unlimited_topic_size(mut self) -> Self {
348        self.unlimited_topic_size = true;
349        self
350    }
351
352    // The topic expiry and size the builder settings ask for. Both forms of
353    // one setting is a conflict, as in Python and TypeScript.
354    fn topic_limits(&self) -> Result<(IggyExpiry, MaxTopicSize), LaserError> {
355        let expiry = match (self.expire_after, self.never_expire) {
356            (Some(_), true) => {
357                return Err(LaserError::Invalid(
358                    "producer takes expire_after or never_expire, not both".to_owned(),
359                ));
360            }
361            (Some(age), false) if age.is_zero() => {
362                return Err(LaserError::Invalid(
363                    "producer expire_after must be greater than zero".to_owned(),
364                ));
365            }
366            (Some(age), false) => IggyExpiry::ExpireDuration(age.into()),
367            (None, true) => IggyExpiry::NeverExpire,
368            (None, false) => IggyExpiry::ServerDefault,
369        };
370        let max_topic_size = match (self.max_topic_bytes, self.unlimited_topic_size) {
371            (Some(_), true) => {
372                return Err(LaserError::Invalid(
373                    "producer takes max_topic_bytes or unlimited_topic_size, not both".to_owned(),
374                ));
375            }
376            (Some(0), false) => {
377                return Err(LaserError::Invalid(
378                    "producer max_topic_bytes must be greater than zero".to_owned(),
379                ));
380            }
381            (Some(bytes), false) => MaxTopicSize::from(bytes),
382            (None, true) => MaxTopicSize::Unlimited,
383            (None, false) => MaxTopicSize::ServerDefault,
384        };
385        Ok((expiry, max_topic_size))
386    }
387
388    /// Switches to Apache Iggy's buffered, sharded `background` send mode
389    /// instead of the default synchronous `direct` mode: `batch_length`/
390    /// `linger` are ignored once this is set, `config` carries their
391    /// equivalents plus sharding and backpressure. Call
392    /// [`Producer::shutdown`] before dropping the built producer, or
393    /// buffered-but-unsent messages are lost.
394    #[must_use]
395    pub fn background(mut self, config: BackgroundConfig) -> Self {
396        self.background = Some(config);
397        self
398    }
399
400    pub async fn build(self) -> Result<Producer, LaserError> {
401        let (expiry, max_topic_size) = self.topic_limits()?;
402        if self.background.is_none() && self.batch_length == 0 {
403            return Err(LaserError::Invalid(
404                "producer batch length must be greater than zero".to_owned(),
405            ));
406        }
407        if self.create_topic && self.partitions == 0 {
408            return Err(LaserError::Invalid(
409                "topic partition count must be greater than zero".to_owned(),
410            ));
411        }
412        let background = self.background.is_some();
413        let mut publish_options = self.topic.laser.publish_options();
414        publish_options.max_retries = self.retries.unwrap_or(0);
415        if let Some(interval) = self.retry_interval {
416            publish_options.retry_backoff = interval;
417        }
418        publish_options.validate()?;
419        let mut builder = self.topic.iggy_producer()?;
420        builder = match self.background {
421            Some(config) => builder.background(config),
422            None => builder.direct(
423                DirectConfig::builder()
424                    .batch_length(self.batch_length)
425                    .linger_time(self.linger.into())
426                    .build(),
427            ),
428        };
429        let retry_interval = self
430            .retry_interval
431            .map(|duration| {
432                positive_duration(
433                    duration,
434                    "producer retry interval must be greater than zero",
435                )
436            })
437            .transpose()?;
438        builder = builder
439            .partitioning(self.routing.into_partitioning()?)
440            .send_retries(
441                if background { self.retries } else { Some(0) },
442                retry_interval,
443            );
444        builder = if self.create_stream {
445            builder.create_stream_if_not_exists()
446        } else {
447            builder.do_not_create_stream_if_not_exists()
448        };
449        builder = if self.create_topic {
450            builder.create_topic_if_not_exists(self.partitions, expiry, max_topic_size)
451        } else {
452            builder.do_not_create_topic_if_not_exists()
453        };
454        let producer = builder.build();
455        let observed = std::sync::atomic::AtomicU64::new(0);
456        publish_options
457            .run(
458                || async {
459                    observed.store(
460                        self.topic.laser.publish_generation(),
461                        std::sync::atomic::Ordering::Release,
462                    );
463                    Ok(crate::laser::init_producer(&producer).await?)
464                },
465                || {
466                    self.topic
467                        .laser
468                        .reconnect_for_publish(observed.load(std::sync::atomic::Ordering::Acquire))
469                },
470            )
471            .await?;
472        let statistics = super::producer_statistics::ProducerRecorder::new(
473            &self.topic.laser,
474            producer.stream().to_string(),
475            producer.topic().to_string(),
476            !background,
477        );
478        Ok(Producer {
479            statistics,
480            inner: Arc::new(producer),
481            laser: self.topic.laser.clone(),
482            publish_options,
483            batch_length: if self.batch_length == 0 {
484                DEFAULT_BATCH_LENGTH
485            } else {
486                self.batch_length
487            } as usize,
488            background,
489        })
490    }
491}
492
493#[derive(Clone)]
494/// A cloneable, initialized streaming producer.
495pub struct Producer {
496    statistics: super::producer_statistics::ProducerRecorder,
497    inner: Arc<IggyProducer>,
498    laser: crate::laser::Laser,
499    publish_options: crate::laser::PublishOptions,
500    batch_length: usize,
501    background: bool,
502}
503
504impl Producer {
505    /// Send one record without user headers using the configured routing.
506    pub async fn send(
507        &self,
508        payload: impl Into<Bytes>,
509    ) -> Result<SendMessagesResponse, LaserError> {
510        self.send_message(ProducerMessage::new(payload)).await
511    }
512
513    /// Send one record using the configured routing.
514    pub async fn send_message(
515        &self,
516        message: ProducerMessage,
517    ) -> Result<SendMessagesResponse, LaserError> {
518        self.send_iggy(vec![message.into_iggy()?], None).await
519    }
520
521    /// Send one record with a per-call routing override.
522    pub async fn send_with_routing(
523        &self,
524        message: ProducerMessage,
525        routing: Routing,
526    ) -> Result<SendMessagesResponse, LaserError> {
527        self.send_iggy(
528            vec![message.into_iggy()?],
529            Some(Arc::new(routing.into_partitioning()?)),
530        )
531        .await
532    }
533
534    /// Send one record with a per-call partition key.
535    pub async fn send_keyed(
536        &self,
537        message: ProducerMessage,
538        key: impl Into<Vec<u8>>,
539    ) -> Result<SendMessagesResponse, LaserError> {
540        self.send_with_routing(message, Routing::key(key)).await
541    }
542
543    /// Send one record to a specific partition.
544    pub async fn send_to_partition(
545        &self,
546        message: ProducerMessage,
547        partition: u32,
548    ) -> Result<SendMessagesResponse, LaserError> {
549        self.send_with_routing(message, Routing::Partition(partition))
550            .await
551    }
552
553    /// Send a batch using the configured routing.
554    pub async fn send_batch(
555        &self,
556        messages: impl IntoIterator<Item = ProducerMessage>,
557    ) -> Result<SendMessagesResponse, LaserError> {
558        self.send_batch_with_routing(messages, None).await
559    }
560
561    /// Send a batch with an optional per-call routing override.
562    pub async fn send_batch_with_routing(
563        &self,
564        messages: impl IntoIterator<Item = ProducerMessage>,
565        routing: Option<Routing>,
566    ) -> Result<SendMessagesResponse, LaserError> {
567        let messages = messages
568            .into_iter()
569            .map(ProducerMessage::into_iggy)
570            .collect::<Result<Vec<_>, _>>()?;
571        if messages.is_empty() {
572            return Ok(SendMessagesResponse {
573                confirmations: Vec::new(),
574            });
575        }
576        let routing = routing
577            .map(Routing::into_partitioning)
578            .transpose()?
579            .map(Arc::new);
580        self.send_iggy(messages, routing).await
581    }
582
583    async fn send_iggy(
584        &self,
585        messages: Vec<IggyMessage>,
586        routing: Option<Arc<Partitioning>>,
587    ) -> Result<SendMessagesResponse, LaserError> {
588        let observation = self.statistics.begin(
589            messages.len() as u64,
590            messages
591                .iter()
592                .map(|message| message.payload.len() as u64)
593                .sum(),
594        );
595        let result = self.send_iggy_observed(messages, routing).await;
596        observation.finish(result.is_ok());
597        result
598    }
599
600    async fn send_iggy_observed(
601        &self,
602        mut messages: Vec<IggyMessage>,
603        routing: Option<Arc<Partitioning>>,
604    ) -> Result<SendMessagesResponse, LaserError> {
605        if self.background {
606            return Ok(self.inner.send_with_partitioning(messages, routing).await?);
607        }
608        crate::laser::prepare_publish_messages(&mut messages);
609        let mut confirmations = Vec::new();
610        let observed = std::sync::atomic::AtomicU64::new(0);
611        for (index, chunk) in messages.chunks(self.batch_length).enumerate() {
612            let response = self
613                .publish_options
614                .run(
615                    || async {
616                        observed.store(
617                            self.laser.publish_generation(),
618                            std::sync::atomic::Ordering::Release,
619                        );
620                        Ok(self
621                            .inner
622                            .send_with_partitioning(
623                                chunk.iter().map(crate::laser::clone_iggy_message).collect(),
624                                routing.clone(),
625                            )
626                            .await?)
627                    },
628                    || {
629                        self.laser.reconnect_for_publish(
630                            observed.load(std::sync::atomic::Ordering::Acquire),
631                        )
632                    },
633                )
634                .await
635                .map_err(|error| {
636                    crate::laser::publish_failure(
637                        error,
638                        &messages[index * self.batch_length..],
639                        std::mem::take(&mut confirmations),
640                        chunk.len(),
641                        (
642                            &self.inner.stream().to_string(),
643                            &self.inner.topic().to_string(),
644                        ),
645                    )
646                })?;
647            confirmations.extend(response.confirmations);
648        }
649        Ok(SendMessagesResponse { confirmations })
650    }
651
652    /// Flushes buffered `background`-mode messages and stops the worker. A
653    /// `direct`-mode producer has nothing to flush, so this is a cheap
654    /// no-op for it. Requires this to be the last live handle to the
655    /// producer, since Apache Iggy's shutdown takes ownership; fails if
656    /// other clones of this `Producer` are still alive.
657    pub async fn shutdown(self) -> Result<(), LaserError> {
658        match Arc::try_unwrap(self.inner) {
659            Ok(producer) => {
660                producer.shutdown().await;
661                Ok(())
662            }
663            Err(_) => Err(LaserError::Invalid(
664                "producer still has other live handles, drop them before shutdown".to_owned(),
665            )),
666        }
667    }
668}
669
670#[derive(Clone)]
671enum ConsumerTarget {
672    Partition { name: String, partition: u32 },
673    Group(Box<ConsumerGroup>),
674}
675
676/// Configures a live partition or consumer-group reader.
677///
678/// A group consumer on a server that resolves group policies reads through
679/// the group-aware engine: the server runs the group's policy, filtered or
680/// unfiltered, and the consumer acknowledges through the fenced group
681/// contract. On Apache Iggy, or a managed server whose probe positively
682/// announced no filters, it is the native Iggy group consumer. Every option
683/// below applies to both, except where noted.
684#[derive(Clone)]
685pub struct ConsumerBuilder {
686    topic: Topic,
687    target: ConsumerTarget,
688    batch_length: u32,
689    poll_interval: Option<Duration>,
690    start: ConsumerStart,
691    commit: CommitPolicy,
692    auto_join_group: bool,
693    create_group: bool,
694    polling_retry_interval: Duration,
695    init_retries: Option<(u32, Duration)>,
696    allow_replay: bool,
697    native: bool,
698}
699
700impl ConsumerBuilder {
701    pub(crate) fn partition(topic: Topic, name: impl Into<String>, partition: u32) -> Self {
702        Self::new(
703            topic,
704            ConsumerTarget::Partition {
705                name: name.into(),
706                partition,
707            },
708        )
709    }
710
711    pub(crate) fn group(group: ConsumerGroup) -> Self {
712        Self::new(
713            group.topic().clone(),
714            ConsumerTarget::Group(Box::new(group)),
715        )
716    }
717
718    fn new(topic: Topic, target: ConsumerTarget) -> Self {
719        Self {
720            topic,
721            target,
722            batch_length: DEFAULT_BATCH_LENGTH,
723            poll_interval: None,
724            start: ConsumerStart::Next,
725            commit: CommitPolicy::Polling,
726            auto_join_group: true,
727            create_group: true,
728            polling_retry_interval: DEFAULT_RETRY_INTERVAL,
729            init_retries: None,
730            allow_replay: false,
731            native: false,
732        }
733    }
734
735    // Read a group natively even on a server that resolves group policies.
736    // Only for a caller that knows no policy can bind the group, because a
737    // bound group read natively would skip its policy.
738    #[cfg_attr(not(feature = "agent"), allow(dead_code))]
739    pub(crate) fn native(mut self) -> Self {
740        self.native = true;
741        self
742    }
743
744    /// Most records one poll returns. A policy-aware group consumer also
745    /// examines at most this many source records per partition poll, so a
746    /// selective policy returns fewer records, down to none, while progress
747    /// still advances.
748    #[must_use]
749    pub fn batch_length(mut self, batch_length: u32) -> Self {
750        self.batch_length = batch_length;
751        self
752    }
753
754    /// How long the consumer waits after a poll that found nothing new.
755    #[must_use]
756    pub fn poll_interval(mut self, poll_interval: Duration) -> Self {
757        self.poll_interval = Some(poll_interval);
758        self
759    }
760
761    /// Poll again at once after an empty poll. A policy-aware group consumer
762    /// keeps a short wait so an idle partition is not spun on.
763    #[must_use]
764    pub fn without_poll_interval(mut self) -> Self {
765        self.poll_interval = None;
766        self
767    }
768
769    #[must_use]
770    pub fn start_at(mut self, start: ConsumerStart) -> Self {
771        self.start = start;
772        self
773    }
774
775    #[must_use]
776    pub fn commit_policy(mut self, commit: CommitPolicy) -> Self {
777        self.commit = commit;
778        self
779    }
780
781    /// Native only. A policy-aware group consumer always joins its group.
782    #[must_use]
783    pub fn auto_join_group(mut self, auto_join: bool) -> Self {
784        self.auto_join_group = auto_join;
785        self
786    }
787
788    #[must_use]
789    pub fn create_group(mut self, create: bool) -> Self {
790        self.create_group = create;
791        self
792    }
793
794    /// How long a failed poll waits before it is retried.
795    #[must_use]
796    pub fn polling_retry_interval(mut self, interval: Duration) -> Self {
797        self.polling_retry_interval = interval;
798        self
799    }
800
801    #[must_use]
802    pub fn init_retries(mut self, retries: u32, interval: Duration) -> Self {
803        self.init_retries = Some((retries, interval));
804        self
805    }
806
807    /// Native only. A policy-aware group consumer honors its start position
808    /// as given.
809    #[must_use]
810    pub fn allow_replay(mut self) -> Self {
811        self.allow_replay = true;
812        self
813    }
814
815    pub async fn build(self) -> Result<Consumer, LaserError> {
816        if self.batch_length == 0 {
817            return Err(LaserError::Invalid(
818                "consumer batch length must be greater than zero".to_owned(),
819            ));
820        }
821        if let ConsumerTarget::Group(group) = &self.target
822            && !self.native
823        {
824            let mut capabilities = self.topic.laser().capabilities().await;
825            if capabilities.hello == HelloOutcome::Unknown {
826                capabilities = self.topic.laser().refresh_capabilities().await;
827            }
828            if policy_aware(&capabilities)? {
829                let group = group.as_ref().clone();
830                return self.build_group(group).await;
831            }
832        }
833        self.build_native().await
834    }
835
836    async fn build_group(self, group: ConsumerGroup) -> Result<Consumer, LaserError> {
837        self.commit.into_auto_commit()?;
838        if !self.auto_join_group {
839            return Err(LaserError::Invalid(
840                "a policy-aware group consumer always joins its group, remove auto_join_group(false)"
841                    .to_owned(),
842            ));
843        }
844        if self.create_group && group.name().is_some() {
845            group.ensure_native().await?;
846        }
847        let polling_retry_interval = positive_duration(
848            self.polling_retry_interval,
849            "consumer polling retry interval must be greater than zero",
850        )?;
851        let mut retries_left = self.init_retries;
852        let reader = loop {
853            let built = group
854                .reader_with(FilterRef::Group)?
855                .start(self.start.into_filtered())
856                .count(self.batch_length)
857                .max_examined(self.batch_length)
858                .idle_interval(self.poll_interval.unwrap_or(DEFAULT_IDLE_INTERVAL))
859                .retry_interval(polling_retry_interval)
860                .build()
861                .await;
862            match built {
863                Ok(reader) => break reader,
864                Err(error)
865                    if error.is_retryable()
866                        && retries_left.is_some_and(|(retries, _)| retries > 0) =>
867                {
868                    let (retries, interval) = retries_left.unwrap_or_default();
869                    retries_left = Some((retries - 1, interval));
870                    sleep(positive_duration(
871                        interval,
872                        "consumer initialization retry interval must be greater than zero",
873                    )?)
874                    .await;
875                }
876                Err(error) => return Err(error),
877            }
878        };
879        let offset_owner = OffsetOwner {
880            laser: self.topic.laser.clone(),
881            stream: self.topic.stream()?.to_owned(),
882            topic: self.topic.name.clone(),
883            consumer: group_offset_consumer(&group)?,
884        };
885        let offsets = Arc::new(std::sync::Mutex::new(GroupOffsets::default()));
886        let deferred = Arc::new(std::sync::Mutex::new(Vec::new()));
887        Ok(Consumer {
888            yielded_zero: BTreeSet::new(),
889            inner: Some(ConsumerInner::Group(Arc::new(tokio::sync::Mutex::new(
890                GroupEngine {
891                    reader,
892                    commit: self.commit,
893                    buffered: VecDeque::new(),
894                    pending_delivery: None,
895                    pending_flush: None,
896                    returned: VecDeque::new(),
897                    yielded_since_flush: 0,
898                    last_flush: Instant::now(),
899                    offsets: Arc::clone(&offsets),
900                    deferred: Arc::clone(&deferred),
901                },
902            )))),
903            manual_commit: self.commit == CommitPolicy::Disabled,
904            shutdown_target: None,
905            next_future: std::sync::Mutex::new(None),
906            offsets: Some(offsets),
907            deferred: Some(deferred),
908            returned_native: std::sync::Mutex::new(VecDeque::new()),
909            offset_owner,
910        })
911    }
912
913    async fn build_native(self) -> Result<Consumer, LaserError> {
914        let manual_commit = self.commit == CommitPolicy::Disabled;
915        let native_group = match &self.target {
916            ConsumerTarget::Group(group) => Some(group.native_name().await?),
917            ConsumerTarget::Partition { .. } => None,
918        };
919        let shutdown_target = match &self.target {
920            ConsumerTarget::Group(_) if manual_commit && self.auto_join_group => {
921                Some(ConsumerGroupTarget {
922                    laser: self.topic.laser.clone(),
923                    stream: self.topic.stream()?.to_owned(),
924                    topic: self.topic.name.clone(),
925                    group: native_group
926                        .clone()
927                        .ok_or(LaserError::Config("consumer group name is absent"))?,
928                })
929            }
930            _ => None,
931        };
932        let offset_owner = OffsetOwner {
933            laser: self.topic.laser.clone(),
934            stream: self.topic.stream()?.to_owned(),
935            topic: self.topic.name.clone(),
936            consumer: match (&self.target, &native_group) {
937                (ConsumerTarget::Partition { name, .. }, _) => {
938                    iggy::prelude::Consumer::new(Identifier::named(name)?)
939                }
940                (ConsumerTarget::Group(_), Some(group)) => {
941                    iggy::prelude::Consumer::group(Identifier::named(group)?)
942                }
943                (ConsumerTarget::Group(_), None) => {
944                    return Err(LaserError::Config("consumer group name is absent"));
945                }
946            },
947        };
948        let group = matches!(self.target, ConsumerTarget::Group(_));
949        let mut builder: IggyConsumerBuilder = match &self.target {
950            ConsumerTarget::Partition { name, partition } => {
951                self.topic.iggy_consumer(name, *partition)?
952            }
953            ConsumerTarget::Group(_) => self.topic.iggy_consumer_group(
954                native_group
955                    .as_deref()
956                    .ok_or(LaserError::Config("consumer group name is absent"))?,
957            )?,
958        };
959        builder = builder
960            .batch_length(self.batch_length)
961            .polling_strategy(self.start.into_polling())
962            .auto_commit(self.commit.into_auto_commit()?)
963            .polling_retry_interval(positive_duration(
964                self.polling_retry_interval,
965                "consumer polling retry interval must be greater than zero",
966            )?);
967        builder = match self.poll_interval {
968            Some(interval) => builder.poll_interval(interval.into()),
969            None => builder.without_poll_interval(),
970        };
971        if group {
972            builder = if self.auto_join_group {
973                builder.auto_join_consumer_group()
974            } else {
975                builder.do_not_auto_join_consumer_group()
976            };
977            builder = if self.create_group {
978                builder.create_consumer_group_if_not_exists()
979            } else {
980                builder.do_not_create_consumer_group_if_not_exists()
981            };
982        }
983        if let Some((retries, interval)) = self.init_retries {
984            builder = builder.init_retries(
985                retries,
986                positive_duration(
987                    interval,
988                    "consumer initialization retry interval must be greater than zero",
989                )?,
990            );
991        }
992        if self.allow_replay {
993            builder = builder.allow_replay();
994        }
995        let mut consumer = builder.build();
996        consumer.init().await?;
997        Ok(Consumer {
998            yielded_zero: BTreeSet::new(),
999            inner: Some(ConsumerInner::Native(Box::new(consumer))),
1000            manual_commit,
1001            shutdown_target,
1002            next_future: std::sync::Mutex::new(None),
1003            offsets: None,
1004            deferred: None,
1005            returned_native: std::sync::Mutex::new(VecDeque::new()),
1006            offset_owner,
1007        })
1008    }
1009}
1010
1011/// Whether a group consumer reads through the group-aware engine. A server
1012/// that advertises group-aware reads does. A server that serves consumer
1013/// filters without them must be upgraded, because its groups may be bound
1014/// and a native poll would read past their policies. A probe that
1015/// established nothing is not a server without filters. Only a server whose
1016/// announcement, or refusal to announce, positively lacks filters reads
1017/// natively.
1018pub(crate) fn policy_aware(capabilities: &Capabilities) -> Result<bool, LaserError> {
1019    if capabilities.filters.group_policy_reads {
1020        return Ok(true);
1021    }
1022    if capabilities.filters.native {
1023        return Err(LaserError::unsupported_feature(
1024            "filters",
1025            "group_policy_reads",
1026            "this server serves consumer filters but not group-aware reads, upgrade it before consuming groups through the Laser SDK",
1027        ));
1028    }
1029    match capabilities.hello {
1030        HelloOutcome::Unknown | HelloOutcome::Failed => Err(LaserError::Timeout(
1031            "the managed probe that decides whether this server resolves consumer group policies, reconnect and build the consumer again",
1032        )),
1033        HelloOutcome::Answered | HelloOutcome::Rejected => Ok(false),
1034    }
1035}
1036
1037// The server identity a group's offsets are stored under, by name or by its
1038// native 32-bit id.
1039fn group_offset_consumer(group: &ConsumerGroup) -> Result<iggy::prelude::Consumer, LaserError> {
1040    let id =
1041        match (group.name(), group.id()) {
1042            (Some(name), _) => Identifier::named(name)?,
1043            (None, Some(id)) => Identifier::numeric(u32::try_from(id).map_err(|_| {
1044                LaserError::Invalid("consumer group id exceeds 32 bits".to_owned())
1045            })?)?,
1046            (None, None) => return Err(LaserError::Config("consumer group name is absent")),
1047        };
1048    Ok(iggy::prelude::Consumer::group(id))
1049}
1050
1051/// When the group engine asks whether to store the handled prefix.
1052#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1053enum CommitPoint {
1054    /// A record was just delivered, `last_of_page` when it ended its poll.
1055    Delivered { last_of_page: bool },
1056    /// The next poll is about to start.
1057    Poll,
1058}
1059
1060/// Whether an automatic commit policy stores the handled prefix now.
1061/// `yielded` counts records delivered since the last store and `elapsed` the
1062/// time since it.
1063const fn commit_due(
1064    commit: CommitPolicy,
1065    yielded: u32,
1066    elapsed: Duration,
1067    point: CommitPoint,
1068) -> bool {
1069    let polling = matches!(point, CommitPoint::Poll) && yielded > 0;
1070    let last_of_page = matches!(point, CommitPoint::Delivered { last_of_page: true });
1071    let delivered = matches!(point, CommitPoint::Delivered { .. });
1072    match commit {
1073        CommitPolicy::Disabled => false,
1074        CommitPolicy::Polling => polling,
1075        CommitPolicy::Interval(interval) => elapsed.as_nanos() >= interval.as_nanos(),
1076        CommitPolicy::IntervalOrPolling(interval) => {
1077            polling || elapsed.as_nanos() >= interval.as_nanos()
1078        }
1079        CommitPolicy::All => last_of_page,
1080        CommitPolicy::IntervalOrAll(interval) => {
1081            last_of_page || elapsed.as_nanos() >= interval.as_nanos()
1082        }
1083        CommitPolicy::Each | CommitPolicy::IntervalOrEach(_) => delivered && yielded > 0,
1084        CommitPolicy::Every(every) => delivered && yielded >= every,
1085        CommitPolicy::IntervalOrEvery(interval, every) => {
1086            (delivered && yielded >= every) || elapsed.as_nanos() >= interval.as_nanos()
1087        }
1088    }
1089}
1090
1091#[derive(Debug, Clone)]
1092/// One live-consumer record with its exact payload, headers, and log position.
1093pub struct ConsumerMessage {
1094    pub payload: Bytes,
1095    pub headers: Headers,
1096    pub message_id: u128,
1097    pub checksum: u64,
1098    pub position: MessageId,
1099    pub current_offset: u64,
1100    pub partition_id: u32,
1101    pub timestamp_micros: u64,
1102    pub origin_timestamp_micros: u64,
1103    /// The header block exactly as stored, for a caller that decodes its
1104    /// entries itself.
1105    pub user_headers: Option<Bytes>,
1106    /// True when the header block does not decode. `headers` is then empty
1107    /// and the payload stays readable.
1108    pub headers_malformed: bool,
1109    // The delivery handle a policy-aware group consumer commits by. Absent
1110    // on a native record.
1111    pub(crate) delivery: Option<Delivery>,
1112}
1113
1114impl ConsumerMessage {
1115    /// Decode the payload as JSON.
1116    pub fn json<T: DeserializeOwned>(&self) -> Result<T, LaserError> {
1117        serde_json::from_slice(&self.payload).map_err(|error| LaserError::Codec(error.to_string()))
1118    }
1119
1120    // A record whose header block does not decode is still delivered: its
1121    // payload and position are intact, and dropping it would lose a record a
1122    // commit before delivery already covered.
1123    pub(crate) fn of(
1124        message: IggyMessage,
1125        partition_id: u32,
1126        current_offset: u64,
1127        delivery: Option<Delivery>,
1128    ) -> Self {
1129        let (headers, headers_malformed) = match message.user_headers_map() {
1130            Ok(headers) => (headers.unwrap_or_default(), false),
1131            Err(_) => (Headers::new(), true),
1132        };
1133        let header = message.header;
1134        Self {
1135            payload: message.payload,
1136            headers,
1137            message_id: header.id,
1138            checksum: header.checksum,
1139            position: MessageId::new(partition_id, header.offset),
1140            current_offset,
1141            partition_id,
1142            timestamp_micros: header.timestamp,
1143            origin_timestamp_micros: header.origin_timestamp,
1144            user_headers: message.user_headers,
1145            headers_malformed,
1146            delivery,
1147        }
1148    }
1149}
1150
1151impl From<ReceivedMessage> for ConsumerMessage {
1152    fn from(received: ReceivedMessage) -> Self {
1153        Self::of(
1154            received.message,
1155            received.partition_id,
1156            received.current_offset,
1157            None,
1158        )
1159    }
1160}
1161
1162/// An initialized live reader with server-managed offsets. A purge restarts
1163/// the partition at offset 0 without telling an open reader, which can keep
1164/// its old position and skip the replacement records, so rebuild it after a
1165/// purge.
1166pub struct Consumer {
1167    yielded_zero: BTreeSet<u32>,
1168    inner: Option<ConsumerInner>,
1169    manual_commit: bool,
1170    shutdown_target: Option<ConsumerGroupTarget>,
1171    // The group engine's read in flight. Behind a lock only so the consumer
1172    // stays `Sync`: it is reached through `&mut self` alone.
1173    next_future: std::sync::Mutex<Option<NextFuture>>,
1174    offsets: Option<Arc<std::sync::Mutex<GroupOffsets>>>,
1175    // Handled deliveries the group engine acknowledges before its next read.
1176    #[cfg_attr(not(feature = "agent"), allow(dead_code))]
1177    deferred: Option<DeferredAcks>,
1178    returned_native: std::sync::Mutex<VecDeque<ConsumerMessage>>,
1179    offset_owner: OffsetOwner,
1180}
1181
1182type DeferredAcks = Arc<std::sync::Mutex<Vec<Delivery>>>;
1183
1184/// A consumer's offset as the server stores it, read by
1185/// [`Consumer::stored_offset`].
1186#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1187pub struct StoredOffset {
1188    /// The offset the server stored for the consumer.
1189    pub stored_offset: u64,
1190    /// The partition's current head offset.
1191    pub current_offset: u64,
1192}
1193
1194type NextFuture = Pin<Box<dyn Future<Output = Option<Result<ConsumerMessage, LaserError>>> + Send>>;
1195
1196enum ConsumerInner {
1197    Native(Box<IggyConsumer>),
1198    Group(Arc<tokio::sync::Mutex<GroupEngine>>),
1199}
1200
1201struct ConsumerGroupTarget {
1202    laser: crate::laser::Laser,
1203    stream: String,
1204    topic: String,
1205    group: String,
1206}
1207
1208// The identity the server stores a consumer's offsets under: its consumer
1209// name, or its group.
1210struct OffsetOwner {
1211    laser: crate::laser::Laser,
1212    stream: String,
1213    topic: String,
1214    consumer: iggy::prelude::Consumer,
1215}
1216
1217/// The offsets a policy-aware group consumer reports: the last record it
1218/// yielded and the last one it stored, per partition.
1219#[derive(Default)]
1220struct GroupOffsets {
1221    consumed: BTreeMap<u32, u64>,
1222    stored: BTreeMap<u32, u64>,
1223    // The partitions the reader reads now, `None` before its first read.
1224    assigned: Option<BTreeSet<u32>>,
1225}
1226
1227/// The group-aware delivery engine behind a policy-aware group consumer: the
1228/// group reader with the commit policy applied on top of its fenced
1229/// acknowledgments. A new read or graceful shutdown marks the previous
1230/// delivery handled, and the policy decides when that prefix is stored.
1231struct GroupEngine {
1232    reader: FilteredReader,
1233    commit: CommitPolicy,
1234    buffered: VecDeque<MatchedRecord>,
1235    pending_delivery: Option<(Delivery, bool)>,
1236    pending_flush: Option<CommitPoint>,
1237    returned: VecDeque<(ConsumerMessage, bool)>,
1238    yielded_since_flush: u32,
1239    last_flush: Instant,
1240    offsets: Arc<std::sync::Mutex<GroupOffsets>>,
1241    deferred: DeferredAcks,
1242}
1243
1244impl GroupEngine {
1245    async fn next(&mut self) -> Option<Result<ConsumerMessage, LaserError>> {
1246        if let Err(error) = self.finish_delivery().await {
1247            return Some(Err(error));
1248        }
1249        if let Err(error) = self.apply_deferred().await {
1250            return Some(Err(error));
1251        }
1252        loop {
1253            if let Some((message, last_of_page)) = self.returned.pop_front() {
1254                if let Some(delivery) = &message.delivery {
1255                    self.pending_delivery = Some((delivery.clone(), last_of_page));
1256                }
1257                return Some(Ok(message));
1258            }
1259            if let Some(record) = self.buffered.pop_front() {
1260                let delivery = record.delivery();
1261                self.pending_delivery = Some((delivery.clone(), self.buffered.is_empty()));
1262                self.offsets
1263                    .lock()
1264                    .unwrap_or_else(std::sync::PoisonError::into_inner)
1265                    .consumed
1266                    .insert(delivery.partition_id, delivery.offset);
1267                return Some(Ok(ConsumerMessage::of(
1268                    record.message,
1269                    record.partition_id,
1270                    record.frontier,
1271                    Some(delivery),
1272                )));
1273            }
1274            // One bounded round at a time, so an interval policy stores the
1275            // handled prefix while the partitions stay idle instead of
1276            // waiting for the next page to arrive.
1277            let page = loop {
1278                if let Err(error) = self.apply_deferred().await {
1279                    return Some(Err(error));
1280                }
1281                if let Err(error) = self.flush_if_due(CommitPoint::Poll).await {
1282                    return Some(Err(error));
1283                }
1284                match self.reader.read_round().await {
1285                    Ok((Some(page), _)) => break page,
1286                    Ok((None, true)) => {}
1287                    Ok((None, false)) => sleep(self.reader.idle_interval()).await,
1288                    Err(error) => {
1289                        self.sync_stored();
1290                        return Some(Err(error));
1291                    }
1292                }
1293            };
1294            self.sync_stored();
1295            self.buffered.extend(page.records);
1296        }
1297    }
1298
1299    async fn finish_delivery(&mut self) -> Result<(), LaserError> {
1300        if let Some((delivery, last_of_page)) = self.pending_delivery.take()
1301            && self.commit != CommitPolicy::Disabled
1302        {
1303            self.reader.handled(&delivery);
1304            self.yielded_since_flush = self.yielded_since_flush.saturating_add(1);
1305            self.pending_flush = Some(CommitPoint::Delivered { last_of_page });
1306        }
1307        if let Some(point) = self.pending_flush {
1308            self.flush_if_due(point).await?;
1309            self.pending_flush = None;
1310        }
1311        Ok(())
1312    }
1313
1314    async fn flush_if_due(&mut self, point: CommitPoint) -> Result<(), LaserError> {
1315        if !commit_due(
1316            self.commit,
1317            self.yielded_since_flush,
1318            self.last_flush.elapsed(),
1319            point,
1320        ) {
1321            return Ok(());
1322        }
1323        if let Err(error) = self.reader.flush_completed().await {
1324            let reason = error.filter_reason();
1325            if matches!(
1326                reason,
1327                Some(
1328                    laser_wire::filter::FilterErrorReason::Conflict
1329                        | laser_wire::filter::FilterErrorReason::SourceChanged
1330                )
1331            ) {
1332                self.buffered
1333                    .retain(|record| self.reader.owns_record(record));
1334                self.pending_flush = None;
1335                self.yielded_since_flush = 0;
1336            }
1337            self.sync_stored();
1338            // A policy change fences the prefix read under the old policy.
1339            // Those records are delivered again from the stored offset, so
1340            // nothing is lost and the consumer goes on without an error.
1341            if reason == Some(laser_wire::filter::FilterErrorReason::Conflict) {
1342                tracing::warn!(target: "laser", %error, "the group's policy changed, progress read under the old policy is delivered again");
1343                self.last_flush = Instant::now();
1344                return Ok(());
1345            }
1346            return Err(error);
1347        }
1348        self.yielded_since_flush = 0;
1349        self.last_flush = Instant::now();
1350        self.sync_stored();
1351        Ok(())
1352    }
1353
1354    async fn commit(&mut self, delivery: &Delivery) -> Result<(), LaserError> {
1355        self.reader.ack_through_delivery(delivery).await?;
1356        self.sync_stored();
1357        Ok(())
1358    }
1359
1360    // Acknowledge the deliveries handled since the last read, in the order
1361    // they were handled. A failed acknowledgment stays queued with the ones
1362    // after it, so the next attempt resends it. A partition this member lost
1363    // refuses its acknowledgment, and the new owner reads those records
1364    // again, so that one is dropped.
1365    async fn apply_deferred(&mut self) -> Result<(), LaserError> {
1366        loop {
1367            let next = self
1368                .deferred
1369                .lock()
1370                .unwrap_or_else(std::sync::PoisonError::into_inner)
1371                .first()
1372                .cloned();
1373            let Some(delivery) = next else {
1374                return Ok(());
1375            };
1376            match self.commit(&delivery).await {
1377                Ok(()) => {}
1378                Err(error)
1379                    if error.filter_reason()
1380                        == Some(laser_wire::filter::FilterErrorReason::MembershipStale) =>
1381                {
1382                    tracing::debug!(target: "laser", %error, partition_id = delivery.partition_id, "a handled record of a lost partition is read again by its new owner");
1383                }
1384                Err(error) => return Err(error),
1385            }
1386            self.deferred
1387                .lock()
1388                .unwrap_or_else(std::sync::PoisonError::into_inner)
1389                .remove(0);
1390        }
1391    }
1392
1393    fn sync_stored(&self) {
1394        let mut offsets = self
1395            .offsets
1396            .lock()
1397            .unwrap_or_else(std::sync::PoisonError::into_inner);
1398        offsets.stored = self.reader.stored_offsets();
1399        offsets.assigned = Some(self.reader.partitions().into_iter().collect());
1400    }
1401}
1402
1403impl Consumer {
1404    /// Wait for the next record.
1405    pub async fn next(&mut self) -> Option<Result<ConsumerMessage, LaserError>> {
1406        futures::StreamExt::next(self).await
1407    }
1408
1409    /// Return a delivery that a language binding could not hand to its caller.
1410    #[doc(hidden)]
1411    pub async fn return_delivery(&self, message: ConsumerMessage) -> Result<(), LaserError> {
1412        match self.inner.as_ref() {
1413            Some(ConsumerInner::Group(engine)) => {
1414                *self
1415                    .next_future
1416                    .lock()
1417                    .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
1418                let mut engine = engine.lock().await;
1419                let pending = engine.pending_delivery.as_ref().ok_or_else(|| {
1420                    LaserError::Invalid(
1421                        "only the latest unhandled delivery can be returned".to_owned(),
1422                    )
1423                })?;
1424                if !message
1425                    .delivery
1426                    .as_ref()
1427                    .is_some_and(|delivery| pending.0.same(delivery))
1428                {
1429                    return Err(LaserError::Invalid(
1430                        "the delivery is not the latest one from this consumer".to_owned(),
1431                    ));
1432                }
1433                let last_of_page = pending.1;
1434                engine.pending_delivery = None;
1435                engine.returned.push_front((message, last_of_page));
1436                Ok(())
1437            }
1438            Some(ConsumerInner::Native(_)) => {
1439                self.returned_native
1440                    .lock()
1441                    .unwrap_or_else(std::sync::PoisonError::into_inner)
1442                    .push_front(message);
1443                Ok(())
1444            }
1445            None => Err(LaserError::Invalid(
1446                "consumer has been shut down".to_owned(),
1447            )),
1448        }
1449    }
1450
1451    /// Wait for the next record, bounding how long a caller sits idle: a typed
1452    /// [`LaserError::Timeout`] past `wait`, a typed [`LaserError::Invalid`] if
1453    /// the stream ended, or the record itself. One call replaces threading a
1454    /// timeout, a stream-end check, and the per-record decode result through
1455    /// every call site by hand.
1456    ///
1457    /// ```no_run
1458    /// # use laser_sdk::prelude::*;
1459    /// # use std::time::Duration;
1460    /// # async fn run(mut consumer: Consumer) -> Result<(), LaserError> {
1461    /// let message = consumer.next_within(Duration::from_secs(10)).await?;
1462    /// # let _ = message; Ok(()) }
1463    /// ```
1464    pub async fn next_within(&mut self, wait: Duration) -> Result<ConsumerMessage, LaserError> {
1465        tokio::time::timeout(wait, self.next())
1466            .await
1467            .map_err(|_| LaserError::Timeout("the live consumer to yield a record"))?
1468            .ok_or_else(|| LaserError::Invalid("the live consumer stream ended".to_owned()))?
1469    }
1470
1471    /// Store a handled record's offset on the server. A policy-aware group
1472    /// consumer stores the contiguous prefix of its partition through this
1473    /// record, so earlier records yielded out of order cannot be skipped.
1474    pub async fn commit(&self, message: &ConsumerMessage) -> Result<(), LaserError> {
1475        match self.inner.as_ref() {
1476            Some(ConsumerInner::Group(engine)) => {
1477                let delivery = message.delivery.as_ref().ok_or_else(|| {
1478                    LaserError::Invalid(
1479                        "the record was not delivered by this group consumer".to_owned(),
1480                    )
1481                })?;
1482                *self
1483                    .next_future
1484                    .lock()
1485                    .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
1486                engine.lock().await.commit(delivery).await
1487            }
1488            _ => {
1489                self.store_offset(message.position.offset, Some(message.partition_id))
1490                    .await
1491            }
1492        }
1493    }
1494
1495    // Commit a handled record without waiting for a read in flight. The
1496    // group engine queues the acknowledgment and sends it before its next
1497    // read, so the read is never cancelled. A native consumer stores the
1498    // offset at once.
1499    #[cfg_attr(not(feature = "agent"), allow(dead_code))]
1500    pub(crate) async fn commit_handled(&self, message: &ConsumerMessage) -> Result<(), LaserError> {
1501        match (self.inner.as_ref(), &self.deferred) {
1502            (Some(ConsumerInner::Group(_)), Some(deferred)) => {
1503                let delivery = message.delivery.clone().ok_or_else(|| {
1504                    LaserError::Invalid(
1505                        "the record was not delivered by this group consumer".to_owned(),
1506                    )
1507                })?;
1508                deferred
1509                    .lock()
1510                    .unwrap_or_else(std::sync::PoisonError::into_inner)
1511                    .push(delivery);
1512                Ok(())
1513            }
1514            _ => self.commit(message).await,
1515        }
1516    }
1517
1518    // The partitions a policy-aware group consumer reads now. `None` for a
1519    // native consumer, whose assignment only the server knows, and before
1520    // the first read.
1521    #[cfg_attr(not(feature = "agent"), allow(dead_code))]
1522    pub(crate) fn assigned_partitions(&self) -> Option<BTreeSet<u32>> {
1523        self.offsets.as_ref().and_then(|offsets| {
1524            offsets
1525                .lock()
1526                .unwrap_or_else(std::sync::PoisonError::into_inner)
1527                .assigned
1528                .clone()
1529        })
1530    }
1531
1532    /// Store an explicit server offset. Native only: a policy-aware group
1533    /// consumer refuses it, because an arbitrary offset bypasses the group's
1534    /// acknowledgment contract. Commit a delivered record instead.
1535    pub async fn store_offset(
1536        &self,
1537        offset: u64,
1538        partition: Option<u32>,
1539    ) -> Result<(), LaserError> {
1540        self.native()?.store_offset(offset, partition).await?;
1541        Ok(())
1542    }
1543
1544    /// Delete the server offset for a partition or the current partition.
1545    /// Native only, like [`store_offset`](Self::store_offset).
1546    pub async fn delete_offset(&self, partition: Option<u32>) -> Result<(), LaserError> {
1547        self.native()?.delete_offset(partition).await?;
1548        Ok(())
1549    }
1550
1551    /// Return the last locally yielded offset for a partition.
1552    pub fn last_consumed_offset(&self, partition: u32) -> Option<u64> {
1553        match self.inner.as_ref() {
1554            Some(ConsumerInner::Native(consumer)) => consumer.get_last_consumed_offset(partition),
1555            Some(ConsumerInner::Group(_)) => self.offsets.as_ref().and_then(|offsets| {
1556                offsets
1557                    .lock()
1558                    .unwrap_or_else(std::sync::PoisonError::into_inner)
1559                    .consumed
1560                    .get(&partition)
1561                    .copied()
1562            }),
1563            None => None,
1564        }
1565    }
1566
1567    /// Return local offset bookkeeping: the native SDK's stored offset, whose
1568    /// initial zero does not prove a durable checkpoint exists, or the last
1569    /// offset a policy-aware group consumer acknowledged. Use `Next` for
1570    /// server-side resume.
1571    pub fn last_stored_offset(&self, partition: u32) -> Option<u64> {
1572        match self.inner.as_ref() {
1573            Some(ConsumerInner::Native(consumer)) => consumer.get_last_stored_offset(partition),
1574            Some(ConsumerInner::Group(_)) => self.offsets.as_ref().and_then(|offsets| {
1575                offsets
1576                    .lock()
1577                    .unwrap_or_else(std::sync::PoisonError::into_inner)
1578                    .stored
1579                    .get(&partition)
1580                    .copied()
1581            }),
1582            None => None,
1583        }
1584    }
1585
1586    /// Read the offset the server stores for this consumer, or for its group,
1587    /// on `partition`, with the partition's current head. `None` when the
1588    /// server stores nothing for it yet. A server read, unlike the local
1589    /// bookkeeping of [`last_stored_offset`](Self::last_stored_offset).
1590    pub async fn stored_offset(&self, partition: u32) -> Result<Option<StoredOffset>, LaserError> {
1591        let owner = &self.offset_owner;
1592        let info = owner
1593            .laser
1594            .client()
1595            .get_consumer_offset(
1596                &owner.consumer,
1597                &Identifier::named(&owner.stream)?,
1598                &Identifier::named(&owner.topic)?,
1599                Some(partition),
1600            )
1601            .await?;
1602        Ok(info.map(|info| StoredOffset {
1603            stored_offset: info.stored_offset,
1604            current_offset: info.current_offset,
1605        }))
1606    }
1607
1608    /// Stop polling and leave the group. Automatic policies store the handled
1609    /// prefix first. For group consumers, a clean stop marks the last
1610    /// delivery handled. Native polling commits before delivery, so its
1611    /// shutdown is not a processing checkpoint. [`CommitPolicy::Disabled`]
1612    /// preserves the last explicit commit.
1613    pub async fn shutdown(&mut self) -> Result<(), LaserError> {
1614        *self
1615            .next_future
1616            .get_mut()
1617            .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
1618        match self.inner.take() {
1619            None => Ok(()),
1620            Some(ConsumerInner::Group(engine)) => {
1621                self.shutdown_target.take();
1622                let engine = Arc::try_unwrap(engine).map_err(|_| {
1623                    LaserError::Invalid("the consumer is still being polled".to_owned())
1624                })?;
1625                let mut engine = engine.into_inner();
1626                let completed = engine.finish_delivery().await;
1627                let completed = match completed {
1628                    Ok(()) => engine.apply_deferred().await,
1629                    Err(error) => Err(error),
1630                };
1631                let closed = engine.reader.close().await;
1632                completed.and(closed)
1633            }
1634            Some(ConsumerInner::Native(mut consumer)) => {
1635                if self.manual_commit {
1636                    drop(consumer);
1637                    if let Some(target) = self.shutdown_target.take() {
1638                        target
1639                            .laser
1640                            .client()
1641                            .leave_consumer_group(
1642                                &Identifier::try_from(target.stream)?,
1643                                &Identifier::try_from(target.topic)?,
1644                                &Identifier::try_from(target.group)?,
1645                            )
1646                            .await?;
1647                    }
1648                    return Ok(());
1649                }
1650                let mut stored = Ok(());
1651                for partition in &self.yielded_zero {
1652                    if consumer
1653                        .get_last_stored_offset(*partition)
1654                        .is_none_or(|offset| offset == 0)
1655                    {
1656                        // The native shutdown treats an unset stored offset as zero.
1657                        // Explicitly store a delivered zero for automatic policies.
1658                        stored = stored.and(consumer.store_offset(0, Some(*partition)).await);
1659                    }
1660                }
1661                let stopped = consumer.shutdown().await;
1662                self.shutdown_target.take();
1663                stored.and(stopped).map_err(Into::into)
1664            }
1665        }
1666    }
1667
1668    fn native(&self) -> Result<&IggyConsumer, LaserError> {
1669        match self.inner.as_ref() {
1670            Some(ConsumerInner::Native(consumer)) => Ok(consumer),
1671            Some(ConsumerInner::Group(_)) => Err(LaserError::Invalid(
1672                "explicit offsets bypass the group's acknowledgment contract, commit a delivered record instead"
1673                    .to_owned(),
1674            )),
1675            None => Err(LaserError::Invalid(
1676                "consumer has been shut down".to_owned(),
1677            )),
1678        }
1679    }
1680}
1681
1682impl Stream for Consumer {
1683    type Item = Result<ConsumerMessage, LaserError>;
1684
1685    fn poll_next(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<Option<Self::Item>> {
1686        if matches!(self.inner, Some(ConsumerInner::Native(_)))
1687            && let Some(message) = self
1688                .returned_native
1689                .get_mut()
1690                .unwrap_or_else(std::sync::PoisonError::into_inner)
1691                .pop_front()
1692        {
1693            return Poll::Ready(Some(Ok(message)));
1694        }
1695        let engine = match self.inner.as_mut() {
1696            None => return Poll::Ready(None),
1697            Some(ConsumerInner::Native(inner)) => {
1698                return match Pin::new(inner).poll_next(context) {
1699                    Poll::Ready(Some(Ok(message))) => {
1700                        let message = ConsumerMessage::from(message);
1701                        if message.position.offset == 0 {
1702                            self.yielded_zero.insert(message.partition_id);
1703                        } else {
1704                            self.yielded_zero.remove(&message.partition_id);
1705                        }
1706                        Poll::Ready(Some(Ok(message)))
1707                    }
1708                    Poll::Ready(Some(Err(error))) => Poll::Ready(Some(Err(error.into()))),
1709                    Poll::Ready(None) => Poll::Ready(None),
1710                    Poll::Pending => Poll::Pending,
1711                };
1712            }
1713            Some(ConsumerInner::Group(engine)) => Arc::clone(engine),
1714        };
1715        let in_flight = self
1716            .next_future
1717            .get_mut()
1718            .unwrap_or_else(std::sync::PoisonError::into_inner);
1719        let next = in_flight
1720            .get_or_insert_with(|| Box::pin(async move { engine.lock().await.next().await }));
1721        match next.as_mut().poll(context) {
1722            Poll::Ready(item) => {
1723                *in_flight = None;
1724                Poll::Ready(item)
1725            }
1726            Poll::Pending => Poll::Pending,
1727        }
1728    }
1729}
1730
1731#[cfg(test)]
1732mod tests {
1733    use super::*;
1734
1735    fn producer_builder() -> ProducerBuilder {
1736        let laser = crate::laser::Laser::from_client(iggy::prelude::IggyClient::default());
1737        laser.stream("fleet").topic("readings").producer()
1738    }
1739
1740    #[tokio::test]
1741    async fn given_both_expiry_settings_when_building_a_producer_then_should_refuse_it() {
1742        let result = producer_builder()
1743            .expire_after(Duration::from_secs(60))
1744            .never_expire()
1745            .build()
1746            .await;
1747        assert!(
1748            matches!(result, Err(LaserError::Invalid(message)) if message.contains("not both"))
1749        );
1750        let result = producer_builder()
1751            .never_expire()
1752            .expire_after(Duration::from_secs(60))
1753            .build()
1754            .await;
1755        assert!(
1756            matches!(result, Err(LaserError::Invalid(message)) if message.contains("not both"))
1757        );
1758    }
1759
1760    #[tokio::test]
1761    async fn given_both_size_settings_when_building_a_producer_then_should_refuse_it() {
1762        let result = producer_builder()
1763            .max_topic_bytes(1_048_576)
1764            .unlimited_topic_size()
1765            .build()
1766            .await;
1767        assert!(
1768            matches!(result, Err(LaserError::Invalid(message)) if message.contains("not both"))
1769        );
1770    }
1771
1772    #[tokio::test]
1773    async fn given_a_zero_expiry_or_size_when_building_a_producer_then_should_refuse_it() {
1774        let result = producer_builder()
1775            .expire_after(Duration::ZERO)
1776            .build()
1777            .await;
1778        assert!(matches!(result, Err(LaserError::Invalid(_))));
1779        let result = producer_builder().max_topic_bytes(0).build().await;
1780        assert!(matches!(result, Err(LaserError::Invalid(_))));
1781    }
1782
1783    #[test]
1784    fn given_one_form_of_each_setting_when_resolved_then_should_map_to_the_topic_limits() {
1785        let (expiry, size) = producer_builder()
1786            .never_expire()
1787            .unlimited_topic_size()
1788            .topic_limits()
1789            .expect("one form each");
1790        assert_eq!(expiry, IggyExpiry::NeverExpire);
1791        assert_eq!(size, MaxTopicSize::Unlimited);
1792        let (expiry, size) = producer_builder().topic_limits().expect("defaults");
1793        assert_eq!(expiry, IggyExpiry::ServerDefault);
1794        assert_eq!(size, MaxTopicSize::ServerDefault);
1795    }
1796
1797    #[test]
1798    fn given_a_consumer_when_shared_across_tasks_then_should_be_send_and_sync() {
1799        fn shared<T: Send + Sync>() {}
1800        shared::<Consumer>();
1801        shared::<ConsumerBuilder>();
1802        shared::<ConsumerMessage>();
1803    }
1804
1805    #[test]
1806    fn given_a_group_handle_when_its_stored_offset_is_addressed_then_should_name_the_group() {
1807        let laser = crate::laser::Laser::from_client(iggy::prelude::IggyClient::default());
1808        let topic = laser.stream("fleet").topic("readings");
1809        let named = group_offset_consumer(&topic.consumer_group("workers")).expect("named group");
1810        assert_eq!(named.kind, iggy::prelude::ConsumerKind::ConsumerGroup);
1811        assert_eq!(named.id, Identifier::named("workers").expect("valid name"));
1812        let numeric = group_offset_consumer(&topic.consumer_group_id(7)).expect("numeric group");
1813        assert_eq!(numeric.id, Identifier::numeric(7).expect("valid id"));
1814        assert!(matches!(
1815            group_offset_consumer(&topic.consumer_group_id(u64::from(u32::MAX) + 1)),
1816            Err(LaserError::Invalid(_))
1817        ));
1818    }
1819
1820    #[test]
1821    fn given_a_server_with_group_reads_when_classified_then_should_use_the_group_engine() {
1822        let capabilities = Capabilities::OPEN.with_filters(true, true);
1823        assert!(policy_aware(&capabilities).expect("classified"));
1824    }
1825
1826    #[test]
1827    fn given_group_reads_without_an_evaluator_when_classified_then_should_preserve_group_policy() {
1828        let mut capabilities = Capabilities::OPEN;
1829        capabilities.filters.group_policy_reads = true;
1830        assert!(policy_aware(&capabilities).expect("group policy resolves without evaluation"));
1831    }
1832
1833    #[test]
1834    fn given_a_server_serving_filters_without_group_reads_when_classified_then_should_require_an_upgrade()
1835     {
1836        let mut capabilities = Capabilities::OPEN.with_filters(true, true);
1837        capabilities.filters.group_policy_reads = false;
1838        let error = policy_aware(&capabilities).expect_err("an old server is refused");
1839        assert!(matches!(
1840            error,
1841            LaserError::Unsupported {
1842                feature: Some("group_policy_reads"),
1843                ..
1844            }
1845        ));
1846    }
1847
1848    #[test]
1849    fn given_a_probe_that_established_nothing_when_classified_then_should_not_read_natively() {
1850        let mut capabilities = Capabilities::OPEN;
1851        for hello in [HelloOutcome::Unknown, HelloOutcome::Failed] {
1852            capabilities.hello = hello;
1853            assert!(matches!(
1854                policy_aware(&capabilities),
1855                Err(LaserError::Timeout(_))
1856            ));
1857        }
1858        for hello in [HelloOutcome::Rejected, HelloOutcome::Answered] {
1859            capabilities.hello = hello;
1860            assert!(
1861                !policy_aware(&capabilities).expect("positively absent"),
1862                "{hello:?} without filters reads natively"
1863            );
1864        }
1865    }
1866
1867    #[test]
1868    fn given_each_commit_policy_when_a_record_is_yielded_then_should_store_on_its_own_cadence() {
1869        let second = Duration::from_secs(1);
1870        let middle = CommitPoint::Delivered {
1871            last_of_page: false,
1872        };
1873        let last = CommitPoint::Delivered { last_of_page: true };
1874        assert!(!commit_due(CommitPolicy::Disabled, 5, second, last));
1875        assert!(!commit_due(
1876            CommitPolicy::Disabled,
1877            5,
1878            second,
1879            CommitPoint::Poll
1880        ));
1881        assert!(commit_due(CommitPolicy::Each, 1, Duration::ZERO, middle));
1882        assert!(!commit_due(
1883            CommitPolicy::Each,
1884            1,
1885            Duration::ZERO,
1886            CommitPoint::Poll
1887        ));
1888        assert!(commit_due(CommitPolicy::All, 1, Duration::ZERO, last));
1889        assert!(!commit_due(CommitPolicy::All, 1, Duration::ZERO, middle));
1890        assert!(commit_due(
1891            CommitPolicy::Every(3),
1892            3,
1893            Duration::ZERO,
1894            middle
1895        ));
1896        assert!(!commit_due(
1897            CommitPolicy::Every(3),
1898            2,
1899            Duration::ZERO,
1900            middle
1901        ));
1902        assert!(commit_due(
1903            CommitPolicy::Interval(second),
1904            0,
1905            second,
1906            CommitPoint::Poll
1907        ));
1908        assert!(!commit_due(
1909            CommitPolicy::Interval(second),
1910            9,
1911            Duration::from_millis(999),
1912            last
1913        ));
1914        assert!(commit_due(
1915            CommitPolicy::IntervalOrEvery(second, 10),
1916            10,
1917            Duration::ZERO,
1918            middle
1919        ));
1920        assert!(commit_due(
1921            CommitPolicy::IntervalOrAll(second),
1922            1,
1923            Duration::ZERO,
1924            last
1925        ));
1926    }
1927
1928    #[test]
1929    fn given_the_polling_policy_when_the_next_poll_starts_then_should_store_the_delivered_prefix() {
1930        let last = CommitPoint::Delivered { last_of_page: true };
1931        assert!(
1932            !commit_due(CommitPolicy::Polling, 4, Duration::ZERO, last),
1933            "a delivered record alone stores nothing"
1934        );
1935        assert!(commit_due(
1936            CommitPolicy::Polling,
1937            4,
1938            Duration::ZERO,
1939            CommitPoint::Poll
1940        ));
1941        assert!(
1942            !commit_due(CommitPolicy::Polling, 0, Duration::ZERO, CommitPoint::Poll),
1943            "an idle poll has nothing to store"
1944        );
1945        assert!(commit_due(
1946            CommitPolicy::IntervalOrPolling(Duration::from_secs(1)),
1947            0,
1948            Duration::from_secs(1),
1949            last
1950        ));
1951    }
1952
1953    #[test]
1954    fn given_every_start_when_mapped_to_a_group_read_then_should_keep_its_meaning() {
1955        assert_eq!(ConsumerStart::First.into_filtered(), FilteredStart::First);
1956        assert_eq!(ConsumerStart::Last.into_filtered(), FilteredStart::Last);
1957        assert_eq!(ConsumerStart::Next.into_filtered(), FilteredStart::Next);
1958        assert_eq!(
1959            ConsumerStart::Offset(7).into_filtered(),
1960            FilteredStart::Offset(7)
1961        );
1962        assert_eq!(
1963            ConsumerStart::TimestampMicros(9).into_filtered(),
1964            FilteredStart::Timestamp(9)
1965        );
1966    }
1967
1968    #[test]
1969    fn given_zero_frequency_when_building_commit_policy_then_should_reject_it() {
1970        let error = CommitPolicy::Every(0)
1971            .into_auto_commit()
1972            .expect_err("zero cannot be a commit frequency");
1973        assert!(matches!(error, LaserError::Invalid(_)));
1974    }
1975
1976    #[test]
1977    fn given_zero_interval_when_building_commit_policy_then_should_reject_it() {
1978        let error = CommitPolicy::Interval(Duration::ZERO)
1979            .into_auto_commit()
1980            .expect_err("zero cannot be a commit interval");
1981        assert!(matches!(error, LaserError::Invalid(_)));
1982    }
1983
1984    #[test]
1985    fn given_empty_key_when_building_routing_then_should_reject_it() {
1986        let error = Routing::key(Vec::new())
1987            .into_partitioning()
1988            .expect_err("an empty partition key should be rejected");
1989        assert!(matches!(error, LaserError::Iggy(_)));
1990    }
1991
1992    #[test]
1993    fn given_typed_header_when_building_message_then_should_preserve_width() {
1994        let key = HeaderKey::try_from("type").expect("the header key should be valid");
1995        let message = ProducerMessage::new(b"event".to_vec())
1996            .header(key.clone(), HeaderValue::from(7_u16))
1997            .into_iggy()
1998            .expect("the producer message should encode");
1999        let headers = message
2000            .user_headers_map()
2001            .expect("the headers should decode")
2002            .expect("the message should carry headers");
2003        assert_eq!(
2004            headers
2005                .get(&key)
2006                .expect("the type header should exist")
2007                .as_uint16()
2008                .expect("the type header should remain uint16"),
2009            7
2010        );
2011    }
2012
2013    #[test]
2014    fn given_bytes_payload_when_lowered_to_iggy_then_should_preserve_pointer_identity() {
2015        let payload = Bytes::from_static(&[0x42; 4096]);
2016        let message = ProducerMessage::new(payload.clone())
2017            .into_iggy()
2018            .expect("the producer message should lower to Iggy");
2019
2020        assert_eq!(message.payload.as_ptr(), payload.as_ptr());
2021    }
2022}