This is an automated email from the ASF dual-hosted git repository.
hubcio pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/iggy.git
The following commit(s) were added to refs/heads/master by this push:
new d7c2b6180 fix(python): read consumer metadata without the consumer
lock (#3888)
d7c2b6180 is described below
commit d7c2b618058f71f7963b8d7ae2e9f21594346c11
Author: Ethan Lin <[email protected]>
AuthorDate: Thu Aug 27 02:05:21 2026 +0800
fix(python): read consumer metadata without the consumer lock (#3888)
---
.../actors/consumer/typed_benchmark_consumer.rs | 12 +-
core/sdk/src/clients/consumer.rs | 388 ++++++++++++---------
core/sdk/src/prelude.rs | 2 +-
foreign/python/apache_iggy.pyi | 12 +-
foreign/python/pyproject.toml | 4 +
foreign/python/src/client.rs | 8 +
foreign/python/src/consumer.rs | 122 +++----
foreign/python/tests/test_consumer_group.py | 118 +++++++
8 files changed, 435 insertions(+), 231 deletions(-)
diff --git a/core/bench/src/actors/consumer/typed_benchmark_consumer.rs
b/core/bench/src/actors/consumer/typed_benchmark_consumer.rs
index ae051fced..a7aa1fad1 100644
--- a/core/bench/src/actors/consumer/typed_benchmark_consumer.rs
+++ b/core/bench/src/actors/consumer/typed_benchmark_consumer.rs
@@ -35,8 +35,8 @@ use bench_report::{
use iggy::prelude::*;
pub enum TypedBenchmarkConsumer {
- High(BenchmarkConsumer<HighLevelConsumerClient>),
- Low(BenchmarkConsumer<LowLevelConsumerClient>),
+ High(Box<BenchmarkConsumer<HighLevelConsumerClient>>),
+ Low(Box<BenchmarkConsumer<LowLevelConsumerClient>>),
}
impl TypedBenchmarkConsumer {
@@ -70,7 +70,7 @@ impl TypedBenchmarkConsumer {
};
if use_high_level_api {
- Self::High(BenchmarkConsumer::new(
+ Self::High(Box::new(BenchmarkConsumer::new(
HighLevelConsumerClient::new(client_factory, config.clone()),
benchmark_kind,
finish_condition,
@@ -78,9 +78,9 @@ impl TypedBenchmarkConsumer {
moving_average_window,
limit_bytes_per_second,
config,
- ))
+ )))
} else {
- Self::Low(BenchmarkConsumer::new(
+ Self::Low(Box::new(BenchmarkConsumer::new(
LowLevelConsumerClient::new(client_factory, config.clone()),
benchmark_kind,
finish_condition,
@@ -88,7 +88,7 @@ impl TypedBenchmarkConsumer {
moving_average_window,
limit_bytes_per_second,
config,
- ))
+ )))
}
}
diff --git a/core/sdk/src/clients/consumer.rs b/core/sdk/src/clients/consumer.rs
index ea5e40440..b9d23a684 100644
--- a/core/sdk/src/clients/consumer.rs
+++ b/core/sdk/src/clients/consumer.rs
@@ -30,6 +30,7 @@ use iggy_common::{
PollingStrategy,
};
use std::collections::VecDeque;
+use std::fmt::{self, Debug, Formatter};
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
@@ -92,6 +93,174 @@ pub enum AutoCommitAfter {
ConsumingEveryNthMessage(u32),
}
+/// A cheap, cloneable view of the state shared with an [`IggyConsumer`].
+///
+/// Consuming borrows the consumer as `&mut` for the whole run, so reading its
getters or
+/// committing an offset concurrently means sharing it behind a lock and then
waiting on
+/// that lock. This view carries the same shared state and needs neither.
+///
+/// Every getter is an independent load rather than part of one snapshot, so
the partition
+/// ID can already have moved on by the time an offset is read for it.
+#[derive(Clone)]
+pub struct IggyConsumerState {
+ client: IggyRwLock<ClientWrapper>,
+ consumer: Arc<Consumer>,
+ stream_id: Arc<Identifier>,
+ topic_id: Arc<Identifier>,
+ is_consumer_group: bool,
+ allow_replay: bool,
+ current_partition_id: Arc<AtomicU32>,
+ last_consumed_offsets: Arc<DashMap<u32, AtomicU64>>,
+ last_stored_offsets: Arc<DashMap<u32, AtomicU64>>,
+}
+
+impl Debug for IggyConsumerState {
+ fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
+ f.debug_struct("IggyConsumerState")
+ .field("consumer", &self.consumer)
+ .field("stream_id", &self.stream_id)
+ .field("topic_id", &self.topic_id)
+ .field("is_consumer_group", &self.is_consumer_group)
+ .field("allow_replay", &self.allow_replay)
+ .field("current_partition_id", &self.partition_id())
+ .finish_non_exhaustive()
+ }
+}
+
+impl IggyConsumerState {
+ fn new(
+ client: IggyRwLock<ClientWrapper>,
+ consumer: Arc<Consumer>,
+ stream_id: Arc<Identifier>,
+ topic_id: Arc<Identifier>,
+ is_consumer_group: bool,
+ allow_replay: bool,
+ ) -> Self {
+ Self {
+ client,
+ consumer,
+ stream_id,
+ topic_id,
+ is_consumer_group,
+ allow_replay,
+ current_partition_id: Arc::new(AtomicU32::new(0)),
+ last_consumed_offsets: Arc::new(DashMap::new()),
+ last_stored_offsets: Arc::new(DashMap::new()),
+ }
+ }
+
+ /// Returns the current partition ID of the consumer.
+ pub fn partition_id(&self) -> u32 {
+ self.current_partition_id.load(ORDERING)
+ }
+
+ /// Retrieves the last consumed offset for the specified partition ID, or
`None` while
+ /// the partition is still untracked. Polling seeds an entry the first
time it sees a
+ /// partition, so `Some(0)` also covers "seen, nothing consumed yet".
+ /// To get the current partition ID use `partition_id()`
+ pub fn get_last_consumed_offset(&self, partition_id: u32) -> Option<u64> {
+ let offset = self.last_consumed_offsets.get(&partition_id)?;
+ Some(offset.load(ORDERING))
+ }
+
+ /// Retrieves the last stored offset (on the server) for the specified
partition ID, or
+ /// `None` while the partition is still untracked. Storing seeds an entry
the first time
+ /// it sees a partition, so `Some(0)` also covers "seen, nothing stored
yet".
+ /// To get the current partition ID use `partition_id()`
+ 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))
+ }
+
+ /// Stores the consumer offset on the server either for the current
partition or the provided partition ID.
+ pub async fn store_offset(
+ &self,
+ offset: u64,
+ partition_id: Option<u32>,
+ ) -> Result<(), IggyError> {
+ let partition_id = partition_id.unwrap_or_else(|| self.partition_id());
+ self.store_consumer_offset(partition_id, offset, self.allow_replay)
+ .await
+ }
+
+ /// Deletes the consumer offset on the server either for the current
partition or the provided partition ID.
+ pub async fn delete_offset(&self, mut partition_id: Option<u32>) ->
Result<(), IggyError> {
+ // `None` is only resolved server-side for consumer groups. For a
standalone consumer
+ // explicitly assign the current partition_id.
+ if partition_id.is_none() && !self.is_consumer_group {
+ partition_id = Some(self.partition_id());
+ }
+ let client = self.client.read().await;
+ client
+ .delete_consumer_offset(
+ &self.consumer,
+ &self.stream_id,
+ &self.topic_id,
+ partition_id,
+ )
+ .await
+ }
+
+ async fn store_consumer_offset(
+ &self,
+ partition_id: u32,
+ offset: u64,
+ allow_replay: bool,
+ ) -> Result<(), IggyError> {
+ let consumer = &self.consumer;
+ let stream_id = &self.stream_id;
+ let topic_id = &self.topic_id;
+ trace!(
+ "Storing offset: {offset} for consumer: {consumer}, partition ID:
{partition_id}, topic: {topic_id}, stream: {stream_id}..."
+ );
+ let stored_offset;
+ if let Some(offset_entry) =
self.last_stored_offsets.get(&partition_id) {
+ stored_offset = offset_entry.load(ORDERING);
+ } else {
+ stored_offset = 0;
+ self.last_stored_offsets
+ .insert(partition_id, AtomicU64::new(0));
+ }
+
+ if !allow_replay && (offset <= stored_offset && offset >= 1) {
+ trace!(
+ "Offset: {offset} is less than or equal to the last stored
offset: {stored_offset} for consumer: {consumer}, partition ID: {partition_id},
topic: {topic_id}, stream: {stream_id}. Skipping storing the offset."
+ );
+ return Ok(());
+ }
+
+ let client = self.client.read().await;
+ if let Err(error) = client
+ .store_consumer_offset(consumer, stream_id, topic_id,
Some(partition_id), offset)
+ .await
+ {
+ error!(
+ "Failed to store offset: {offset} for consumer: {consumer},
partition ID: {partition_id}, topic: {topic_id}, stream: {stream_id}. {error}"
+ );
+ return Err(error);
+ }
+ trace!(
+ "Stored offset: {offset} for consumer: {consumer}, partition ID:
{partition_id}, topic: {topic_id}, stream: {stream_id}."
+ );
+ if let Some(last_offset_entry) =
self.last_stored_offsets.get(&partition_id) {
+ last_offset_entry.store(offset, ORDERING);
+ } else {
+ self.last_stored_offsets
+ .insert(partition_id, AtomicU64::new(offset));
+ }
+ Ok(())
+ }
+
+ /// Snapshots the last consumed offset of every tracked partition.
Collecting up front
+ /// releases the map guards, which must not be held across the store round
trip.
+ fn last_consumed_offsets(&self) -> Vec<(u32, u64)> {
+ self.last_consumed_offsets
+ .iter()
+ .map(|entry| (*entry.key(), entry.load(ORDERING)))
+ .collect()
+ }
+}
+
// SAFETY: IggyConsumer is Sync because:
// 1. The only non-Sync field is `poll_future: Option<PollMessagesFuture>`
// 2. `poll_future` is only accessed through `poll_next()` which requires
`Pin<&mut Self>`
@@ -120,8 +289,7 @@ pub struct IggyConsumer {
auto_commit_after_polling: bool,
auto_join_consumer_group: bool,
create_consumer_group_if_not_exists: bool,
- last_stored_offsets: Arc<DashMap<u32, AtomicU64>>,
- last_consumed_offsets: Arc<DashMap<u32, AtomicU64>>,
+ state: IggyConsumerState,
current_offsets: Arc<DashMap<u32, AtomicU64>>,
poll_future: Option<PollMessagesFuture>,
buffered_messages: VecDeque<IggyMessage>,
@@ -134,7 +302,6 @@ pub struct IggyConsumer {
store_offset_after_all_messages: bool,
store_after_every_nth_message: u64,
last_polled_at: Arc<AtomicU64>,
- current_partition_id: Arc<AtomicU32>,
reconnection_retry_interval: NonZeroIggyDuration,
init_retries: Option<u32>,
init_retry_interval: NonZeroIggyDuration,
@@ -165,22 +332,33 @@ impl IggyConsumer {
offset_drain_timeout: IggyDuration,
) -> Self {
let (store_offset_sender, _) = flume::unbounded();
+ let is_consumer_group = consumer.kind == ConsumerKind::ConsumerGroup;
+ let consumer = Arc::new(consumer);
+ let stream_id = Arc::new(stream_id);
+ let topic_id = Arc::new(topic_id);
+ let state = IggyConsumerState::new(
+ client.clone(),
+ consumer.clone(),
+ stream_id.clone(),
+ topic_id.clone(),
+ is_consumer_group,
+ allow_replay,
+ );
Self {
initialized: false,
shutdown: Arc::new(AtomicBool::new(false)),
- is_consumer_group: consumer.kind == ConsumerKind::ConsumerGroup,
+ is_consumer_group,
joined_consumer_group: Arc::new(AtomicBool::new(false)),
can_poll: Arc::new(AtomicBool::new(true)),
client,
consumer_name,
- consumer: Arc::new(consumer),
- stream_id: Arc::new(stream_id),
- topic_id: Arc::new(topic_id),
+ consumer,
+ stream_id,
+ topic_id,
partition_id,
polling_strategy,
poll_interval_micros: polling_interval.map_or(0, |interval|
interval.as_micros()),
- last_stored_offsets: Arc::new(DashMap::new()),
- last_consumed_offsets: Arc::new(DashMap::new()),
+ state,
current_offsets: Arc::new(DashMap::new()),
poll_future: None,
batch_length,
@@ -216,7 +394,6 @@ impl IggyConsumer {
_ => 0,
},
last_polled_at: Arc::new(AtomicU64::new(0)),
- current_partition_id: Arc::new(AtomicU32::new(0)),
reconnection_retry_interval,
init_retries,
init_retry_interval,
@@ -246,7 +423,12 @@ impl IggyConsumer {
/// Returns the current partition ID of the consumer.
pub fn partition_id(&self) -> u32 {
- self.current_partition_id.load(ORDERING)
+ self.state.partition_id()
+ }
+
+ /// Returns a view of the consumer state that can be read without
exclusive access.
+ pub fn state(&self) -> IggyConsumerState {
+ self.state.clone()
}
/// Stores the consumer offset on the server either for the current
partition or the provided partition ID.
@@ -255,54 +437,24 @@ impl IggyConsumer {
offset: u64,
partition_id: Option<u32>,
) -> Result<(), IggyError> {
- let partition_id = if let Some(partition_id) = partition_id {
- partition_id
- } else {
- self.current_partition_id.load(ORDERING)
- };
- Self::store_consumer_offset(
- &self.client,
- &self.consumer,
- &self.stream_id,
- &self.topic_id,
- partition_id,
- offset,
- &self.last_stored_offsets,
- self.allow_replay,
- )
- .await
+ self.state.store_offset(offset, partition_id).await
}
/// Retrieves the last consumed offset for the specified partition ID.
/// To get the current partition ID use `partition_id()`
pub fn get_last_consumed_offset(&self, partition_id: u32) -> Option<u64> {
- let offset = self.last_consumed_offsets.get(&partition_id)?;
- Some(offset.load(ORDERING))
+ self.state.get_last_consumed_offset(partition_id)
}
/// Deletes the consumer offset on the server either for the current
partition or the provided partition ID.
- pub async fn delete_offset(&self, mut partition_id: Option<u32>) ->
Result<(), IggyError> {
- // `None` is only resolved server-side for consumer groups. For a
standalone consumer
- // explicitly assign the current partition_id.
- if partition_id.is_none() && !self.is_consumer_group {
- partition_id = Some(self.current_partition_id.load(ORDERING));
- }
- let client = self.client.read().await;
- client
- .delete_consumer_offset(
- &self.consumer,
- &self.stream_id,
- &self.topic_id,
- partition_id,
- )
- .await
+ pub async fn delete_offset(&self, partition_id: Option<u32>) -> Result<(),
IggyError> {
+ self.state.delete_offset(partition_id).await
}
/// Retrieves the last stored offset (on the server) for the specified
partition ID.
/// To get the current partition ID use `partition_id()`
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))
+ self.state.get_last_stored_offset(partition_id)
}
/// Initializes the consumer by subscribing to diagnostic events,
initializing the consumer group if needed, storing the offsets in the
background etc.
@@ -397,30 +549,19 @@ impl IggyConsumer {
_ => {}
}
- let client = self.client.clone();
- let consumer = self.consumer.clone();
- let stream_id = self.stream_id.clone();
- let topic_id = self.topic_id.clone();
- let last_stored_offsets = self.last_stored_offsets.clone();
+ let state = self.state.clone();
let (store_offset_sender, store_offset_receiver) = flume::unbounded();
self.store_offset_sender = store_offset_sender;
self.store_offset_task = Some(tokio::spawn(async move {
while let Ok((partition_id, offset)) =
store_offset_receiver.recv_async().await {
trace!(
- "Received offset to store: {offset}, partition ID:
{partition_id}, stream: {stream_id}, topic: {topic_id}"
+ "Received offset to store: {offset}, partition ID:
{partition_id}, stream: {}, topic: {}",
+ state.stream_id, state.topic_id
);
- _ = Self::store_consumer_offset(
- &client,
- &consumer,
- &stream_id,
- &topic_id,
- partition_id,
- offset,
- &last_stored_offsets,
- false,
- )
- .await
+ _ = state
+ .store_consumer_offset(partition_id, offset, false)
+ .await
}
}));
@@ -432,63 +573,8 @@ impl IggyConsumer {
Ok(())
}
- #[allow(clippy::too_many_arguments)]
- async fn store_consumer_offset(
- client: &IggyRwLock<ClientWrapper>,
- consumer: &Consumer,
- stream_id: &Identifier,
- topic_id: &Identifier,
- partition_id: u32,
- offset: u64,
- last_stored_offsets: &DashMap<u32, AtomicU64>,
- allow_replay: bool,
- ) -> Result<(), IggyError> {
- trace!(
- "Storing offset: {offset} for consumer: {consumer}, partition ID:
{partition_id}, topic: {topic_id}, stream: {stream_id}..."
- );
- let stored_offset;
- if let Some(offset_entry) = last_stored_offsets.get(&partition_id) {
- stored_offset = offset_entry.load(ORDERING);
- } else {
- stored_offset = 0;
- last_stored_offsets.insert(partition_id, AtomicU64::new(0));
- }
-
- if !allow_replay && (offset <= stored_offset && offset >= 1) {
- trace!(
- "Offset: {offset} is less than or equal to the last stored
offset: {stored_offset} for consumer: {consumer}, partition ID: {partition_id},
topic: {topic_id}, stream: {stream_id}. Skipping storing the offset."
- );
- return Ok(());
- }
-
- let client = client.read().await;
- if let Err(error) = client
- .store_consumer_offset(consumer, stream_id, topic_id,
Some(partition_id), offset)
- .await
- {
- error!(
- "Failed to store offset: {offset} for consumer: {consumer},
partition ID: {partition_id}, topic: {topic_id}, stream: {stream_id}. {error}"
- );
- return Err(error);
- }
- trace!(
- "Stored offset: {offset} for consumer: {consumer}, partition ID:
{partition_id}, topic: {topic_id}, stream: {stream_id}."
- );
- if let Some(last_offset_entry) =
last_stored_offsets.get(&partition_id) {
- last_offset_entry.store(offset, ORDERING);
- } else {
- last_stored_offsets.insert(partition_id, AtomicU64::new(offset));
- }
- Ok(())
- }
-
fn store_offsets_in_background(&self, interval: NonZeroIggyDuration) ->
JoinHandle<()> {
- let client = self.client.clone();
- let consumer = self.consumer.clone();
- let stream_id = self.stream_id.clone();
- let topic_id = self.topic_id.clone();
- let last_consumed_offsets = self.last_consumed_offsets.clone();
- let last_stored_offsets = self.last_stored_offsets.clone();
+ let state = self.state.clone();
let shutdown = self.shutdown.clone();
let notify = self.background_commit_notify.clone();
tokio::spawn(async move {
@@ -504,20 +590,10 @@ impl IggyConsumer {
trace!("Shutdown signal received, stopping background
offset storage");
break;
}
- for entry in last_consumed_offsets.iter() {
- let partition_id = *entry.key();
- let consumed_offset = entry.load(ORDERING);
- _ = Self::store_consumer_offset(
- &client,
- &consumer,
- &stream_id,
- &topic_id,
- partition_id,
- consumed_offset,
- &last_stored_offsets,
- false,
- )
- .await;
+ for (partition_id, consumed_offset) in
state.last_consumed_offsets() {
+ _ = state
+ .store_consumer_offset(partition_id, consumed_offset,
false)
+ .await;
}
}
})
@@ -680,8 +756,8 @@ impl IggyConsumer {
let last_polled_at = self.last_polled_at.clone();
let can_poll = self.can_poll.clone();
let retry_interval = self.reconnection_retry_interval;
- let last_stored_offset = self.last_stored_offsets.clone();
- let last_consumed_offset = self.last_consumed_offsets.clone();
+ let last_stored_offset = self.state.last_stored_offsets.clone();
+ let last_consumed_offset = self.state.last_consumed_offsets.clone();
let allow_replay = self.allow_replay;
let is_consumer_group = self.is_consumer_group;
let auto_join_consumer_group = self.auto_join_consumer_group;
@@ -979,15 +1055,16 @@ impl Stream for IggyConsumer {
type Item = Result<ReceivedMessage, IggyError>;
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) ->
Poll<Option<Self::Item>> {
- let partition_id = self.current_partition_id.load(ORDERING);
+ let partition_id = self.state.partition_id();
if let Some(message) = self.buffered_messages.pop_front() {
{
if let Some(last_consumed_offset_entry) =
- self.last_consumed_offsets.get(&partition_id)
+ self.state.last_consumed_offsets.get(&partition_id)
{
last_consumed_offset_entry.store(message.header.offset,
ORDERING);
} else {
- self.last_consumed_offsets
+ self.state
+ .last_consumed_offsets
.insert(partition_id,
AtomicU64::new(message.header.offset));
}
@@ -1032,7 +1109,9 @@ impl Stream for IggyConsumer {
match future.poll_unpin(cx) {
Poll::Ready(Ok(mut polled_messages)) => {
let partition_id = polled_messages.partition_id;
- self.current_partition_id.store(partition_id, ORDERING);
+ self.state
+ .current_partition_id
+ .store(partition_id, ORDERING);
if polled_messages.messages.is_empty() {
self.poll_future =
Some(Box::pin(self.create_poll_messages_future()));
} else {
@@ -1088,11 +1167,12 @@ impl Stream for IggyConsumer {
}
if let Some(last_consumed_offset_entry) =
- self.last_consumed_offsets.get(&partition_id)
+ self.state.last_consumed_offsets.get(&partition_id)
{
last_consumed_offset_entry.store(message.header.offset, ORDERING);
} else {
- self.last_consumed_offsets
+ self.state
+ .last_consumed_offsets
.insert(partition_id,
AtomicU64::new(message.header.offset));
}
@@ -1171,32 +1251,18 @@ impl IggyConsumer {
);
}
- for entry in self.last_consumed_offsets.iter() {
- let partition_id = *entry.key();
- let consumed_offset = entry.load(ORDERING);
-
- let stored_offset = self
- .last_stored_offsets
- .get(&partition_id)
- .map(|e| e.load(ORDERING))
- .unwrap_or(0);
+ for (partition_id, consumed_offset) in
self.state.last_consumed_offsets() {
+ let stored_offset =
self.state.get_last_stored_offset(partition_id).unwrap_or(0);
if consumed_offset > stored_offset {
trace!(
"Flushing final offset: {consumed_offset} for partition:
{partition_id}, stream: {}, topic: {}",
self.stream_id, self.topic_id
);
- let _ = Self::store_consumer_offset(
- &self.client,
- &self.consumer,
- &self.stream_id,
- &self.topic_id,
- partition_id,
- consumed_offset,
- &self.last_stored_offsets,
- self.allow_replay,
- )
- .await;
+ let _ = self
+ .state
+ .store_consumer_offset(partition_id, consumed_offset,
self.allow_replay)
+ .await;
}
}
diff --git a/core/sdk/src/prelude.rs b/core/sdk/src/prelude.rs
index 326432ce3..81f7d8c16 100644
--- a/core/sdk/src/prelude.rs
+++ b/core/sdk/src/prelude.rs
@@ -33,7 +33,7 @@ pub use
crate::client_wrappers::connection_info::ConnectionInfo;
pub use crate::clients::client::IggyClient;
pub use crate::clients::client_builder::IggyClientBuilder;
pub use crate::clients::consumer::{
- AutoCommit, AutoCommitAfter, AutoCommitWhen, IggyConsumer, ReceivedMessage,
+ AutoCommit, AutoCommitAfter, AutoCommitWhen, IggyConsumer,
IggyConsumerState, ReceivedMessage,
};
pub use crate::clients::consumer_builder::IggyConsumerBuilder;
pub use crate::clients::producer::IggyProducer;
diff --git a/foreign/python/apache_iggy.pyi b/foreign/python/apache_iggy.pyi
index e93a8ebfa..80137b39f 100644
--- a/foreign/python/apache_iggy.pyi
+++ b/foreign/python/apache_iggy.pyi
@@ -1412,11 +1412,15 @@ class IggyConsumer:
self, partition_id: builtins.int
) -> builtins.int | None:
r"""
- Get the last consumed offset or `None` if no offset has been consumed
yet.
+ Get the last consumed offset for the given partition, or `None` while
that partition
+ is untracked. Polling starts tracking a partition at `0`, so `0` also
means
+ "seen, nothing consumed yet".
"""
def get_last_stored_offset(self, partition_id: builtins.int) ->
builtins.int | None:
r"""
- Get the last stored offset or `None` if no offset has been stored yet.
+ Get the last stored offset for the given partition, or `None` while
that partition is
+ untracked. Polling starts tracking a partition at `0`, so `0` also
means
+ "seen, nothing stored yet", including under `AutoCommit.Disabled()`.
"""
def name(self) -> builtins.str:
r"""
@@ -1428,11 +1432,11 @@ class IggyConsumer:
"""
def stream(self) -> builtins.str | builtins.int:
r"""
- Gets the name of the stream this consumer group is configured for.
+ Gets the identifier of the stream this consumer group is configured
for.
"""
def topic(self) -> builtins.str | builtins.int:
r"""
- Gets the name of the topic this consumer group is configured for.
+ Gets the identifier of the topic this consumer group is configured for.
"""
def store_offset(
self, offset: builtins.int, partition_id: builtins.int | None
diff --git a/foreign/python/pyproject.toml b/foreign/python/pyproject.toml
index 00903f926..b7c0d2f9f 100644
--- a/foreign/python/pyproject.toml
+++ b/foreign/python/pyproject.toml
@@ -133,6 +133,10 @@ all = [
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "session"
timeout = 30
+# A test that pins the GIL leaves neither pytest-timeout nor asyncio able to
fire.
+# The faulthandler watchdog needs no GIL, so it dumps every thread and aborts
the run.
+faulthandler_timeout = 60
+faulthandler_exit_on_timeout = true
testpaths = ["tests"]
python_files = ["test_*.py"]
python_classes = ["Test*"]
diff --git a/foreign/python/src/client.rs b/foreign/python/src/client.rs
index 00f4122f3..f669a8748 100644
--- a/foreign/python/src/client.rs
+++ b/foreign/python/src/client.rs
@@ -1201,8 +1201,16 @@ impl IggyClient {
.init()
.await
.map_err(|e| PyErr::new::<pyo3::exceptions::PyRuntimeError,
_>(e.to_string()))?;
+ let state = consumer.state();
+ let name = consumer.name().to_string();
+ let stream = PyIdentifier::try_from(consumer.stream())?;
+ let topic = PyIdentifier::try_from(consumer.topic())?;
Ok(IggyConsumer {
inner: Arc::new(Mutex::new(consumer)),
+ state,
+ name,
+ stream,
+ topic,
})
})
}
diff --git a/foreign/python/src/consumer.rs b/foreign/python/src/consumer.rs
index 74f521ad0..e313a3be9 100644
--- a/foreign/python/src/consumer.rs
+++ b/foreign/python/src/consumer.rs
@@ -24,7 +24,7 @@ use iggy::prelude::{
AutoCommitWhen as RustAutoCommitWhen, Consumer as RustConsumer,
ConsumerGroup as RustConsumerGroup, ConsumerGroupDetails as
RustConsumerGroupDetails,
ConsumerGroupMember as RustConsumerGroupMember, Identifier, IggyConsumer
as RustIggyConsumer,
- IggyError, NonZeroIggyDuration, ReceivedMessage,
+ IggyConsumerState as RustIggyConsumerState, IggyError,
NonZeroIggyDuration, ReceivedMessage,
};
use pyo3::exceptions::PyStopAsyncIteration;
use pyo3::types::PyDelta;
@@ -44,51 +44,55 @@ use crate::receive_message::ReceiveMessage;
/// A Python class representing the Iggy consumer.
/// It provides asynchronous functionality through the contained runtime.
+// `inner` stays locked for the whole duration of a consumption run, so
everything that can
+// be served from `state` or from a snapshot must not touch it.
#[gen_stub_pyclass]
#[pyclass]
pub struct IggyConsumer {
pub(crate) inner: Arc<Mutex<RustIggyConsumer>>,
+ pub(crate) state: RustIggyConsumerState,
+ pub(crate) name: String,
+ pub(crate) stream: PyIdentifier,
+ pub(crate) topic: PyIdentifier,
}
#[gen_stub_pymethods]
#[pymethods]
impl IggyConsumer {
- /// Get the last consumed offset or `None` if no offset has been consumed
yet.
+ /// Get the last consumed offset for the given partition, or `None` while
that partition
+ /// is untracked. Polling starts tracking a partition at `0`, so `0` also
means
+ /// "seen, nothing consumed yet".
#[gen_stub(override_return_type(type_repr = "builtins.int | None"))]
fn get_last_consumed_offset(&self, partition_id: u32) -> Option<u64> {
- self.inner
- .blocking_lock()
- .get_last_consumed_offset(partition_id)
+ self.state.get_last_consumed_offset(partition_id)
}
- /// Get the last stored offset or `None` if no offset has been stored yet.
+ /// Get the last stored offset for the given partition, or `None` while
that partition is
+ /// untracked. Polling starts tracking a partition at `0`, so `0` also
means
+ /// "seen, nothing stored yet", including under `AutoCommit.Disabled()`.
#[gen_stub(override_return_type(type_repr = "builtins.int | None"))]
fn get_last_stored_offset(&self, partition_id: u32) -> Option<u64> {
- self.inner
- .blocking_lock()
- .get_last_stored_offset(partition_id)
+ self.state.get_last_stored_offset(partition_id)
}
/// Gets the name of the consumer group.
- fn name(&self) -> String {
- self.inner.blocking_lock().name().to_string()
+ fn name(&self) -> &str {
+ &self.name
}
/// Gets the current partition id or `0` if no messages have been polled
yet.
fn partition_id(&self) -> u32 {
- self.inner.blocking_lock().partition_id()
+ self.state.partition_id()
}
- /// Gets the name of the stream this consumer group is configured for.
- fn stream(&self) -> PyResult<PyIdentifier> {
- let guard = self.inner.blocking_lock();
- PyIdentifier::try_from(guard.stream())
+ /// Gets the identifier of the stream this consumer group is configured
for.
+ fn stream(&self) -> PyIdentifier {
+ self.stream.clone()
}
- /// Gets the name of the topic this consumer group is configured for.
- fn topic(&self) -> PyResult<PyIdentifier> {
- let guard = self.inner.blocking_lock();
- PyIdentifier::try_from(guard.topic())
+ /// Gets the identifier of the topic this consumer group is configured for.
+ fn topic(&self) -> PyIdentifier {
+ self.topic.clone()
}
/// Stores the provided offset for the provided partition id or if none is
specified
@@ -101,11 +105,9 @@ impl IggyConsumer {
offset: u64,
#[gen_stub(override_type(type_repr = "builtins.int | None"))]
partition_id: Option<u32>,
) -> PyResult<Bound<'a, PyAny>> {
- let inner = self.inner.clone();
+ let state = self.state.clone();
future_into_py(py, async move {
- inner
- .lock()
- .await
+ state
.store_offset(offset, partition_id)
.await
.map_err(|e| PyErr::new::<pyo3::exceptions::PyRuntimeError,
_>(e.to_string()))
@@ -121,11 +123,9 @@ impl IggyConsumer {
py: Python<'a>,
#[gen_stub(override_type(type_repr = "builtins.int | None"))]
partition_id: Option<u32>,
) -> PyResult<Bound<'a, PyAny>> {
- let inner = self.inner.clone();
+ let state = self.state.clone();
future_into_py(py, async move {
- inner
- .lock()
- .await
+ state
.delete_offset(partition_id)
.await
.map_err(|e| PyErr::new::<pyo3::exceptions::PyRuntimeError,
_>(e.to_string()))
@@ -175,36 +175,32 @@ impl IggyConsumer {
let mut inner = inner.lock().await;
Ok(inner.consume_messages(&consumer, shutdown_rx).await)
}));
- let consume_result;
-
- if let Some(shutdown_event) = shutdown_event {
- let task_locals =
Python::attach(pyo3_async_runtimes::tokio::get_current_locals)?;
- async fn shutdown_impl(
- shutdown_event: Py<PyAny>,
- shutdown_tx: Sender<()>,
- ) -> PyResult<()> {
- Python::attach(|py| {
-
into_future(shutdown_event.bind(py).as_any().call_method0("wait")?)
- })?
- .await?;
- shutdown_tx.send(()).map_err(|_| {
- PyErr::new::<pyo3::exceptions::PyRuntimeError, _>(
- "Failed to signal shutdown",
- )
- })?;
- Ok(())
+ let handle_shutdown = shutdown_event
+ .map(|shutdown_event| -> PyResult<JoinHandle<PyResult<()>>> {
+ let task_locals =
+
Python::attach(pyo3_async_runtimes::tokio::get_current_locals)?;
+ Ok(get_runtime().spawn(scope(
+ task_locals,
+ wait_for_shutdown(shutdown_event, shutdown_tx),
+ )))
+ })
+ .transpose()?;
+
+ let consume_result = handle_consume.await;
+
+ if let Some(handle_shutdown) = handle_shutdown {
+ // Consuming can also end on its own, and the shutdown task
would then park on
+ // `Event.wait()` forever.
+ handle_shutdown.abort();
+ match handle_shutdown.await {
+ Ok(shutdown_result) => shutdown_result?,
+ Err(error) if error.is_cancelled() => {}
+ Err(error) => {
+ return
Err(PyErr::new::<pyo3::exceptions::PyRuntimeError, _>(
+ error.to_string(),
+ ));
+ }
}
- let handle_shutdown: JoinHandle<Result<(), PyErr>> =
get_runtime().spawn(scope(
- task_locals,
- shutdown_impl(shutdown_event, shutdown_tx),
- ));
- let shutdown_result;
- (consume_result, shutdown_result) =
tokio::join!(handle_consume, handle_shutdown);
- shutdown_result.map_err(|e| {
- PyErr::new::<pyo3::exceptions::PyRuntimeError,
_>(e.to_string())
- })??;
- } else {
- consume_result = handle_consume.await;
}
consume_result
@@ -215,6 +211,15 @@ impl IggyConsumer {
}
}
+async fn wait_for_shutdown(shutdown_event: Py<PyAny>, shutdown_tx: Sender<()>)
-> PyResult<()> {
+ Python::attach(|py|
into_future(shutdown_event.bind(py).as_any().call_method0("wait")?))?
+ .await?;
+ // A closed receiver only means consuming has already stopped, so there is
nothing left
+ // to signal and the result of the run is the one worth reporting.
+ let _ = shutdown_tx.send(());
+ Ok(())
+}
+
/// The consumer polling the messages. It selects both the consumer kind and
the
/// identifier the server keys the stored offset on.
#[derive(Clone)]
@@ -407,8 +412,7 @@ struct PyCallbackConsumer {
impl MessageConsumer for PyCallbackConsumer {
async fn consume(&self, received: ReceivedMessage) -> Result<(),
IggyError> {
let callback = self.callback.clone();
- let task_locals = self.task_locals.clone().lock_owned().await;
- let task_locals = task_locals.clone();
+ let task_locals = self.task_locals.lock().await.clone();
let message = ReceiveMessage {
inner: received.message,
partition_id: received.partition_id,
diff --git a/foreign/python/tests/test_consumer_group.py
b/foreign/python/tests/test_consumer_group.py
index f8adf8262..31f6f0623 100644
--- a/foreign/python/tests/test_consumer_group.py
+++ b/foreign/python/tests/test_consumer_group.py
@@ -856,6 +856,124 @@ class TestConsumerGroup:
assert consumer.get_last_consumed_offset(partition_id) is None
assert consumer.get_last_stored_offset(partition_id) is None
+ @pytest.mark.asyncio
+ async def test_consumer_group_metadata_while_consuming(
+ self, iggy_client: IggyClient, unique_name
+ ):
+ """Test that metadata can be read while a consumption run is in
progress."""
+ consumer_name = unique_name()
+ stream_name = unique_name()
+ topic_name = unique_name()
+ partition_id = 0
+ message = f"Metadata test - {unique_name()}"
+ received_messages = []
+ consuming = asyncio.Event()
+ shutdown_event = asyncio.Event()
+
+ await iggy_client.create_stream(stream_name)
+ await iggy_client.create_topic(
+ stream=stream_name,
+ name=topic_name,
+ partitions_count=1,
+ )
+
+ consumer = await iggy_client.consumer_group(
+ consumer_name,
+ stream_name,
+ topic_name,
+ partition_id,
+ PollingStrategy.First(),
+ 10,
+ auto_commit=AutoCommit.Disabled(),
+ poll_interval=timedelta(milliseconds=25),
+ )
+
+ async def take(received: ReceiveMessage) -> None:
+ received_messages.append(received)
+ consuming.set()
+
+ await iggy_client.send_messages(
+ stream_name,
+ topic_name,
+ partition_id,
+ [Message(message)],
+ )
+
+ consume = consumer.consume_messages(take, shutdown_event)
+ try:
+ await asyncio.wait_for(consuming.wait(), timeout=10)
+
+ current_partition_id = consumer.partition_id()
+ assert consumer.name() == consumer_name
+ assert consumer.stream() == stream_name
+ assert consumer.topic() == topic_name
+ assert (
+ consumer.get_last_consumed_offset(current_partition_id)
+ == received_messages[-1].offset()
+ )
+ assert consumer.get_last_stored_offset(current_partition_id) == 0
+ finally:
+ shutdown_event.set()
+ await consume
+
+ @pytest.mark.asyncio
+ async def test_consumer_group_manual_commit_while_consuming(
+ self, iggy_client: IggyClient, unique_name
+ ):
+ """Test that offsets can be committed from inside a consumption
callback."""
+ consumer_name = unique_name()
+ stream_name = unique_name()
+ topic_name = unique_name()
+ partition_id = 0
+ messages = [f"Manual commit {i} - {unique_name()}" for i in range(2)]
+ received_messages = []
+ stored_offsets = []
+ committed = asyncio.Event()
+ shutdown_event = asyncio.Event()
+
+ await iggy_client.create_stream(stream_name)
+ await iggy_client.create_topic(
+ stream=stream_name,
+ name=topic_name,
+ partitions_count=1,
+ )
+
+ consumer = await iggy_client.consumer_group(
+ consumer_name,
+ stream_name,
+ topic_name,
+ partition_id,
+ PollingStrategy.First(),
+ 10,
+ auto_commit=AutoCommit.Disabled(),
+ poll_interval=timedelta(milliseconds=25),
+ )
+
+ async def take(received: ReceiveMessage) -> None:
+ received_messages.append(received)
+ await consumer.store_offset(received.offset(), None)
+
stored_offsets.append(consumer.get_last_stored_offset(partition_id))
+ if len(received_messages) == len(messages):
+ await consumer.delete_offset(partition_id)
+ committed.set()
+
+ await iggy_client.send_messages(
+ stream_name,
+ topic_name,
+ partition_id,
+ [Message(message) for message in messages],
+ )
+
+ consume = consumer.consume_messages(take, shutdown_event)
+ try:
+ await asyncio.wait_for(committed.wait(), timeout=10)
+ assert stored_offsets == [
+ received.offset() for received in received_messages
+ ]
+ finally:
+ shutdown_event.set()
+ await consume
+
@pytest.mark.asyncio
async def test_get_last_consumed_offset_updates_as_messages_are_consumed(
self, iggy_client: IggyClient, unique_name