blob: f9b3defb6c069a4484052f3205dead5ecd78e2e0 [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.
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)