feat(api): complete Phase 2 - framework-less asyncio Ingestion API with backpressure and metrics
This commit is contained in:
185
app/api/producer.py
Normal file
185
app/api/producer.py
Normal file
@@ -0,0 +1,185 @@
|
||||
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()
|
||||
Reference in New Issue
Block a user