Source code for base.kafka.producer

"""Kafka producer hierarchy for simple, buffered, and EoS delivery."""

import time
import uuid
from abc import abstractmethod
from collections.abc import Iterator, Sequence
from contextlib import contextmanager
from typing import TYPE_CHECKING, Callable

from confluent_kafka import KafkaException, Producer

from src.base.kafka import config as kafka_config
from src.base.kafka.client import KafkaHandler
from src.base.kafka.records import ConsumedKafkaMessage, KafkaProduceRecord
from src.base.kafka.resilience import (
    is_retriable_kafka_error,
    is_retriable_kafka_exception,
)
from src.base.log_config import get_logger
from src.base.retry import retry_forever
from src.base.utils import kafka_delivery_report

if TYPE_CHECKING:
    from src.base.kafka.consumer import KafkaConsumeHandler

logger = get_logger()


[docs] class KafkaProduceHandler(KafkaHandler): """Common lifecycle and retry behavior for Kafka producers.""" def __init__(self, conf): super().__init__() self.conf = conf self.producer = self._new_producer() def _new_producer(self): return retry_forever( lambda: Producer(self.conf), "Kafka producer creation", kafka_config.RETRY_SETTINGS, ) def _reset_producer(self) -> None: try: if self.producer: self.producer.flush(5) except Exception as exception: logger.warning( "Ignoring Kafka producer flush failure during reconnect: %s", exception, ) self.producer = self._new_producer() def _with_producer_retry( self, description: str, operation: Callable[[], None] ) -> None: def attempt(): try: operation() except Exception as exception: if not is_retriable_kafka_exception(exception): raise logger.warning( "%s failed, recreating Kafka producer: %s", description, exception, ) self._reset_producer() raise retry_forever( attempt, description, kafka_config.RETRY_SETTINGS, retryable=(KafkaException, BufferError, RuntimeError, OSError), )
[docs] @abstractmethod def produce(self, *args, **kwargs): raise NotImplementedError
def __del__(self) -> None: if getattr(self, "producer", None): self.producer.flush()
[docs] class SimpleKafkaProduceHandler(KafkaProduceHandler): """Synchronous Kafka producer without transactional semantics.""" def __init__(self): self.brokers = kafka_config.bootstrap_servers() super().__init__( { "bootstrap.servers": self.brokers, "enable.idempotence": False, "acks": "1", "message.max.bytes": 1_000_000_000, } )
[docs] def produce(self, topic: str, data: str, key: None | str = None) -> None: if not data: return def operation(): delivery_errors = [] def delivery_callback(error, message): kafka_delivery_report(error, message) if error: delivery_errors.append(error) self.producer.flush() self.producer.produce( topic=topic, key=key, value=data, callback=delivery_callback, ) self.producer.flush() if delivery_errors: delivery_error = delivery_errors[0] if is_retriable_kafka_error(delivery_error): raise KafkaException(delivery_error) raise ValueError(f"Kafka delivery failed: {delivery_error}") self._with_producer_retry(f"Kafka produce to {topic}", operation)
[docs] class BufferedKafkaProduceHandler(SimpleKafkaProduceHandler): """Asynchronous producer with bounded local backpressure.""" _QUEUE_POLL_TIMEOUT_SECONDS = 0.1 def __init__(self): self.brokers = kafka_config.bootstrap_servers() KafkaProduceHandler.__init__( self, { "bootstrap.servers": self.brokers, "enable.idempotence": False, "acks": "1", "message.max.bytes": 1_000_000_000, "linger.ms": 10, "batch.num.messages": 1000, "queue.buffering.max.messages": 10000, }, )
[docs] def produce(self, topic: str, data: str, key: None | str = None) -> None: if not data: return def delivery_callback(error, message): kafka_delivery_report(error, message) def operation(): queue_was_full = False while True: self.producer.poll(0) try: self.producer.produce( topic=topic, key=key, value=data, callback=delivery_callback, ) return except BufferError: if not queue_was_full: logger.warning( "Kafka telemetry producer queue is full; " "waiting for delivery reports." ) queue_was_full = True self.producer.poll(self._QUEUE_POLL_TIMEOUT_SECONDS) self._with_producer_retry(f"Buffered Kafka produce to {topic}", operation)
[docs] class ExactlyOnceKafkaProduceHandler(KafkaProduceHandler): """Transactional producer that atomically commits outputs and input offsets.""" def __init__(self): self._transaction_records: list[KafkaProduceRecord] | None = None self.brokers = kafka_config.bootstrap_servers() super().__init__( { "bootstrap.servers": self.brokers, "transactional.id": f"{kafka_config.HOSTNAME}-{uuid.uuid4()}", "enable.idempotence": True, "message.max.bytes": 1_000_000_000, } ) self._init_transactions_with_retry() def _reset_producer(self) -> None: super()._reset_producer() self._init_transactions_with_retry() def _init_transactions_with_retry(self) -> None: retry_forever( lambda: self.producer.init_transactions(), "Kafka transactional producer initialization", kafka_config.RETRY_SETTINGS, )
[docs] def produce(self, topic: str, data: str, key: None | str = None) -> None: if not data: return record = KafkaProduceRecord(topic=topic, data=data, key=key) if self._transaction_records is not None: self._transaction_records.append(record) return self._run_transaction([record])
[docs] @contextmanager def transaction_batch( self, consumer: "KafkaConsumeHandler", consumed_messages: Sequence[ConsumedKafkaMessage], ) -> Iterator[None]: """Collect outputs and commit them with source offsets on clean exit.""" if self._transaction_records is not None: raise RuntimeError("Kafka transaction batches cannot be nested.") if not consumed_messages: raise ValueError("A Kafka transaction batch requires source messages.") self._transaction_records = [] try: yield records = self._transaction_records except Exception: self._transaction_records = None raise else: self._transaction_records = None self._run_transaction( records, consumer=consumer, consumed_messages=consumed_messages, )
def _run_transaction( self, records: Sequence[KafkaProduceRecord], consumer: "KafkaConsumeHandler | None" = None, consumed_messages: Sequence[ConsumedKafkaMessage] = (), ) -> None: def operation(): self.producer.begin_transaction() try: for record in records: if not record.data: continue self.producer.produce( topic=record.topic, key=record.key, value=record.data, callback=kafka_delivery_report, ) if consumer is not None: self.producer.send_offsets_to_transaction( consumer.offsets_for(consumed_messages), consumer.group_metadata(), ) self.commit_transaction_with_retry() except Exception as exception: logger.info("Aborting Kafka transaction.") try: self.producer.abort_transaction() except Exception as abort_exception: logger.warning( "Kafka transaction abort failed: %s", abort_exception ) logger.error("Transaction aborted.") logger.error(exception) raise self._with_producer_retry("Kafka transaction", operation)
[docs] def commit_transaction_with_retry( self, max_retries: int = 3, retry_interval_ms: int = 1000 ) -> None: committed = False retry_count = 0 while not committed and retry_count < max_retries: try: self.producer.commit_transaction() committed = True except KafkaException as exception: if ( "Conflicting commit_transaction API call is already in progress" in str(exception) ): retry_count += 1 logger.debug( "Conflicting commit_transaction API call is already in " "progress: Retrying" ) time.sleep(retry_interval_ms / 1000.0) else: raise if not committed: raise RuntimeError("Failed to commit transaction after retries.")