Files
ray-project--ray/python/ray/data/tests/datasource/test_kafka.py
T
2026-07-13 13:17:40 +08:00

1410 lines
44 KiB
Python

import json
import time
from datetime import datetime, timedelta, timezone
import pytest
import ray
from ray.data._internal.datasource.kafka_datasink import KafkaDatasink
from ray.data._internal.datasource.kafka_datasource import (
KafkaAuthConfig,
_build_consumer_config_for_read,
_datetime_to_ms,
)
pytest.importorskip("confluent_kafka")
def _wait_for_watermark(bootstrap_server, topic, expected_count, timeout=5):
"""Poll until the topic's high watermark reaches expected_count messages."""
from confluent_kafka import Consumer, TopicPartition
consumer = Consumer(
{
"bootstrap.servers": bootstrap_server,
"group.id": "test-watermark-poller",
}
)
try:
deadline = time.monotonic() + timeout
total = 0
while True:
remaining = deadline - time.monotonic()
if remaining <= 0:
break
metadata = consumer.list_topics(topic, timeout=remaining)
topic_meta = metadata.topics.get(topic)
if topic_meta and topic_meta.partitions:
total = 0
for pid in topic_meta.partitions:
remaining = deadline - time.monotonic()
if remaining <= 0:
break
_, high = consumer.get_watermark_offsets(
TopicPartition(topic, pid), timeout=remaining
)
total += high
if total >= expected_count:
return
time.sleep(0.1)
raise TimeoutError(
f"Timed out waiting for {expected_count} messages in topic {topic!r} "
f"(got {total})"
)
finally:
consumer.close()
@pytest.fixture(scope="session")
def kafka_container():
from testcontainers.kafka import KafkaContainer
print("\nStarting Kafka container (shared across all tests)...")
with KafkaContainer() as kafka:
bootstrap_server = kafka.get_bootstrap_server()
print(f"Kafka container started at {bootstrap_server}")
yield kafka
print("\nShutting down Kafka container...")
@pytest.fixture(scope="session")
def bootstrap_server(kafka_container):
return kafka_container.get_bootstrap_server()
def _json_value_serializer(obj, ctx):
return json.dumps(obj).encode("utf-8")
def _str_key_serializer(obj, ctx):
return obj.encode("utf-8") if obj else None
@pytest.fixture(scope="session")
def kafka_producer(bootstrap_server):
from confluent_kafka.serializing_producer import SerializingProducer
print(f"Creating shared Kafka producer for {bootstrap_server}")
producer = SerializingProducer(
{
"bootstrap.servers": bootstrap_server,
"value.serializer": _json_value_serializer,
"key.serializer": _str_key_serializer,
}
)
yield producer
producer.flush()
print("Closing shared Kafka producer")
producer.close()
@pytest.fixture(scope="session")
def kafka_consumer(bootstrap_server):
from confluent_kafka import Consumer
print(f"Creating shared Kafka consumer for {bootstrap_server}")
consumer = Consumer(
{
"bootstrap.servers": bootstrap_server,
"group.id": "ray-test-consumer",
"auto.offset.reset": "earliest",
"enable.auto.commit": False,
}
)
yield consumer
print("Closing shared Kafka consumer")
consumer.close()
def consume_messages(consumer, topic, expected_count, timeout=10):
"""Helper function to consume messages from a topic."""
from confluent_kafka import KafkaError, TopicPartition
# Discover partitions for the topic
metadata = consumer.list_topics(timeout=10)
topic_meta = metadata.topics.get(topic)
if topic_meta is None or not topic_meta.partitions:
time.sleep(2) # Wait a bit more for topic to be created
metadata = consumer.list_topics(timeout=10)
topic_meta = metadata.topics.get(topic)
if topic_meta and topic_meta.partitions:
topic_partitions = [
TopicPartition(topic, p, 0) for p in topic_meta.partitions.keys()
]
consumer.assign(topic_partitions)
messages = []
start_time = time.time()
while len(messages) < expected_count and (time.time() - start_time) < timeout:
msg = consumer.poll(timeout=1.0)
if msg is None:
continue
if msg.error():
if msg.error().code() == KafkaError._PARTITION_EOF:
continue
break
messages.append(msg)
return messages
def test_build_consumer_config_for_read():
"""Test read config builder."""
bootstrap_servers = ["localhost:9092"]
# Test basic config
config = _build_consumer_config_for_read(bootstrap_servers, None)
assert config["bootstrap.servers"] == "localhost:9092"
assert config["enable.auto.commit"] is False
assert "group.id" in config
# Test with authentication via consumer_config
user_conf = {
"security.protocol": "SASL_SSL",
"sasl.mechanism": "PLAIN",
"sasl.username": "user",
"sasl.password": "pass",
}
config_with_auth = _build_consumer_config_for_read(
bootstrap_servers, None, user_conf
)
assert config_with_auth["security.protocol"] == "SASL_SSL"
assert config_with_auth["sasl.mechanism"] == "PLAIN"
assert config_with_auth["sasl.username"] == "user"
assert config_with_auth["sasl.password"] == "pass"
def test_build_consumer_config_with_pass_through():
"""Test that extra consumer_config options pass through and cannot override bootstrap.servers."""
bootstrap_servers = ["localhost:9092"]
# Extra options should pass through
extra = {
"ssl.endpoint.identification.algorithm": "none",
"group.id": "custom-group",
"enable.auto.commit": True,
}
config = _build_consumer_config_for_read(bootstrap_servers, None, extra)
assert config["bootstrap.servers"] == "localhost:9092"
assert config["ssl.endpoint.identification.algorithm"] == "none"
assert config["group.id"] == "custom-group"
assert config["enable.auto.commit"] is True
# Attempt to override bootstrap.servers should be ignored
override = {"bootstrap.servers": "override:9092"}
config2 = _build_consumer_config_for_read(bootstrap_servers, None, override)
assert config2["bootstrap.servers"] == "localhost:9092"
def test_read_kafka_config_conflict_raises():
"""Specifying both kafka_auth_config and consumer_config should error."""
with pytest.raises(
ValueError, match="Provide only one of kafka_auth_config.* or consumer_config"
):
ray.data.read_kafka(
topics="t",
bootstrap_servers="localhost:9092",
kafka_auth_config=KafkaAuthConfig(security_protocol="SSL"),
consumer_config={"security.protocol": "SSL"},
)
def test_build_consumer_config_with_kafka_auth_config_deprecated():
"""Test kafka-python style KafkaAuthConfig mapping (deprecated path)."""
bootstrap_servers = ["localhost:9092"]
auth = KafkaAuthConfig(
security_protocol="SASL_SSL",
sasl_mechanism="SCRAM-SHA-256",
sasl_plain_username="testuser",
sasl_plain_password="testpass",
sasl_kerberos_name="kafka/hostname@REALM",
sasl_kerberos_service_name="kafka",
# These are unsupported and should be ignored with warnings
sasl_kerberos_domain_name="example.com",
sasl_oauth_token_provider=object(),
ssl_context=object(),
# ssl_check_hostname False is unsafe to map; ensure not weakening
ssl_check_hostname=False,
# SSL files
ssl_cafile="/path/to/ca.pem",
ssl_certfile="/path/to/cert.pem",
ssl_keyfile="/path/to/key.pem",
ssl_password="keypassword",
ssl_ciphers="HIGH:!aNULL",
ssl_crlfile="/path/to/crl.pem",
)
config = _build_consumer_config_for_read(bootstrap_servers, auth, None)
assert config["security.protocol"] == "SASL_SSL"
assert config["sasl.mechanism"] == "SCRAM-SHA-256"
assert config["sasl.username"] == "testuser"
assert config["sasl.password"] == "testpass"
assert config["sasl.kerberos.principal"] == "kafka/hostname@REALM"
assert config["sasl.kerberos.service.name"] == "kafka"
# No weakening of TLS verification
assert "enable.ssl.certificate.verification" not in config
assert config["ssl.ca.location"] == "/path/to/ca.pem"
assert config["ssl.certificate.location"] == "/path/to/cert.pem"
assert config["ssl.key.location"] == "/path/to/key.pem"
assert config["ssl.key.password"] == "keypassword"
assert config["ssl.cipher.suites"] == "HIGH:!aNULL"
assert config["ssl.crl.location"] == "/path/to/crl.pem"
def test_datetime_to_ms_without_timezone():
"""Test that datetimes without timezone info are treated as UTC."""
assert _datetime_to_ms(datetime(1970, 1, 1, 0, 0, 0)) == 0
assert _datetime_to_ms(datetime(2025, 1, 1, 0, 0, 0)) == 1735689600000
def test_datetime_to_ms_with_timezone():
"""Test that timezone-aware datetimes are converted to UTC correctly."""
from datetime import timezone
assert _datetime_to_ms(datetime(1970, 1, 1, 0, 0, 0, tzinfo=timezone.utc)) == 0
assert (
_datetime_to_ms(datetime(2025, 1, 1, 0, 0, 0, tzinfo=timezone.utc))
== 1735689600000
)
def test_read_kafka_datetime_validation():
"""Test that start_offset > end_offset with datetimes raises ValueError."""
with pytest.raises(ValueError, match="start_offset must be less than end_offset"):
ray.data.read_kafka(
topics="test-topic",
bootstrap_servers="localhost:9092",
start_offset=datetime(2025, 6, 1),
end_offset=datetime(2025, 1, 1),
)
# Integration Tests (require Kafka container)
def test_read_kafka_basic(bootstrap_server, kafka_producer, ray_start_regular_shared):
topic = "test-basic"
for i in range(100):
message = {"id": i, "value": f"message-{i}"}
kafka_producer.produce(topic, value=message, key=f"key-{i}")
kafka_producer.flush()
_wait_for_watermark(bootstrap_server, topic, 100)
# Read from Kafka
ds = ray.data.read_kafka(
topics=[topic],
bootstrap_servers=[bootstrap_server],
)
# Verify data
records = ds.take_all()
assert len(records) == 100
# Check first record structure
first_record = records[0]
assert "offset" in first_record
assert "key" in first_record
assert "value" in first_record
assert "topic" in first_record
assert "partition" in first_record
assert "timestamp" in first_record
assert first_record["topic"] == topic
# Verify data types: key is bytes, value is binary
assert isinstance(first_record["key"], bytes)
assert isinstance(first_record["value"], bytes)
key_str = first_record["key"].decode("utf-8")
assert key_str.startswith("key-")
value_obj = json.loads(first_record["value"].decode("utf-8"))
assert "id" in value_obj
assert "value" in value_obj
@pytest.mark.parametrize(
"total_messages,start_offset,end_offset,expected_count,test_id",
[
(100, 20, 80, 60, "both-set"),
(100, 50, None, 50, "start-offset-only"),
(100, None, 50, 50, "end-offset-only"),
(100, None, None, 100, "both-none"),
(100, "earliest", 30, 30, "earliest-start-offset-number-end-offset"),
(100, 50, "latest", 50, "number-start-offset-number-end-offset"),
],
)
def test_read_kafka_with_offsets(
bootstrap_server,
kafka_producer,
ray_start_regular_shared,
total_messages,
start_offset,
end_offset,
expected_count,
test_id,
):
topic = f"test-{test_id}"
for i in range(total_messages):
message = {"id": i, "value": f"message-{i}"}
kafka_producer.produce(topic, value=message)
kafka_producer.flush()
_wait_for_watermark(bootstrap_server, topic, total_messages)
ds = ray.data.read_kafka(
topics=[topic],
bootstrap_servers=[bootstrap_server],
start_offset=start_offset,
end_offset=end_offset,
)
records = ds.take_all()
assert len(records) == expected_count
def test_read_kafka_multiple_partitions(
bootstrap_server, kafka_producer, ray_start_regular_shared
):
from confluent_kafka.admin import AdminClient, NewTopic
topic = "test-multi-partition"
# Create topic with 3 partitions
admin_client = AdminClient({"bootstrap.servers": bootstrap_server})
topic_config = NewTopic(topic, num_partitions=3, replication_factor=1)
admin_client.create_topics([topic_config])
time.sleep(2) # Wait for topic creation
# Send messages to different partitions
for i in range(150):
message = {"id": i, "value": f"message-{i}"}
partition = i % 3
kafka_producer.produce(topic, value=message, partition=partition)
kafka_producer.flush()
_wait_for_watermark(bootstrap_server, topic, 150)
# Read from all partitions
ds = ray.data.read_kafka(
topics=[topic],
bootstrap_servers=[bootstrap_server],
)
records = ds.take_all()
assert len(records) == 150
def test_read_kafka_multiple_topics(
bootstrap_server, kafka_producer, ray_start_regular_shared
):
topic1 = "test-multi-topic-1"
topic2 = "test-multi-topic-2"
# Send messages to topic1
for i in range(50):
message = {"id": i, "value": f"topic1-message-{i}"}
kafka_producer.produce(topic1, value=message)
# Send messages to topic2
for i in range(30):
message = {"id": i, "value": f"topic2-message-{i}"}
kafka_producer.produce(topic2, value=message)
kafka_producer.flush()
_wait_for_watermark(bootstrap_server, topic1, 50)
_wait_for_watermark(bootstrap_server, topic2, 30)
# Read from both topics
ds = ray.data.read_kafka(
topics=[topic1, topic2],
bootstrap_servers=[bootstrap_server],
)
records = ds.take_all()
assert len(records) == 80
def test_read_kafka_with_message_headers(
bootstrap_server, kafka_producer, ray_start_regular_shared
):
topic = "test-headers"
for i in range(10):
message = {"id": i, "value": f"message-{i}"}
headers = [
("header1", b"value1"),
("header2", f"value-{i}".encode("utf-8")),
]
kafka_producer.produce(topic, value=message, headers=headers)
kafka_producer.flush()
_wait_for_watermark(bootstrap_server, topic, 10)
ds = ray.data.read_kafka(
topics=[topic],
bootstrap_servers=[bootstrap_server],
)
records = ds.take_all()
assert len(records) == 10
first_record = records[0]
assert "headers" in first_record
assert isinstance(first_record["headers"]["header1"], bytes)
assert first_record["headers"]["header1"].decode("utf-8") == "value1"
@pytest.mark.parametrize(
"start_offset,end_offset,expected_count,test_id",
[
(0, 150, 100, "end-offset-exceeds-available-messages"),
(
"earliest",
150,
100,
"earliest-start-offset-end-offset-exceeds-available-messages",
),
],
)
def test_read_kafka_offset_exceeds_available_messages(
bootstrap_server,
kafka_producer,
ray_start_regular_shared,
start_offset,
end_offset,
expected_count,
test_id,
):
topic = f"test-offset-timeout-{test_id}"
for i in range(100):
message = {"id": i, "value": f"message-{i}"}
kafka_producer.produce(topic, value=message)
kafka_producer.flush()
_wait_for_watermark(bootstrap_server, topic, 100)
# end_offset exceeds available messages, but it gets clamped to the high
# watermark during offset resolution, so the read completes without
# hanging.
ds = ray.data.read_kafka(
topics=[topic],
bootstrap_servers=[bootstrap_server],
start_offset=start_offset,
end_offset=end_offset,
)
records = ds.take_all()
assert len(records) == expected_count
def test_read_kafka_default_no_timeout(
bootstrap_server, kafka_producer, ray_start_regular_shared
):
topic = "test-default-no-timeout"
for i in range(50):
message = {"id": i, "value": f"message-{i}"}
kafka_producer.produce(topic, value=message)
kafka_producer.flush()
_wait_for_watermark(bootstrap_server, topic, 50)
# timeout_ms is intentionally omitted (defaults to None).
# Verifies the no-timeout code path works correctly.
ds = ray.data.read_kafka(
topics=[topic],
bootstrap_servers=[bootstrap_server],
start_offset=0,
end_offset=50,
)
records = ds.take_all()
assert len(records) == 50
def test_read_kafka_invalid_topic(bootstrap_server, ray_start_regular_shared):
with pytest.raises(ValueError, match="has no partitions or doesn't exist"):
ds = ray.data.read_kafka(
topics=["non-existent-topic"],
bootstrap_servers=[bootstrap_server],
)
ds.take_all()
@pytest.mark.parametrize(
"start_offset,end_offset,expected_error,topic",
[
(0, "earliest", "end_offset cannot be 'earliest'", "test-invalid-offsets-0"),
("latest", 1000, "start_offset cannot be 'latest'", "test-invalid-offsets-1"),
(80, 20, "start_offset must be less than end_offset", "test-invalid-offsets-2"),
(
150,
"latest",
r"start_offset \(150\) > end_offset \(latest \(resolved to 100\)\) for partition 0 in topic test-invalid-offsets-3",
"test-invalid-offsets-3",
),
(
150,
200,
r"start_offset \(150\) > end_offset \(200 \(resolved to 100\)\) for partition 0 in topic test-invalid-offsets-4",
"test-invalid-offsets-4",
),
],
)
def test_read_kafka_invalid_offsets(
bootstrap_server,
kafka_producer,
ray_start_regular_shared,
start_offset,
end_offset,
expected_error,
topic,
):
for i in range(100):
message = {"id": i, "value": f"message-{i}"}
kafka_producer.produce(topic, value=message)
kafka_producer.flush()
_wait_for_watermark(bootstrap_server, topic, 100)
with pytest.raises(ValueError, match=expected_error):
ds = ray.data.read_kafka(
topics=[topic],
bootstrap_servers=[bootstrap_server],
start_offset=start_offset,
end_offset=end_offset,
)
ds.take_all()
def test_read_kafka_with_datetime_offsets(
bootstrap_server, kafka_producer, ray_start_regular_shared
):
"""Test reading Kafka messages using datetime-based start and end offsets."""
topic = "test-datetime-offsets"
now = datetime.now(timezone.utc)
time_before = now - timedelta(hours=1)
time_after = now + timedelta(hours=1)
msg_ts = _datetime_to_ms(now)
for i in range(3):
kafka_producer.produce(topic, value={"id": i}, timestamp=msg_ts)
kafka_producer.flush()
_wait_for_watermark(bootstrap_server, topic, 3)
ds = ray.data.read_kafka(
topics=[topic],
bootstrap_servers=[bootstrap_server],
start_offset=time_before,
end_offset=time_after,
)
records = ds.take_all()
assert len(records) == 3
def test_read_kafka_datetime_partial_range(
bootstrap_server, kafka_producer, ray_start_regular_shared
):
"""Test that only messages within the datetime range are returned."""
topic = "test-datetime-partial-range"
# First batch at Jan 1 2025, second batch at Feb 1 2025
batch1_ts = _datetime_to_ms(datetime(2025, 1, 1))
batch2_ts = _datetime_to_ms(datetime(2025, 2, 1))
boundary_time = datetime(2025, 1, 15) # Between the two batches
kafka_producer.produce(
topic,
value={"batch": 1, "id": 0},
timestamp=batch1_ts,
)
kafka_producer.produce(
topic,
value={"batch": 1, "id": 1},
timestamp=batch1_ts,
)
kafka_producer.produce(
topic,
value={"batch": 2, "id": 0},
timestamp=batch2_ts,
)
kafka_producer.produce(
topic,
value={"batch": 2, "id": 1},
timestamp=batch2_ts,
)
kafka_producer.flush()
_wait_for_watermark(bootstrap_server, topic, 4)
# Read only the second batch using boundary_time as start
ds = ray.data.read_kafka(
topics=[topic],
bootstrap_servers=[bootstrap_server],
start_offset=boundary_time,
end_offset="latest",
)
records = ds.take_all()
assert len(records) == 2
# Read only the first batch using boundary_time as end
ds = ray.data.read_kafka(
topics=[topic],
bootstrap_servers=[bootstrap_server],
start_offset="earliest",
end_offset=boundary_time,
)
records = ds.take_all()
assert len(records) == 2
def test_read_kafka_datetime_after_all_messages(
bootstrap_server, kafka_producer, ray_start_regular_shared
):
"""Test datetime start_offset after all messages returns 0 rows."""
topic = "test-datetime-after-all"
kafka_producer.produce(topic, value={"id": 0})
kafka_producer.flush()
_wait_for_watermark(bootstrap_server, topic, 1)
future_time = datetime(2099, 1, 1)
ds = ray.data.read_kafka(
topics=[topic],
bootstrap_servers=[bootstrap_server],
start_offset=future_time,
end_offset="latest",
)
records = ds.take_all()
assert len(records) == 0
def test_read_kafka_datetime_before_all_messages(
bootstrap_server, kafka_producer, ray_start_regular_shared
):
"""Test datetime end_offset before all messages returns 0 rows."""
topic = "test-datetime-before-all"
kafka_producer.produce(topic, value={"id": 0})
kafka_producer.flush()
_wait_for_watermark(bootstrap_server, topic, 1)
past_time = datetime(1970, 1, 2)
ds = ray.data.read_kafka(
topics=[topic],
bootstrap_servers=[bootstrap_server],
start_offset="earliest",
end_offset=past_time,
)
records = ds.take_all()
# All messages have timestamps after 1970, so offsets_for_times will
# return the first offset. This means end_offset resolves to the
# beginning, yielding 0 rows.
assert len(records) == 0
# Kafka Datasink Unit Tests
def test_kafka_datasink_initialization():
"""Test KafkaDatasink initialization and validation."""
# Valid initialization
sink = KafkaDatasink(
topic="test-topic",
bootstrap_servers="localhost:9092",
key_field="id",
key_serializer="string",
value_serializer="json",
)
assert sink.topic == "test-topic"
assert sink.bootstrap_servers == "localhost:9092"
assert sink.key_field == "id"
assert sink.key_serializer == "string"
assert sink.value_serializer == "json"
# Invalid key serializer
with pytest.raises(ValueError, match="key_serializer must be one of"):
KafkaDatasink(
topic="test-topic",
bootstrap_servers="localhost:9092",
key_serializer="invalid",
)
# Invalid value serializer
with pytest.raises(ValueError, match="value_serializer must be one of"):
KafkaDatasink(
topic="test-topic",
bootstrap_servers="localhost:9092",
value_serializer="invalid",
)
def test_kafka_datasink_row_to_dict():
"""Test _row_to_dict conversion for different row types."""
sink = KafkaDatasink(topic="test", bootstrap_servers="localhost:9092")
# Test with dict
dict_row = {"a": 1, "b": 2}
assert sink._row_to_dict(dict_row) == dict_row
# Test with object that has as_pydict
class MockArrowRow:
def as_pydict(self):
return {"x": 10, "y": 20}
arrow_row = MockArrowRow()
assert sink._row_to_dict(arrow_row) == {"x": 10, "y": 20}
# Test with NamedTuple
from collections import namedtuple
Point = namedtuple("Point", ["x", "y"])
point = Point(x=5, y=10)
result = sink._row_to_dict(point)
assert result == {"x": 5, "y": 10}
# Test with primitive
assert sink._row_to_dict("string") == "string"
assert sink._row_to_dict(123) == 123
def test_kafka_datasink_serialize_value():
"""Test value serialization for different formats."""
# JSON serializer
sink_json = KafkaDatasink(
topic="test", bootstrap_servers="localhost:9092", value_serializer="json"
)
result = sink_json._serialize_value({"key": "value"})
assert result == b'{"key": "value"}'
# String serializer
sink_string = KafkaDatasink(
topic="test", bootstrap_servers="localhost:9092", value_serializer="string"
)
result = sink_string._serialize_value({"key": "value"})
assert result == b"{'key': 'value'}"
# Bytes serializer
sink_bytes = KafkaDatasink(
topic="test", bootstrap_servers="localhost:9092", value_serializer="bytes"
)
result = sink_bytes._serialize_value(b"raw bytes")
assert result == b"raw bytes"
def test_kafka_datasink_serialize_key():
"""Test key serialization for different formats."""
# JSON serializer
sink_json = KafkaDatasink(
topic="test", bootstrap_servers="localhost:9092", key_serializer="json"
)
result = sink_json._serialize_key({"id": 123})
assert result == b'{"id": 123}'
# String serializer (default)
sink_string = KafkaDatasink(
topic="test", bootstrap_servers="localhost:9092", key_serializer="string"
)
result = sink_string._serialize_key(456)
assert result == b"456"
# Bytes serializer
sink_bytes = KafkaDatasink(
topic="test", bootstrap_servers="localhost:9092", key_serializer="bytes"
)
result = sink_bytes._serialize_key(b"key-bytes")
assert result == b"key-bytes"
def test_kafka_datasink_extract_key():
"""Test key extraction from rows."""
sink = KafkaDatasink(
topic="test",
bootstrap_servers="localhost:9092",
key_field="user_id",
key_serializer="string",
)
# Test with dict containing key_field
row = {"user_id": 123, "name": "Alice"}
key = sink._extract_key(row)
assert key == b"123"
# Test with dict without key_field
row_no_key = {"name": "Bob"}
key = sink._extract_key(row_no_key)
assert key is None
# Test with no key_field configured
sink_no_key = KafkaDatasink(topic="test", bootstrap_servers="localhost:9092")
key = sink_no_key._extract_key({"user_id": 456})
assert key is None
def test_kafka_datasink_extract_key_uses_serializer():
"""Test that _extract_key properly uses the configured key_serializer."""
# JSON serializer should produce valid JSON
sink_json = KafkaDatasink(
topic="test",
bootstrap_servers="localhost:9092",
key_field="key",
key_serializer="json",
)
# Test with dict key value - JSON uses double quotes, str() uses single quotes
row_dict = {"key": {"nested": "value"}, "data": "test"}
key = sink_json._extract_key(row_dict)
assert key == b'{"nested": "value"}' # JSON format with double quotes
# Test with string that needs JSON escaping
row_str = {"key": 'hello "world"', "data": "test"}
key = sink_json._extract_key(row_str)
assert key == b'"hello \\"world\\""' # Properly JSON-escaped
# Bytes serializer should preserve bytes
sink_bytes = KafkaDatasink(
topic="test",
bootstrap_servers="localhost:9092",
key_field="key",
key_serializer="bytes",
)
row_bytes = {"key": b"raw-bytes", "data": "test"}
key = sink_bytes._extract_key(row_bytes)
assert key == b"raw-bytes"
@pytest.fixture
def mock_write_env():
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
sink = KafkaDatasink(topic="test-topic", bootstrap_servers="localhost:9092")
mock_producer = MagicMock()
mock_producer.flush.return_value = 0
mock_block = MagicMock()
mock_accessor = MagicMock()
mock_accessor.iter_rows.return_value = [{"id": 1}]
with (
patch("confluent_kafka.Producer", return_value=mock_producer),
patch(
"ray.data._internal.datasource.kafka_datasink.BlockAccessor.for_block",
return_value=mock_accessor,
),
):
yield SimpleNamespace(
sink=sink,
producer=mock_producer,
block=mock_block,
accessor=mock_accessor,
ctx=MagicMock(),
)
def test_write_delivery_failure_raises(mock_write_env):
"""Test that any delivery failure raises RuntimeError."""
from unittest.mock import MagicMock
from confluent_kafka import KafkaError
env = mock_write_env
mock_kafka_error = MagicMock(spec=KafkaError)
mock_kafka_error.str.return_value = "MSG_SIZE_TOO_LARGE"
def fake_produce(topic, value, key, on_delivery):
on_delivery(mock_kafka_error, None)
env.producer.produce.side_effect = fake_produce
with pytest.raises(RuntimeError, match="Failed to write 1 out of 1"):
env.sink.write([env.block], ctx=env.ctx)
def test_write_in_flight_after_flush_raises(mock_write_env):
"""Test that messages stuck in-flight after flush raises RuntimeError."""
env = mock_write_env
env.producer.produce.return_value = None
env.producer.flush.return_value = 1 # 1 message still in-flight
with pytest.raises(
RuntimeError,
match="1 out of 1 messages were still in-flight after flush timeout",
):
env.sink.write([env.block], ctx=env.ctx)
def test_write_buffer_error_retry(mock_write_env):
"""Test that BufferError triggers poll and retry, then succeeds."""
env = mock_write_env
# First produce() raises BufferError, retry succeeds
env.producer.produce.side_effect = [BufferError("queue full"), None]
result = env.sink.write([env.block], ctx=env.ctx)
assert result["total_records"] == 1
assert result["failed_messages"] == 0
def test_write_buffer_error_persistent_raises(mock_write_env):
"""Test that persistent BufferError (retry also fails) raises RuntimeError."""
env = mock_write_env
env.producer.produce.side_effect = BufferError("queue full")
with pytest.raises(RuntimeError, match="producer queue is still full"):
env.sink.write([env.block], ctx=env.ctx)
def test_write_success_returns_stats(mock_write_env):
"""Test successful write returns correct stats."""
env = mock_write_env
env.producer.produce.return_value = None
env.accessor.iter_rows.return_value = [{"id": i} for i in range(3)]
result = env.sink.write([env.block], ctx=env.ctx)
assert result == {"total_records": 3, "failed_messages": 0}
# Kafka Datasink Integration Tests (require Kafka container)
def test_write_kafka_basic(bootstrap_server, kafka_consumer, ray_start_regular_shared):
"""Test basic write to Kafka."""
topic = "test-write-basic"
# Create dataset
ds = ray.data.range(100)
# Write to Kafka
ds.write_kafka(topic=topic, bootstrap_servers=bootstrap_server)
# Consume and verify
messages = consume_messages(kafka_consumer, topic, expected_count=100)
assert len(messages) == 100
# Verify message structure
first_msg = messages[0]
assert first_msg.topic() == topic
assert first_msg.key() is None # No key field specified
# Verify value is JSON encoded
value = json.loads(first_msg.value().decode("utf-8"))
assert "id" in value
assert isinstance(value["id"], int)
def test_write_kafka_with_keys(
bootstrap_server, kafka_consumer, ray_start_regular_shared
):
"""Test writing to Kafka with keys."""
topic = "test-write-with-keys"
# Create dataset with id field
data = [{"id": i, "name": f"user-{i}", "value": i * 10} for i in range(50)]
ds = ray.data.from_items(data)
# Write to Kafka with id as key
ds.write_kafka(
topic=topic,
bootstrap_servers=bootstrap_server,
key_field="id",
key_serializer="string",
)
# Consume and verify
messages = consume_messages(kafka_consumer, topic, expected_count=50)
assert len(messages) == 50
# Verify keys
for msg in messages:
assert msg.key() is not None
key_str = msg.key().decode("utf-8")
assert key_str.isdigit()
# Verify value
value = json.loads(msg.value().decode("utf-8"))
assert value["id"] == int(key_str)
@pytest.mark.parametrize(
"key_serializer,value_serializer",
[
("string", "json"),
("json", "string"),
("string", "string"),
("json", "json"),
],
)
def test_write_kafka_serializers(
bootstrap_server,
kafka_consumer,
ray_start_regular_shared,
key_serializer,
value_serializer,
):
"""Test different serializer combinations."""
topic = f"test-serializers-{key_serializer}-{value_serializer}"
# Create dataset
data = [{"id": i, "message": f"msg-{i}"} for i in range(20)]
ds = ray.data.from_items(data)
# Write with specified serializers
ds.write_kafka(
topic=topic,
bootstrap_servers=bootstrap_server,
key_field="id",
key_serializer=key_serializer,
value_serializer=value_serializer,
)
# Consume and verify
messages = consume_messages(kafka_consumer, topic, expected_count=20)
assert len(messages) == 20
# Verify first message can be deserialized correctly
first_msg = messages[0]
if key_serializer == "json":
key_data = json.loads(first_msg.key().decode("utf-8"))
assert isinstance(key_data, int)
else: # string
key_str = first_msg.key().decode("utf-8")
assert key_str.isdigit()
if value_serializer == "json":
value_data = json.loads(first_msg.value().decode("utf-8"))
assert "id" in value_data
assert "message" in value_data
else: # string
value_str = first_msg.value().decode("utf-8")
assert "id" in value_str
assert "message" in value_str
def test_write_kafka_multiple_blocks(
bootstrap_server, kafka_consumer, ray_start_regular_shared
):
"""Test writing dataset with multiple blocks."""
topic = "test-write-multiple-blocks"
# Create dataset and repartition to ensure multiple blocks
ds = ray.data.range(200).repartition(5)
# Write to Kafka
ds.write_kafka(topic=topic, bootstrap_servers=bootstrap_server)
# Consume and verify
messages = consume_messages(kafka_consumer, topic, expected_count=200)
assert len(messages) == 200
# Verify all ids are present
ids = set()
for msg in messages:
value = json.loads(msg.value().decode("utf-8"))
ids.add(value["id"])
assert len(ids) == 200
assert ids == set(range(200))
def test_write_kafka_empty_dataset(
bootstrap_server, kafka_consumer, ray_start_regular_shared
):
"""Test writing an empty dataset."""
topic = "test-write-empty"
# Create empty dataset
ds = ray.data.from_items([])
# Write to Kafka (should succeed without errors)
ds.write_kafka(topic=topic, bootstrap_servers=bootstrap_server)
# Try to consume (should get no messages)
messages = consume_messages(kafka_consumer, topic, expected_count=0, timeout=3)
assert len(messages) == 0
def test_write_kafka_with_producer_config(
bootstrap_server, kafka_consumer, ray_start_regular_shared
):
"""Test writing with custom producer configuration."""
topic = "test-write-producer-config"
# Create dataset
ds = ray.data.range(30)
# Write with custom producer config (confluent-kafka/librdkafka format)
ds.write_kafka(
topic=topic,
bootstrap_servers=bootstrap_server,
producer_config={
"acks": "all",
"retries": 3,
"max.in.flight.requests.per.connection": 1,
},
)
# Consume and verify
messages = consume_messages(kafka_consumer, topic, expected_count=30)
assert len(messages) == 30
def test_write_kafka_with_complex_data(
bootstrap_server, kafka_consumer, ray_start_regular_shared
):
"""Test writing complex nested data structures."""
topic = "test-write-complex"
# Create dataset with nested structures
data = [
{
"id": i,
"user": {"name": f"user-{i}", "email": f"user{i}@example.com"},
"tags": [f"tag{j}" for j in range(3)],
"metadata": {"created": "2024-01-01", "score": i * 1.5},
}
for i in range(15)
]
ds = ray.data.from_items(data)
# Write to Kafka
ds.write_kafka(topic=topic, bootstrap_servers=bootstrap_server)
# Consume and verify
messages = consume_messages(kafka_consumer, topic, expected_count=15)
assert len(messages) == 15
# Verify nested structure is preserved
first_value = json.loads(messages[0].value().decode("utf-8"))
assert "user" in first_value
assert "name" in first_value["user"]
assert "tags" in first_value
assert isinstance(first_value["tags"], list)
assert "metadata" in first_value
def test_write_kafka_invalid_bootstrap_server(ray_start_regular_shared):
"""Test error handling with invalid bootstrap server."""
topic = "test-invalid-server"
ds = ray.data.range(10)
# Use a short message.timeout.ms so librdkafka gives up quickly
# instead of waiting the full 30s flush timeout.
with pytest.raises(Exception):
ds.write_kafka(
topic=topic,
bootstrap_servers="invalid-server:9999",
producer_config={"message.timeout.ms": 3000},
)
def test_write_kafka_dataset_with_nulls(
bootstrap_server, kafka_consumer, ray_start_regular_shared
):
"""Test writing dataset with null/None values."""
topic = "test-write-nulls"
# Create dataset with None values
data = [{"id": i, "value": f"val-{i}" if i % 2 == 0 else None} for i in range(20)]
ds = ray.data.from_items(data)
# Write to Kafka
ds.write_kafka(topic=topic, bootstrap_servers=bootstrap_server)
# Consume and verify
messages = consume_messages(kafka_consumer, topic, expected_count=20)
assert len(messages) == 20
# Verify None values are serialized
for msg in messages:
value = json.loads(msg.value().decode("utf-8"))
assert "id" in value
# value["value"] should be either a string or null
@pytest.mark.parametrize(
"start_offset,expected_error",
[
(
{"my-topic": {0: "latest"}},
r"start_offset\['my-topic'\]\[0\] cannot be 'latest'",
),
(
{"my-topic": {"not-an-int": 100}},
r"start_offset\['my-topic'\] keys must be integers",
),
(
{"my-topic": "not-a-dict"},
r"start_offset\['my-topic'\] must be a dict",
),
],
)
def test_per_partition_start_offset_invalid_values(start_offset, expected_error):
"""Per-partition start_offset with disallowed values raises ValueError at init."""
with pytest.raises(ValueError, match=expected_error):
ray.data.read_kafka(
topics="my-topic",
bootstrap_servers="localhost:9092",
start_offset=start_offset,
)
@pytest.mark.parametrize(
"end_offset,expected_error",
[
(
{"my-topic": {0: "earliest"}},
r"end_offset\['my-topic'\]\[0\] cannot be 'earliest'",
),
(
{"my-topic": {"not-an-int": 100}},
r"end_offset\['my-topic'\] keys must be integers",
),
(
{"my-topic": "not-a-dict"},
r"end_offset\['my-topic'\] must be a dict",
),
],
)
def test_per_partition_end_offset_invalid_values(end_offset, expected_error):
"""Per-partition end_offset with disallowed values raises ValueError at init."""
with pytest.raises(ValueError, match=expected_error):
ray.data.read_kafka(
topics="my-topic",
bootstrap_servers="localhost:9092",
end_offset=end_offset,
)
def test_per_partition_start_offset_non_existent_partition(
bootstrap_server, kafka_producer, ray_start_regular_shared
):
"""Per-partition dict referencing a non-existent partition raises ValueError."""
topic = "test-per-partition-bad-partition"
for i in range(10):
message = {"id": i, "value": f"message-{i}"}
kafka_producer.produce(topic, value=message)
kafka_producer.flush()
_wait_for_watermark(bootstrap_server, topic, 10)
with pytest.raises(
ValueError,
match=r"start_offset references partition 99 in topic",
):
ds = ray.data.read_kafka(
topics=[topic],
bootstrap_servers=[bootstrap_server],
start_offset={topic: {99: 0}},
)
ds.take_all()
def test_per_partition_start_offset_specific_offsets(
bootstrap_server, kafka_producer, ray_start_regular_shared
):
"""Per-partition start_offset reads correct slice from each partition."""
from confluent_kafka.admin import AdminClient, NewTopic
topic = "test-per-partition-start-offset"
admin_client = AdminClient({"bootstrap.servers": bootstrap_server})
topic_config = NewTopic(topic, num_partitions=2, replication_factor=1)
admin_client.create_topics([topic_config])
time.sleep(2) # Wait for topic creation
# Send 50 messages to partition 0 and 50 to partition 1
for i in range(50):
kafka_producer.produce(topic, value={"id": i}, partition=0)
kafka_producer.produce(topic, value={"id": i}, partition=1)
kafka_producer.flush()
_wait_for_watermark(bootstrap_server, topic, 100)
# Read partition 0 from offset 20, partition 1 from offset 40
# Expected: 30 messages from partition 0 + 10 messages from partition 1 = 40
ds = ray.data.read_kafka(
topics=[topic],
bootstrap_servers=[bootstrap_server],
start_offset={topic: {0: 20, 1: 40}},
end_offset="latest",
)
records = ds.take_all()
assert len(records) == 40
def test_per_partition_end_offset_specific_offsets(
bootstrap_server, kafka_producer, ray_start_regular_shared
):
"""Per-partition end_offset reads correct slice from each partition."""
from confluent_kafka.admin import AdminClient, NewTopic
topic = "test-per-partition-end-offset"
admin_client = AdminClient({"bootstrap.servers": bootstrap_server})
topic_config = NewTopic(topic, num_partitions=2, replication_factor=1)
admin_client.create_topics([topic_config])
time.sleep(2) # Wait for topic creation
# Send 50 messages to partition 0 and 50 to partition 1
for i in range(50):
kafka_producer.produce(topic, value={"id": i}, partition=0)
kafka_producer.produce(topic, value={"id": i}, partition=1)
kafka_producer.flush()
_wait_for_watermark(bootstrap_server, topic, 100)
# End partition 0 at offset 30, partition 1 at offset 20
# Expected: 30 messages from partition 0 + 20 messages from partition 1 = 50
ds = ray.data.read_kafka(
topics=[topic],
bootstrap_servers=[bootstrap_server],
start_offset="earliest",
end_offset={topic: {0: 30, 1: 20}},
)
records = ds.take_all()
assert len(records) == 50
def test_per_partition_start_offset_fallback_to_earliest(
bootstrap_server, kafka_producer, ray_start_regular_shared
):
"""Partitions not listed in per-partition dict fall back to 'earliest'."""
from confluent_kafka.admin import AdminClient, NewTopic
topic = "test-per-partition-fallback"
admin_client = AdminClient({"bootstrap.servers": bootstrap_server})
topic_config = NewTopic(topic, num_partitions=2, replication_factor=1)
admin_client.create_topics([topic_config])
time.sleep(2) # Wait for topic creation
# Send 50 messages to each partition
for i in range(50):
kafka_producer.produce(topic, value={"id": i}, partition=0)
kafka_producer.produce(topic, value={"id": i}, partition=1)
kafka_producer.flush()
_wait_for_watermark(bootstrap_server, topic, 100)
# Only specify offset for partition 0; partition 1 should fall back to earliest (0)
# Expected: 30 messages from partition 0 + 50 messages from partition 1 = 80
ds = ray.data.read_kafka(
topics=[topic],
bootstrap_servers=[bootstrap_server],
start_offset={topic: {0: 20}},
end_offset="latest",
)
records = ds.take_all()
assert len(records) == 80
if __name__ == "__main__":
import sys
sys.exit(pytest.main(["-v", __file__]))