hubcio commented on code in PR #3913:
URL: https://github.com/apache/iggy/pull/3913#discussion_r3804116395


##########
core/sdk/src/clients/consumer.rs:
##########
@@ -100,6 +100,316 @@ pub enum AutoCommitAfter {
 // 4. All `&self` methods only access Sync-safe fields
 unsafe impl Sync for IggyConsumer {}
 
+/// Reads messages from the partitions of one topic and yields them one at a 
time.
+///
+/// A topic is split into partitions, and a partition is an ordered log that 
producers append to.
+/// Every message sits at an *offset*, its position in that log. Reading is 
therefore always the
+/// same three decisions: which partition to read, where in it to start, and 
how to keep track of
+/// how far you got so the next run can continue there.
+///
+/// `IggyConsumer` handles all three. It fetches batches of messages from the 
server, keeps them in
+/// an in-memory buffer, decrypts them when the client is configured with an 
encryptor, and records
+/// how far it has read. **It implements [`Stream`], so consuming is a loop 
over [`StreamExt::next`].**
+///
+/// You can use a consumer as a worker draining a topic, a reader that replays 
a
+/// partition from a chosen point, and a pool of consumers sharing a workload 
through a consumer
+/// group.
+///
+/// # Creating a consumer
+///
+/// Easiest way is to use the [`IggyClient`] with a configured connection. 
Then:
+/// - [`IggyClient::consumer()`] builds a standalone consumer, bound to the 
one partition passed
+///   in.
+/// - [`IggyClient::consumer_group()`] builds a member of a consumer group. 
The server gives every
+///   partition to exactly one member, so several consumers using the same 
group name split the
+///   topic between them and share one set of offsets.
+///
+/// Note, building never talks to the server. [`init()`](Self::init) must be 
awaited once before the
+/// first message is read.
+///
+/// # Examples
+///
+/// A standalone consumer reading partition 1 with the defaults:
+///
+/// ```rust,no_run
+/// use futures_util::StreamExt;
+/// use iggy::prelude::*;
+///
+/// # async fn example() -> Result<(), IggyError> {
+/// let client = 
IggyClient::from_connection_string("iggy://iggy:iggy@localhost:8090")?;
+/// client.connect().await?;
+///
+/// let mut consumer = client
+///     .consumer("my-consumer", "my-stream", "my-topic", 1)?
+///     .batch_length(100)
+///     .poll_interval(IggyDuration::new_from_secs(1))
+///     .build();
+/// consumer.init().await?;
+///
+/// while let Some(received) = consumer.next().await {
+///     match received {
+///         Ok(received) => println!("Offset: {}", 
received.message.header.offset),
+///         Err(error) => eprintln!("Failed to read a message: {error}"),
+///     }
+/// }
+/// # Ok(())
+/// # }
+/// ```
+///
+/// A group member that commits every message right after it is handed over, 
and shuts down
+/// cleanly:
+///
+/// ```rust,no_run
+/// use futures_util::StreamExt;
+/// use iggy::prelude::*;
+///
+/// # async fn handle(message: &IggyMessage) {}
+/// # async fn example() -> Result<(), IggyError> {
+/// let client = 
IggyClient::from_connection_string("iggy://iggy:iggy@localhost:8090")?;
+/// client.connect().await?;
+///
+/// let mut consumer = client
+///     .consumer_group("order-workers", "my-stream", "my-topic")?
+///     .auto_commit(AutoCommit::When(AutoCommitWhen::ConsumingEachMessage))
+///     .polling_strategy(PollingStrategy::next())
+///     .build();
+/// consumer.init().await?;
+///
+/// let mut consumed = 0;
+/// while let Some(received) = consumer.next().await {
+///     handle(&received?.message).await;
+///     consumed += 1;
+///     if consumed == 100 {
+///         break;
+///     }
+/// }
+///
+/// consumer.shutdown().await?;
+/// # Ok(())
+/// # }
+/// ```
+///
+/// Committing by hand, so that a message the handler could not process comes 
back:

Review Comment:
   the example doesn't deliver this promise - if message N fails but N+1 
succeeds, `store_offset(N+1)` marks N consumed (it's a cumulative watermark), 
so N never comes back, in-process or after restart. it only works if you stop 
on failure. either break on error or call out the cumulative-commit caveat.



##########
core/sdk/src/clients/consumer.rs:
##########
@@ -100,6 +100,316 @@ pub enum AutoCommitAfter {
 // 4. All `&self` methods only access Sync-safe fields
 unsafe impl Sync for IggyConsumer {}
 
+/// Reads messages from the partitions of one topic and yields them one at a 
time.
+///
+/// A topic is split into partitions, and a partition is an ordered log that 
producers append to.
+/// Every message sits at an *offset*, its position in that log. Reading is 
therefore always the
+/// same three decisions: which partition to read, where in it to start, and 
how to keep track of
+/// how far you got so the next run can continue there.
+///
+/// `IggyConsumer` handles all three. It fetches batches of messages from the 
server, keeps them in
+/// an in-memory buffer, decrypts them when the client is configured with an 
encryptor, and records
+/// how far it has read. **It implements [`Stream`], so consuming is a loop 
over [`StreamExt::next`].**
+///
+/// You can use a consumer as a worker draining a topic, a reader that replays 
a
+/// partition from a chosen point, and a pool of consumers sharing a workload 
through a consumer
+/// group.
+///
+/// # Creating a consumer
+///
+/// Easiest way is to use the [`IggyClient`] with a configured connection. 
Then:
+/// - [`IggyClient::consumer()`] builds a standalone consumer, bound to the 
one partition passed
+///   in.
+/// - [`IggyClient::consumer_group()`] builds a member of a consumer group. 
The server gives every
+///   partition to exactly one member, so several consumers using the same 
group name split the
+///   topic between them and share one set of offsets.
+///
+/// Note, building never talks to the server. [`init()`](Self::init) must be 
awaited once before the
+/// first message is read.
+///
+/// # Examples
+///
+/// A standalone consumer reading partition 1 with the defaults:
+///
+/// ```rust,no_run
+/// use futures_util::StreamExt;
+/// use iggy::prelude::*;
+///
+/// # async fn example() -> Result<(), IggyError> {
+/// let client = 
IggyClient::from_connection_string("iggy://iggy:iggy@localhost:8090")?;
+/// client.connect().await?;
+///
+/// let mut consumer = client
+///     .consumer("my-consumer", "my-stream", "my-topic", 1)?
+///     .batch_length(100)
+///     .poll_interval(IggyDuration::new_from_secs(1))
+///     .build();
+/// consumer.init().await?;
+///
+/// while let Some(received) = consumer.next().await {
+///     match received {
+///         Ok(received) => println!("Offset: {}", 
received.message.header.offset),
+///         Err(error) => eprintln!("Failed to read a message: {error}"),
+///     }
+/// }
+/// # Ok(())
+/// # }
+/// ```
+///
+/// A group member that commits every message right after it is handed over, 
and shuts down
+/// cleanly:
+///
+/// ```rust,no_run
+/// use futures_util::StreamExt;
+/// use iggy::prelude::*;
+///
+/// # async fn handle(message: &IggyMessage) {}
+/// # async fn example() -> Result<(), IggyError> {
+/// let client = 
IggyClient::from_connection_string("iggy://iggy:iggy@localhost:8090")?;
+/// client.connect().await?;
+///
+/// let mut consumer = client
+///     .consumer_group("order-workers", "my-stream", "my-topic")?
+///     .auto_commit(AutoCommit::When(AutoCommitWhen::ConsumingEachMessage))
+///     .polling_strategy(PollingStrategy::next())
+///     .build();
+/// consumer.init().await?;
+///
+/// let mut consumed = 0;
+/// while let Some(received) = consumer.next().await {
+///     handle(&received?.message).await;
+///     consumed += 1;
+///     if consumed == 100 {
+///         break;
+///     }
+/// }
+///
+/// consumer.shutdown().await?;
+/// # Ok(())
+/// # }
+/// ```
+///
+/// Committing by hand, so that a message the handler could not process comes 
back:
+///
+/// ```rust,no_run
+/// use futures_util::StreamExt;
+/// use iggy::prelude::*;
+///
+/// # async fn handle(message: &IggyMessage) -> Result<(), IggyError> { Ok(()) 
}
+/// # async fn example() -> Result<(), IggyError> {
+/// let client = 
IggyClient::from_connection_string("iggy://iggy:iggy@localhost:8090")?;
+/// client.connect().await?;
+///
+/// let mut consumer = client
+///     .consumer("my-consumer", "my-stream", "my-topic", 1)?
+///     .auto_commit(AutoCommit::Disabled)
+///     .polling_strategy(PollingStrategy::next())
+///     // Without this, messages already handed over once are never handed 
over again.
+///     .allow_replay()
+///     .build();
+/// consumer.init().await?;
+///
+/// while let Some(received) = consumer.next().await {
+///     let received = received?;
+///     if handle(&received.message).await.is_ok() {
+///         consumer
+///             .store_offset(received.message.header.offset, 
Some(received.partition_id))
+///             .await?;
+///     }
+/// }
+/// # Ok(())
+/// # }
+/// ```
+///
+/// # Which partitions are read
+///
+/// A **standalone consumer** reads exactly one partition, the one passed to
+/// [`IggyClient::consumer()`]. Covering a whole topic with several partitions
+/// means running one consumer per partition and dividing the work yourself.
+///
+/// A **consumer group member** does not choose. The server hands every 
partition of the topic to
+/// exactly one member, so consumers sharing a group name split the topic 
between them without
+/// coordinating. [`partition_id()`](Self::partition_id) reports where the 
last message came from.
+///
+/// What to know when working with consumer groups:
+/// - A member joins during [`init()`](Self::init), creating the group first if
+///   [`create_consumer_group_if_not_exists()`] is set (the default). It 
rejoins on its own after a
+///   reconnect and whenever the server reports that its membership is gone.
+/// - Until the join has succeeded the consumer does not poll. It waits for
+///   [`polling_retry_interval()`] and tries again.
+/// - Partitions are redistributed whenever members join or leave, so a member 
reads different
+///   partitions over time and messages from several partitions interleave in 
its stream.
+/// - More members than partitions leaves the surplus members idle. The 
partition count of the
+///   topic is the ceiling on how far one group can be scaled out.
+/// - The group shares one set of stored offsets, kept under the group name. 
Thus,
+///   a partition taken over by another member continues where the previous one
+///   committed.
+///
+/// # How messages are read
+///
+/// Reading is done by polling. One request fetches up to [`batch_length()`] 
messages. The consumer
+/// passes the first one to the caller and buffers the rest. The next request 
is sent once that buffer
+/// is empty.
+///
+/// [`poll_interval()`] sets a timeout between two requests should the buffer 
be empty.
+/// Without it the next request goes out as soon as the previous one is
+/// answered, which is the fastest option but keeps a busy loop running 
against an idle topic.
+///
+/// [`polling_strategy()`] decides **where** in the partition reading begins:
+///
+/// | Strategy | Starts at |
+/// | --- | --- |
+/// | [`PollingStrategy::next()`] (default) | the message after the offset 
that is stored on the server |
+/// | [`PollingStrategy::first()`] | the oldest message in the partition |
+/// | [`PollingStrategy::last()`] | the end of the partition (returns up to 
[`batch_length()`] of the most recent messages) |
+/// | [`PollingStrategy::offset()`] | a custom offset |
+/// | [`PollingStrategy::timestamp()`] | the first message at or after a given 
point in time |
+///
+/// Only [`PollingStrategy::next()`] consults the offset stored on the server.
+/// Use this if you want to resume where a previous run stopped. The other 
four are starting points for the first request only.
+/// From the second request onwards, the consumer asks for whatever follows it.
+///
+/// Note, when polling, [`StreamExt::next`] never returns `None`, not when the 
topic is empty
+/// and not while the client is disconnected. A `while let Some(..)` loop only 
ends when the
+/// loop body breaks out of it. A request that comes back empty is not an 
error and not the end of
+/// the stream, it just means nothing new has arrived yet.
+///
+/// A failed request is yielded as `Some(Err(..))` and leaves the consumer 
usable, while the next call
+/// retries. Connection and authentication failures pause polling until the 
client has reconnected
+/// and signed in again, which the consumer handles automatically. Hence, 
deciding when to give up on
+/// repeated errors is up to you.
+///
+/// For a boilerplate implementation of such a loop Iggy provides 
[`IggyConsumerMessageExt::consume_messages`].
+///
+/// # Tracking what has been read
+///
+/// An offset is the index tracking what has been already read from a 
partition by the consumer.
+/// Managing the offset has implications on where consumers resume reading 
messages.
+///
+/// There are two positions (offsets) tracked in two different places:
+/// - The **reading position** is held by the consumer, one per partition, and 
is the offset of the
+///   last message handed over
+///   ([`get_last_consumed_offset()`](Self::get_last_consumed_offset)). It 
dies with the process.
+/// - The **stored offset** lives on the server under the consumer name, or 
the group name for a
+///   group. This offset survives restarts. Writing it is called *storing* or 
*committing* an offset.
+///
+/// Committing matters because [`PollingStrategy::next()`] resumes from the 
stored offset. A
+/// consumer that never commits keeps starting over from the same place.
+/// Note, *committing* is a request of its own, not a side effect of reading 
and therefore controllable.
+///
+/// [`auto_commit()`] decides when the consumer commits by itself:
+///
+/// | Setting | Commits |
+/// | --- | --- |
+/// | [`AutoCommit::Disabled`] | never on its own, decide manually with 
[`store_offset()`](Self::store_offset) or on [`shutdown()`](Self::shutdown) |
+/// | [`AutoCommit::Interval`] | on every tick, for every partition read so 
far |
+/// | [`AutoCommitWhen::PollingMessages`] | with the poll request itself, 
before your code sees the batch |
+/// | [`AutoCommitWhen::ConsumingEachMessage`] | after every message was 
handed over to the calling code |
+/// | [`AutoCommitWhen::ConsumingEveryNthMessage`] | when the offset of a 
message handed over divides by `n` |
+/// | [`AutoCommitWhen::ConsumingAllMessages`] | when the buffer of the 
current batch runs empty |
+/// | [`AutoCommitAfter`] variants | as their [`AutoCommitWhen`] counterparts, 
but once the handler returned, and only under 
[`IggyConsumerMessageExt::consume_messages`] |
+///
+/// [`AutoCommit::IntervalOrWhen`] and [`AutoCommit::IntervalOrAfter`] combine 
an interval with a
+/// message trigger. The default is [`AutoCommit::IntervalOrWhen`] with one 
second and
+/// [`AutoCommitWhen::PollingMessages`].
+/// Important implications of these defaults:
+/// - [`AutoCommitWhen::PollingMessages`] marks a batch as consumed while it 
is being delivered,
+///   before your code has seen any of it. If a crash must not skip messages, 
commit after handling
+///   with [`AutoCommitWhen::ConsumingEachMessage`] instead.
+/// - [`AutoCommitWhen::ConsumingEveryNthMessage`] tests the offset of a 
message, not a counter of
+///   messages this process handled, so it commits at every `n`-th offset of 
the partition.
+///
+/// ## Guarantees
+///
+/// - **Each message is handed over once per consumer.** Messages whose offset 
is not greater than
+///   the reading position of their partition are dropped before they reach 
the stream. Re-reading
+///   a partition, or letting a message come back because your handler failed, 
needs
+///   [`allow_replay()`], which turns that filter off.
+/// - **Delivery is at-least-once.** A crash between handling a message and 
committing its offset

Review Comment:
   "at-least-once" isn't true for the default config - `PollingMessages` 
commits the whole batch as part of the poll request, before your code sees any 
of it, so a crash mid-batch skips the rest. same for `ConsumingEachMessage` 
(see above). at-least-once only holds when committing after handling - worth 
qualifying, since this is the guarantees section.



##########
core/sdk/src/clients/consumer.rs:
##########
@@ -100,6 +100,316 @@ pub enum AutoCommitAfter {
 // 4. All `&self` methods only access Sync-safe fields
 unsafe impl Sync for IggyConsumer {}
 
+/// Reads messages from the partitions of one topic and yields them one at a 
time.
+///
+/// A topic is split into partitions, and a partition is an ordered log that 
producers append to.
+/// Every message sits at an *offset*, its position in that log. Reading is 
therefore always the
+/// same three decisions: which partition to read, where in it to start, and 
how to keep track of
+/// how far you got so the next run can continue there.
+///
+/// `IggyConsumer` handles all three. It fetches batches of messages from the 
server, keeps them in
+/// an in-memory buffer, decrypts them when the client is configured with an 
encryptor, and records
+/// how far it has read. **It implements [`Stream`], so consuming is a loop 
over [`StreamExt::next`].**
+///
+/// You can use a consumer as a worker draining a topic, a reader that replays 
a
+/// partition from a chosen point, and a pool of consumers sharing a workload 
through a consumer
+/// group.
+///
+/// # Creating a consumer
+///
+/// Easiest way is to use the [`IggyClient`] with a configured connection. 
Then:
+/// - [`IggyClient::consumer()`] builds a standalone consumer, bound to the 
one partition passed
+///   in.
+/// - [`IggyClient::consumer_group()`] builds a member of a consumer group. 
The server gives every
+///   partition to exactly one member, so several consumers using the same 
group name split the
+///   topic between them and share one set of offsets.
+///
+/// Note, building never talks to the server. [`init()`](Self::init) must be 
awaited once before the
+/// first message is read.
+///
+/// # Examples
+///
+/// A standalone consumer reading partition 1 with the defaults:
+///
+/// ```rust,no_run
+/// use futures_util::StreamExt;
+/// use iggy::prelude::*;
+///
+/// # async fn example() -> Result<(), IggyError> {
+/// let client = 
IggyClient::from_connection_string("iggy://iggy:iggy@localhost:8090")?;
+/// client.connect().await?;
+///
+/// let mut consumer = client
+///     .consumer("my-consumer", "my-stream", "my-topic", 1)?
+///     .batch_length(100)
+///     .poll_interval(IggyDuration::new_from_secs(1))
+///     .build();
+/// consumer.init().await?;
+///
+/// while let Some(received) = consumer.next().await {
+///     match received {
+///         Ok(received) => println!("Offset: {}", 
received.message.header.offset),
+///         Err(error) => eprintln!("Failed to read a message: {error}"),
+///     }
+/// }
+/// # Ok(())
+/// # }
+/// ```
+///
+/// A group member that commits every message right after it is handed over, 
and shuts down
+/// cleanly:
+///
+/// ```rust,no_run
+/// use futures_util::StreamExt;
+/// use iggy::prelude::*;
+///
+/// # async fn handle(message: &IggyMessage) {}
+/// # async fn example() -> Result<(), IggyError> {
+/// let client = 
IggyClient::from_connection_string("iggy://iggy:iggy@localhost:8090")?;
+/// client.connect().await?;
+///
+/// let mut consumer = client
+///     .consumer_group("order-workers", "my-stream", "my-topic")?
+///     .auto_commit(AutoCommit::When(AutoCommitWhen::ConsumingEachMessage))
+///     .polling_strategy(PollingStrategy::next())
+///     .build();
+/// consumer.init().await?;
+///
+/// let mut consumed = 0;
+/// while let Some(received) = consumer.next().await {
+///     handle(&received?.message).await;
+///     consumed += 1;
+///     if consumed == 100 {
+///         break;
+///     }
+/// }
+///
+/// consumer.shutdown().await?;
+/// # Ok(())
+/// # }
+/// ```
+///
+/// Committing by hand, so that a message the handler could not process comes 
back:
+///
+/// ```rust,no_run
+/// use futures_util::StreamExt;
+/// use iggy::prelude::*;
+///
+/// # async fn handle(message: &IggyMessage) -> Result<(), IggyError> { Ok(()) 
}
+/// # async fn example() -> Result<(), IggyError> {
+/// let client = 
IggyClient::from_connection_string("iggy://iggy:iggy@localhost:8090")?;
+/// client.connect().await?;
+///
+/// let mut consumer = client
+///     .consumer("my-consumer", "my-stream", "my-topic", 1)?
+///     .auto_commit(AutoCommit::Disabled)
+///     .polling_strategy(PollingStrategy::next())
+///     // Without this, messages already handed over once are never handed 
over again.
+///     .allow_replay()
+///     .build();
+/// consumer.init().await?;
+///
+/// while let Some(received) = consumer.next().await {
+///     let received = received?;
+///     if handle(&received.message).await.is_ok() {
+///         consumer
+///             .store_offset(received.message.header.offset, 
Some(received.partition_id))
+///             .await?;
+///     }
+/// }
+/// # Ok(())
+/// # }
+/// ```
+///
+/// # Which partitions are read
+///
+/// A **standalone consumer** reads exactly one partition, the one passed to
+/// [`IggyClient::consumer()`]. Covering a whole topic with several partitions
+/// means running one consumer per partition and dividing the work yourself.
+///
+/// A **consumer group member** does not choose. The server hands every 
partition of the topic to
+/// exactly one member, so consumers sharing a group name split the topic 
between them without
+/// coordinating. [`partition_id()`](Self::partition_id) reports where the 
last message came from.
+///
+/// What to know when working with consumer groups:
+/// - A member joins during [`init()`](Self::init), creating the group first if
+///   [`create_consumer_group_if_not_exists()`] is set (the default). It 
rejoins on its own after a
+///   reconnect and whenever the server reports that its membership is gone.
+/// - Until the join has succeeded the consumer does not poll. It waits for
+///   [`polling_retry_interval()`] and tries again.
+/// - Partitions are redistributed whenever members join or leave, so a member 
reads different
+///   partitions over time and messages from several partitions interleave in 
its stream.
+/// - More members than partitions leaves the surplus members idle. The 
partition count of the
+///   topic is the ceiling on how far one group can be scaled out.
+/// - The group shares one set of stored offsets, kept under the group name. 
Thus,
+///   a partition taken over by another member continues where the previous one
+///   committed.
+///
+/// # How messages are read
+///
+/// Reading is done by polling. One request fetches up to [`batch_length()`] 
messages. The consumer
+/// passes the first one to the caller and buffers the rest. The next request 
is sent once that buffer
+/// is empty.
+///
+/// [`poll_interval()`] sets a timeout between two requests should the buffer 
be empty.
+/// Without it the next request goes out as soon as the previous one is
+/// answered, which is the fastest option but keeps a busy loop running 
against an idle topic.
+///
+/// [`polling_strategy()`] decides **where** in the partition reading begins:
+///
+/// | Strategy | Starts at |
+/// | --- | --- |
+/// | [`PollingStrategy::next()`] (default) | the message after the offset 
that is stored on the server |
+/// | [`PollingStrategy::first()`] | the oldest message in the partition |
+/// | [`PollingStrategy::last()`] | the end of the partition (returns up to 
[`batch_length()`] of the most recent messages) |
+/// | [`PollingStrategy::offset()`] | a custom offset |
+/// | [`PollingStrategy::timestamp()`] | the first message at or after a given 
point in time |
+///
+/// Only [`PollingStrategy::next()`] consults the offset stored on the server.
+/// Use this if you want to resume where a previous run stopped. The other 
four are starting points for the first request only.
+/// From the second request onwards, the consumer asks for whatever follows it.
+///
+/// Note, when polling, [`StreamExt::next`] never returns `None`, not when the 
topic is empty
+/// and not while the client is disconnected. A `while let Some(..)` loop only 
ends when the
+/// loop body breaks out of it. A request that comes back empty is not an 
error and not the end of
+/// the stream, it just means nothing new has arrived yet.
+///
+/// A failed request is yielded as `Some(Err(..))` and leaves the consumer 
usable, while the next call
+/// retries. Connection and authentication failures pause polling until the 
client has reconnected
+/// and signed in again, which the consumer handles automatically. Hence, 
deciding when to give up on
+/// repeated errors is up to you.
+///
+/// For a boilerplate implementation of such a loop Iggy provides 
[`IggyConsumerMessageExt::consume_messages`].
+///
+/// # Tracking what has been read
+///
+/// An offset is the index tracking what has been already read from a 
partition by the consumer.
+/// Managing the offset has implications on where consumers resume reading 
messages.
+///
+/// There are two positions (offsets) tracked in two different places:
+/// - The **reading position** is held by the consumer, one per partition, and 
is the offset of the
+///   last message handed over
+///   ([`get_last_consumed_offset()`](Self::get_last_consumed_offset)). It 
dies with the process.
+/// - The **stored offset** lives on the server under the consumer name, or 
the group name for a
+///   group. This offset survives restarts. Writing it is called *storing* or 
*committing* an offset.
+///
+/// Committing matters because [`PollingStrategy::next()`] resumes from the 
stored offset. A
+/// consumer that never commits keeps starting over from the same place.
+/// Note, *committing* is a request of its own, not a side effect of reading 
and therefore controllable.
+///
+/// [`auto_commit()`] decides when the consumer commits by itself:
+///
+/// | Setting | Commits |
+/// | --- | --- |
+/// | [`AutoCommit::Disabled`] | never on its own, decide manually with 
[`store_offset()`](Self::store_offset) or on [`shutdown()`](Self::shutdown) |
+/// | [`AutoCommit::Interval`] | on every tick, for every partition read so 
far |
+/// | [`AutoCommitWhen::PollingMessages`] | with the poll request itself, 
before your code sees the batch |
+/// | [`AutoCommitWhen::ConsumingEachMessage`] | after every message was 
handed over to the calling code |
+/// | [`AutoCommitWhen::ConsumingEveryNthMessage`] | when the offset of a 
message handed over divides by `n` |
+/// | [`AutoCommitWhen::ConsumingAllMessages`] | when the buffer of the 
current batch runs empty |
+/// | [`AutoCommitAfter`] variants | as their [`AutoCommitWhen`] counterparts, 
but once the handler returned, and only under 
[`IggyConsumerMessageExt::consume_messages`] |
+///
+/// [`AutoCommit::IntervalOrWhen`] and [`AutoCommit::IntervalOrAfter`] combine 
an interval with a
+/// message trigger. The default is [`AutoCommit::IntervalOrWhen`] with one 
second and
+/// [`AutoCommitWhen::PollingMessages`].
+/// Important implications of these defaults:
+/// - [`AutoCommitWhen::PollingMessages`] marks a batch as consumed while it 
is being delivered,
+///   before your code has seen any of it. If a crash must not skip messages, 
commit after handling

Review Comment:
   this advice doesn't hold - `ConsumingEachMessage` commits at handover, 
inside `poll_next` before your handler runs (the store fires at 1442-1447 / 
1557-1567 and lands concurrently with the handler). a crash mid-handling can 
still skip the message. crash-safe options are `AutoCommit::Disabled` + 
`store_offset()` after handling, or the `AutoCommitAfter` variants under 
`consume_messages`.



##########
core/sdk/src/clients/consumer.rs:
##########
@@ -297,16 +639,79 @@ impl IggyConsumer {
             .await
     }
 
-    /// Retrieves the last stored offset (on the server) for the specified 
partition ID.
-    /// To get the current partition ID use `partition_id()`
+    /// Returns the offset this consumer last stored on the server for the 
given partition, or
+    /// `None` if it has not stored one yet.
+    ///
+    /// The value is this consumer's own record of what it committed, kept in 
memory rather than
+    /// read back from the server.
     pub fn get_last_stored_offset(&self, partition_id: u32) -> Option<u64> {
         let offset = self.last_stored_offsets.get(&partition_id)?;
         Some(offset.load(ORDERING))
     }
 
-    /// Initializes the consumer by subscribing to diagnostic events, 
initializing the consumer group if needed, storing the offsets in the 
background etc.
+    /// Initializes the consumer and makes it ready to poll messages.
+    ///
+    /// This must be called before the consumer can start polling messages. 
Calling it again on an
+    /// initialized consumer does nothing and returns immediately.
+    ///
+    /// Initialization ensures that:
+    /// - the consumers `stream_id` and `topic_id` exist on the server.
+    ///   It retries for a number of `init_retries` (defaults to `None`, which 
is treated as no
+    ///   retry) with `init_retry_interval` (defaults to one
+    ///   second) time in between retries. Both can be set together through
+    ///   
[`IggyConsumerBuilder::init_retries`](crate::clients::consumer_builder::IggyConsumerBuilder::init_retries).
+    /// - the consumer subscribes to connection lifecycle events 
([`DiagnosticEvent`]) in order to
+    ///   update its state, should it receive a shutdown, connected, 
disconnected, log in or log out event.
+    /// - if the consumer belongs to a group and `auto_join_consumer_group` is 
enabled, the group is
+    ///   initialized if it does not exist yet, and the consumer joins that 
group.
+    /// - the tasks that store the offset on the server are spawned.
+    ///
+    /// # Lifecycle events
+    ///
+    /// Calling init spawns a background tasks that listens for lifecycle 
changes ([`DiagnosticEvent`]s) of the
+    /// client connection.
+    /// - [`DiagnosticEvent::Connected`]: a fresh connection has not joined 
anything yet.
+    ///   Polling resumes immediately only for a consumer that is not a group 
member.
+    /// - [`DiagnosticEvent::SignedIn`]: re-enables polling. A group member 
signing in after a
+    ///   reconnect rejoins its group first and only polls once that 
succeeded. A failed rejoin is
+    ///   logged and leaves polling disabled until the next event.
+    /// - [`DiagnosticEvent::Disconnected`] and [`DiagnosticEvent::SignedOut`] 
disables polling.
+    /// - [`DiagnosticEvent::Shutdown`] disables polling and terminates the 
background task listening
+    ///   for lifecycle changes. It does not flush in-flight commits; that 
only happens when
+    ///   [`shutdown()`](Self::shutdown) itself is called.
+    ///
+    /// # Storing offsets
     ///
-    /// Note: This method must be called before polling messages.
+    /// An offset is the position of a message within a partition, and storing 
one tells the server
+    /// how many this consumer (or its consumer group) has consumed already.
+    /// When this offset is stored at the server is configured in 
`auto_commit`, which defaults to
+    /// [`AutoCommit::IntervalOrWhen`] equal to 1s and 
[`AutoCommitWhen::PollingMessages`].
+    /// - An interval background task is only spawned for the variants that 
carry an interval
+    ///   ([`AutoCommit::Interval`], [`AutoCommit::IntervalOrWhen`], 
[`AutoCommit::IntervalOrAfter`]).
+    /// - The offset store task is spawned in any case. It can be configured 
with [`AutoCommitWhen::ConsumingEachMessage`],
+    ///   [`AutoCommitWhen::ConsumingEveryNthMessage`], 
[`AutoCommitWhen::ConsumingAllMessages`] and
+    ///   their [`AutoCommitAfter`] counterparts. Under 
[`AutoCommit::Disabled`] nothing is
+    ///   ever sent and the task stays idle.
+    ///
+    /// A variant such as [`AutoCommit::IntervalOrWhen`] runs both together. 
The message count
+    /// trigger stores as messages are consumed, the interval stores what the 
trigger has not
+    /// covered yet. There is no double-work, since an offset that is not 
ahead of the one last stored
+    /// for that partition is skipped instead of sent.
+    /// Unless `allow_replay` is enabled an offset that is not past the last 
stored offset on the server

Review Comment:
   `allow_replay` doesn't affect the auto-commit paths at all - both background 
tasks hardcode it to `false` (lines 840 and 940). it only applies to manual 
`store_offset()` and the shutdown flush. also the skip check reads the 
in-memory `last_stored_offsets`, not the server.



##########
core/sdk/src/clients/consumer.rs:
##########
@@ -404,6 +821,9 @@ impl IggyConsumer {
         let (store_offset_sender, store_offset_receiver) = flume::unbounded();
         self.store_offset_sender = store_offset_sender;
 
+        // The IggyClients `poll_next` implementation sends store offset 
requests down to this receiver.

Review Comment:
   `poll_next` is `IggyConsumer`'s `Stream` impl, not `IggyClient`'s. 
`consume_messages` also sends into this channel.



##########
core/sdk/src/clients/consumer.rs:
##########
@@ -1229,6 +1724,10 @@ impl IggyConsumer {
     }
 }
 
+/// Stops the background tasks, nothing more.
+///
+/// Dropping cannot await, so it neither commits pending offsets nor leaves 
the consumer group.

Review Comment:
   two edges off here: the diagnostics listener task isn't stopped by drop (it 
never checks the shutdown flag, only exits on the `Shutdown` event or channel 
close), and the store-offset task may still commit queued offsets after drop 
since the channel drains before closing.



##########
core/sdk/src/clients/consumer.rs:
##########
@@ -229,26 +539,48 @@ impl IggyConsumer {
     }
 
     /// Returns the name of the consumer.
+    ///
+    /// For a consumer group this is also the name of the group.
     pub fn name(&self) -> &str {
         &self.consumer_name
     }
 
-    /// Returns the topic ID of the consumer.
+    /// Returns the identifier of the topic this consumer reads from.
     pub fn topic(&self) -> &Identifier {
         &self.topic_id
     }
 
-    /// Returns the stream ID of the consumer.
+    /// Returns the identifier of the stream this consumer reads from.
     pub fn stream(&self) -> &Identifier {
         &self.stream_id
     }
 
-    /// Returns the current partition ID of the consumer.
+    /// Returns the partition the last message came from.
+    ///
+    /// This is `0` until the first message has been read, because a partition 
is only known once
+    /// the server has answered. For a consumer group the value changes over 
time, as the server
+    /// can hand different partitions to this member.
     pub fn partition_id(&self) -> u32 {
         self.current_partition_id.load(ORDERING)
     }
 
-    /// Stores the consumer offset on the server either for the current 
partition or the provided partition ID.
+    /// Stores an offset on the server, marking every message up to and 
including it as consumed.
+    ///
+    /// This is the manual counterpart to [`AutoCommit`] and is meant for
+    /// [`AutoCommit::Disabled`].
+    ///
+    /// Pass `None` as `partition_id` to use the partition of the most recent 
batch polled.
+    ///
+    /// An offset that is not ahead of the last one this consumer stored for 
that partition is

Review Comment:
   small carve-out: the skip check is `offset <= stored_offset && offset >= 1` 
(line 876), so offset 0 is always sent. and the server allows an explicit store 
to rewind the watermark, so `store_offset(0)` actually rewinds even without 
`allow_replay`.



##########
core/sdk/src/clients/consumer.rs:
##########
@@ -100,6 +100,316 @@ pub enum AutoCommitAfter {
 // 4. All `&self` methods only access Sync-safe fields
 unsafe impl Sync for IggyConsumer {}
 
+/// Reads messages from the partitions of one topic and yields them one at a 
time.
+///
+/// A topic is split into partitions, and a partition is an ordered log that 
producers append to.
+/// Every message sits at an *offset*, its position in that log. Reading is 
therefore always the
+/// same three decisions: which partition to read, where in it to start, and 
how to keep track of
+/// how far you got so the next run can continue there.
+///
+/// `IggyConsumer` handles all three. It fetches batches of messages from the 
server, keeps them in
+/// an in-memory buffer, decrypts them when the client is configured with an 
encryptor, and records
+/// how far it has read. **It implements [`Stream`], so consuming is a loop 
over [`StreamExt::next`].**
+///
+/// You can use a consumer as a worker draining a topic, a reader that replays 
a
+/// partition from a chosen point, and a pool of consumers sharing a workload 
through a consumer
+/// group.
+///
+/// # Creating a consumer
+///
+/// Easiest way is to use the [`IggyClient`] with a configured connection. 
Then:
+/// - [`IggyClient::consumer()`] builds a standalone consumer, bound to the 
one partition passed
+///   in.
+/// - [`IggyClient::consumer_group()`] builds a member of a consumer group. 
The server gives every
+///   partition to exactly one member, so several consumers using the same 
group name split the
+///   topic between them and share one set of offsets.
+///
+/// Note, building never talks to the server. [`init()`](Self::init) must be 
awaited once before the
+/// first message is read.
+///
+/// # Examples
+///
+/// A standalone consumer reading partition 1 with the defaults:
+///
+/// ```rust,no_run
+/// use futures_util::StreamExt;
+/// use iggy::prelude::*;
+///
+/// # async fn example() -> Result<(), IggyError> {
+/// let client = 
IggyClient::from_connection_string("iggy://iggy:iggy@localhost:8090")?;
+/// client.connect().await?;
+///
+/// let mut consumer = client
+///     .consumer("my-consumer", "my-stream", "my-topic", 1)?
+///     .batch_length(100)
+///     .poll_interval(IggyDuration::new_from_secs(1))
+///     .build();
+/// consumer.init().await?;
+///
+/// while let Some(received) = consumer.next().await {
+///     match received {
+///         Ok(received) => println!("Offset: {}", 
received.message.header.offset),
+///         Err(error) => eprintln!("Failed to read a message: {error}"),
+///     }
+/// }
+/// # Ok(())
+/// # }
+/// ```
+///
+/// A group member that commits every message right after it is handed over, 
and shuts down
+/// cleanly:
+///
+/// ```rust,no_run
+/// use futures_util::StreamExt;
+/// use iggy::prelude::*;
+///
+/// # async fn handle(message: &IggyMessage) {}
+/// # async fn example() -> Result<(), IggyError> {
+/// let client = 
IggyClient::from_connection_string("iggy://iggy:iggy@localhost:8090")?;
+/// client.connect().await?;
+///
+/// let mut consumer = client
+///     .consumer_group("order-workers", "my-stream", "my-topic")?
+///     .auto_commit(AutoCommit::When(AutoCommitWhen::ConsumingEachMessage))
+///     .polling_strategy(PollingStrategy::next())
+///     .build();
+/// consumer.init().await?;
+///
+/// let mut consumed = 0;
+/// while let Some(received) = consumer.next().await {
+///     handle(&received?.message).await;
+///     consumed += 1;
+///     if consumed == 100 {
+///         break;
+///     }
+/// }
+///
+/// consumer.shutdown().await?;
+/// # Ok(())
+/// # }
+/// ```
+///
+/// Committing by hand, so that a message the handler could not process comes 
back:
+///
+/// ```rust,no_run
+/// use futures_util::StreamExt;
+/// use iggy::prelude::*;
+///
+/// # async fn handle(message: &IggyMessage) -> Result<(), IggyError> { Ok(()) 
}
+/// # async fn example() -> Result<(), IggyError> {
+/// let client = 
IggyClient::from_connection_string("iggy://iggy:iggy@localhost:8090")?;
+/// client.connect().await?;
+///
+/// let mut consumer = client
+///     .consumer("my-consumer", "my-stream", "my-topic", 1)?
+///     .auto_commit(AutoCommit::Disabled)
+///     .polling_strategy(PollingStrategy::next())
+///     // Without this, messages already handed over once are never handed 
over again.
+///     .allow_replay()
+///     .build();
+/// consumer.init().await?;
+///
+/// while let Some(received) = consumer.next().await {
+///     let received = received?;
+///     if handle(&received.message).await.is_ok() {
+///         consumer
+///             .store_offset(received.message.header.offset, 
Some(received.partition_id))
+///             .await?;
+///     }
+/// }
+/// # Ok(())
+/// # }
+/// ```
+///
+/// # Which partitions are read
+///
+/// A **standalone consumer** reads exactly one partition, the one passed to
+/// [`IggyClient::consumer()`]. Covering a whole topic with several partitions
+/// means running one consumer per partition and dividing the work yourself.
+///
+/// A **consumer group member** does not choose. The server hands every 
partition of the topic to
+/// exactly one member, so consumers sharing a group name split the topic 
between them without
+/// coordinating. [`partition_id()`](Self::partition_id) reports where the 
last message came from.
+///
+/// What to know when working with consumer groups:
+/// - A member joins during [`init()`](Self::init), creating the group first if
+///   [`create_consumer_group_if_not_exists()`] is set (the default). It 
rejoins on its own after a
+///   reconnect and whenever the server reports that its membership is gone.
+/// - Until the join has succeeded the consumer does not poll. It waits for
+///   [`polling_retry_interval()`] and tries again.
+/// - Partitions are redistributed whenever members join or leave, so a member 
reads different
+///   partitions over time and messages from several partitions interleave in 
its stream.
+/// - More members than partitions leaves the surplus members idle. The 
partition count of the
+///   topic is the ceiling on how far one group can be scaled out.
+/// - The group shares one set of stored offsets, kept under the group name. 
Thus,
+///   a partition taken over by another member continues where the previous one
+///   committed.
+///
+/// # How messages are read
+///
+/// Reading is done by polling. One request fetches up to [`batch_length()`] 
messages. The consumer
+/// passes the first one to the caller and buffers the rest. The next request 
is sent once that buffer
+/// is empty.
+///
+/// [`poll_interval()`] sets a timeout between two requests should the buffer 
be empty.
+/// Without it the next request goes out as soon as the previous one is
+/// answered, which is the fastest option but keeps a busy loop running 
against an idle topic.
+///
+/// [`polling_strategy()`] decides **where** in the partition reading begins:
+///
+/// | Strategy | Starts at |
+/// | --- | --- |
+/// | [`PollingStrategy::next()`] (default) | the message after the offset 
that is stored on the server |
+/// | [`PollingStrategy::first()`] | the oldest message in the partition |
+/// | [`PollingStrategy::last()`] | the end of the partition (returns up to 
[`batch_length()`] of the most recent messages) |
+/// | [`PollingStrategy::offset()`] | a custom offset |
+/// | [`PollingStrategy::timestamp()`] | the first message at or after a given 
point in time |
+///
+/// Only [`PollingStrategy::next()`] consults the offset stored on the server.
+/// Use this if you want to resume where a previous run stopped. The other 
four are starting points for the first request only.
+/// From the second request onwards, the consumer asks for whatever follows it.
+///
+/// Note, when polling, [`StreamExt::next`] never returns `None`, not when the 
topic is empty
+/// and not while the client is disconnected. A `while let Some(..)` loop only 
ends when the
+/// loop body breaks out of it. A request that comes back empty is not an 
error and not the end of
+/// the stream, it just means nothing new has arrived yet.
+///
+/// A failed request is yielded as `Some(Err(..))` and leaves the consumer 
usable, while the next call
+/// retries. Connection and authentication failures pause polling until the 
client has reconnected
+/// and signed in again, which the consumer handles automatically. Hence, 
deciding when to give up on
+/// repeated errors is up to you.
+///
+/// For a boilerplate implementation of such a loop Iggy provides 
[`IggyConsumerMessageExt::consume_messages`].
+///
+/// # Tracking what has been read
+///
+/// An offset is the index tracking what has been already read from a 
partition by the consumer.
+/// Managing the offset has implications on where consumers resume reading 
messages.
+///
+/// There are two positions (offsets) tracked in two different places:
+/// - The **reading position** is held by the consumer, one per partition, and 
is the offset of the
+///   last message handed over
+///   ([`get_last_consumed_offset()`](Self::get_last_consumed_offset)). It 
dies with the process.
+/// - The **stored offset** lives on the server under the consumer name, or 
the group name for a
+///   group. This offset survives restarts. Writing it is called *storing* or 
*committing* an offset.
+///
+/// Committing matters because [`PollingStrategy::next()`] resumes from the 
stored offset. A
+/// consumer that never commits keeps starting over from the same place.
+/// Note, *committing* is a request of its own, not a side effect of reading 
and therefore controllable.
+///
+/// [`auto_commit()`] decides when the consumer commits by itself:
+///
+/// | Setting | Commits |
+/// | --- | --- |
+/// | [`AutoCommit::Disabled`] | never on its own, decide manually with 
[`store_offset()`](Self::store_offset) or on [`shutdown()`](Self::shutdown) |
+/// | [`AutoCommit::Interval`] | on every tick, for every partition read so 
far |
+/// | [`AutoCommitWhen::PollingMessages`] | with the poll request itself, 
before your code sees the batch |
+/// | [`AutoCommitWhen::ConsumingEachMessage`] | after every message was 
handed over to the calling code |
+/// | [`AutoCommitWhen::ConsumingEveryNthMessage`] | when the offset of a 
message handed over divides by `n` |
+/// | [`AutoCommitWhen::ConsumingAllMessages`] | when the buffer of the 
current batch runs empty |
+/// | [`AutoCommitAfter`] variants | as their [`AutoCommitWhen`] counterparts, 
but once the handler returned, and only under 
[`IggyConsumerMessageExt::consume_messages`] |
+///
+/// [`AutoCommit::IntervalOrWhen`] and [`AutoCommit::IntervalOrAfter`] combine 
an interval with a
+/// message trigger. The default is [`AutoCommit::IntervalOrWhen`] with one 
second and
+/// [`AutoCommitWhen::PollingMessages`].
+/// Important implications of these defaults:
+/// - [`AutoCommitWhen::PollingMessages`] marks a batch as consumed while it 
is being delivered,
+///   before your code has seen any of it. If a crash must not skip messages, 
commit after handling
+///   with [`AutoCommitWhen::ConsumingEachMessage`] instead.
+/// - [`AutoCommitWhen::ConsumingEveryNthMessage`] tests the offset of a 
message, not a counter of
+///   messages this process handled, so it commits at every `n`-th offset of 
the partition.
+///
+/// ## Guarantees
+///
+/// - **Each message is handed over once per consumer.** Messages whose offset 
is not greater than
+///   the reading position of their partition are dropped before they reach 
the stream. Re-reading
+///   a partition, or letting a message come back because your handler failed, 
needs
+///   [`allow_replay()`], which turns that filter off.
+/// - **Delivery is at-least-once.** A crash between handling a message and 
committing its offset
+///   replays that message on the next run, so handlers have to tolerate 
seeing one twice. No
+///   setting makes this exactly-once.
+///
+/// # Options and defaults
+///
+/// Everything is configured on the [`IggyConsumerBuilder`] before [`build()`] 
and is fixed
+/// afterwards.
+///
+/// | Option | Default | Controls |
+/// | --- | --- | --- |
+/// | [`stream()`], [`topic()`], [`partition()`] | the values passed to the 
entry point | what is read |
+/// | [`batch_length()`] | 1000 | messages fetched per request |
+/// | [`poll_interval()`] | none | smallest gap between two requests |
+/// | [`polling_strategy()`] | [`PollingStrategy::next()`] | where reading 
starts |
+/// | [`auto_commit()`] | [`AutoCommit::IntervalOrWhen`], one second, 
[`AutoCommitWhen::PollingMessages`] | when offsets are committed |
+/// | [`allow_replay()`] | off | whether a message can be handed over again |
+/// | [`auto_join_consumer_group()`] | on | joining the group during 
[`init()`](Self::init) |
+/// | [`create_consumer_group_if_not_exists()`] | on | creating the group when 
it is missing |
+/// | [`polling_retry_interval()`] | one second | wait between attempts while 
polling is blocked |
+/// | [`init_retries()`] | none, one second apart | retries when the stream or 
topic is missing at [`init()`](Self::init) |
+/// | [`offset_drain_timeout()`] | five seconds | how long 
[`shutdown()`](Self::shutdown) waits for pending commits |
+/// | [`encryptor()`] | inherited from the client | decrypting payloads and 
user headers |
+///
+/// The switches have inverse setters as well, such as 
[`without_poll_interval()`],
+/// [`without_encryptor()`], [`do_not_auto_join_consumer_group()`] and
+/// [`do_not_create_consumer_group_if_not_exists()`].
+///
+/// # Encryption
+///
+/// When the [`IggyClient`] was created with an encryptor, payloads and user 
headers are decrypted
+/// before a message is yielded, which only works if the producer encrypted 
them with a matching
+/// key. This is guaranteed if you spawned both, the [`IggyProducer`] and the 
[`IggyConsumer`] from the same [`IggyClient`].
+/// A message that cannot be decrypted is yielded as an error and the rest of 
its batch is

Review Comment:
   messages before the failing one are dropped too - decryption runs over the 
whole batch before anything is handed over, so the entire batch goes. and since 
no offset advanced, the next poll re-fetches the same batch and fails again, so 
it loops.



##########
core/sdk/src/clients/consumer.rs:
##########
@@ -229,26 +539,48 @@ impl IggyConsumer {
     }
 
     /// Returns the name of the consumer.
+    ///
+    /// For a consumer group this is also the name of the group.
     pub fn name(&self) -> &str {
         &self.consumer_name
     }
 
-    /// Returns the topic ID of the consumer.
+    /// Returns the identifier of the topic this consumer reads from.
     pub fn topic(&self) -> &Identifier {
         &self.topic_id
     }
 
-    /// Returns the stream ID of the consumer.
+    /// Returns the identifier of the stream this consumer reads from.
     pub fn stream(&self) -> &Identifier {
         &self.stream_id
     }
 
-    /// Returns the current partition ID of the consumer.
+    /// Returns the partition the last message came from.
+    ///
+    /// This is `0` until the first message has been read, because a partition 
is only known once
+    /// the server has answered. For a consumer group the value changes over 
time, as the server
+    /// can hand different partitions to this member.
     pub fn partition_id(&self) -> u32 {
         self.current_partition_id.load(ORDERING)
     }
 
-    /// Stores the consumer offset on the server either for the current 
partition or the provided partition ID.
+    /// Stores an offset on the server, marking every message up to and 
including it as consumed.
+    ///
+    /// This is the manual counterpart to [`AutoCommit`] and is meant for
+    /// [`AutoCommit::Disabled`].
+    ///
+    /// Pass `None` as `partition_id` to use the partition of the most recent 
batch polled.
+    ///
+    /// An offset that is not ahead of the last one this consumer stored for 
that partition is
+    /// skipped and `Ok(())` is returned without a request.
+    /// If you to re-read messages again, e.g. want to move an offset 
backwards configure the consumer

Review Comment:
   this sentence got scrambled ("If you to re-read messages again... backwards 
configure"). similar small ones: 658 "the consumers", 671 "a background tasks", 
742 "init_retires", 805 "If a the ... an time interval", 915 "Wait the task 
until", 1482 "A previous used", 1666 "if it the", 371-372 missing word before 
"Hence", 385 "a `IggyConsumer`", 360 "both, the".



##########
core/sdk/src/clients/consumer.rs:
##########
@@ -297,16 +639,79 @@ impl IggyConsumer {
             .await
     }
 
-    /// Retrieves the last stored offset (on the server) for the specified 
partition ID.
-    /// To get the current partition ID use `partition_id()`
+    /// Returns the offset this consumer last stored on the server for the 
given partition, or
+    /// `None` if it has not stored one yet.
+    ///
+    /// The value is this consumer's own record of what it committed, kept in 
memory rather than

Review Comment:
   under the default auto-commit-on-poll this lags the server by up to one 
batch - the local mirror gets the pre-batch consumed offset (1173-1175) while 
the server committed the batch end.



##########
core/sdk/src/clients/consumer.rs:
##########
@@ -1128,36 +1588,66 @@ impl Stream for IggyConsumer {
 }
 
 impl IggyConsumer {
+    /// Shuts the consumer down.
+    ///
+    /// Specifically, run shutdown and await before dropping the consumer to
+    /// - finish storing the offsets that are currently in-flight.
+    ///   There are two background tasks that can have commits in flight. The 
interval-based one
+    ///   (only spawned for [`AutoCommit`] variants that carry an interval) 
and the one driven by
+    ///   [`AutoCommitWhen`]/[`AutoCommitAfter`] (always spawned). The 
consumer waits for
+    ///   `offset_drain_timeout` on each in turn before forcing it to abort.
+    ///   Any offset that is not stored until then will be lost.
+    /// - commit every offset from partitions where the consumed offset is 
ahead of the stored one.
+    ///   Note, this happens even under [`AutoCommit::Disabled`].
+    /// - leave the consumer group, if this consumer is a group member. This 
lets the server give its partitions to
+    ///   the remaining members immediately instead of waiting for the 
connection to time out.
+    ///
+    /// # Errors
+    ///
+    /// Returns `Ok(())` even when the final commits or the group leave 
failed, since those
+    /// failures are logged and do not leave anything for the caller to undo. 
The
+    /// [`Result`] is part of the signature for forward compatibility.
     pub async fn shutdown(&mut self) -> Result<(), IggyError> {
+        // Immediately return, if the consumer is already shut down.
+        // Otherwise, swap so background tasks see that the consumer got shut 
down.
         if self.shutdown.swap(true, ORDERING) {
             return Ok(());
         }
 
         info!("Shutting down consumer: {}...", self.consumer_name);
 
-        // Drain the background commit tasks while still a group member,
-        // before leaving below — otherwise a store they send afterward hits
-        // a group we've already left.
+        // Wake the task responsible for storing the offsets (spawned in 
store_offset_in_background())
         self.background_commit_notify.notify_one();
+
+        // A background_commit_task exists, if auto_commit is configured with 
an interval option.
+        // If it exists, the task may be waiting or currently perform the 
interval based store offset operation.
+        // In case it is currently working, wait until drain timeout has 
passed and then force
+        // the task to abort.
         if let Some(mut task) = self.background_commit_task.take()
             && time::timeout(self.offset_drain_timeout.get_duration(), &mut 
task)
                 .await
                 .is_err()
         {
-            // Still running past the bound: abort it rather than leaving it
-            // detached, so it can't send a stale store after we leave below.
             task.abort();
             warn!(
                 "Timed out waiting for the background offset-commit task to 
stop for consumer: {}, aborted",
                 self.consumer_name
             );
         }
 
+        // Drop the sending end of the store offset task to end the 
`recv_async()` loop in `send_store_offset().

Review Comment:
   the `recv_async()` loop lives in the task spawned in `init()`, not in 
`send_store_offset()` - that's the sender side. unclosed backtick too.



##########
core/sdk/src/clients/consumer.rs:
##########
@@ -1128,36 +1588,66 @@ impl Stream for IggyConsumer {
 }
 
 impl IggyConsumer {
+    /// Shuts the consumer down.
+    ///
+    /// Specifically, run shutdown and await before dropping the consumer to
+    /// - finish storing the offsets that are currently in-flight.
+    ///   There are two background tasks that can have commits in flight. The 
interval-based one
+    ///   (only spawned for [`AutoCommit`] variants that carry an interval) 
and the one driven by
+    ///   [`AutoCommitWhen`]/[`AutoCommitAfter`] (always spawned). The 
consumer waits for
+    ///   `offset_drain_timeout` on each in turn before forcing it to abort.
+    ///   Any offset that is not stored until then will be lost.

Review Comment:
   not quite - the final flush below (1668-1695) commits every partition where 
consumed > stored, and everything the aborted tasks had queued is covered by 
that. offsets are only lost when the flush request itself fails (errors are 
swallowed at 1683).



##########
core/sdk/src/clients/consumer.rs:
##########
@@ -1128,36 +1588,66 @@ impl Stream for IggyConsumer {
 }
 
 impl IggyConsumer {
+    /// Shuts the consumer down.
+    ///
+    /// Specifically, run shutdown and await before dropping the consumer to
+    /// - finish storing the offsets that are currently in-flight.
+    ///   There are two background tasks that can have commits in flight. The 
interval-based one
+    ///   (only spawned for [`AutoCommit`] variants that carry an interval) 
and the one driven by
+    ///   [`AutoCommitWhen`]/[`AutoCommitAfter`] (always spawned). The 
consumer waits for
+    ///   `offset_drain_timeout` on each in turn before forcing it to abort.
+    ///   Any offset that is not stored until then will be lost.
+    /// - commit every offset from partitions where the consumed offset is 
ahead of the stored one.
+    ///   Note, this happens even under [`AutoCommit::Disabled`].
+    /// - leave the consumer group, if this consumer is a group member. This 
lets the server give its partitions to
+    ///   the remaining members immediately instead of waiting for the 
connection to time out.
+    ///
+    /// # Errors
+    ///
+    /// Returns `Ok(())` even when the final commits or the group leave 
failed, since those
+    /// failures are logged and do not leave anything for the caller to undo. 
The
+    /// [`Result`] is part of the signature for forward compatibility.
     pub async fn shutdown(&mut self) -> Result<(), IggyError> {
+        // Immediately return, if the consumer is already shut down.
+        // Otherwise, swap so background tasks see that the consumer got shut 
down.
         if self.shutdown.swap(true, ORDERING) {
             return Ok(());
         }
 
         info!("Shutting down consumer: {}...", self.consumer_name);
 
-        // Drain the background commit tasks while still a group member,
-        // before leaving below — otherwise a store they send afterward hits
-        // a group we've already left.
+        // Wake the task responsible for storing the offsets (spawned in 
store_offset_in_background())

Review Comment:
   typo: the fn is `store_offsets_in_background()`. also the old comment here 
explained why the drain happens before leaving the group (a store sent after 
the leave would hit a group we already left) - that ordering constraint, and 
the abort-so-no-stale-store rationale below, are worth keeping.



##########
core/sdk/src/clients/consumer.rs:
##########
@@ -229,26 +539,48 @@ impl IggyConsumer {
     }
 
     /// Returns the name of the consumer.
+    ///
+    /// For a consumer group this is also the name of the group.
     pub fn name(&self) -> &str {
         &self.consumer_name
     }
 
-    /// Returns the topic ID of the consumer.
+    /// Returns the identifier of the topic this consumer reads from.
     pub fn topic(&self) -> &Identifier {
         &self.topic_id
     }
 
-    /// Returns the stream ID of the consumer.
+    /// Returns the identifier of the stream this consumer reads from.
     pub fn stream(&self) -> &Identifier {
         &self.stream_id
     }
 
-    /// Returns the current partition ID of the consumer.
+    /// Returns the partition the last message came from.
+    ///
+    /// This is `0` until the first message has been read, because a partition 
is only known once

Review Comment:
   it's set on any poll answer, even an empty one (stored at 1492 before the 
empty check), so it can be nonzero with zero messages read. the all-duplicates 
filter path also resets it to 0 via `PolledMessages::empty()`. "until the 
server has answered the first poll" would be closer.



##########
core/sdk/src/clients/consumer.rs:
##########
@@ -974,13 +1412,24 @@ impl ReceivedMessage {
     }
 }
 
+/// Yields messages one at a time.
+///
+/// Tries the buffer first, before a new batch is fetched from the server and 
stored in the buffer.
+///
+/// The stream never yields `None`. So a `while let Some(..)` loop over it runs
+/// until the loop body breaks out. Errors are yielded as items and do not end 
the stream, polling
+/// again retries. See the [type documentation](IggyConsumer#polling) for the 
details of polling.

Review Comment:
   there's no `#polling` anchor - the section is "How messages are read", so 
`#how-messages-are-read`. rustdoc doesn't check fragments, so this stays green 
but lands at the top of the page.



##########
core/sdk/src/clients/consumer.rs:
##########
@@ -100,6 +100,316 @@ pub enum AutoCommitAfter {
 // 4. All `&self` methods only access Sync-safe fields
 unsafe impl Sync for IggyConsumer {}
 
+/// Reads messages from the partitions of one topic and yields them one at a 
time.
+///
+/// A topic is split into partitions, and a partition is an ordered log that 
producers append to.
+/// Every message sits at an *offset*, its position in that log. Reading is 
therefore always the
+/// same three decisions: which partition to read, where in it to start, and 
how to keep track of
+/// how far you got so the next run can continue there.
+///
+/// `IggyConsumer` handles all three. It fetches batches of messages from the 
server, keeps them in
+/// an in-memory buffer, decrypts them when the client is configured with an 
encryptor, and records
+/// how far it has read. **It implements [`Stream`], so consuming is a loop 
over [`StreamExt::next`].**
+///
+/// You can use a consumer as a worker draining a topic, a reader that replays 
a
+/// partition from a chosen point, and a pool of consumers sharing a workload 
through a consumer
+/// group.
+///
+/// # Creating a consumer
+///
+/// Easiest way is to use the [`IggyClient`] with a configured connection. 
Then:
+/// - [`IggyClient::consumer()`] builds a standalone consumer, bound to the 
one partition passed
+///   in.
+/// - [`IggyClient::consumer_group()`] builds a member of a consumer group. 
The server gives every
+///   partition to exactly one member, so several consumers using the same 
group name split the
+///   topic between them and share one set of offsets.
+///
+/// Note, building never talks to the server. [`init()`](Self::init) must be 
awaited once before the
+/// first message is read.
+///
+/// # Examples
+///
+/// A standalone consumer reading partition 1 with the defaults:
+///
+/// ```rust,no_run
+/// use futures_util::StreamExt;
+/// use iggy::prelude::*;
+///
+/// # async fn example() -> Result<(), IggyError> {
+/// let client = 
IggyClient::from_connection_string("iggy://iggy:iggy@localhost:8090")?;
+/// client.connect().await?;
+///
+/// let mut consumer = client
+///     .consumer("my-consumer", "my-stream", "my-topic", 1)?
+///     .batch_length(100)
+///     .poll_interval(IggyDuration::new_from_secs(1))
+///     .build();
+/// consumer.init().await?;
+///
+/// while let Some(received) = consumer.next().await {
+///     match received {
+///         Ok(received) => println!("Offset: {}", 
received.message.header.offset),
+///         Err(error) => eprintln!("Failed to read a message: {error}"),
+///     }
+/// }
+/// # Ok(())
+/// # }
+/// ```
+///
+/// A group member that commits every message right after it is handed over, 
and shuts down
+/// cleanly:
+///
+/// ```rust,no_run
+/// use futures_util::StreamExt;
+/// use iggy::prelude::*;
+///
+/// # async fn handle(message: &IggyMessage) {}
+/// # async fn example() -> Result<(), IggyError> {
+/// let client = 
IggyClient::from_connection_string("iggy://iggy:iggy@localhost:8090")?;
+/// client.connect().await?;
+///
+/// let mut consumer = client
+///     .consumer_group("order-workers", "my-stream", "my-topic")?
+///     .auto_commit(AutoCommit::When(AutoCommitWhen::ConsumingEachMessage))
+///     .polling_strategy(PollingStrategy::next())
+///     .build();
+/// consumer.init().await?;
+///
+/// let mut consumed = 0;
+/// while let Some(received) = consumer.next().await {
+///     handle(&received?.message).await;
+///     consumed += 1;
+///     if consumed == 100 {
+///         break;
+///     }
+/// }
+///
+/// consumer.shutdown().await?;
+/// # Ok(())
+/// # }
+/// ```
+///
+/// Committing by hand, so that a message the handler could not process comes 
back:
+///
+/// ```rust,no_run
+/// use futures_util::StreamExt;
+/// use iggy::prelude::*;
+///
+/// # async fn handle(message: &IggyMessage) -> Result<(), IggyError> { Ok(()) 
}
+/// # async fn example() -> Result<(), IggyError> {
+/// let client = 
IggyClient::from_connection_string("iggy://iggy:iggy@localhost:8090")?;
+/// client.connect().await?;
+///
+/// let mut consumer = client
+///     .consumer("my-consumer", "my-stream", "my-topic", 1)?
+///     .auto_commit(AutoCommit::Disabled)
+///     .polling_strategy(PollingStrategy::next())
+///     // Without this, messages already handed over once are never handed 
over again.
+///     .allow_replay()
+///     .build();
+/// consumer.init().await?;
+///
+/// while let Some(received) = consumer.next().await {
+///     let received = received?;
+///     if handle(&received.message).await.is_ok() {
+///         consumer
+///             .store_offset(received.message.header.offset, 
Some(received.partition_id))
+///             .await?;
+///     }
+/// }
+/// # Ok(())
+/// # }
+/// ```
+///
+/// # Which partitions are read
+///
+/// A **standalone consumer** reads exactly one partition, the one passed to
+/// [`IggyClient::consumer()`]. Covering a whole topic with several partitions
+/// means running one consumer per partition and dividing the work yourself.
+///
+/// A **consumer group member** does not choose. The server hands every 
partition of the topic to
+/// exactly one member, so consumers sharing a group name split the topic 
between them without
+/// coordinating. [`partition_id()`](Self::partition_id) reports where the 
last message came from.
+///
+/// What to know when working with consumer groups:
+/// - A member joins during [`init()`](Self::init), creating the group first if
+///   [`create_consumer_group_if_not_exists()`] is set (the default). It 
rejoins on its own after a
+///   reconnect and whenever the server reports that its membership is gone.
+/// - Until the join has succeeded the consumer does not poll. It waits for
+///   [`polling_retry_interval()`] and tries again.
+/// - Partitions are redistributed whenever members join or leave, so a member 
reads different
+///   partitions over time and messages from several partitions interleave in 
its stream.
+/// - More members than partitions leaves the surplus members idle. The 
partition count of the
+///   topic is the ceiling on how far one group can be scaled out.
+/// - The group shares one set of stored offsets, kept under the group name. 
Thus,
+///   a partition taken over by another member continues where the previous one
+///   committed.
+///
+/// # How messages are read
+///
+/// Reading is done by polling. One request fetches up to [`batch_length()`] 
messages. The consumer
+/// passes the first one to the caller and buffers the rest. The next request 
is sent once that buffer
+/// is empty.
+///
+/// [`poll_interval()`] sets a timeout between two requests should the buffer 
be empty.
+/// Without it the next request goes out as soon as the previous one is
+/// answered, which is the fastest option but keeps a busy loop running 
against an idle topic.
+///
+/// [`polling_strategy()`] decides **where** in the partition reading begins:
+///
+/// | Strategy | Starts at |
+/// | --- | --- |
+/// | [`PollingStrategy::next()`] (default) | the message after the offset 
that is stored on the server |
+/// | [`PollingStrategy::first()`] | the oldest message in the partition |
+/// | [`PollingStrategy::last()`] | the end of the partition (returns up to 
[`batch_length()`] of the most recent messages) |
+/// | [`PollingStrategy::offset()`] | a custom offset |
+/// | [`PollingStrategy::timestamp()`] | the first message at or after a given 
point in time |
+///
+/// Only [`PollingStrategy::next()`] consults the offset stored on the server.
+/// Use this if you want to resume where a previous run stopped. The other 
four are starting points for the first request only.
+/// From the second request onwards, the consumer asks for whatever follows it.
+///
+/// Note, when polling, [`StreamExt::next`] never returns `None`, not when the 
topic is empty
+/// and not while the client is disconnected. A `while let Some(..)` loop only 
ends when the
+/// loop body breaks out of it. A request that comes back empty is not an 
error and not the end of
+/// the stream, it just means nothing new has arrived yet.
+///
+/// A failed request is yielded as `Some(Err(..))` and leaves the consumer 
usable, while the next call
+/// retries. Connection and authentication failures pause polling until the 
client has reconnected
+/// and signed in again, which the consumer handles automatically. Hence, 
deciding when to give up on
+/// repeated errors is up to you.
+///
+/// For a boilerplate implementation of such a loop Iggy provides 
[`IggyConsumerMessageExt::consume_messages`].
+///
+/// # Tracking what has been read
+///
+/// An offset is the index tracking what has been already read from a 
partition by the consumer.
+/// Managing the offset has implications on where consumers resume reading 
messages.
+///
+/// There are two positions (offsets) tracked in two different places:
+/// - The **reading position** is held by the consumer, one per partition, and 
is the offset of the
+///   last message handed over
+///   ([`get_last_consumed_offset()`](Self::get_last_consumed_offset)). It 
dies with the process.
+/// - The **stored offset** lives on the server under the consumer name, or 
the group name for a
+///   group. This offset survives restarts. Writing it is called *storing* or 
*committing* an offset.
+///
+/// Committing matters because [`PollingStrategy::next()`] resumes from the 
stored offset. A
+/// consumer that never commits keeps starting over from the same place.
+/// Note, *committing* is a request of its own, not a side effect of reading 
and therefore controllable.

Review Comment:
   under the default `PollingMessages` the commit is a flag on the poll request 
itself (line 1141), not a request of its own - the table row below says exactly 
that. maybe scope this sentence to the other modes.



##########
core/sdk/src/clients/consumer.rs:
##########
@@ -1207,9 +1702,9 @@ impl IggyConsumer {
             );
 
             let client = self.client.read().await;
-            // Cleared either way: this consumer is torn down regardless of
-            // whether the broker confirmed the leave.
+            // Update consumer state to not being part of a consumer group.

Review Comment:
   the old comment explained why this is cleared before the leave result is 
known (the consumer is torn down either way, even if the broker never confirms) 
- the replacement lost that.



##########
core/sdk/src/clients/consumer.rs:
##########
@@ -332,7 +737,11 @@ impl IggyConsumer {
             let mut stream_exists = 
client.get_stream(&stream_id).await?.is_some();
             let mut topic_exists = client.get_topic(&stream_id, 
&topic_id).await?.is_some();
 
+            // Absent streams or topics are not necessarily permanent failures.
+            // It may happen that get_stream/ get_topic races the initial 
setup of the stream/ topic.
+            // Retry for init_retires times, while waiting interval between 
retries.
             loop {
+                // immediate happy path

Review Comment:
   "immediate happy path" and a few others (1539, 1707) just restate the next 
line - they can go.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to