Source code for litestar_queues.execution.kafka.backend

import asyncio
import time
from contextlib import suppress
from importlib import import_module
from typing import TYPE_CHECKING, Any, cast
from uuid import UUID, uuid4

from litestar_queues.consumer import TaskExitCode, consume_one
from litestar_queues.exceptions import MissingDependencyError, QueueDispatchError
from litestar_queues.execution.base import BaseConsumerExecutionBackend, DispatchRepairResult
from litestar_queues.execution.kafka.config import KafkaExecutionConfig, _execution_config_from_queue_config

if TYPE_CHECKING:
    from collections.abc import Awaitable, Callable

    from litestar_queues.config import QueueConfig
    from litestar_queues.execution.kafka._typing import KafkaConsumer, KafkaProducer
    from litestar_queues.models import QueuedTaskRecord
    from litestar_queues.service import QueueService

__all__ = ("KafkaExecutionBackend",)

ATTEMPT_HEADER = "litestar_queues_attempt"


[docs] class KafkaExecutionBackend(BaseConsumerExecutionBackend): """Dispatch bare task identifiers through a Kafka consumer group.""" __slots__ = ( "_consumer", "_drain_timeout", "_execution_config", "_inflight", "_listener", "_owns_consumer", "_owns_producer", "_producer", )
[docs] def __init__( self, config: "QueueConfig | None" = None, *, execution_config: "KafkaExecutionConfig | None" = None, producer: "KafkaProducer | None" = None, consumer: "KafkaConsumer | None" = None, ) -> "None": super().__init__(config=config) self._execution_config = execution_config self._producer = producer self._consumer = consumer self._owns_producer = False self._owns_consumer = False self._inflight: "dict[Any, asyncio.Task[None]]" = {} self._listener: "Any | None" = None self._drain_timeout = 0.0
@property def is_external(self) -> "bool": return True @property def execution_config(self) -> "KafkaExecutionConfig": if self._execution_config is None: self._execution_config = _execution_config_from_queue_config(self.config) return self._execution_config
[docs] async def dispatch(self, service: "QueueService", record: "QueuedTaskRecord") -> "str | None": attempt_ref = _new_attempt(record.retry_count) reserved = await service.get_queue_backend().reserve_external_dispatch( record.id, "kafka", attempt_ref, execution_profile=record.execution_profile, expected_retry_count=record.retry_count, ) if reserved is None: return None await self._send(service, reserved, attempt_ref) return attempt_ref
async def _send(self, service: "QueueService", record: "QueuedTaskRecord", attempt_ref: "str") -> "None": producer = await self._get_producer() try: await asyncio.wait_for( producer.send_and_wait( self.execution_config.topic, str(record.id).encode(), headers=[(ATTEMPT_HEADER, attempt_ref.encode())], ), timeout=self.execution_config.api_timeout, ) except asyncio.CancelledError: raise except Exception as exc: if _is_definitive_publish_error(exc): with suppress(Exception): await service.get_queue_backend().clear_execution_ref(record.id, record.retry_count, attempt_ref) raise msg = "Kafka dispatch outcome is unknown" raise QueueDispatchError(msg, task_id=record.id, committed=True) from exc
[docs] async def repair(self, service: "QueueService", *, limit: "int") -> "DispatchRepairResult": if limit <= 0: return DispatchRepairResult() records = await service.get_queue_backend().list_running_external(limit=limit) changed = 0 now_ms = int(time.time() * 1000) stale_ms = self.execution_config.dispatch_stale_after * 1000 for record in records: old_ref = record.execution_ref parsed = _parse_attempt(old_ref) if old_ref is None or parsed is None: continue attempt_retry, attempt_ms = parsed if attempt_retry == record.retry_count and now_ms - attempt_ms < stale_ms: continue new_ref = _new_attempt(record.retry_count) rotated = await service.get_queue_backend().replace_execution_ref( record.id, record.retry_count, old_ref, new_ref ) if rotated is None: continue try: await self._send(service, rotated, new_ref) except asyncio.CancelledError: raise except Exception: self._logger.warning("Kafka delivery repair failed", exc_info=True) else: changed += 1 return DispatchRepairResult(examined=len(records), changed=changed)
[docs] async def execute( self, service: "QueueService", record: "QueuedTaskRecord", *, worker_id: "str | None" = None ) -> "QueuedTaskRecord": del worker_id await self.dispatch(service, record) return await service.get_queue_backend().get_task(record.id) or record
async def _get_producer(self) -> "KafkaProducer": if self._producer is None: aiokafka = _aiokafka() producer = cast( "KafkaProducer", aiokafka.AIOKafkaProducer( bootstrap_servers=self.execution_config.bootstrap_servers, acks="all", **self.execution_config.producer_options, ), ) try: await producer.start() except BaseException: with suppress(Exception): await producer.stop() raise self._producer = producer self._owns_producer = True return self._producer async def _get_consumer(self) -> "KafkaConsumer": if self._consumer is None: aiokafka = _aiokafka() self._consumer = cast( "KafkaConsumer", aiokafka.AIOKafkaConsumer( bootstrap_servers=self.execution_config.bootstrap_servers, group_id=self.execution_config.consumer_group, enable_auto_commit=False, auto_offset_reset="earliest", **self.execution_config.consumer_options, ), ) self._owns_consumer = True return self._consumer
[docs] async def run_consumer(self, service: "QueueService", *, max_concurrency: "int", drain_timeout: "float") -> "None": consumer = await self._get_consumer() self._drain_timeout = drain_timeout async def drain_revoked(revoked: "set[Any]") -> "None": await self._drain_inflight(self._drain_timeout, revoked) self._listener = _create_rebalance_listener(drain_revoked) consumer.subscribe([self.execution_config.topic], listener=self._listener) try: await consumer.start() except BaseException: if self._owns_consumer: with suppress(Exception): await consumer.stop() raise try: while True: batches = await consumer.getmany(timeout_ms=1_000, max_records=max_concurrency) tasks = [ self._start_partition(service, consumer, partition, messages) for partition, messages in batches.items() ] if tasks: results = await asyncio.shield(asyncio.gather(*tasks, return_exceptions=True)) for result in results: if isinstance(result, asyncio.CancelledError): continue if isinstance(result, BaseException): self._logger.error( "Kafka partition consumer failed", exc_info=(type(result), result, result.__traceback__) ) raise result except asyncio.CancelledError: await self._drain_inflight(drain_timeout) raise finally: if self._owns_consumer: await consumer.stop() self._consumer = None self._owns_consumer = False
def _start_partition( self, service: "QueueService", consumer: "KafkaConsumer", partition: "Any", messages: "list[Any]" ) -> "asyncio.Task[None]": task = asyncio.create_task(self._consume_partition(service, consumer, partition, messages)) self._inflight[partition] = task def discard(completed: "asyncio.Task[None]") -> "None": if self._inflight.get(partition) is completed: self._inflight.pop(partition, None) task.add_done_callback(discard) return task async def _drain_inflight(self, timeout: "float", partitions: "set[Any] | None" = None) -> "None": tasks = {task for partition, task in self._inflight.items() if partitions is None or partition in partitions} if not tasks: return done, pending = await asyncio.wait(tasks, timeout=timeout) for task in pending: task.cancel() await asyncio.gather(*done, *pending, return_exceptions=True) async def _consume_partition( self, service: "QueueService", consumer: "KafkaConsumer", partition: "Any", messages: "list[Any]" ) -> "None": """Process one partition in order and commit only its contiguous durable prefix.""" for message in messages: assignment = getattr(consumer, "assignment", None) if assignment is not None and partition not in assignment(): return if not await self._consume_message(service, message): return try: await consumer.commit({partition: message.offset + 1}) except asyncio.CancelledError: raise except Exception: # Rebalance failures leave the offset uncommitted. self._logger.warning("Kafka offset commit failed", exc_info=True) return async def _consume_message(self, service: "QueueService", message: "Any") -> "bool": attempt = dict(message.headers or ()).get(ATTEMPT_HEADER) if isinstance(attempt, bytes): with suppress(UnicodeDecodeError): attempt = attempt.decode() parsed = _parse_attempt(attempt) try: task_id = UUID(message.value.decode()) except (ValueError, UnicodeDecodeError, AttributeError): task_id = None if task_id is None or not isinstance(attempt, str) or parsed is None: return True retry_count, _timestamp = parsed try: current = await service.get_task(task_id) if current is None or current.is_terminal: return True outcome = await consume_one( service, task_id, expected_retry_count=retry_count, expected_execution_ref=attempt ) if outcome == TaskExitCode.FAILURE: latest = await service.get_task(task_id) if latest is None: return False if not latest.is_terminal: cleared = await service.get_queue_backend().clear_execution_ref( task_id, latest.retry_count, attempt ) if cleared is None: return False except asyncio.CancelledError: raise except Exception: # noqa: BLE001 -- leave the offset uncommitted for broker redelivery return False else: return True
[docs] async def close(self) -> "None": if self._owns_consumer and self._consumer is not None: await self._consumer.stop() if self._owns_producer and self._producer is not None: await self._producer.stop() self._consumer = None self._producer = None self._owns_consumer = False self._owns_producer = False self._inflight.clear() self._listener = None
def _create_rebalance_listener(on_revoked: "Callable[[set[Any]], Awaitable[None]]") -> "Any": aiokafka = _aiokafka() async def revoked(_self: "object", partitions: "set[Any]") -> "None": await on_revoked(partitions) async def assigned(_self: "object", partitions: "set[Any]") -> "None": del partitions listener_type = type( "KafkaRebalanceListener", (aiokafka.ConsumerRebalanceListener,), {"on_partitions_revoked": revoked, "on_partitions_assigned": assigned}, ) return listener_type() def _aiokafka() -> "Any": try: return import_module("aiokafka") except ImportError as exc: package = "aiokafka" extra = "kafka" raise MissingDependencyError(package, extra) from exc def _is_definitive_publish_error(exc: "BaseException") -> "bool": return exc.__class__.__name__ in { "InvalidTopicError", "MessageSizeTooLargeError", "TopicAuthorizationFailedError", "UnsupportedVersionError", } def _new_attempt(retry_count: "int") -> "str": return f"kafka:{retry_count}:{int(time.time() * 1000)}:{uuid4()}" def _parse_attempt(value: "object") -> "tuple[int, int] | None": if not isinstance(value, str): return None parts = value.split(":") if len(parts) != 4 or parts[0] != "kafka": # noqa: PLR2004 return None try: retry_count = int(parts[1]) timestamp = int(parts[2]) UUID(parts[3]) except (ValueError, TypeError): return None return (retry_count, timestamp) if retry_count >= 0 and timestamp >= 0 else None