This is an automated email from the ASF dual-hosted git repository.
numinnex 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 7b79dbdc4 feat(python): expose consumer group delete join and leave
APIs (#3607)
7b79dbdc4 is described below
commit 7b79dbdc4e79edb76cdcc9b8476ad7dd5d17433c
Author: WaterWhisperer <[email protected]>
AuthorDate: Mon Jul 13 12:49:59 2026 +0800
feat(python): expose consumer group delete join and leave APIs (#3607)
---
core/integration/tests/sdk/consumer_group.rs | 163 ++++++++++++
core/integration/tests/sdk/mod.rs | 1 +
core/sdk/src/clients/consumer.rs | 29 ++
foreign/python/apache_iggy.pyi | 71 +++++
foreign/python/src/client.rs | 113 ++++++++
foreign/python/tests/test_consumer_group.py | 384 +++++++++++++++++++++++++++
6 files changed, 761 insertions(+)
diff --git a/core/integration/tests/sdk/consumer_group.rs
b/core/integration/tests/sdk/consumer_group.rs
new file mode 100644
index 000000000..5ea300c6b
--- /dev/null
+++ b/core/integration/tests/sdk/consumer_group.rs
@@ -0,0 +1,163 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use std::str::FromStr;
+use std::time::Duration;
+
+use futures::StreamExt;
+use iggy::prelude::*;
+use integration::iggy_harness;
+use tokio::time::timeout;
+
+const STREAM_NAME: &str = "consumer-group-rejoin-stream";
+const TOPIC_NAME: &str = "consumer-group-rejoin-topic";
+const CONSUMER_GROUP_NAME: &str = "consumer-group-rejoin-group";
+const CONSUMER_USERNAME: &str = "consumer-group-rejoin-user";
+const CONSUMER_PASSWORD: &str = "password123";
+const CONSUMER_REJOIN_TIMEOUT: Duration = Duration::from_secs(10);
+
+#[iggy_harness(test_client_transport = [Tcp, WebSocket, Quic])]
+async fn consumer_group_retries_rejoin_after_failure(harness: &TestHarness) {
+ let root_client = harness
+ .root_client()
+ .await
+ .expect("Failed to get root client");
+ let stream_id = Identifier::named(STREAM_NAME).unwrap();
+ let topic_id = Identifier::named(TOPIC_NAME).unwrap();
+ let group_id = Identifier::named(CONSUMER_GROUP_NAME).unwrap();
+ let user_id = Identifier::named(CONSUMER_USERNAME).unwrap();
+ let consumer_permissions = Permissions {
+ global: GlobalPermissions {
+ read_streams: true,
+ ..Default::default()
+ },
+ streams: None,
+ };
+
+ root_client.create_stream(STREAM_NAME).await.unwrap();
+ root_client
+ .create_topic(
+ &stream_id,
+ TOPIC_NAME,
+ 1,
+ CompressionAlgorithm::default(),
+ None,
+ IggyExpiry::NeverExpire,
+ MaxTopicSize::ServerDefault,
+ )
+ .await
+ .unwrap();
+ root_client
+ .create_consumer_group(&stream_id, &topic_id, CONSUMER_GROUP_NAME)
+ .await
+ .unwrap();
+ root_client
+ .create_user(
+ CONSUMER_USERNAME,
+ CONSUMER_PASSWORD,
+ UserStatus::Active,
+ Some(consumer_permissions.clone()),
+ )
+ .await
+ .unwrap();
+
+ let consumer_client = harness.new_client().await.expect("Failed to create
client");
+ consumer_client
+ .login_user(CONSUMER_USERNAME, CONSUMER_PASSWORD)
+ .await
+ .unwrap();
+
+ let mut consumer = consumer_client
+ .consumer_group(CONSUMER_GROUP_NAME, STREAM_NAME, TOPIC_NAME)
+ .unwrap()
+ .batch_length(1)
+ .auto_join_consumer_group()
+ .build();
+ consumer.init().await.unwrap();
+
+ let group = root_client
+ .get_consumer_group(&stream_id, &topic_id, &group_id)
+ .await
+ .unwrap()
+ .expect("Consumer group should exist");
+ assert_eq!(group.members_count, 1);
+ assert_eq!(group.members.len(), 1);
+
+ let mut messages = vec![IggyMessage::from_str("message").unwrap()];
+ root_client
+ .send_messages(
+ &stream_id,
+ &topic_id,
+ &Partitioning::partition_id(0),
+ &mut messages,
+ )
+ .await
+ .unwrap();
+
+ consumer_client
+ .leave_consumer_group(&stream_id, &topic_id, &group_id)
+ .await
+ .unwrap();
+
+ let group = root_client
+ .get_consumer_group(&stream_id, &topic_id, &group_id)
+ .await
+ .unwrap()
+ .expect("Consumer group should exist");
+ assert_eq!(group.members_count, 0);
+ assert!(group.members.is_empty());
+
+ root_client
+ .update_permissions(
+ &user_id,
+ Some(Permissions {
+ global: GlobalPermissions {
+ poll_messages: true,
+ ..Default::default()
+ },
+ streams: None,
+ }),
+ )
+ .await
+ .unwrap();
+
+ let rejoin_result = timeout(CONSUMER_REJOIN_TIMEOUT, consumer.next())
+ .await
+ .expect("Consumer rejoin should fail before timeout")
+ .expect("Consumer stream should remain open");
+ assert!(matches!(rejoin_result, Err(IggyError::Unauthorized)));
+
+ root_client
+ .update_permissions(&user_id, Some(consumer_permissions))
+ .await
+ .unwrap();
+
+ let received = timeout(CONSUMER_REJOIN_TIMEOUT, consumer.next())
+ .await
+ .expect("Consumer should recover before timeout")
+ .expect("Consumer stream should remain open")
+ .expect("Consumer should rejoin after its membership is revoked");
+ assert_eq!(received.message.payload, "message");
+
+ let group = root_client
+ .get_consumer_group(&stream_id, &topic_id, &group_id)
+ .await
+ .unwrap()
+ .expect("Consumer group should exist");
+ assert_eq!(group.members_count, 1);
+ assert_eq!(group.members.len(), 1);
+}
diff --git a/core/integration/tests/sdk/mod.rs
b/core/integration/tests/sdk/mod.rs
index 0cd484a18..6c4dabe19 100644
--- a/core/integration/tests/sdk/mod.rs
+++ b/core/integration/tests/sdk/mod.rs
@@ -15,6 +15,7 @@
// specific language governing permissions and limitations
// under the License.
+mod consumer_group;
mod hello_world;
#[cfg(feature = "vsr")]
mod http_refresh;
diff --git a/core/sdk/src/clients/consumer.rs b/core/sdk/src/clients/consumer.rs
index 918245953..21b98fa4f 100644
--- a/core/sdk/src/clients/consumer.rs
+++ b/core/sdk/src/clients/consumer.rs
@@ -658,6 +658,8 @@ impl IggyConsumer {
let last_consumed_offset = self.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;
+ let create_consumer_group_if_not_exists =
self.create_consumer_group_if_not_exists;
let joined_consumer_group = self.joined_consumer_group.clone();
async move {
@@ -783,6 +785,33 @@ impl IggyConsumer {
let error = polled_messages.unwrap_err();
error!("Failed to poll messages: {error}");
+ if is_consumer_group
+ && auto_join_consumer_group
+ && matches!(&error, IggyError::ConsumerGroupMemberNotFound(..))
+ {
+ joined_consumer_group.store(false, ORDERING);
+ let consumer_name = consumer.id.as_string();
+ info!(
+ "Consumer group membership was revoked for consumer:
{consumer_name}, stream: {stream_id}, topic: {topic_id}. Rejoining..."
+ );
+ if let Err(error) = Self::initialize_consumer_group(
+ client,
+ create_consumer_group_if_not_exists,
+ stream_id,
+ topic_id,
+ consumer,
+ &consumer_name,
+ joined_consumer_group.clone(),
+ )
+ .await
+ {
+ // Allow the next poll to retry rejoining
+ joined_consumer_group.store(true, ORDERING);
+ return Err(error);
+ }
+ return Ok(PolledMessages::empty());
+ }
+
// Handle connection/auth errors - disable polling until event
task re-enables
// it after reconnection and rejoin complete
if matches!(
diff --git a/foreign/python/apache_iggy.pyi b/foreign/python/apache_iggy.pyi
index 6a9ad26ba..5b5366938 100644
--- a/foreign/python/apache_iggy.pyi
+++ b/foreign/python/apache_iggy.pyi
@@ -519,6 +519,77 @@ class IggyClient:
PyValueError: If an identifier is invalid.
PyRuntimeError: If the request fails.
"""
+ def delete_consumer_group(
+ self,
+ stream_id: builtins.str | builtins.int,
+ topic_id: builtins.str | builtins.int,
+ group_id: builtins.str | builtins.int,
+ ) -> collections.abc.Awaitable[None]:
+ r"""
+ Delete a consumer group for a stream and topic.
+
+ Args:
+ stream_id: Stream identifier as `str | int`.
+ topic_id: Topic identifier as `str | int`.
+ group_id: Consumer group identifier as `str | int`.
+
+ Returns:
+ An awaitable that resolves to `None` when the consumer group is
deleted.
+
+ Raises:
+ PyValueError: If a string identifier is invalid.
+ PyRuntimeError: If the request fails.
+ """
+ def join_consumer_group(
+ self,
+ stream_id: builtins.str | builtins.int,
+ topic_id: builtins.str | builtins.int,
+ group_id: builtins.str | builtins.int,
+ ) -> collections.abc.Awaitable[None]:
+ r"""
+ Join a consumer group for a stream and topic.
+
+ This method only registers the current client as a group member. To
consume messages
+ as a group, use `consumer_group()`, which enables auto-join by default.
+
+ Args:
+ stream_id: Stream identifier as `str | int`.
+ topic_id: Topic identifier as `str | int`.
+ group_id: Consumer group identifier as `str | int`.
+
+ Returns:
+ An awaitable that resolves to `None` when the client joins the
consumer group.
+
+ Raises:
+ PyValueError: If a string identifier is invalid.
+ PyRuntimeError: If the request fails, including `Feature is
unavailable` on HTTP transport.
+ """
+ def leave_consumer_group(
+ self,
+ stream_id: builtins.str | builtins.int,
+ topic_id: builtins.str | builtins.int,
+ group_id: builtins.str | builtins.int,
+ ) -> collections.abc.Awaitable[None]:
+ r"""
+ Leave a consumer group for a stream and topic.
+
+ Args:
+ stream_id: Stream identifier as `str | int`.
+ topic_id: Topic identifier as `str | int`.
+ group_id: Consumer group identifier as `str | int`.
+
+ Returns:
+ An awaitable that resolves to `None` when the client leaves the
consumer group.
+
+ Note:
+ Consumers created from this client for the same group share one
server-side
+ membership. Leaving revokes that membership. Consumers with
auto-join enabled
+ rejoin on their next poll.
+
+ Raises:
+ PyValueError: If a string identifier is invalid.
+ PyRuntimeError: If the request fails, including `Feature is
unavailable` on HTTP transport.
+ """
def send_messages(
self,
stream: builtins.str | builtins.int,
diff --git a/foreign/python/src/client.rs b/foreign/python/src/client.rs
index b604f62c6..2a36ef333 100644
--- a/foreign/python/src/client.rs
+++ b/foreign/python/src/client.rs
@@ -515,6 +515,119 @@ impl IggyClient {
})
}
+ /// Delete a consumer group for a stream and topic.
+ ///
+ /// Args:
+ /// stream_id: Stream identifier as `str | int`.
+ /// topic_id: Topic identifier as `str | int`.
+ /// group_id: Consumer group identifier as `str | int`.
+ ///
+ /// Returns:
+ /// An awaitable that resolves to `None` when the consumer group is
deleted.
+ ///
+ /// Raises:
+ /// PyValueError: If a string identifier is invalid.
+ /// PyRuntimeError: If the request fails.
+
#[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[None]",
imports=("collections.abc")))]
+ fn delete_consumer_group<'a>(
+ &self,
+ py: Python<'a>,
+ stream_id: PyIdentifier,
+ topic_id: PyIdentifier,
+ group_id: PyIdentifier,
+ ) -> PyResult<Bound<'a, PyAny>> {
+ let stream_id = Identifier::try_from(stream_id)?;
+ let topic_id = Identifier::try_from(topic_id)?;
+ let group_id = Identifier::try_from(group_id)?;
+ let inner = self.inner.clone();
+
+ future_into_py(py, async move {
+ inner
+ .delete_consumer_group(&stream_id, &topic_id, &group_id)
+ .await
+ .map_err(|e| PyErr::new::<pyo3::exceptions::PyRuntimeError,
_>(e.to_string()))?;
+ Ok(())
+ })
+ }
+
+ /// Join a consumer group for a stream and topic.
+ ///
+ /// This method only registers the current client as a group member. To
consume messages
+ /// as a group, use `consumer_group()`, which enables auto-join by default.
+ ///
+ /// Args:
+ /// stream_id: Stream identifier as `str | int`.
+ /// topic_id: Topic identifier as `str | int`.
+ /// group_id: Consumer group identifier as `str | int`.
+ ///
+ /// Returns:
+ /// An awaitable that resolves to `None` when the client joins the
consumer group.
+ ///
+ /// Raises:
+ /// PyValueError: If a string identifier is invalid.
+ /// PyRuntimeError: If the request fails, including `Feature is
unavailable` on HTTP transport.
+
#[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[None]",
imports=("collections.abc")))]
+ fn join_consumer_group<'a>(
+ &self,
+ py: Python<'a>,
+ stream_id: PyIdentifier,
+ topic_id: PyIdentifier,
+ group_id: PyIdentifier,
+ ) -> PyResult<Bound<'a, PyAny>> {
+ let stream_id = Identifier::try_from(stream_id)?;
+ let topic_id = Identifier::try_from(topic_id)?;
+ let group_id = Identifier::try_from(group_id)?;
+ let inner = self.inner.clone();
+
+ future_into_py(py, async move {
+ inner
+ .join_consumer_group(&stream_id, &topic_id, &group_id)
+ .await
+ .map_err(|e| PyErr::new::<pyo3::exceptions::PyRuntimeError,
_>(e.to_string()))?;
+ Ok(())
+ })
+ }
+
+ /// Leave a consumer group for a stream and topic.
+ ///
+ /// Args:
+ /// stream_id: Stream identifier as `str | int`.
+ /// topic_id: Topic identifier as `str | int`.
+ /// group_id: Consumer group identifier as `str | int`.
+ ///
+ /// Returns:
+ /// An awaitable that resolves to `None` when the client leaves the
consumer group.
+ ///
+ /// Note:
+ /// Consumers created from this client for the same group share one
server-side
+ /// membership. Leaving revokes that membership. Consumers with
auto-join enabled
+ /// rejoin on their next poll.
+ ///
+ /// Raises:
+ /// PyValueError: If a string identifier is invalid.
+ /// PyRuntimeError: If the request fails, including `Feature is
unavailable` on HTTP transport.
+
#[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[None]",
imports=("collections.abc")))]
+ fn leave_consumer_group<'a>(
+ &self,
+ py: Python<'a>,
+ stream_id: PyIdentifier,
+ topic_id: PyIdentifier,
+ group_id: PyIdentifier,
+ ) -> PyResult<Bound<'a, PyAny>> {
+ let stream_id = Identifier::try_from(stream_id)?;
+ let topic_id = Identifier::try_from(topic_id)?;
+ let group_id = Identifier::try_from(group_id)?;
+ let inner = self.inner.clone();
+
+ future_into_py(py, async move {
+ inner
+ .leave_consumer_group(&stream_id, &topic_id, &group_id)
+ .await
+ .map_err(|e| PyErr::new::<pyo3::exceptions::PyRuntimeError,
_>(e.to_string()))?;
+ Ok(())
+ })
+ }
+
/// Sends a list of messages to the specified topic.
/// Returns Ok(()) on successful sending or a PyRuntimeError on failure.
#[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[None]",
imports=("collections.abc")))]
diff --git a/foreign/python/tests/test_consumer_group.py
b/foreign/python/tests/test_consumer_group.py
index d193a9a64..acb126048 100644
--- a/foreign/python/tests/test_consumer_group.py
+++ b/foreign/python/tests/test_consumer_group.py
@@ -34,6 +34,36 @@ from apache_iggy import SendMessage as Message
from .utils import get_server_config, wait_for_ping, wait_for_server
+async def _create_consumer_group_with_numeric_ids(
+ iggy_client: IggyClient,
+ unique_name,
+ group_name: str,
+) -> tuple[int, int, int]:
+ stream_name = unique_name()
+ topic_name = unique_name()
+
+ await iggy_client.create_stream(stream_name)
+ stream = await iggy_client.get_stream(stream_name)
+ assert stream is not None
+ assert isinstance(stream.id, int)
+
+ await iggy_client.create_topic(
+ stream=stream.id,
+ name=topic_name,
+ partitions_count=1,
+ )
+ topic = await iggy_client.get_topic(stream.id, topic_name)
+ assert topic is not None
+ assert isinstance(topic.id, int)
+
+ await iggy_client.create_consumer_group(stream.id, topic.id, group_name)
+ group = await iggy_client.get_consumer_group(stream.id, topic.id,
group_name)
+ assert group is not None
+ assert isinstance(group.id, int)
+
+ return stream.id, topic.id, group.id
+
+
class TestCreateConsumerGroup:
"""Test consumer group creation via create_consumer_group."""
@@ -436,6 +466,360 @@ class TestGetConsumerGroups:
await client.get_consumer_groups(unique_name(), unique_name())
+class TestDeleteConsumerGroup:
+ """Test deleting consumer groups via delete_consumer_group."""
+
+ @pytest.mark.asyncio
+ async def test_delete_consumer_group_removes_only_target_group(
+ self, iggy_client: IggyClient, unique_name
+ ):
+ """Test delete_consumer_group removes the target group only."""
+ stream_name = unique_name()
+ topic_name = unique_name()
+ group_to_delete = unique_name()
+ group_to_keep = unique_name()
+
+ await iggy_client.create_stream(stream_name)
+ await iggy_client.create_topic(
+ stream=stream_name,
+ name=topic_name,
+ partitions_count=1,
+ )
+ await iggy_client.create_consumer_group(
+ stream_name,
+ topic_name,
+ group_to_delete,
+ )
+ await iggy_client.create_consumer_group(
+ stream_name,
+ topic_name,
+ group_to_keep,
+ )
+
+ await iggy_client.delete_consumer_group(
+ stream_name,
+ topic_name,
+ group_to_delete,
+ )
+
+ assert (
+ await iggy_client.get_consumer_group(
+ stream_name,
+ topic_name,
+ group_to_delete,
+ )
+ is None
+ )
+
+ remaining = await iggy_client.get_consumer_groups(stream_name,
topic_name)
+ assert len(remaining) == 1
+ assert remaining[0].name == group_to_keep
+ assert remaining[0].partitions_count == 1
+ assert remaining[0].members_count == 0
+
+ @pytest.mark.asyncio
+ async def test_delete_consumer_group_accepts_numeric_ids(
+ self, iggy_client: IggyClient, unique_name
+ ):
+ """Test delete_consumer_group accepts numeric stream, topic, and group
ids."""
+ group_name = unique_name()
+
+ stream_id, topic_id, group_id = await
_create_consumer_group_with_numeric_ids(
+ iggy_client,
+ unique_name,
+ group_name,
+ )
+
+ await iggy_client.delete_consumer_group(stream_id, topic_id, group_id)
+
+ assert (
+ await iggy_client.get_consumer_group(stream_id, topic_id,
group_id) is None
+ )
+
+ @pytest.mark.asyncio
+ async def test_delete_nonexistent_consumer_group_fails(
+ self, iggy_client: IggyClient, unique_name
+ ):
+ """Test delete_consumer_group raises for a non-existent group."""
+ stream_name = unique_name()
+ topic_name = unique_name()
+
+ await iggy_client.create_stream(stream_name)
+ await iggy_client.create_topic(
+ stream=stream_name,
+ name=topic_name,
+ partitions_count=1,
+ )
+
+ with pytest.raises(RuntimeError, match="Consumer group with ID:"):
+ await iggy_client.delete_consumer_group(
+ stream_name,
+ topic_name,
+ unique_name(),
+ )
+
+ @pytest.mark.asyncio
+ async def test_delete_consumer_group_removes_live_member(
+ self, iggy_client: IggyClient, unique_name
+ ):
+ """Test deleting a consumer group also removes its live member."""
+ group_name = unique_name()
+
+ stream_id, topic_id, group_id = await
_create_consumer_group_with_numeric_ids(
+ iggy_client,
+ unique_name,
+ group_name,
+ )
+ await iggy_client.join_consumer_group(stream_id, topic_id, group_id)
+
+ group = await iggy_client.get_consumer_group(
+ stream_id,
+ topic_id,
+ group_id,
+ )
+ assert group is not None
+ assert group.members_count == 1
+ assert len(group.members) == 1
+
+ await iggy_client.delete_consumer_group(stream_id, topic_id, group_id)
+
+ assert (
+ await iggy_client.get_consumer_group(
+ stream_id,
+ topic_id,
+ group_id,
+ )
+ is None
+ )
+ assert await iggy_client.get_consumer_groups(stream_id, topic_id) == []
+
+
+class TestJoinConsumerGroup:
+ """Test joining consumer groups via join_consumer_group."""
+
+ @pytest.mark.asyncio
+ async def test_join_consumer_group_adds_member_once_when_repeated(
+ self, iggy_client: IggyClient, unique_name
+ ):
+ """Test join_consumer_group adds one stable member for repeated
joins."""
+ stream_name = unique_name()
+ topic_name = unique_name()
+ group_name = unique_name()
+
+ await iggy_client.create_stream(stream_name)
+ await iggy_client.create_topic(
+ stream=stream_name,
+ name=topic_name,
+ partitions_count=1,
+ )
+ await iggy_client.create_consumer_group(stream_name, topic_name,
group_name)
+
+ await iggy_client.join_consumer_group(stream_name, topic_name,
group_name)
+ await iggy_client.join_consumer_group(stream_name, topic_name,
group_name)
+
+ group = await iggy_client.get_consumer_group(
+ stream_name,
+ topic_name,
+ group_name,
+ )
+ assert group is not None
+ assert group.name == group_name
+ assert group.partitions_count == 1
+ assert group.members_count == 1
+ assert len(group.members) == 1
+ assert group.members[0].partitions_count == 1
+ assert group.members[0].partitions == [0]
+
+ @pytest.mark.asyncio
+ async def test_join_consumer_group_accepts_numeric_ids(
+ self, iggy_client: IggyClient, unique_name
+ ):
+ """Test join_consumer_group accepts numeric stream, topic, and group
ids."""
+ group_name = unique_name()
+
+ stream_id, topic_id, group_id = await
_create_consumer_group_with_numeric_ids(
+ iggy_client,
+ unique_name,
+ group_name,
+ )
+
+ await iggy_client.join_consumer_group(stream_id, topic_id, group_id)
+
+ joined_group = await iggy_client.get_consumer_group(
+ stream_id,
+ topic_id,
+ group_id,
+ )
+ assert joined_group is not None
+ assert joined_group.id == group_id
+ assert joined_group.name == group_name
+ assert joined_group.partitions_count == 1
+ assert joined_group.members_count == 1
+ assert len(joined_group.members) == 1
+ assert joined_group.members[0].partitions_count == 1
+ assert joined_group.members[0].partitions == [0]
+
+ @pytest.mark.asyncio
+ async def test_join_nonexistent_consumer_group_fails(
+ self, iggy_client: IggyClient, unique_name
+ ):
+ """Test join_consumer_group raises for a non-existent group."""
+ stream_name = unique_name()
+ topic_name = unique_name()
+
+ await iggy_client.create_stream(stream_name)
+ await iggy_client.create_topic(
+ stream=stream_name,
+ name=topic_name,
+ partitions_count=1,
+ )
+
+ with pytest.raises(RuntimeError, match="Consumer group with ID:"):
+ await iggy_client.join_consumer_group(
+ stream_name,
+ topic_name,
+ unique_name(),
+ )
+
+
+class TestLeaveConsumerGroup:
+ """Test leaving consumer groups via leave_consumer_group."""
+
+ @pytest.mark.asyncio
+ async def test_leave_consumer_group_removes_member(
+ self, iggy_client: IggyClient, unique_name
+ ):
+ """Test leave_consumer_group removes the current client from the
group."""
+ stream_name = unique_name()
+ topic_name = unique_name()
+ group_name = unique_name()
+
+ await iggy_client.create_stream(stream_name)
+ await iggy_client.create_topic(
+ stream=stream_name,
+ name=topic_name,
+ partitions_count=1,
+ )
+ await iggy_client.create_consumer_group(stream_name, topic_name,
group_name)
+ await iggy_client.join_consumer_group(stream_name, topic_name,
group_name)
+
+ await iggy_client.leave_consumer_group(stream_name, topic_name,
group_name)
+
+ group = await iggy_client.get_consumer_group(
+ stream_name,
+ topic_name,
+ group_name,
+ )
+ assert group is not None
+ assert group.name == group_name
+ assert group.partitions_count == 1
+ assert group.members_count == 0
+ assert group.members == []
+
+ @pytest.mark.asyncio
+ async def test_leave_consumer_group_accepts_numeric_ids(
+ self, iggy_client: IggyClient, unique_name
+ ):
+ """Test leave_consumer_group accepts numeric stream, topic, and group
ids."""
+ group_name = unique_name()
+
+ stream_id, topic_id, group_id = await
_create_consumer_group_with_numeric_ids(
+ iggy_client,
+ unique_name,
+ group_name,
+ )
+ await iggy_client.join_consumer_group(stream_id, topic_id, group_id)
+
+ await iggy_client.leave_consumer_group(stream_id, topic_id, group_id)
+
+ left_group = await iggy_client.get_consumer_group(
+ stream_id,
+ topic_id,
+ group_id,
+ )
+ assert left_group is not None
+ assert left_group.id == group_id
+ assert left_group.name == group_name
+ assert left_group.partitions_count == 1
+ assert left_group.members_count == 0
+ assert left_group.members == []
+
+ @pytest.mark.asyncio
+ async def test_leave_consumer_group_twice_fails_second_time(
+ self, iggy_client: IggyClient, unique_name
+ ):
+ """Test leaving a group twice raises on the second call."""
+ stream_name = unique_name()
+ topic_name = unique_name()
+ group_name = unique_name()
+
+ await iggy_client.create_stream(stream_name)
+ await iggy_client.create_topic(
+ stream=stream_name,
+ name=topic_name,
+ partitions_count=1,
+ )
+ await iggy_client.create_consumer_group(stream_name, topic_name,
group_name)
+ await iggy_client.join_consumer_group(stream_name, topic_name,
group_name)
+
+ await iggy_client.leave_consumer_group(stream_name, topic_name,
group_name)
+ with pytest.raises(
+ RuntimeError,
+ match="Consumer group member with client ID:",
+ ):
+ await iggy_client.leave_consumer_group(stream_name, topic_name,
group_name)
+
+ @pytest.mark.asyncio
+ async def test_leave_nonexistent_consumer_group_fails(
+ self, iggy_client: IggyClient, unique_name
+ ):
+ """Test leave_consumer_group raises for a non-existent group."""
+ stream_name = unique_name()
+ topic_name = unique_name()
+
+ await iggy_client.create_stream(stream_name)
+ await iggy_client.create_topic(
+ stream=stream_name,
+ name=topic_name,
+ partitions_count=1,
+ )
+
+ with pytest.raises(RuntimeError, match="Consumer group with ID:"):
+ await iggy_client.leave_consumer_group(
+ stream_name,
+ topic_name,
+ unique_name(),
+ )
+
+
[email protected](
+ "method_name",
+ [
+ "delete_consumer_group",
+ "join_consumer_group",
+ "leave_consumer_group",
+ ],
+)
[email protected]
+async def test_consumer_group_lifecycle_requires_connection_and_auth(
+ method_name, unique_name
+):
+ """Test lifecycle methods fail before connecting and before login."""
+ host, port = get_server_config()
+ wait_for_server(host, port)
+
+ client = IggyClient(f"{host}:{port}")
+ method = getattr(client, method_name)
+ identifiers = (unique_name(), unique_name(), unique_name())
+
+ with pytest.raises(RuntimeError, match="Disconnected"):
+ await method(*identifiers)
+
+ await client.connect()
+ with pytest.raises(RuntimeError, match="Unauthenticated"):
+ await method(*identifiers)
+
+
class TestConsumerGroup:
"""Test consumer group consumers."""