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__]))