297 lines
11 KiB
Python
297 lines
11 KiB
Python
"""Kafka datasink
|
|
|
|
This module provides a Kafka datasink implementation for Ray Data.
|
|
|
|
Requires:
|
|
- confluent-kafka: https://docs.confluent.io/platform/current/clients/confluent-kafka-python/html/
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
from collections.abc import Iterable, Mapping
|
|
from enum import Enum
|
|
from typing import Any, Optional
|
|
|
|
from ray.data._internal.execution.interfaces import TaskContext
|
|
from ray.data._internal.util import _check_import
|
|
from ray.data.block import Block, BlockAccessor
|
|
from ray.data.datasource.datasink import Datasink
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Polling/flush constants. These are intentionally conservative defaults
|
|
# that work well for typical workloads. All three can be tuned indirectly
|
|
# through producer_config (e.g. queue.buffering.max.messages, message.timeout.ms)
|
|
# or overridden by subclassing if needed.
|
|
|
|
# Number of messages to produce before polling for delivery reports.
|
|
# At ~1.5 KB per message (a common upper bound), 10 000 messages ≈ 15 MB
|
|
# of buffered data — well within librdkafka's default queue limits while
|
|
# keeping Python→C crossing overhead low.
|
|
_POLL_BATCH_SIZE = 10000
|
|
# Timeout (seconds) for the final flush that waits for all in-flight messages
|
|
_FLUSH_TIMEOUT_S = 30
|
|
# Timeout (seconds) when polling to drain the queue after a BufferError
|
|
_BUFFER_FULL_POLL_TIMEOUT_S = 10
|
|
|
|
|
|
class SerializerFormat(str, Enum):
|
|
"""Supported serialization formats for Kafka message keys and values."""
|
|
|
|
JSON = "json"
|
|
STRING = "string"
|
|
BYTES = "bytes"
|
|
|
|
|
|
def _serialize(data: Any, serializer: SerializerFormat) -> bytes:
|
|
"""Serialize *data* according to *serializer*.
|
|
|
|
This is a standalone function so it can be used without a class instance.
|
|
"""
|
|
if serializer == SerializerFormat.JSON:
|
|
return json.dumps(data).encode("utf-8")
|
|
elif serializer == SerializerFormat.STRING:
|
|
return str(data).encode("utf-8")
|
|
else: # BYTES
|
|
return data if isinstance(data, bytes) else str(data).encode("utf-8")
|
|
|
|
|
|
class KafkaDatasink(Datasink):
|
|
"""
|
|
Ray Data sink for writing to Apache Kafka topics using confluent-kafka.
|
|
|
|
Writes blocks of data to Kafka with configurable serialization
|
|
and producer settings.
|
|
|
|
Delivery guarantees:
|
|
This sink provides best-effort delivery. Partial writes can occur if a
|
|
task fails midway (already-flushed messages are not rolled back), and
|
|
duplicates are possible when the system retries a failed task (e.g., on
|
|
node failure), since each attempt re-sends all messages from scratch
|
|
without Kafka transactions or cross-task deduplication.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
topic: str,
|
|
bootstrap_servers: str,
|
|
key_field: Optional[str] = None,
|
|
key_serializer: SerializerFormat = SerializerFormat.STRING,
|
|
value_serializer: SerializerFormat = SerializerFormat.JSON,
|
|
producer_config: Optional[dict[str, Any]] = None,
|
|
):
|
|
"""
|
|
Initialize Kafka sink.
|
|
|
|
Args:
|
|
topic: Kafka topic name
|
|
bootstrap_servers: Comma-separated Kafka broker addresses (e.g., 'localhost:9092')
|
|
key_field: Optional field name to use as message key
|
|
key_serializer: Key serialization format ('json', 'string', or 'bytes')
|
|
value_serializer: Value serialization format ('json', 'string', or 'bytes')
|
|
producer_config: Additional Kafka producer configuration.
|
|
Uses confluent-kafka / librdkafka configuration keys
|
|
(see https://github.com/confluentinc/librdkafka/blob/master/CONFIGURATION.md).
|
|
Example: ``{"linger.ms": 5, "acks": "all"}``.
|
|
The ``bootstrap.servers`` option is derived from ``bootstrap_servers``
|
|
and cannot be overridden here.
|
|
"""
|
|
_check_import(self, module="confluent_kafka", package="confluent-kafka")
|
|
|
|
try:
|
|
key_serializer = SerializerFormat(key_serializer)
|
|
except ValueError:
|
|
raise ValueError(
|
|
f"key_serializer must be one of "
|
|
f"{[s.value for s in SerializerFormat]}, "
|
|
f"got '{key_serializer}'"
|
|
)
|
|
try:
|
|
value_serializer = SerializerFormat(value_serializer)
|
|
except ValueError:
|
|
raise ValueError(
|
|
f"value_serializer must be one of "
|
|
f"{[s.value for s in SerializerFormat]}, "
|
|
f"got '{value_serializer}'"
|
|
)
|
|
|
|
self.topic = topic
|
|
self.bootstrap_servers = bootstrap_servers
|
|
self.key_field = key_field
|
|
self.key_serializer = key_serializer
|
|
self.value_serializer = value_serializer
|
|
self.producer_config = producer_config or {}
|
|
|
|
@staticmethod
|
|
def _row_to_dict(row: Any) -> Any:
|
|
"""Convert a Ray data row to a plain dict if possible.
|
|
|
|
Handles Ray's internal row types (ArrowRow, PandasRow), namedtuples,
|
|
and generic Mappings. Returns the input unchanged for primitives.
|
|
"""
|
|
if isinstance(row, dict):
|
|
return row
|
|
if hasattr(row, "as_pydict"):
|
|
return row.as_pydict()
|
|
if hasattr(row, "_asdict"):
|
|
return row._asdict()
|
|
if isinstance(row, Mapping):
|
|
return dict(row)
|
|
return row
|
|
|
|
def _serialize_value(self, value: Any) -> bytes:
|
|
"""Serialize value based on configured format."""
|
|
return _serialize(value, self.value_serializer)
|
|
|
|
def _serialize_key(self, key: Any) -> bytes:
|
|
"""Serialize key based on configured format."""
|
|
return _serialize(key, self.key_serializer)
|
|
|
|
def _extract_key(self, row_dict: Any) -> Optional[bytes]:
|
|
"""Extract and serialize the message key from a row dict.
|
|
|
|
Returns ``None`` when no ``key_field`` is configured, when the row is
|
|
not a dict, or when the key field is absent/``None`` in the row. A
|
|
``None`` key tells the Kafka producer to use the default partitioner
|
|
(round-robin or sticky partitioning depending on librdkafka version),
|
|
distributing messages evenly across partitions.
|
|
"""
|
|
if self.key_field and isinstance(row_dict, dict):
|
|
key_value = row_dict.get(self.key_field)
|
|
if key_value is not None:
|
|
return self._serialize_key(key_value)
|
|
return None
|
|
|
|
def _produce_with_retry(self, producer, value, key, on_delivery):
|
|
"""Produce a single message, retrying once on ``BufferError``.
|
|
|
|
``producer.produce()`` is asynchronous — it enqueues the message into
|
|
librdkafka's internal buffer and returns immediately. Actual delivery
|
|
happens in a background thread; results are reported via the
|
|
*on_delivery* callback when ``producer.poll()`` or ``producer.flush()``
|
|
is called.
|
|
|
|
If the internal buffer is full, a ``BufferError`` is raised. We handle
|
|
this by polling to drain completed deliveries (which frees buffer
|
|
space) and retrying once.
|
|
"""
|
|
try:
|
|
producer.produce(
|
|
self.topic,
|
|
value=value,
|
|
key=key,
|
|
on_delivery=on_delivery,
|
|
)
|
|
except BufferError:
|
|
# Internal queue is full — poll to serve delivery callbacks and
|
|
# free space, then retry. The poll timeout caps how long we block
|
|
# waiting for the broker to acknowledge in-flight messages.
|
|
producer.poll(_BUFFER_FULL_POLL_TIMEOUT_S)
|
|
try:
|
|
producer.produce(
|
|
self.topic,
|
|
value=value,
|
|
key=key,
|
|
on_delivery=on_delivery,
|
|
)
|
|
except BufferError:
|
|
raise RuntimeError(
|
|
f"Kafka producer queue is still full after "
|
|
f"{_BUFFER_FULL_POLL_TIMEOUT_S}s of polling "
|
|
f"for topic '{self.topic}'. "
|
|
f"Consider increasing queue.buffering.max.messages "
|
|
f"in producer_config."
|
|
)
|
|
|
|
def write(
|
|
self,
|
|
blocks: Iterable[Block],
|
|
ctx: TaskContext,
|
|
) -> Any:
|
|
"""
|
|
Write blocks of data to Kafka.
|
|
|
|
Args:
|
|
blocks: Iterable of Ray data blocks
|
|
ctx: Ray data context
|
|
|
|
Returns:
|
|
Dict with ``total_records`` and ``failed_messages`` counts.
|
|
"""
|
|
from confluent_kafka import KafkaException, Producer
|
|
|
|
# Build confluent config
|
|
config: dict[str, Any] = {
|
|
"bootstrap.servers": self.bootstrap_servers,
|
|
}
|
|
for k, v in self.producer_config.items():
|
|
if k == "bootstrap.servers":
|
|
logger.warning(
|
|
"Ignoring 'bootstrap.servers' from producer_config; "
|
|
"use bootstrap_servers parameter instead."
|
|
)
|
|
continue
|
|
config[k] = v
|
|
|
|
producer = Producer(config)
|
|
total_records = 0
|
|
remaining = 0
|
|
# Mutable container so on_delivery callback can update without nonlocal
|
|
delivery_state = {"failed": 0, "first_exception": None}
|
|
|
|
def on_delivery(err, msg):
|
|
if err is not None:
|
|
delivery_state["failed"] += 1
|
|
if delivery_state["first_exception"] is None:
|
|
delivery_state["first_exception"] = KafkaException(err)
|
|
|
|
try:
|
|
for block in blocks:
|
|
block_accessor = BlockAccessor.for_block(block)
|
|
|
|
for row in block_accessor.iter_rows(public_row_format=False):
|
|
row_dict = self._row_to_dict(row)
|
|
key = self._extract_key(row_dict)
|
|
value = self._serialize_value(row_dict)
|
|
|
|
self._produce_with_retry(producer, value, key, on_delivery)
|
|
total_records += 1
|
|
|
|
# Periodically poll to serve delivery report callbacks
|
|
# and avoid unbounded internal queue growth.
|
|
if total_records % _POLL_BATCH_SIZE == 0:
|
|
producer.poll(0)
|
|
|
|
# Final flush: blocks until all in-flight messages are delivered
|
|
# or the timeout expires. Returns the count of messages still
|
|
# queued (0 = everything delivered). Does NOT raise on timeout.
|
|
remaining = producer.flush(timeout=_FLUSH_TIMEOUT_S)
|
|
|
|
except KafkaException as e:
|
|
raise RuntimeError(
|
|
f"Failed to write to Kafka topic '{self.topic}': {e}"
|
|
) from e
|
|
|
|
if remaining > 0:
|
|
raise RuntimeError(
|
|
f"{remaining} out of {total_records} messages were still "
|
|
f"in-flight after flush timeout for topic '{self.topic}'. "
|
|
f"This usually means the broker is unreachable."
|
|
)
|
|
|
|
failed_messages = delivery_state["failed"]
|
|
if failed_messages > 0:
|
|
raise RuntimeError(
|
|
f"Failed to write {failed_messages} out of {total_records} "
|
|
f"messages to Kafka topic '{self.topic}'."
|
|
) from delivery_state["first_exception"]
|
|
|
|
# Logged once per write task (one task per data block partition).
|
|
logger.debug(
|
|
"Wrote %d records to Kafka topic '%s'.",
|
|
total_records,
|
|
self.topic,
|
|
)
|
|
|
|
return {"total_records": total_records, "failed_messages": failed_messages}
|