186 lines
5.6 KiB
Python
186 lines
5.6 KiB
Python
import asyncio
|
|
import logging
|
|
from abc import ABC, abstractmethod
|
|
from typing import Any, Callable, Dict, Optional
|
|
|
|
from app.common.config import config
|
|
from app.common.logging import setup_logger
|
|
|
|
logger = setup_logger("ingestion-api")
|
|
|
|
|
|
class MessageProducer(ABC):
|
|
@abstractmethod
|
|
def produce(self, topic: str, key: Optional[str], value: bytes) -> bool:
|
|
"""Produce a message synchronously or enqueue in broker buffer."""
|
|
pass
|
|
|
|
@abstractmethod
|
|
def is_healthy(self) -> bool:
|
|
"""Check if producer is healthy and connected to broker."""
|
|
pass
|
|
|
|
@abstractmethod
|
|
def flush(self, timeout: float = 5.0) -> int:
|
|
"""Flush any pending messages."""
|
|
pass
|
|
|
|
@abstractmethod
|
|
def close(self) -> None:
|
|
"""Close producer connection."""
|
|
pass
|
|
|
|
|
|
class ConfluentKafkaProducer(MessageProducer):
|
|
"""
|
|
Production Kafka producer implementation wrapping confluent-kafka.
|
|
Uses 'raw_id' as partition key and LZ4 compression.
|
|
"""
|
|
|
|
def __init__(self, bootstrap_servers: Optional[str] = None):
|
|
try:
|
|
import confluent_kafka
|
|
self._ck = confluent_kafka
|
|
except ImportError:
|
|
raise ImportError(
|
|
"confluent-kafka library is not installed. Run 'pip install confluent-kafka'."
|
|
)
|
|
|
|
self.bootstrap_servers = bootstrap_servers or config.kafka.bootstrap_servers
|
|
self._producer_config = {
|
|
"bootstrap.servers": self.bootstrap_servers,
|
|
"compression.type": "lz4",
|
|
"queue.buffering.max.messages": 100000,
|
|
"queue.buffering.max.ms": 5,
|
|
"acks": "all",
|
|
"retries": 3,
|
|
}
|
|
self._producer = self._ck.Producer(self._producer_config)
|
|
self._healthy = True
|
|
|
|
def _delivery_report(self, err: Any, msg: Any) -> None:
|
|
if err is not None:
|
|
self._healthy = False
|
|
raw_id = msg.key().decode("utf-8") if msg and msg.key() else None
|
|
logger.error(
|
|
f"Kafka delivery failed: {err}",
|
|
extra={"raw_id": raw_id, "event": "kafka_produce_failed"},
|
|
)
|
|
else:
|
|
self._healthy = True
|
|
|
|
def produce(self, topic: str, key: Optional[str], value: bytes) -> bool:
|
|
try:
|
|
key_bytes = key.encode("utf-8") if key else None
|
|
self._producer.produce(
|
|
topic=topic,
|
|
key=key_bytes,
|
|
value=value,
|
|
on_delivery=self._delivery_report,
|
|
)
|
|
# Serve delivery callback queue non-blockingly
|
|
self._producer.poll(0)
|
|
return True
|
|
except BufferError:
|
|
logger.warning(
|
|
"Kafka local producer buffer full",
|
|
extra={"raw_id": key, "event": "kafka_buffer_full"},
|
|
)
|
|
self._producer.poll(0.1)
|
|
return False
|
|
except Exception as e:
|
|
self._healthy = False
|
|
logger.error(
|
|
f"Kafka produce error: {e}",
|
|
extra={"raw_id": key, "event": "kafka_error"},
|
|
)
|
|
return False
|
|
|
|
def is_healthy(self) -> bool:
|
|
return self._healthy
|
|
|
|
def flush(self, timeout: float = 5.0) -> int:
|
|
return self._producer.flush(timeout)
|
|
|
|
def close(self) -> None:
|
|
self.flush()
|
|
|
|
|
|
class MockMessageProducer(MessageProducer):
|
|
"""
|
|
In-memory mock producer for offline testing and verification.
|
|
"""
|
|
|
|
def __init__(self):
|
|
self.messages = []
|
|
self._healthy = True
|
|
|
|
def produce(self, topic: str, key: Optional[str], value: bytes) -> bool:
|
|
if not self._healthy:
|
|
return False
|
|
self.messages.append({"topic": topic, "key": key, "value": value})
|
|
return True
|
|
|
|
def is_healthy(self) -> bool:
|
|
return self._healthy
|
|
|
|
def set_healthy(self, healthy: bool) -> None:
|
|
self._healthy = healthy
|
|
|
|
def flush(self, timeout: float = 5.0) -> int:
|
|
return 0
|
|
|
|
def close(self) -> None:
|
|
pass
|
|
|
|
|
|
class AsyncProducerWorker:
|
|
"""
|
|
Background worker consuming from the bounded async queue and writing to the Kafka producer.
|
|
"""
|
|
|
|
def __init__(self, queue: asyncio.Queue, producer: MessageProducer, topic: str):
|
|
self.queue = queue
|
|
self.producer = producer
|
|
self.topic = topic
|
|
self._running = False
|
|
self._task: Optional[asyncio.Task] = None
|
|
|
|
async def start(self) -> None:
|
|
self._running = True
|
|
self._task = asyncio.create_task(self._run_loop())
|
|
|
|
async def _run_loop(self) -> None:
|
|
while self._running:
|
|
try:
|
|
item = await self.queue.get()
|
|
if item is None: # Sentinel to stop
|
|
self.queue.task_done()
|
|
break
|
|
|
|
key, value = item
|
|
success = self.producer.produce(self.topic, key, value)
|
|
if not success:
|
|
logger.error(
|
|
"Failed to publish message from async queue to Kafka",
|
|
extra={"raw_id": key, "event": "queue_dispatch_failed"},
|
|
)
|
|
self.queue.task_done()
|
|
except asyncio.CancelledError:
|
|
break
|
|
except Exception as e:
|
|
logger.error(
|
|
f"Unexpected error in async producer worker: {e}",
|
|
extra={"event": "worker_unexpected_error"},
|
|
)
|
|
|
|
async def stop(self) -> None:
|
|
self._running = False
|
|
if self._task:
|
|
self._task.cancel()
|
|
try:
|
|
await self._task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
self.producer.flush()
|