This is an automated email from the ASF dual-hosted git repository.
spetz 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 be3663805 feat(python): support message partitioning strategies (#3927)
be3663805 is described below
commit be3663805829492a41c971b3056f2da8680e2035
Author: Gunther Xing <[email protected]>
AuthorDate: Tue Sep 8 02:23:55 2026 +0800
feat(python): support message partitioning strategies (#3927)
Closes #3896
---
examples/python/README.md | 2 +-
examples/python/basic/producer.py | 8 +-
foreign/python/apache_iggy.pyi | 66 ++++++++++--
foreign/python/src/client.rs | 37 +++++--
foreign/python/src/lib.rs | 3 +
foreign/python/src/options.rs | 1 +
foreign/python/src/partitioning.rs | 105 ++++++++++++++++++
foreign/python/tests/test_message_operations.py | 137 ++++++++++++++++++++++++
8 files changed, 341 insertions(+), 18 deletions(-)
diff --git a/examples/python/README.md b/examples/python/README.md
index 4e5c0c740..e10810592 100644
--- a/examples/python/README.md
+++ b/examples/python/README.md
@@ -12,7 +12,7 @@ docker run --rm -p 8080:8080 -p 3000:3000 -p 8090:8090 \
-e IGGY_NODE_ADVERTISED_ADDRESS=localhost apache/iggy:latest
# Or build from source (recommended for development)
-cd ../../ && cargo run --bin iggy-server
+cd ../../ && cargo run --bin iggy-server -- --with-default-root-credentials
--fresh
```
For server configuration options and help:
diff --git a/examples/python/basic/producer.py
b/examples/python/basic/producer.py
index cf3e4b84a..13a4ae34b 100644
--- a/examples/python/basic/producer.py
+++ b/examples/python/basic/producer.py
@@ -19,7 +19,7 @@ import argparse
import asyncio
from typing import NamedTuple
-from apache_iggy import IggyClient, StreamDetails, TopicDetails
+from apache_iggy import IggyClient, Partitioning, StreamDetails, TopicDetails
from apache_iggy import SendMessage as Message
from loguru import logger
@@ -106,7 +106,11 @@ async def produce_messages(client: IggyClient):
await client.send_messages(
stream=STREAM_NAME,
topic=TOPIC_NAME,
- partitioning=PARTITION_ID,
+ # A fixed strategy sends the whole batch to this partition.
+ # For topics with multiple partitions, Partitioning.balanced()
+ # distributes batches round-robin, while
+ # Partitioning.messages_key(key) keeps equal keys on the same
partition.
+ partitioning=Partitioning.partition_id(PARTITION_ID),
messages=messages,
)
n_sent_batches += 1
diff --git a/foreign/python/apache_iggy.pyi b/foreign/python/apache_iggy.pyi
index 0400a1a51..ffed2857d 100644
--- a/foreign/python/apache_iggy.pyi
+++ b/foreign/python/apache_iggy.pyi
@@ -46,6 +46,7 @@ __all__ = [
"MaxTopicSize",
"OptionSpec",
"Partition",
+ "Partitioning",
"Permissions",
"PollingStrategy",
"QuicConfig",
@@ -1570,15 +1571,32 @@ class IggyClient:
self,
stream: builtins.str | builtins.int,
topic: builtins.str | builtins.int,
- partitioning: builtins.int,
+ partitioning: Partitioning | builtins.int,
messages: list[SendMessage],
) -> collections.abc.Awaitable[SendMessagesResponse]:
r"""
- Sends a list of messages to the specified topic.
- Returns a SendMessagesResponse carrying the per-partition commit
- confirmations, or a PyRuntimeError on failure. The confirmation list is
- empty when the server reports no offsets, and the legacy server never
- reports any.
+ Sends a batch of messages to a topic using the selected partitioning
strategy.
+
+ Args:
+ stream: Stream identifier as `str | int`.
+ topic: Topic identifier as `str | int`.
+ partitioning: A `Partitioning` strategy or an integer partition ID.
+ Use `Partitioning.balanced()`,
`Partitioning.partition_id(id)`, or
+ `Partitioning.messages_key(key)`. An integer is shorthand for
+ `Partitioning.partition_id(id)`.
+ messages: Messages to send as `list[SendMessage]`.
+
+ Returns:
+ An awaitable that resolves to `SendMessagesResponse`. Its
confirmations
+ report the committed partition and batch base offset. The list is
empty
+ when the server reports no offsets, including on the legacy server.
+
+ Raises:
+ ValueError: If a string stream or topic identifier is invalid.
+ TypeError: If `partitioning` or `messages` has an unsupported type.
+ OverflowError: If a numeric stream, topic, or partition ID is
outside
+ the supported unsigned 32-bit range.
+ RuntimeError: If the request fails.
"""
def poll_messages(
self,
@@ -1889,6 +1907,42 @@ class Partition:
The number of messages in the partition.
"""
[email protected]
+class Partitioning:
+ r"""
+ Defines how a batch of messages is assigned to a topic partition.
+ """
+ @staticmethod
+ def balanced() -> Partitioning:
+ r"""
+ Routes the batch to one partition selected by round-robin.
+ """
+ @staticmethod
+ def partition_id(partition_id: builtins.int) -> Partitioning:
+ r"""
+ Routes the batch to the specified partition.
+
+ `partition_id` must be between 0 and `2**32 - 1`. The topic must
contain
+ that partition when the batch is sent.
+
+ Raises:
+ TypeError: If `partition_id` is not an integer.
+ OverflowError: If `partition_id` is outside the supported unsigned
+ 32-bit range.
+ """
+ @staticmethod
+ def messages_key(key: builtins.str | bytes) -> Partitioning:
+ r"""
+ Routes the batch to one partition selected by hashing `key`.
+
+ `key` may be `str` or `bytes`. Strings are encoded as UTF-8; the
encoded
+ key must contain between 1 and 255 bytes.
+
+ Raises:
+ ValueError: If the encoded key is empty or exceeds 255 bytes.
+ TypeError: If `key` is not `str` or `bytes`.
+ """
+
@typing.final
class Permissions:
r"""
diff --git a/foreign/python/src/client.rs b/foreign/python/src/client.rs
index 7d19de995..168ef2f6d 100644
--- a/foreign/python/src/client.rs
+++ b/foreign/python/src/client.rs
@@ -19,7 +19,7 @@ use bytes::Bytes;
use iggy::prelude::{
AutoCommit as RustAutoCommit, Consumer as RustConsumer, IggyClient as
RustIggyClient,
IggyExpiry as RustIggyExpiry, IggyMessage as RustMessage, MaxTopicSize as
RustMaxTopicSize,
- PollingStrategy as RustPollingStrategy, *,
+ Partitioning as RustPartitioning, PollingStrategy as RustPollingStrategy,
*,
};
use pyo3::PyRef;
use pyo3::prelude::*;
@@ -40,6 +40,7 @@ use crate::consumer::{
use crate::duration::{py_delta_to_iggy_duration, reject_zero};
use crate::identifier::PyIdentifier;
use crate::options::OptionSpec as PyOptionSpec;
+use crate::partitioning::PyPartitioning;
use crate::permissions::Permissions as PyPermissions;
use crate::receive_message::{PollingStrategy, ReceiveMessage};
use crate::send_message::{SendMessage, SendMessagesResponse as
PySendMessagesResponse};
@@ -1190,18 +1191,36 @@ impl IggyClient {
})
}
- /// Sends a list of messages to the specified topic.
- /// Returns a SendMessagesResponse carrying the per-partition commit
- /// confirmations, or a PyRuntimeError on failure. The confirmation list is
- /// empty when the server reports no offsets, and the legacy server never
- /// reports any.
+ /// Sends a batch of messages to a topic using the selected partitioning
strategy.
+ ///
+ /// Args:
+ /// stream: Stream identifier as `str | int`.
+ /// topic: Topic identifier as `str | int`.
+ /// partitioning: A `Partitioning` strategy or an integer partition ID.
+ /// Use `Partitioning.balanced()`,
`Partitioning.partition_id(id)`, or
+ /// `Partitioning.messages_key(key)`. An integer is shorthand for
+ /// `Partitioning.partition_id(id)`.
+ /// messages: Messages to send as `list[SendMessage]`.
+ ///
+ /// Returns:
+ /// An awaitable that resolves to `SendMessagesResponse`. Its
confirmations
+ /// report the committed partition and batch base offset. The list is
empty
+ /// when the server reports no offsets, including on the legacy server.
+ ///
+ /// Raises:
+ /// ValueError: If a string stream or topic identifier is invalid.
+ /// TypeError: If `partitioning` or `messages` has an unsupported type.
+ /// OverflowError: If a numeric stream, topic, or partition ID is
outside
+ /// the supported unsigned 32-bit range.
+ /// RuntimeError: If the request fails.
#[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[SendMessagesResponse]",
imports=("collections.abc")))]
fn send_messages<'a>(
&self,
py: Python<'a>,
stream: PyIdentifier,
topic: PyIdentifier,
- partitioning: u32,
+ #[gen_stub(override_type(type_repr = "Partitioning | builtins.int"))]
+ partitioning: PyPartitioning,
#[gen_stub(override_type(type_repr = "list[SendMessage]"))] messages:
&Bound<'_, PyList>,
) -> PyResult<Bound<'a, PyAny>> {
let messages: Vec<SendMessage> = messages
@@ -1218,7 +1237,7 @@ impl IggyClient {
let stream = Identifier::try_from(stream)?;
let topic = Identifier::try_from(topic)?;
- let partitioning = Partitioning::partition_id(partitioning);
+ let partitioning = RustPartitioning::from(partitioning);
let inner = self.inner.clone();
future_into_py(py, async move {
@@ -1246,7 +1265,7 @@ impl IggyClient {
polling_strategy: &PollingStrategy,
count: u32,
auto_commit: bool,
- partition_id: Option<u32>,
+ #[gen_stub(override_type(type_repr = "builtins.int | None"))]
partition_id: Option<u32>,
) -> PyResult<Bound<'a, PyAny>> {
let consumer = RustConsumer::try_from(consumer)?;
let stream = Identifier::try_from(stream)?;
diff --git a/foreign/python/src/lib.rs b/foreign/python/src/lib.rs
index 80a123ee0..78b777918 100644
--- a/foreign/python/src/lib.rs
+++ b/foreign/python/src/lib.rs
@@ -21,6 +21,7 @@ mod consumer;
mod duration;
mod identifier;
mod options;
+mod partitioning;
mod permissions;
mod receive_message;
mod send_message;
@@ -39,6 +40,7 @@ use consumer::{
ConsumerGroupMember, IggyConsumer, ReceiveMessageIterator,
};
use options::OptionSpec;
+use partitioning::Partitioning;
use permissions::{GlobalPermissions, Permissions, StreamPermissions,
TopicPermissions};
use pyo3::prelude::*;
use receive_message::{PollingStrategy, ReceiveMessage};
@@ -73,6 +75,7 @@ fn apache_iggy(_py: Python, m: &Bound<'_, PyModule>) ->
PyResult<()> {
m.add_class::<IggyExpiry>()?;
m.add_class::<MaxTopicSize>()?;
m.add_class::<OptionSpec>()?;
+ m.add_class::<Partitioning>()?;
m.add_class::<Partition>()?;
m.add_class::<Consumer>()?;
m.add_class::<ConsumerGroup>()?;
diff --git a/foreign/python/src/options.rs b/foreign/python/src/options.rs
index fa489c550..a0d4fa809 100644
--- a/foreign/python/src/options.rs
+++ b/foreign/python/src/options.rs
@@ -64,6 +64,7 @@ impl OptionSpec {
/// options ride that codec.
#[gen_stub(override_return_type(type_repr = "HeaderValue | None"))]
#[getter]
+ #[gen_stub(override_return_type(type_repr = "HeaderValue | None"))]
pub fn default_value<'a>(&self, py: Python<'a>) ->
PyResult<Option<Bound<'a, HeaderValue>>> {
if self.inner.default_value.is_empty() {
return Ok(None);
diff --git a/foreign/python/src/partitioning.rs
b/foreign/python/src/partitioning.rs
new file mode 100644
index 000000000..83d9715d8
--- /dev/null
+++ b/foreign/python/src/partitioning.rs
@@ -0,0 +1,105 @@
+// 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 iggy::prelude::Partitioning as RustPartitioning;
+use pyo3::{exceptions::PyValueError, prelude::*, types::PyBytes};
+use pyo3_stub_gen::{
+ derive::{gen_stub_pyclass, gen_stub_pymethods},
+ impl_stub_type,
+};
+
+/// Defines how a batch of messages is assigned to a topic partition.
+#[derive(Clone)]
+#[pyclass(from_py_object)]
+#[gen_stub_pyclass]
+pub struct Partitioning {
+ pub(crate) inner: RustPartitioning,
+}
+
+#[gen_stub_pymethods]
+#[pymethods]
+impl Partitioning {
+ /// Routes the batch to one partition selected by round-robin.
+ #[staticmethod]
+ pub fn balanced() -> Self {
+ Self {
+ inner: RustPartitioning::balanced(),
+ }
+ }
+
+ /// Routes the batch to the specified partition.
+ ///
+ /// `partition_id` must be between 0 and `2**32 - 1`. The topic must
contain
+ /// that partition when the batch is sent.
+ ///
+ /// Raises:
+ /// TypeError: If `partition_id` is not an integer.
+ /// OverflowError: If `partition_id` is outside the supported unsigned
+ /// 32-bit range.
+ #[staticmethod]
+ pub fn partition_id(partition_id: u32) -> Self {
+ Self {
+ inner: RustPartitioning::partition_id(partition_id),
+ }
+ }
+
+ /// Routes the batch to one partition selected by hashing `key`.
+ ///
+ /// `key` may be `str` or `bytes`. Strings are encoded as UTF-8; the
encoded
+ /// key must contain between 1 and 255 bytes.
+ ///
+ /// Raises:
+ /// ValueError: If the encoded key is empty or exceeds 255 bytes.
+ /// TypeError: If `key` is not `str` or `bytes`.
+ #[staticmethod]
+ pub fn messages_key(py: Python<'_>, key: PyMessagesKey) -> PyResult<Self> {
+ let key = match key {
+ PyMessagesKey::String(key) => key.into_bytes(),
+ PyMessagesKey::Bytes(key) => key.extract::<Vec<u8>>(py)?,
+ };
+ let inner = RustPartitioning::messages_key(&key)
+ .map_err(|error| PyValueError::new_err(error.to_string()))?;
+ Ok(Self { inner })
+ }
+}
+
+#[derive(FromPyObject)]
+pub enum PyMessagesKey {
+ #[pyo3(transparent, annotation = "str")]
+ String(String),
+ #[pyo3(transparent, annotation = "bytes")]
+ Bytes(Py<PyBytes>),
+}
+impl_stub_type!(PyMessagesKey = String | PyBytes);
+
+#[derive(FromPyObject)]
+pub(crate) enum PyPartitioning {
+ #[pyo3(transparent, annotation = "Partitioning")]
+ Strategy(Partitioning),
+ #[pyo3(transparent, annotation = "int")]
+ PartitionId(u32),
+}
+impl_stub_type!(PyPartitioning = Partitioning | isize);
+
+impl From<PyPartitioning> for RustPartitioning {
+ fn from(partitioning: PyPartitioning) -> Self {
+ match partitioning {
+ PyPartitioning::Strategy(partitioning) => partitioning.inner,
+ PyPartitioning::PartitionId(partition_id) =>
Self::partition_id(partition_id),
+ }
+ }
+}
diff --git a/foreign/python/tests/test_message_operations.py
b/foreign/python/tests/test_message_operations.py
index bbf5b1d0e..af5924c4d 100644
--- a/foreign/python/tests/test_message_operations.py
+++ b/foreign/python/tests/test_message_operations.py
@@ -25,12 +25,54 @@ from apache_iggy import (
HeaderKey,
HeaderValue,
IggyClient,
+ Partitioning,
PollingStrategy,
UserHeaders,
)
from apache_iggy import SendMessage as Message
+class TestPartitioning:
+ """Test message partitioning strategy construction."""
+
+ @pytest.mark.unit
+ def test_balanced_and_partition_id_strategies(self):
+ assert isinstance(Partitioning.balanced(), Partitioning)
+ assert isinstance(Partitioning.partition_id(1), Partitioning)
+ assert isinstance(Partitioning.partition_id(2**32 - 1), Partitioning)
+
+ @pytest.mark.unit
+ @pytest.mark.parametrize("key", [b"customer-42", "customer-42", "客户-42"])
+ def test_messages_key_accepts_bytes_and_strings(self, key):
+ assert isinstance(Partitioning.messages_key(key), Partitioning)
+
+ @pytest.mark.unit
+ @pytest.mark.parametrize("key", [b"a" * 255, "a" * 255, "界" * 85])
+ def test_messages_key_accepts_255_bytes(self, key):
+ assert isinstance(Partitioning.messages_key(key), Partitioning)
+
+ @pytest.mark.unit
+ @pytest.mark.parametrize("key", [b"", "", b"a" * 256, "a" * 256, "界" * 86])
+ def test_messages_key_rejects_invalid_encoded_length(self, key):
+ with pytest.raises(ValueError):
+ Partitioning.messages_key(key)
+
+ @pytest.mark.unit
+ @pytest.mark.parametrize("partition_id", [-1, 2**32])
+ def test_partition_id_rejects_values_outside_u32(self, partition_id):
+ with pytest.raises(OverflowError):
+ Partitioning.partition_id(partition_id)
+
+ @pytest.mark.unit
+ def test_partitioning_rejects_invalid_types(self):
+ with pytest.raises(TypeError):
+ # pyrefly: ignore # bad-argument-type
+ Partitioning.partition_id("0")
+ with pytest.raises(TypeError):
+ # pyrefly: ignore # bad-argument-type
+ Partitioning.messages_key(1)
+
+
class TestMessageOperations:
"""Test message sending, polling, and processing."""
@@ -112,6 +154,101 @@ class TestMessageOperations:
assert confirmation.partition_id == partition_id
assert confirmation.base_offset == polled_messages[0].offset()
+ @pytest.mark.asyncio
+ async def test_send_messages_with_partition_id_strategy(
+ self, iggy_client: IggyClient, unique_name
+ ):
+ stream_name = unique_name()
+ topic_name = unique_name()
+ partition_id = 2
+
+ await iggy_client.create_stream(stream_name)
+ await iggy_client.create_topic(
+ stream=stream_name, name=topic_name, partitions_count=3
+ )
+
+ response = await iggy_client.send_messages(
+ stream=stream_name,
+ topic=topic_name,
+ partitioning=Partitioning.partition_id(partition_id),
+ messages=[Message("fixed partition 1"), Message("fixed partition
2")],
+ )
+
+ assert len(response.confirmations) == 1
+ assert response.confirmations[0].partition_id == partition_id
+
+ with pytest.raises(RuntimeError):
+ await iggy_client.send_messages(
+ stream=stream_name,
+ topic=topic_name,
+ partitioning=Partitioning.partition_id(3),
+ messages=[Message("missing partition")],
+ )
+
+ @pytest.mark.asyncio
+ async def test_send_messages_with_balanced_strategy(
+ self, iggy_client: IggyClient, unique_name
+ ):
+ stream_name = unique_name()
+ topic_name = unique_name()
+ partitions_count = 3
+
+ await iggy_client.create_stream(stream_name)
+ await iggy_client.create_topic(
+ stream=stream_name,
+ name=topic_name,
+ partitions_count=partitions_count,
+ )
+
+ responses = [
+ await iggy_client.send_messages(
+ stream=stream_name,
+ topic=topic_name,
+ partitioning=Partitioning.balanced(),
+ messages=[Message(f"balanced {index}")],
+ )
+ for index in range(partitions_count)
+ ]
+
+ assert all(len(response.confirmations) == 1 for response in responses)
+ assert (
+ len({response.confirmations[0].partition_id for response in
responses})
+ == partitions_count
+ )
+
+ @pytest.mark.asyncio
+ @pytest.mark.parametrize("key", [b"customer-42", "customer-42"])
+ async def test_send_messages_with_same_key_uses_same_partition(
+ self, iggy_client: IggyClient, unique_name, key
+ ):
+ 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=3
+ )
+ partitioning = Partitioning.messages_key(key)
+
+ first = await iggy_client.send_messages(
+ stream=stream_name,
+ topic=topic_name,
+ partitioning=partitioning,
+ messages=[Message("first")],
+ )
+ second = await iggy_client.send_messages(
+ stream=stream_name,
+ topic=topic_name,
+ partitioning=partitioning,
+ messages=[Message("second")],
+ )
+
+ assert len(first.confirmations) == 1
+ assert len(second.confirmations) == 1
+ assert (
+ first.confirmations[0].partition_id ==
second.confirmations[0].partition_id
+ )
+
@pytest.mark.asyncio
async def test_send_and_poll_messages_as_bytes(
self, iggy_client: IggyClient, unique_name