blob: 418b7631ea3f3bbbc52845852ed87c9ec54cedb9 [file]
# 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.
"""Tests for the high-level producer API and both send modes."""
import ast
import asyncio
import contextlib
import os
import subprocess
import sys
import time
from datetime import timedelta
from pathlib import Path
import pytest
from apache_iggy import (
BackgroundProducerConfig,
BackpressureMode,
Consumer,
DirectProducerConfig,
IggyClient,
IggyExpiry,
IggyProducer,
MaxTopicSize,
Partitioning,
PollingStrategy,
ProducerSendError,
ProducerSharding,
SendMessage,
SendMessagesResponse,
)
from .utils import wait_for_server
REPOSITORY_ROOT = Path(__file__).resolve().parents[3]
SERVER_BINARY = (
REPOSITORY_ROOT
/ "target"
/ "debug"
/ ("iggy-server.exe" if sys.platform == "win32" else "iggy-server")
)
def spawn_restartable_server(
data_path: Path,
tcp_address: str,
) -> subprocess.Popen[bytes]:
data_path.mkdir(parents=True, exist_ok=True)
environment = os.environ.copy()
# Python test connection settings use the IGGY_SERVER_ prefix, but the
# server rejects them as unknown configuration overrides.
for name in tuple(environment):
if name.startswith("IGGY_SERVER_"):
del environment[name]
environment.update(
{
"IGGY_PATH": str(data_path),
"IGGY_TCP_ADDRESS": tcp_address,
"IGGY_HTTP_ENABLED": "false",
"IGGY_QUIC_ENABLED": "false",
"IGGY_WEBSOCKET_ENABLED": "false",
"IGGY_LOGGING_LEVEL": "error",
}
)
with (data_path / "server.log").open("ab") as server_log:
return subprocess.Popen( # noqa: S603
[str(SERVER_BINARY), "--with-default-root-credentials"],
cwd=REPOSITORY_ROOT,
env=environment,
stdout=server_log,
stderr=subprocess.STDOUT,
)
async def stop_restartable_server(process: subprocess.Popen[bytes]) -> None:
if process.poll() is not None:
return
process.terminate()
try:
await asyncio.to_thread(process.wait, 10)
except subprocess.TimeoutExpired:
process.kill()
await asyncio.to_thread(process.wait)
async def discover_server_address(
data_path: Path,
process: subprocess.Popen[bytes],
timeout: float = 15,
) -> tuple[str, int]:
config_path = data_path / "runtime" / "current_config.toml"
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if process.poll() is not None:
server_log = (data_path / "server.log").read_text(errors="replace")
raise RuntimeError(
f"Restartable Iggy server exited with code {process.returncode}:\n"
f"{server_log[-4_000:]}"
)
if config_path.exists():
in_tcp_section = False
for line in config_path.read_text().splitlines():
stripped = line.strip()
if stripped.startswith("["):
in_tcp_section = stripped == "[tcp]"
elif in_tcp_section and stripped.startswith("address = "):
address = stripped.split('"', 2)[1]
host, port = address.rsplit(":", 1)
return host, int(port)
await asyncio.sleep(0.05)
raise TimeoutError("Restartable Iggy server did not publish its TCP address")
async def poll_payloads(
client: IggyClient,
stream: str,
topic: str,
*,
partition_id: int = 0,
) -> list[str]:
messages = await client.poll_messages(
stream=stream,
topic=topic,
consumer=Consumer.Single(1),
partition_id=partition_id,
polling_strategy=PollingStrategy.First(),
count=100,
auto_commit=False,
)
return [message.payload().decode("utf-8") for message in messages]
async def wait_for_payloads(
client: IggyClient,
stream: str,
topic: str,
expected: list[str],
*,
partition_id: int = 0,
timeout: float = 2,
) -> list[str]:
deadline = time.monotonic() + timeout
payloads: list[str] = []
while time.monotonic() < deadline:
payloads = await poll_payloads(
client,
stream,
topic,
partition_id=partition_id,
)
if payloads == expected:
return payloads
await asyncio.sleep(0.01)
raise AssertionError(
f"Timed out waiting for payloads {expected!r}; last payloads were {payloads!r}"
)
@pytest.mark.unit
class TestDirectProducerConfig:
"""Test direct-mode configuration independently of a server."""
def test_defaults_match_the_rust_sdk(self):
config = DirectProducerConfig()
assert config.batch_length == 1_000
assert config.linger_time == timedelta(0)
def test_fields_are_keyword_only_and_round_trip(self):
config = DirectProducerConfig(
batch_length=25,
linger_time=timedelta(milliseconds=125),
)
assert config.batch_length == 25
assert config.linger_time == timedelta(milliseconds=125)
with pytest.raises(TypeError):
# pyrefly: ignore # bad-argument-count
DirectProducerConfig(25, timedelta(milliseconds=125))
def test_zero_batch_length_retains_the_rust_sentinel(self):
assert DirectProducerConfig(batch_length=0).batch_length == 0
def test_largest_batch_length_round_trips(self):
assert DirectProducerConfig(batch_length=2**32 - 1).batch_length == 2**32 - 1
@pytest.mark.parametrize("batch_length", [-1, 2**32])
def test_batch_length_outside_u32_is_rejected(self, batch_length: int):
with pytest.raises(ValueError, match="batch_length"):
DirectProducerConfig(batch_length=batch_length)
def test_batch_length_binding_overflow_is_distinct(self):
with pytest.raises(OverflowError):
DirectProducerConfig(batch_length=2**63)
@pytest.mark.parametrize(
"linger_time",
[timedelta(microseconds=-1), timedelta(seconds=-1)],
)
def test_negative_linger_is_rejected(self, linger_time: timedelta):
with pytest.raises(ValueError, match="negative"):
DirectProducerConfig(linger_time=linger_time)
def test_linger_above_u64_microseconds_is_rejected(self):
with pytest.raises(ValueError, match="linger_time"):
DirectProducerConfig(linger_time=timedelta(days=999_999_999))
def test_wrong_field_types_are_rejected(self):
with pytest.raises(TypeError):
# pyrefly: ignore # bad-argument-type
DirectProducerConfig(batch_length="1000")
with pytest.raises(TypeError):
# pyrefly: ignore # bad-argument-type
DirectProducerConfig(linger_time=1)
def test_repr_contains_pasteable_python_values(self):
config = DirectProducerConfig(
batch_length=25,
linger_time=timedelta(milliseconds=125),
)
printed = repr(config)
assert "batch_length=25" in printed
assert "linger_time=datetime.timedelta(microseconds=125000)" in printed
ast.parse(printed)
@pytest.mark.unit
class TestBackgroundProducerSurface:
"""Test the background configuration model independently of a server."""
def test_sharding_values_are_public_and_distinct(self):
assert ProducerSharding.ORDERED != ProducerSharding.BALANCED
assert repr(ProducerSharding.ORDERED) == "ProducerSharding.ORDERED"
assert repr(ProducerSharding.BALANCED) == "ProducerSharding.BALANCED"
def test_backpressure_constructors_and_getters(self):
blocking = BackpressureMode.block()
timed = BackpressureMode.block_with_timeout(timedelta(milliseconds=250))
immediate = BackpressureMode.fail_immediately()
assert blocking.timeout is None
assert timed.timeout == timedelta(milliseconds=250)
assert immediate.timeout is None
assert repr(blocking) == "BackpressureMode.block()"
assert repr(timed) == (
"BackpressureMode.block_with_timeout("
"datetime.timedelta(microseconds=250000))"
)
assert repr(immediate) == "BackpressureMode.fail_immediately()"
def test_backpressure_timeout_validation(self):
zero = BackpressureMode.block_with_timeout(timedelta(0))
assert zero.timeout == timedelta(0)
with pytest.raises(ValueError, match="negative"):
BackpressureMode.block_with_timeout(timedelta(microseconds=-1))
with pytest.raises(TypeError):
# pyrefly: ignore # bad-argument-type
BackpressureMode.block_with_timeout(1)
def test_defaults_match_the_rust_sdk(self):
config = BackgroundProducerConfig()
assert config.num_shards == 1
assert config.linger_time == timedelta(milliseconds=1)
assert config.batch_size == 1024 * 1024
assert config.batch_length == 1_000
assert config.max_buffer_size == 32 * 1024 * 1024
assert repr(config.failure_mode) == "BackpressureMode.block()"
assert config.max_in_flight == 1
assert config.sharding == ProducerSharding.ORDERED
def test_every_field_round_trips(self):
failure_mode = BackpressureMode.block_with_timeout(timedelta(milliseconds=500))
config = BackgroundProducerConfig(
num_shards=4,
linger_time=timedelta(milliseconds=10),
batch_size=256 * 1024,
batch_length=50,
max_buffer_size=8 * 1024 * 1024,
failure_mode=failure_mode,
max_in_flight=3,
sharding=ProducerSharding.BALANCED,
)
assert config.num_shards == 4
assert config.linger_time == timedelta(milliseconds=10)
assert config.batch_size == 256 * 1024
assert config.batch_length == 50
assert config.max_buffer_size == 8 * 1024 * 1024
assert repr(config.failure_mode) == repr(failure_mode)
assert config.max_in_flight == 3
assert config.sharding == ProducerSharding.BALANCED
def test_fields_are_keyword_only_and_read_only(self):
config = BackgroundProducerConfig()
with pytest.raises(TypeError):
# pyrefly: ignore # bad-argument-count
BackgroundProducerConfig(2)
with pytest.raises(AttributeError):
# pyrefly: ignore # read-only
config.num_shards = 2
@pytest.mark.parametrize(
"field",
[
"num_shards",
"batch_size",
"batch_length",
"max_buffer_size",
"max_in_flight",
],
)
def test_unsigned_fields_reject_negative_values(self, field: str):
with pytest.raises(ValueError, match=field):
# pyrefly: ignore # bad-argument-type
BackgroundProducerConfig(**{field: -1})
@pytest.mark.parametrize(
"field",
[
"num_shards",
"batch_size",
"batch_length",
"max_buffer_size",
"max_in_flight",
],
)
def test_unsigned_fields_reject_wrong_types(self, field: str):
with pytest.raises(TypeError):
# pyrefly: ignore # bad-argument-type
BackgroundProducerConfig(**{field: "1"})
def test_max_buffer_size_checks_semantic_range_before_binding_overflow(self):
maximum = 2**64 - 1
assert (
BackgroundProducerConfig(max_buffer_size=maximum).max_buffer_size == maximum
)
with pytest.raises(ValueError, match="max_buffer_size"):
BackgroundProducerConfig(max_buffer_size=2**64)
with pytest.raises(OverflowError):
BackgroundProducerConfig(max_buffer_size=2**127)
@pytest.mark.parametrize("value", [0, 1])
def test_zero_and_one_are_preserved_for_threshold_fields(self, value: int):
config = BackgroundProducerConfig(
num_shards=value,
batch_size=value,
batch_length=value,
max_buffer_size=value,
max_in_flight=value,
)
assert config.num_shards == value
assert config.batch_size == value
assert config.batch_length == value
assert config.max_buffer_size == value
assert config.max_in_flight == value
def test_wrong_variant_and_duration_types_are_rejected(self):
with pytest.raises(TypeError):
# pyrefly: ignore # bad-argument-type
BackgroundProducerConfig(linger_time=1)
with pytest.raises(TypeError):
# pyrefly: ignore # bad-argument-type
BackgroundProducerConfig(failure_mode="block")
with pytest.raises(TypeError):
# pyrefly: ignore # bad-argument-type
BackgroundProducerConfig(sharding="ordered")
def test_negative_linger_is_rejected(self):
with pytest.raises(ValueError, match="negative"):
BackgroundProducerConfig(linger_time=timedelta(microseconds=-1))
def test_repr_contains_every_public_field(self):
config = BackgroundProducerConfig(
num_shards=2,
linger_time=timedelta(milliseconds=5),
batch_size=4_096,
batch_length=8,
max_buffer_size=65_536,
failure_mode=BackpressureMode.fail_immediately(),
max_in_flight=2,
sharding=ProducerSharding.BALANCED,
)
printed = repr(config)
for expected in [
"num_shards=2",
"linger_time=datetime.timedelta(microseconds=5000)",
"batch_size=4096",
"batch_length=8",
"max_buffer_size=65536",
"failure_mode=BackpressureMode.fail_immediately()",
"max_in_flight=2",
"sharding=ProducerSharding.BALANCED",
]:
assert expected in printed
ast.parse(printed)
class TestProducerCreation:
"""Test initialization and producer-owned resource creation."""
@pytest.mark.asyncio
async def test_default_producer_creates_resources_and_is_immediately_usable(
self, iggy_client: IggyClient, unique_name
):
stream_name = unique_name()
topic_name = unique_name()
producer = await iggy_client.producer(stream_name, topic_name)
try:
response = await producer.send_one(SendMessage("ready"))
assert isinstance(producer, IggyProducer)
assert isinstance(response, SendMessagesResponse)
assert len(response.confirmations) == 1
assert await iggy_client.get_stream(stream_name) is not None
assert await iggy_client.get_topic(stream_name, topic_name) is not None
finally:
await producer.shutdown()
@pytest.mark.asyncio
async def test_none_mode_uses_the_default_direct_mode(
self, iggy_client: IggyClient, unique_name
):
producer = await iggy_client.producer(
unique_name(),
unique_name(),
mode=None,
)
try:
response = await producer.send_one(SendMessage("default mode"))
assert len(response.confirmations) == 1
finally:
await producer.shutdown()
@pytest.mark.asyncio
async def test_existing_resources_work_when_creation_is_disabled(
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_name, topic_name, 1)
producer = await iggy_client.producer(
stream_name,
topic_name,
create_stream_if_not_exists=False,
create_topic_if_not_exists=False,
)
try:
response = await producer.send_one(SendMessage("existing"))
assert len(response.confirmations) == 1
finally:
await producer.shutdown()
@pytest.mark.asyncio
async def test_missing_stream_fails_without_returning_a_producer(
self, iggy_client: IggyClient, unique_name
):
stream_name = unique_name()
topic_name = unique_name()
not_returned = object()
result = not_returned
with pytest.raises(RuntimeError):
result = await iggy_client.producer(
stream_name,
topic_name,
create_stream_if_not_exists=False,
)
assert result is not_returned
assert await iggy_client.get_stream(stream_name) is None
@pytest.mark.asyncio
async def test_missing_topic_fails_after_creating_the_bound_stream(
self, iggy_client: IggyClient, unique_name
):
stream_name = unique_name()
topic_name = unique_name()
not_returned = object()
result = not_returned
with pytest.raises(RuntimeError):
result = await iggy_client.producer(
stream_name,
topic_name,
create_topic_if_not_exists=False,
)
assert result is not_returned
assert await iggy_client.get_stream(stream_name) is not None
assert await iggy_client.get_topic(stream_name, topic_name) is None
@pytest.mark.asyncio
async def test_created_topic_uses_requested_topology_and_retention(
self, iggy_client: IggyClient, unique_name
):
stream_name = unique_name()
topic_name = unique_name()
expiry = timedelta(minutes=15)
maximum_size = 2_000_000_000
producer = await iggy_client.producer(
stream_name,
topic_name,
topic_partitions_count=3,
topic_message_expiry=IggyExpiry.ExpireDuration(expiry),
topic_max_size=MaxTopicSize.Custom(maximum_size),
)
try:
topic = await iggy_client.get_topic(stream_name, topic_name)
assert topic is not None
assert topic.partitions_count == 3
assert isinstance(topic.message_expiry, IggyExpiry.ExpireDuration)
assert topic.message_expiry.duration == expiry
assert isinstance(topic.max_topic_size, MaxTopicSize.Custom)
assert topic.max_topic_size.bytes == maximum_size
finally:
await producer.shutdown()
@pytest.mark.asyncio
async def test_background_producer_is_initialized_and_immediately_usable(
self, iggy_client: IggyClient, unique_name
):
stream_name = unique_name("background-", min_bytes=20, max_bytes=20)
topic_name = unique_name("topic-", min_bytes=20, max_bytes=20)
producer = await iggy_client.producer(
stream_name,
topic_name,
mode=BackgroundProducerConfig(),
)
try:
response = await producer.send_one(SendMessage("ready"))
assert isinstance(producer, IggyProducer)
assert response.confirmations == []
finally:
await producer.shutdown()
assert await wait_for_payloads(
iggy_client, stream_name, topic_name, ["ready"]
) == ["ready"]
class TestProducerSends:
"""Test all direct send operations and partitioning fallbacks."""
@pytest.mark.asyncio
async def test_send_and_send_one_return_confirmations(
self, iggy_client: IggyClient, unique_name
):
producer = await iggy_client.producer(unique_name(), unique_name())
try:
batch = await producer.send([SendMessage("first"), SendMessage("second")])
single = await producer.send_one(SendMessage("third"))
assert isinstance(batch, SendMessagesResponse)
assert isinstance(single, SendMessagesResponse)
assert len(batch.confirmations) == 1
assert len(single.confirmations) == 1
assert single.confirmations[0].base_offset == 2
finally:
await producer.shutdown()
@pytest.mark.asyncio
async def test_default_producer_partitioning_is_balanced(
self, iggy_client: IggyClient, unique_name
):
producer = await iggy_client.producer(
unique_name(),
unique_name(),
topic_partitions_count=3,
)
try:
responses = [
await producer.send_one(SendMessage(str(index))) for index in range(3)
]
assert {
response.confirmations[0].partition_id for response in responses
} == {0, 1, 2}
finally:
await producer.shutdown()
@pytest.mark.asyncio
async def test_per_call_partitioning_overrides_and_falls_back(
self, iggy_client: IggyClient, unique_name
):
producer = await iggy_client.producer(
unique_name(),
unique_name(),
partitioning=Partitioning.partition_id(2),
topic_partitions_count=3,
)
try:
configured = await producer.send([SendMessage("configured")])
overridden = await producer.send_with_partitioning(
[SendMessage("override")], Partitioning.partition_id(0)
)
omitted = await producer.send_with_partitioning([SendMessage("omitted")])
explicit_none = await producer.send_with_partitioning(
[SendMessage("none")], None
)
assert configured.confirmations[0].partition_id == 2
assert overridden.confirmations[0].partition_id == 0
assert omitted.confirmations[0].partition_id == 2
assert explicit_none.confirmations[0].partition_id == 2
finally:
await producer.shutdown()
@pytest.mark.asyncio
async def test_send_to_accepts_string_and_numeric_identifiers(
self, iggy_client: IggyClient, unique_name
):
destination_stream = unique_name()
destination_topic = unique_name()
await iggy_client.create_stream(destination_stream)
await iggy_client.create_topic(destination_stream, destination_topic, 2)
stream = await iggy_client.get_stream(destination_stream)
topic = await iggy_client.get_topic(destination_stream, destination_topic)
assert stream is not None
assert topic is not None
producer = await iggy_client.producer(
unique_name(),
unique_name(),
partitioning=Partitioning.partition_id(1),
)
try:
by_name = await producer.send_to(
destination_stream,
destination_topic,
[SendMessage("names")],
)
by_id = await producer.send_to(
stream.id,
topic.id,
[SendMessage("ids")],
None,
)
assert by_name.confirmations[0].stream_id == stream.id
assert by_name.confirmations[0].topic_id == topic.id
assert by_name.confirmations[0].partition_id == 1
assert by_id.confirmations[0].stream_id == stream.id
assert by_id.confirmations[0].topic_id == topic.id
assert by_id.confirmations[0].partition_id == 1
finally:
await producer.shutdown()
@pytest.mark.asyncio
async def test_empty_batches_are_successful_no_ops_before_shutdown(
self, iggy_client: IggyClient, unique_name
):
stream_name = unique_name()
topic_name = unique_name()
producer = await iggy_client.producer(stream_name, topic_name)
try:
responses = [
await producer.send([]),
await producer.send_with_partitioning([]),
await producer.send_to(stream_name, topic_name, []),
]
assert all(
isinstance(response, SendMessagesResponse) for response in responses
)
assert all(response.confirmations == [] for response in responses)
finally:
await producer.shutdown()
@pytest.mark.asyncio
async def test_batch_length_splits_direct_requests(
self, iggy_client: IggyClient, unique_name
):
producer = await iggy_client.producer(
unique_name(),
unique_name(),
mode=DirectProducerConfig(batch_length=2),
)
try:
response = await producer.send(
[SendMessage(str(index)) for index in range(5)]
)
assert len(response.confirmations) == 3
assert [
confirmation.base_offset for confirmation in response.confirmations
] == [0, 2, 4]
finally:
await producer.shutdown()
@pytest.mark.asyncio
async def test_linger_paces_sequential_direct_sends(
self, iggy_client: IggyClient, unique_name
):
linger = timedelta(milliseconds=150)
producer = await iggy_client.producer(
unique_name(),
unique_name(),
mode=DirectProducerConfig(linger_time=linger),
)
try:
await producer.send_one(SendMessage("first"))
started_at = time.monotonic()
await producer.send_one(SendMessage("second"))
elapsed = time.monotonic() - started_at
assert elapsed >= 0.1
finally:
await producer.shutdown()
class TestBackgroundProducerSends:
"""Test background acceptance, batching, routing, and graceful flush."""
@pytest.mark.asyncio
async def test_all_send_methods_accept_messages_without_confirmations(
self, iggy_client: IggyClient, unique_name
):
stream_name = unique_name("bg-stream-", min_bytes=20, max_bytes=20)
topic_name = unique_name("bg-topic-", min_bytes=20, max_bytes=20)
destination_stream = unique_name("to-stream-", min_bytes=20, max_bytes=20)
destination_topic = unique_name("to-topic-", min_bytes=20, max_bytes=20)
await iggy_client.create_stream(destination_stream)
await iggy_client.create_topic(destination_stream, destination_topic, 1)
producer = await iggy_client.producer(
stream_name,
topic_name,
partitioning=Partitioning.partition_id(1),
mode=BackgroundProducerConfig(
num_shards=2,
linger_time=timedelta(seconds=60),
batch_size=0,
batch_length=0,
max_in_flight=2,
sharding=ProducerSharding.BALANCED,
),
topic_partitions_count=3,
)
try:
responses = [
await producer.send(
[SendMessage("batch-one"), SendMessage("batch-two")]
),
await producer.send_one(SendMessage("single")),
await producer.send_with_partitioning(
[SendMessage("override")], Partitioning.partition_id(2)
),
await producer.send_to(
destination_stream,
destination_topic,
[SendMessage("send-to")],
Partitioning.partition_id(0),
),
]
assert all(
isinstance(response, SendMessagesResponse) for response in responses
)
assert all(response.confirmations == [] for response in responses)
finally:
await producer.shutdown()
payloads = await poll_payloads(
iggy_client, stream_name, topic_name, partition_id=1
)
assert sorted(payloads) == ["batch-one", "batch-two", "single"]
assert await poll_payloads(
iggy_client, stream_name, topic_name, partition_id=2
) == ["override"]
assert await poll_payloads(
iggy_client, destination_stream, destination_topic
) == ["send-to"]
@pytest.mark.asyncio
async def test_batch_length_flushes_after_the_configured_number_of_sends(
self, iggy_client: IggyClient, unique_name
):
stream_name = unique_name("length-", min_bytes=12, max_bytes=12)
topic_name = unique_name("topic-", min_bytes=12, max_bytes=12)
producer = await iggy_client.producer(
stream_name,
topic_name,
mode=BackgroundProducerConfig(
linger_time=timedelta(seconds=5),
batch_size=0,
batch_length=2,
),
)
try:
await producer.send([SendMessage("first-a"), SendMessage("first-b")])
await asyncio.sleep(0.05)
assert await poll_payloads(iggy_client, stream_name, topic_name) == []
await producer.send_one(SendMessage("second-send"))
assert await wait_for_payloads(
iggy_client,
stream_name,
topic_name,
["first-a", "first-b", "second-send"],
) == ["first-a", "first-b", "second-send"]
finally:
await producer.shutdown()
@pytest.mark.asyncio
async def test_batch_size_flushes_after_buffered_bytes_reach_the_threshold(
self, iggy_client: IggyClient, unique_name
):
stream_name = unique_name("size-", min_bytes=12, max_bytes=12)
topic_name = unique_name("topic-", min_bytes=12, max_bytes=12)
first = "a" * 50
second = "b" * 50
producer = await iggy_client.producer(
stream_name,
topic_name,
mode=BackgroundProducerConfig(
linger_time=timedelta(seconds=5),
batch_size=200,
batch_length=0,
),
)
try:
# With two 12-byte identifiers and the 64-byte message header,
# either send is below 200 reported bytes while both exceed it.
await producer.send_one(SendMessage(first))
await asyncio.sleep(0.05)
assert await poll_payloads(iggy_client, stream_name, topic_name) == []
await producer.send_one(SendMessage(second))
assert await wait_for_payloads(
iggy_client, stream_name, topic_name, [first, second]
) == [first, second]
finally:
await producer.shutdown()
@pytest.mark.asyncio
async def test_linger_time_flushes_a_non_empty_buffer(
self, iggy_client: IggyClient, unique_name
):
stream_name = unique_name("linger-", min_bytes=12, max_bytes=12)
topic_name = unique_name("topic-", min_bytes=12, max_bytes=12)
producer = await iggy_client.producer(
stream_name,
topic_name,
mode=BackgroundProducerConfig(
linger_time=timedelta(milliseconds=200),
batch_size=0,
batch_length=0,
),
)
try:
await producer.send_one(SendMessage("linger"))
await asyncio.sleep(0.05)
assert await poll_payloads(iggy_client, stream_name, topic_name) == []
assert await wait_for_payloads(
iggy_client, stream_name, topic_name, ["linger"]
) == ["linger"]
finally:
await producer.shutdown()
@pytest.mark.asyncio
async def test_zero_sentinels_use_one_shard_and_unlimited_capacity(
self, iggy_client: IggyClient, unique_name
):
stream_name = unique_name("zero-", min_bytes=12, max_bytes=12)
topic_name = unique_name("topic-", min_bytes=12, max_bytes=12)
producer = await iggy_client.producer(
stream_name,
topic_name,
mode=BackgroundProducerConfig(
num_shards=0,
linger_time=timedelta(0),
batch_size=0,
batch_length=0,
max_buffer_size=0,
max_in_flight=0,
),
)
try:
response = await producer.send_one(SendMessage("zero sentinels"))
assert response.confirmations == []
finally:
await producer.shutdown()
assert await poll_payloads(iggy_client, stream_name, topic_name) == [
"zero sentinels"
]
@pytest.mark.asyncio
async def test_ordered_sharding_preserves_order_for_one_destination(
self, iggy_client: IggyClient, unique_name
):
stream_name = unique_name("ordered-", min_bytes=16, max_bytes=16)
topic_name = unique_name("topic-", min_bytes=16, max_bytes=16)
expected = [str(index) for index in range(30)]
producer = await iggy_client.producer(
stream_name,
topic_name,
mode=BackgroundProducerConfig(
num_shards=4,
linger_time=timedelta(0),
batch_size=0,
batch_length=1,
max_in_flight=4,
sharding=ProducerSharding.ORDERED,
),
)
try:
for payload in expected:
await producer.send_one(SendMessage(payload))
finally:
await producer.shutdown()
assert await poll_payloads(iggy_client, stream_name, topic_name) == expected
class TestBackgroundProducerBackpressure:
"""Test each byte-budget backpressure policy through the Python API."""
@staticmethod
def config(
failure_mode: BackpressureMode,
*,
linger_time: timedelta,
) -> BackgroundProducerConfig:
return BackgroundProducerConfig(
linger_time=linger_time,
batch_size=0,
batch_length=0,
max_buffer_size=250,
failure_mode=failure_mode,
)
@pytest.mark.asyncio
async def test_fail_immediately_rejects_a_send_when_the_buffer_is_full(
self, iggy_client: IggyClient, unique_name
):
producer = await iggy_client.producer(
unique_name("fail-", min_bytes=12, max_bytes=12),
unique_name("topic-", min_bytes=12, max_bytes=12),
mode=self.config(
BackpressureMode.fail_immediately(),
linger_time=timedelta(seconds=5),
),
)
try:
assert (await producer.send_one(SendMessage("a" * 128))).confirmations == []
with pytest.raises(RuntimeError, match="(?i)buffer|overflow"):
await producer.send_one(SendMessage("b" * 128))
finally:
await producer.shutdown()
@pytest.mark.asyncio
async def test_block_with_timeout_waits_then_reports_a_timeout(
self, iggy_client: IggyClient, unique_name
):
producer = await iggy_client.producer(
unique_name("timeout-", min_bytes=16, max_bytes=16),
unique_name("topic-", min_bytes=16, max_bytes=16),
mode=self.config(
BackpressureMode.block_with_timeout(timedelta(milliseconds=100)),
linger_time=timedelta(seconds=5),
),
)
try:
await producer.send_one(SendMessage("a" * 128))
started_at = time.monotonic()
with pytest.raises(RuntimeError, match="(?i)timeout"):
await producer.send_one(SendMessage("b" * 128))
elapsed = time.monotonic() - started_at
assert 0.05 <= elapsed < 1
finally:
await producer.shutdown()
@pytest.mark.asyncio
async def test_block_waits_until_a_previous_batch_releases_capacity(
self, iggy_client: IggyClient, unique_name
):
producer = await iggy_client.producer(
unique_name("block-", min_bytes=12, max_bytes=12),
unique_name("topic-", min_bytes=12, max_bytes=12),
mode=self.config(
BackpressureMode.block(),
linger_time=timedelta(milliseconds=200),
),
)
try:
await producer.send_one(SendMessage("a" * 128))
started_at = time.monotonic()
response = await producer.send_one(SendMessage("b" * 128))
elapsed = time.monotonic() - started_at
assert response.confirmations == []
assert elapsed >= 0.1
finally:
await producer.shutdown()
@pytest.mark.asyncio
async def test_batch_larger_than_the_total_budget_fails_without_blocking(
self, iggy_client: IggyClient, unique_name
):
producer = await iggy_client.producer(
unique_name("oversize-", min_bytes=16, max_bytes=16),
unique_name("topic-", min_bytes=16, max_bytes=16),
mode=BackgroundProducerConfig(
linger_time=timedelta(seconds=5),
batch_size=0,
batch_length=0,
max_buffer_size=100,
failure_mode=BackpressureMode.block(),
),
)
try:
with pytest.raises(RuntimeError, match="(?i)buffer|overflow"):
await asyncio.wait_for(
producer.send_one(SendMessage("larger than the budget")),
timeout=1,
)
finally:
await producer.shutdown()
class TestProducerLifecycle:
"""Test producer ownership, shutdown, and asynchronous context management."""
@pytest.mark.asyncio
async def test_linger_sends_run_concurrently_under_shared_lifecycle_access(
self, iggy_client: IggyClient, unique_name
):
linger_seconds = 2
producer = await iggy_client.producer(
unique_name(),
unique_name(),
mode=DirectProducerConfig(linger_time=timedelta(seconds=linger_seconds)),
)
try:
await producer.send_one(SendMessage("prime linger"))
started_at = time.monotonic()
responses = await asyncio.gather(
producer.send_one(SendMessage("concurrent one")),
producer.send_one(SendMessage("concurrent two")),
)
elapsed = time.monotonic() - started_at
assert len(responses) == 2
assert all(len(response.confirmations) == 1 for response in responses)
# Both reads wait against the same timestamp. A mutex around the
# producer would make the second wait through another full linger.
assert 1.2 <= elapsed < 3.6
finally:
await producer.shutdown()
@pytest.mark.asyncio
async def test_shutdown_is_sequentially_and_concurrently_idempotent(
self, iggy_client: IggyClient, unique_name
):
producer = await iggy_client.producer(unique_name(), unique_name())
assert await asyncio.gather(producer.shutdown(), producer.shutdown()) == [
None,
None,
]
assert await producer.shutdown() is None
@pytest.mark.asyncio
async def test_background_shutdown_is_idempotent_and_flushes_accepted_messages(
self, iggy_client: IggyClient, unique_name
):
stream_name = unique_name("shutdown-", min_bytes=20, max_bytes=20)
topic_name = unique_name("topic-", min_bytes=20, max_bytes=20)
producer = await iggy_client.producer(
stream_name,
topic_name,
mode=BackgroundProducerConfig(
linger_time=timedelta(seconds=60),
batch_size=0,
batch_length=0,
),
)
await producer.send([SendMessage("one"), SendMessage("two")])
assert await asyncio.gather(producer.shutdown(), producer.shutdown()) == [
None,
None,
]
assert await producer.shutdown() is None
assert await poll_payloads(iggy_client, stream_name, topic_name) == [
"one",
"two",
]
@pytest.mark.asyncio
async def test_every_send_rejects_use_after_shutdown(
self, iggy_client: IggyClient, unique_name
):
stream_name = unique_name()
topic_name = unique_name()
producer = await iggy_client.producer(stream_name, topic_name)
await producer.shutdown()
calls = [
producer.send([]),
producer.send_one(SendMessage("closed")),
producer.send_with_partitioning([], None),
producer.send_to(stream_name, topic_name, [], None),
]
for call in calls:
with pytest.raises(RuntimeError, match="closed|shut down"):
await call
@pytest.mark.asyncio
async def test_shutdown_waits_for_a_send_holding_lifecycle_access(
self, iggy_client: IggyClient, unique_name
):
producer = await iggy_client.producer(
unique_name(),
unique_name(),
mode=DirectProducerConfig(linger_time=timedelta(milliseconds=150)),
)
await producer.send_one(SendMessage("prime linger"))
sending = asyncio.ensure_future(producer.send_one(SendMessage("in flight")))
async def wait_for_active_send():
# pyrefly: ignore # missing-attribute
while not producer._is_send_active():
await asyncio.sleep(0)
await asyncio.wait_for(wait_for_active_send(), timeout=1)
shutting_down = asyncio.ensure_future(producer.shutdown())
response, shutdown_result = await asyncio.wait_for(
asyncio.gather(sending, shutting_down), timeout=2
)
assert len(response.confirmations) == 1
assert shutdown_result is None
with pytest.raises(RuntimeError, match="closed|shut down"):
await producer.send_one(SendMessage("too late"))
@pytest.mark.asyncio
async def test_background_send_racing_shutdown_completes_without_deadlock(
self, iggy_client: IggyClient, unique_name
):
stream_name = unique_name("race-", min_bytes=12, max_bytes=12)
topic_name = unique_name("topic-", min_bytes=12, max_bytes=12)
producer = await iggy_client.producer(
stream_name,
topic_name,
mode=BackgroundProducerConfig(
linger_time=timedelta(milliseconds=200),
batch_size=0,
batch_length=0,
max_buffer_size=250,
failure_mode=BackpressureMode.block(),
),
)
await producer.send_one(SendMessage("a" * 128))
sending = asyncio.ensure_future(producer.send_one(SendMessage("b" * 128)))
async def wait_for_blocked_send():
# pyrefly: ignore # missing-attribute
while not producer._is_send_active():
await asyncio.sleep(0)
await asyncio.wait_for(wait_for_blocked_send(), timeout=1)
shutting_down = asyncio.ensure_future(producer.shutdown())
response, shutdown_result = await asyncio.wait_for(
asyncio.gather(sending, shutting_down), timeout=2
)
assert response.confirmations == []
assert shutdown_result is None
assert await poll_payloads(iggy_client, stream_name, topic_name) == [
"a" * 128,
"b" * 128,
]
@pytest.mark.asyncio
async def test_async_context_manager_returns_self_and_closes_normally(
self, iggy_client: IggyClient, unique_name
):
producer = await iggy_client.producer(unique_name(), unique_name())
async with producer as entered:
assert entered is producer
assert (
len((await entered.send_one(SendMessage("inside"))).confirmations) == 1
)
with pytest.raises(RuntimeError, match="closed|shut down"):
await producer.send_one(SendMessage("outside"))
@pytest.mark.asyncio
async def test_async_context_manager_closes_on_exception_without_suppressing_it(
self, iggy_client: IggyClient, unique_name
):
producer = await iggy_client.producer(unique_name(), unique_name())
with pytest.raises(LookupError, match="application failure"):
async with producer:
raise LookupError("application failure")
with pytest.raises(RuntimeError, match="closed|shut down"):
await producer.send_one(SendMessage("outside"))
@pytest.mark.asyncio
@pytest.mark.parametrize(
"raises_inside", [False, True], ids=["normal", "exception"]
)
async def test_background_context_manager_flushes_on_every_exit(
self, iggy_client: IggyClient, unique_name, raises_inside: bool
):
stream_name = unique_name("context-", min_bytes=20, max_bytes=20)
topic_name = unique_name("topic-", min_bytes=20, max_bytes=20)
producer = await iggy_client.producer(
stream_name,
topic_name,
mode=BackgroundProducerConfig(
linger_time=timedelta(seconds=60),
batch_size=0,
batch_length=0,
),
)
async def use_context():
async with producer:
await producer.send_one(SendMessage("buffered"))
if raises_inside:
raise LookupError("application failure")
if raises_inside:
with pytest.raises(LookupError, match="application failure"):
await use_context()
else:
await use_context()
assert await poll_payloads(iggy_client, stream_name, topic_name) == ["buffered"]
with pytest.raises(RuntimeError, match="closed|shut down"):
await producer.send_one(SendMessage("outside"))
class TestProducerValidationAndRetries:
"""Test stable error categories and observable retry policy behavior."""
@pytest.mark.asyncio
@pytest.mark.parametrize(
("kwargs", "expected_exception"),
[
({"partitioning": 0}, TypeError),
({"mode": object()}, TypeError),
({"topic_partitions_count": -1}, ValueError),
({"topic_partitions_count": 2**32}, ValueError),
({"topic_partitions_count": 2**63}, OverflowError),
({"topic_message_expiry": timedelta(seconds=1)}, TypeError),
({"topic_max_size": 1000}, TypeError),
({"send_retries": -1}, ValueError),
({"send_retries": 2**32}, ValueError),
({"send_retries": 2**63}, OverflowError),
({"send_retry_interval": timedelta(0)}, ValueError),
({"send_retry_interval": timedelta(microseconds=-1)}, ValueError),
({"send_retry_interval": 1}, TypeError),
],
)
async def test_producer_configuration_errors_have_stable_categories(
self,
iggy_client: IggyClient,
unique_name,
kwargs: dict,
expected_exception: type[Exception],
):
with pytest.raises(expected_exception):
await iggy_client.producer(unique_name(), unique_name(), **kwargs)
@pytest.mark.asyncio
@pytest.mark.parametrize(
"mode",
[
BackgroundProducerConfig(max_buffer_size=2**64 - 1),
BackgroundProducerConfig(max_in_flight=sys.maxsize),
],
ids=["max-buffer-size", "max-in-flight"],
)
async def test_background_limits_that_would_panic_rust_are_value_errors(
self,
iggy_client: IggyClient,
unique_name,
mode: BackgroundProducerConfig,
):
with pytest.raises(ValueError, match="must not exceed"):
await iggy_client.producer(
unique_name(),
unique_name(),
mode=mode,
)
@pytest.mark.asyncio
async def test_bound_destination_requires_names(
self, iggy_client: IggyClient, unique_name
):
with pytest.raises(TypeError):
# pyrefly: ignore # bad-argument-type
await iggy_client.producer(1, unique_name())
with pytest.raises(TypeError):
# pyrefly: ignore # bad-argument-type
await iggy_client.producer(unique_name(), 1)
@pytest.mark.asyncio
@pytest.mark.parametrize(
("stream", "topic"),
[
("", "topic"),
("stream", ""),
("x" * 256, "topic"),
("stream", "x" * 256),
],
)
async def test_bound_destination_rejects_invalid_names_as_value_errors(
self, iggy_client: IggyClient, stream: str, topic: str
):
with pytest.raises(ValueError):
await iggy_client.producer(stream, topic)
@pytest.mark.asyncio
async def test_send_arguments_reject_low_level_shorthands_and_wrong_types(
self, iggy_client: IggyClient, unique_name
):
producer = await iggy_client.producer(unique_name(), unique_name())
try:
with pytest.raises(TypeError):
# pyrefly: ignore # bad-argument-type
await producer.send((SendMessage("tuple"),))
with pytest.raises(TypeError):
# pyrefly: ignore # bad-argument-type
await producer.send(["payload"])
with pytest.raises(TypeError):
# pyrefly: ignore # bad-argument-type
await producer.send_one("payload")
with pytest.raises(TypeError):
# pyrefly: ignore # bad-argument-type
await producer.send_with_partitioning([SendMessage("integer")], 0)
with pytest.raises(TypeError):
# pyrefly: ignore # bad-argument-type
await producer.send_to(object(), "topic", [SendMessage("bad stream")])
finally:
await producer.shutdown()
@pytest.mark.asyncio
@pytest.mark.parametrize("identifier", [-1, 2**32, 2**63])
async def test_send_to_identifier_overflow_is_not_folded_into_type_error(
self, iggy_client: IggyClient, unique_name, identifier: int
):
producer = await iggy_client.producer(unique_name(), unique_name())
try:
for stream, topic in [(identifier, "topic"), ("stream", identifier)]:
with pytest.raises(OverflowError):
await producer.send_to(
stream,
topic,
[SendMessage("outside u32")],
)
finally:
await producer.shutdown()
@pytest.mark.asyncio
async def test_server_send_errors_preserve_recovery_state(
self, iggy_client: IggyClient, unique_name
):
producer = await iggy_client.producer(
unique_name(),
unique_name(),
send_retries=0,
)
try:
with pytest.raises(ProducerSendError) as raised:
await producer.send_with_partitioning(
[SendMessage("missing partition")],
Partitioning.partition_id(1),
)
error = raised.value
assert isinstance(error, RuntimeError)
assert error.cause
assert isinstance(error.__cause__, RuntimeError)
assert str(error.__cause__) == error.cause
assert len(error.failed) == 1
assert isinstance(error.failed[0], SendMessage)
assert error.committed == []
finally:
await producer.shutdown()
@pytest.mark.asyncio
@pytest.mark.parametrize("send_retries", [None, 0])
async def test_none_and_zero_disable_retries(
self, iggy_client: IggyClient, unique_name, send_retries: int | None
):
producer = await iggy_client.producer(
unique_name(),
unique_name(),
send_retries=send_retries,
send_retry_interval=timedelta(seconds=5),
)
try:
with pytest.raises(RuntimeError):
await asyncio.wait_for(
producer.send_to(
unique_name(), unique_name(), [SendMessage("single attempt")]
),
timeout=1,
)
finally:
await producer.shutdown()
@pytest.mark.asyncio
async def test_explicit_none_interval_retries_without_delay(
self, iggy_client: IggyClient, unique_name
):
producer = await iggy_client.producer(
unique_name(),
unique_name(),
send_retries=10,
send_retry_interval=None,
)
try:
with pytest.raises(RuntimeError):
await asyncio.wait_for(
producer.send_to(
unique_name(), unique_name(), [SendMessage("immediate retries")]
),
timeout=1,
)
finally:
await producer.shutdown()
@pytest.mark.asyncio
async def test_custom_retry_interval_paces_later_retries(
self, iggy_client: IggyClient, unique_name
):
producer = await iggy_client.producer(
unique_name(),
unique_name(),
send_retries=2,
send_retry_interval=timedelta(milliseconds=150),
)
try:
started_at = time.monotonic()
with pytest.raises(RuntimeError):
await producer.send_to(
unique_name(), unique_name(), [SendMessage("paced retries")]
)
elapsed = time.monotonic() - started_at
assert elapsed >= 0.1
finally:
await producer.shutdown()
@pytest.mark.asyncio
async def test_default_retry_policy_retains_the_rust_interval(
self, iggy_client: IggyClient, unique_name
):
producer = await iggy_client.producer(unique_name(), unique_name())
try:
started_at = time.monotonic()
with pytest.raises(RuntimeError):
await asyncio.wait_for(
producer.send_to(
unique_name(), unique_name(), [SendMessage("default retries")]
),
timeout=6,
)
elapsed = time.monotonic() - started_at
# The first retry is immediate. Three retries with the one-second
# default wait for two later interval ticks.
assert elapsed >= 1.5
finally:
await producer.shutdown()
@pytest.mark.asyncio
async def test_background_write_retries_until_a_destination_appears(
self, iggy_client: IggyClient, unique_name
):
destination_stream = unique_name("retry-stream-", min_bytes=24, max_bytes=24)
destination_topic = unique_name("retry-topic-", min_bytes=24, max_bytes=24)
producer = await iggy_client.producer(
unique_name("bound-stream-", min_bytes=24, max_bytes=24),
unique_name("bound-topic-", min_bytes=24, max_bytes=24),
mode=BackgroundProducerConfig(
linger_time=timedelta(0),
batch_length=1,
),
send_retries=10,
send_retry_interval=timedelta(milliseconds=100),
)
try:
response = await producer.send_to(
destination_stream,
destination_topic,
[SendMessage("eventual destination")],
)
assert response.confirmations == []
# The first retry is immediate. Create the destination before a
# later interval tick so the worker can recover asynchronously.
await asyncio.sleep(0.03)
await iggy_client.create_stream(destination_stream)
await iggy_client.create_topic(destination_stream, destination_topic, 1)
finally:
await producer.shutdown()
assert await wait_for_payloads(
iggy_client,
destination_stream,
destination_topic,
["eventual destination"],
) == ["eventual destination"]
@pytest.mark.asyncio
async def test_background_write_reconnects_after_server_restart(
self,
tmp_path: Path,
unique_name,
):
data_path = tmp_path / "restartable-server"
server = spawn_restartable_server(data_path, "127.0.0.1:0")
producer: IggyProducer | None = None
try:
host, port = await discover_server_address(data_path, server)
await asyncio.to_thread(wait_for_server, host, port, 15, 1)
client = IggyClient.from_connection_string(
f"iggy+tcp://iggy:iggy@{host}:{port}"
"?reconnection_interval=100ms&reestablish_after=0"
)
await client.connect()
stream = unique_name("reconnect-stream-", min_bytes=28, max_bytes=28)
topic = unique_name("reconnect-topic-", min_bytes=28, max_bytes=28)
producer = await client.producer(
stream,
topic,
mode=BackgroundProducerConfig(
linger_time=timedelta(0),
batch_length=1,
),
send_retries=20,
send_retry_interval=timedelta(milliseconds=100),
)
await stop_restartable_server(server)
server = None
accepted = await producer.send_one(SendMessage("after restart"))
assert accepted.confirmations == []
server = spawn_restartable_server(data_path, f"{host}:{port}")
await asyncio.to_thread(wait_for_server, host, port, 15, 1)
await asyncio.wait_for(producer.shutdown(), timeout=15)
producer = None
assert await wait_for_payloads(
client,
stream,
topic,
["after restart"],
timeout=10,
) == ["after restart"]
finally:
if producer is not None:
if server is None:
server = spawn_restartable_server(data_path, f"{host}:{port}")
await asyncio.to_thread(wait_for_server, host, port, 15, 1)
with contextlib.suppress(RuntimeError, TimeoutError):
await asyncio.wait_for(producer.shutdown(), timeout=5)
if server is not None:
await stop_restartable_server(server)