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 8f83f011d feat(python): expose delete_segments on IggyClient (#4357)
8f83f011d is described below

commit 8f83f011d608d87573078ec28a470573c89b514c
Author: FenjuFu <[email protected]>
AuthorDate: Mon Oct 12 22:32:50 2026 +0800

    feat(python): expose delete_segments on IggyClient (#4357)
---
 foreign/python/apache_iggy.pyi       |  36 ++++
 foreign/python/src/client.rs         |  49 ++++++
 foreign/python/tests/test_segment.py | 325 +++++++++++++++++++++++++++++++++++
 3 files changed, 410 insertions(+)

diff --git a/foreign/python/apache_iggy.pyi b/foreign/python/apache_iggy.pyi
index 7e32341a2..b78344210 100644
--- a/foreign/python/apache_iggy.pyi
+++ b/foreign/python/apache_iggy.pyi
@@ -1661,6 +1661,42 @@ class IggyClient:
                 `manage_topics`, or per-topic `manage_topic` permission, or the
                 request fails.
         """
+    def delete_segments(
+        self,
+        stream_id: builtins.str | builtins.int,
+        topic_id: builtins.str | builtins.int,
+        partition_id: builtins.int,
+        segments_count: builtins.int,
+    ) -> collections.abc.Awaitable[None]:
+        r"""
+        Delete the oldest sealed segments of a partition, including all 
messages
+        stored in them. The active segment is never deleted, and deletion 
stops at
+        the lowest offset any consumer or consumer group has committed on the
+        partition, so fewer than `segments_count` segments may be removed at 
first.
+        The server keeps the request and removes the remaining segments later, 
up
+        to the requested count, as consumers commit past them.
+
+        Args:
+            stream_id: Stream identifier as `str | int`.
+            topic_id: Topic identifier as `str | int`.
+            partition_id: Partition ID as `int`.
+            segments_count: Maximum number of segments to delete as `int`, 
starting
+                from the oldest; `0` deletes nothing.
+
+        Returns:
+            An awaitable that resolves to `None` when deletion is accepted; 
segment
+            files are removed asynchronously.
+
+        Raises:
+            ValueError: If an identifier is invalid.
+            TypeError: If an integer identifier is outside the unsigned 32-bit 
range.
+            OverflowError: If `partition_id` or `segments_count` is outside the
+                unsigned 32-bit range.
+            RuntimeError: If the client is not authenticated, lacks global
+                `manage_streams` or `manage_topics`, per-stream 
`manage_stream` or
+                `manage_topics`, or per-topic `manage_topic` permission, the
+                stream, topic, or partition does not exist, or the request 
fails.
+        """
     def create_consumer_group(
         self,
         stream_id: builtins.str | builtins.int,
diff --git a/foreign/python/src/client.rs b/foreign/python/src/client.rs
index 7f35fb29b..1502baf0c 100644
--- a/foreign/python/src/client.rs
+++ b/foreign/python/src/client.rs
@@ -1030,6 +1030,55 @@ impl IggyClient {
         })
     }
 
+    /// Delete the oldest sealed segments of a partition, including all 
messages
+    /// stored in them. The active segment is never deleted, and deletion 
stops at
+    /// the lowest offset any consumer or consumer group has committed on the
+    /// partition, so fewer than `segments_count` segments may be removed at 
first.
+    /// The server keeps the request and removes the remaining segments later, 
up
+    /// to the requested count, as consumers commit past them.
+    ///
+    /// Args:
+    ///     stream_id: Stream identifier as `str | int`.
+    ///     topic_id: Topic identifier as `str | int`.
+    ///     partition_id: Partition ID as `int`.
+    ///     segments_count: Maximum number of segments to delete as `int`, 
starting
+    ///         from the oldest; `0` deletes nothing.
+    ///
+    /// Returns:
+    ///     An awaitable that resolves to `None` when deletion is accepted; 
segment
+    ///     files are removed asynchronously.
+    ///
+    /// Raises:
+    ///     ValueError: If an identifier is invalid.
+    ///     TypeError: If an integer identifier is outside the unsigned 32-bit 
range.
+    ///     OverflowError: If `partition_id` or `segments_count` is outside the
+    ///         unsigned 32-bit range.
+    ///     RuntimeError: If the client is not authenticated, lacks global
+    ///         `manage_streams` or `manage_topics`, per-stream 
`manage_stream` or
+    ///         `manage_topics`, or per-topic `manage_topic` permission, the
+    ///         stream, topic, or partition does not exist, or the request 
fails.
+    
#[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[None]", 
imports=("collections.abc")))]
+    fn delete_segments<'a>(
+        &self,
+        py: Python<'a>,
+        stream_id: PyIdentifier,
+        topic_id: PyIdentifier,
+        partition_id: u32,
+        segments_count: u32,
+    ) -> PyResult<Bound<'a, PyAny>> {
+        let stream_id = Identifier::try_from(stream_id)?;
+        let topic_id = Identifier::try_from(topic_id)?;
+        let inner = self.inner.clone();
+
+        future_into_py(py, async move {
+            inner
+                .delete_segments(&stream_id, &topic_id, partition_id, 
segments_count)
+                .await
+                .map_err(to_runtime_error)?;
+            Ok(Python::attach(|py| py.None()))
+        })
+    }
+
     /// Create a consumer group for a stream and topic.
     ///
     /// Args:
diff --git a/foreign/python/tests/test_segment.py 
b/foreign/python/tests/test_segment.py
new file mode 100644
index 000000000..f9b3defb6
--- /dev/null
+++ b/foreign/python/tests/test_segment.py
@@ -0,0 +1,325 @@
+# 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.
+
+import asyncio
+
+import pytest
+
+from apache_iggy import (
+    Consumer,
+    Durability,
+    IggyClient,
+    Permissions,
+    PollingStrategy,
+    SendMessage,
+    StreamPermissions,
+    TopicPermissions,
+)
+
+from .utils import login_fresh_client, unique_credentials
+
+PARTITION_ID = 0
+
+# Smallest segment size a topic may declare. With one message per batch and
+# every message flushed (messages_required_to_save=1), the on-disk size of a
+# 220_000-byte payload seals a segment after five messages and not after four,
+# mirroring the layout in the server's segment deletion scenario.
+SEGMENT_SIZE = 1024 * 1024
+PAYLOAD_SIZE = 220_000
+MESSAGES_PER_SEALED_SEGMENT = 5
+
+
+class TestSegmentManagement:
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize("numeric_ids", [False, True])
+    async def test_delete_segments_removes_oldest_sealed_segment(
+        self, iggy_client: IggyClient, unique_name, numeric_ids: bool
+    ):
+        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,
+            segment_size=SEGMENT_SIZE,
+            durability=Durability.PERSISTED,
+            messages_required_to_save=1,
+        )
+        stream = await iggy_client.get_stream(stream_name)
+        assert stream is not None
+        topic = await iggy_client.get_topic(stream.id, topic_name)
+        assert topic is not None
+        stream_id = stream.id if numeric_ids else stream_name
+        topic_id = topic.id if numeric_ids else topic_name
+        for _ in range(2 * MESSAGES_PER_SEALED_SEGMENT):
+            await iggy_client.send_messages(
+                stream_id, topic_id, PARTITION_ID, [SendMessage(b"x" * 
PAYLOAD_SIZE)]
+            )
+
+        result = await iggy_client.delete_segments(stream_id, topic_id, 
PARTITION_ID, 1)
+
+        assert result is None
+        # Deletion is acknowledged once committed; segment files are removed
+        # afterwards, so poll until the surviving offsets settle.
+        for _ in range(100):
+            polled = await iggy_client.poll_messages(
+                stream=stream_id,
+                topic=topic_id,
+                partition_id=PARTITION_ID,
+                consumer=Consumer.Single(99),
+                polling_strategy=PollingStrategy.First(),
+                count=100,
+                auto_commit=False,
+            )
+            offsets = [message.offset() for message in polled]
+            if offsets == list(range(5, 10)):
+                break
+            await asyncio.sleep(0.1)
+        assert offsets == list(range(5, 10))
+
+    @pytest.mark.asyncio
+    async def test_delete_segments_keeps_active_segment(
+        self, iggy_client: IggyClient, unique_name
+    ):
+        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,
+            segment_size=SEGMENT_SIZE,
+            durability=Durability.PERSISTED,
+            messages_required_to_save=1,
+        )
+        for _ in range(2 * MESSAGES_PER_SEALED_SEGMENT + 2):
+            await iggy_client.send_messages(
+                stream_name,
+                topic_name,
+                PARTITION_ID,
+                [SendMessage(b"x" * PAYLOAD_SIZE)],
+            )
+
+        await iggy_client.delete_segments(
+            stream_name, topic_name, PARTITION_ID, 2**32 - 1
+        )
+
+        for _ in range(100):
+            polled = await iggy_client.poll_messages(
+                stream=stream_name,
+                topic=topic_name,
+                partition_id=PARTITION_ID,
+                consumer=Consumer.Single(99),
+                polling_strategy=PollingStrategy.First(),
+                count=100,
+                auto_commit=False,
+            )
+            offsets = [message.offset() for message in polled]
+            if offsets == [10, 11]:
+                break
+            await asyncio.sleep(0.1)
+        assert offsets == [10, 11]
+
+    @pytest.mark.asyncio
+    async def test_delete_segments_stops_at_committed_consumer_offset(
+        self, iggy_client: IggyClient, unique_name
+    ):
+        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,
+            segment_size=SEGMENT_SIZE,
+            durability=Durability.PERSISTED,
+            messages_required_to_save=1,
+        )
+        for _ in range(2 * MESSAGES_PER_SEALED_SEGMENT + 2):
+            await iggy_client.send_messages(
+                stream_name,
+                topic_name,
+                PARTITION_ID,
+                [SendMessage(b"x" * PAYLOAD_SIZE)],
+            )
+        # Commit offset 5: the first sealed segment (offsets 0-4) is behind the
+        # consumer and deletable, the second (offsets 5-9) is not.
+        polled = await iggy_client.poll_messages(
+            stream=stream_name,
+            topic=topic_name,
+            partition_id=PARTITION_ID,
+            consumer=Consumer.Single(1),
+            polling_strategy=PollingStrategy.First(),
+            count=MESSAGES_PER_SEALED_SEGMENT + 1,
+            auto_commit=True,
+        )
+        assert [message.offset() for message in polled] == list(range(6))
+
+        await iggy_client.delete_segments(stream_name, topic_name, 
PARTITION_ID, 2)
+
+        for _ in range(100):
+            polled = await iggy_client.poll_messages(
+                stream=stream_name,
+                topic=topic_name,
+                partition_id=PARTITION_ID,
+                consumer=Consumer.Single(99),
+                polling_strategy=PollingStrategy.First(),
+                count=100,
+                auto_commit=False,
+            )
+            offsets = [message.offset() for message in polled]
+            if offsets == list(range(5, 12)):
+                break
+            await asyncio.sleep(0.1)
+        assert offsets == list(range(5, 12))
+
+    @pytest.mark.asyncio
+    async def test_delete_segments_zero_count_is_noop(
+        self, iggy_client: IggyClient, unique_name
+    ):
+        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,
+            segment_size=SEGMENT_SIZE,
+            durability=Durability.PERSISTED,
+            messages_required_to_save=1,
+        )
+        for _ in range(MESSAGES_PER_SEALED_SEGMENT + 1):
+            await iggy_client.send_messages(
+                stream_name,
+                topic_name,
+                PARTITION_ID,
+                [SendMessage(b"x" * PAYLOAD_SIZE)],
+            )
+
+        await iggy_client.delete_segments(stream_name, topic_name, 
PARTITION_ID, 0)
+
+        # Segment removal runs on a later reconciler pass, so give it time to
+        # act before checking that nothing was trimmed.
+        await asyncio.sleep(1.5)
+        polled = await iggy_client.poll_messages(
+            stream=stream_name,
+            topic=topic_name,
+            partition_id=PARTITION_ID,
+            consumer=Consumer.Single(99),
+            polling_strategy=PollingStrategy.First(),
+            count=100,
+            auto_commit=False,
+        )
+        assert [message.offset() for message in polled] == list(range(6))
+
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize("missing", ["stream", "topic", "partition"])
+    async def test_delete_segments_rejects_missing_target(
+        self, iggy_client: IggyClient, unique_name, missing: str
+    ):
+        stream_name = unique_name()
+        topic_name = unique_name()
+        await iggy_client.create_stream(stream_name)
+        await iggy_client.create_topic(stream_name, topic_name, 
partitions_count=1)
+        missing_name = unique_name()
+        stream_id = missing_name if missing == "stream" else stream_name
+        topic_id = missing_name if missing == "topic" else topic_name
+        partition_id = 999 if missing == "partition" else PARTITION_ID
+
+        with pytest.raises(RuntimeError, match=r"was not found\."):
+            await iggy_client.delete_segments(stream_id, topic_id, 
partition_id, 1)
+
+    @pytest.mark.asyncio
+    async def test_delete_segments_rejects_invalid_identifier(
+        self, iggy_client: IggyClient
+    ):
+        # Rejected while converting arguments, before any request is sent.
+        with pytest.raises(ValueError):
+            await iggy_client.delete_segments("", "topic", PARTITION_ID, 1)
+
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize("argument", ["partition_id", "segments_count"])
+    @pytest.mark.parametrize("value", [-1, 2**32])
+    async def test_delete_segments_rejects_out_of_range_python_integer(
+        self, iggy_client: IggyClient, argument: str, value: int
+    ):
+        # Rejected while converting arguments, before any request is sent.
+        arguments = {"partition_id": PARTITION_ID, "segments_count": 1, 
argument: value}
+
+        with pytest.raises(OverflowError):
+            await iggy_client.delete_segments("stream", "topic", **arguments)
+
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize("argument", ["stream_id", "topic_id"])
+    @pytest.mark.parametrize("value", [-1, 2**32])
+    async def test_delete_segments_rejects_out_of_range_integer_identifier(
+        self, iggy_client: IggyClient, argument: str, value: int
+    ):
+        # An identifier is either a str or an unsigned 32-bit int, so an int
+        # outside that range matches neither variant.
+        arguments = {"stream_id": 1, "topic_id": 1, argument: value}
+
+        with pytest.raises(TypeError):
+            await iggy_client.delete_segments(
+                **arguments, partition_id=0, segments_count=1
+            )
+
+    @pytest.mark.asyncio
+    async def test_delete_segments_requires_scoped_manage_topic(
+        self, iggy_client: IggyClient, unique_name
+    ):
+        stream_name = unique_name()
+        topic_name = unique_name()
+        other_topic_name = unique_name()
+        await iggy_client.create_stream(stream_name)
+        await iggy_client.create_topic(stream_name, topic_name, 
partitions_count=1)
+        await iggy_client.create_topic(
+            stream_name, other_topic_name, partitions_count=1
+        )
+        stream = await iggy_client.get_stream(stream_name)
+        assert stream is not None
+        topic = await iggy_client.get_topic(stream.id, topic_name)
+        other_topic = await iggy_client.get_topic(stream.id, other_topic_name)
+        assert topic is not None
+        assert other_topic is not None
+
+        denied_username, denied_password = unique_credentials(unique_name)
+        denied_user = await iggy_client.create_user(denied_username, 
denied_password)
+        denied = await login_fresh_client(denied_username, denied_password)
+        with pytest.raises(RuntimeError, match="Unauthorized"):
+            await denied.delete_segments(stream.id, topic.id, PARTITION_ID, 1)
+
+        allowed_username, allowed_password = unique_credentials(unique_name)
+        allowed_user = await iggy_client.create_user(
+            allowed_username,
+            allowed_password,
+            permissions=Permissions(
+                streams={
+                    stream.id: StreamPermissions(
+                        topics={topic.id: TopicPermissions(manage_topic=True)}
+                    )
+                }
+            ),
+        )
+        allowed = await login_fresh_client(allowed_username, allowed_password)
+        await allowed.delete_segments(stream.id, topic.id, PARTITION_ID, 1)
+        with pytest.raises(RuntimeError, match="Unauthorized"):
+            await allowed.delete_segments(stream.id, other_topic.id, 
PARTITION_ID, 1)
+
+        await iggy_client.delete_user(denied_user.id)
+        await iggy_client.delete_user(allowed_user.id)

Reply via email to