feat: add clickhouse micro-batch storage
This commit is contained in:
15
.env.sample
15
.env.sample
@@ -58,10 +58,23 @@ KAFKA_PRODUCER_MAX_MESSAGES=100000
|
||||
|
||||
CLICKHOUSE_VERSION=26.8.7.19
|
||||
|
||||
CLICKHOUSE_HOST=clickhouse
|
||||
# Used by Python applications running directly on the host.
|
||||
CLICKHOUSE_HOST=localhost
|
||||
|
||||
# Used later by Dockerized API/worker services.
|
||||
CLICKHOUSE_INTERNAL_HOST=clickhouse
|
||||
|
||||
CLICKHOUSE_PORT=8123
|
||||
CLICKHOUSE_HTTP_HOST_PORT=8123
|
||||
|
||||
CLICKHOUSE_DATABASE=telecom
|
||||
CLICKHOUSE_USER=telecom
|
||||
CLICKHOUSE_PASSWORD=telecom_dev_password
|
||||
|
||||
|
||||
# =========================
|
||||
# Worker batching
|
||||
# =========================
|
||||
|
||||
BATCH_SIZE=1000
|
||||
BATCH_FLUSH_INTERVAL_SECONDS=2
|
||||
|
||||
16
README.md
16
README.md
@@ -28,8 +28,12 @@ Redpanda / cdr.raw
|
||||
Processing Worker
|
||||
↓
|
||||
Validation + Normalization
|
||||
├── valid → processed by worker
|
||||
└── invalid → cdr.raw.dlq
|
||||
├── invalid → cdr.raw.dlq
|
||||
└── valid
|
||||
↓
|
||||
Micro-Batch
|
||||
↓
|
||||
ClickHouse
|
||||
```
|
||||
|
||||
Infrastructure already available:
|
||||
@@ -72,6 +76,11 @@ worker processing is not connected yet.
|
||||
* DLQ routing for malformed and unprocessable Kafka records
|
||||
* DLQ records with failure reason, failed field, timestamp, and worker instance
|
||||
* Kafka offsets committed only after the current processing outcome succeeds
|
||||
* ClickHouse bulk storage through the official Python client
|
||||
* Configurable CDR micro-batching
|
||||
* Flush on 1,000 records or 2-second timeout
|
||||
* Kafka offsets committed only after successful ClickHouse batch writes
|
||||
* Remaining worker buffer flushed during graceful shutdown
|
||||
|
||||
## Project Structure
|
||||
|
||||
@@ -106,6 +115,8 @@ telecom-cdr-pipeline/
|
||||
│ │
|
||||
│ └── worker/
|
||||
│ ├── __init__.py
|
||||
│ ├── batch.py
|
||||
│ ├── clickhouse.py
|
||||
│ ├── consumer.py
|
||||
│ ├── dlq.py
|
||||
│ ├── main.py
|
||||
@@ -116,6 +127,7 @@ telecom-cdr-pipeline/
|
||||
│ │ └── test_ingestion_api.py
|
||||
│ │
|
||||
│ └── unit/
|
||||
│ ├── test_batch.py
|
||||
│ ├── test_dlq.py
|
||||
│ ├── test_normalization.py
|
||||
│ ├── test_project_setup.py
|
||||
|
||||
@@ -9,6 +9,7 @@ dependencies = [
|
||||
"uvicorn[standard]>=0.30,<1.0",
|
||||
"confluent-kafka>=2.15,<3.0",
|
||||
"pydantic-settings>=2.0,<3.0",
|
||||
"clickhouse-connect>=1.8,<2.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -31,6 +31,16 @@ class Settings(BaseSettings):
|
||||
|
||||
worker_instance_id: str = "worker-1"
|
||||
|
||||
clickhouse_host: str = "localhost"
|
||||
clickhouse_port: int = 8123
|
||||
clickhouse_database: str = "telecom"
|
||||
clickhouse_user: str = "telecom"
|
||||
clickhouse_password: str = ""
|
||||
|
||||
batch_size: int = 1_000
|
||||
batch_flush_interval_seconds: float = 2.0
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
|
||||
73
src/core/worker/batch.py
Normal file
73
src/core/worker/batch.py
Normal file
@@ -0,0 +1,73 @@
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
from core.domain.models import NormalizedCDR
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class KafkaPosition:
|
||||
topic: str
|
||||
partition: int
|
||||
offset: int
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class BufferedCDR:
|
||||
cdr: NormalizedCDR
|
||||
position: KafkaPosition
|
||||
|
||||
|
||||
class MicroBatch:
|
||||
def __init__(
|
||||
self,
|
||||
max_size: int,
|
||||
flush_interval_seconds: float,
|
||||
) -> None:
|
||||
self._max_size = max_size
|
||||
self._flush_interval_seconds = flush_interval_seconds
|
||||
|
||||
self._items: list[BufferedCDR] = []
|
||||
|
||||
self._started_at: float | None = None
|
||||
|
||||
def add(
|
||||
self,
|
||||
item: BufferedCDR,
|
||||
) -> None:
|
||||
if not self._items:
|
||||
self._started_at = time.monotonic()
|
||||
|
||||
self._items.append(item)
|
||||
|
||||
@property
|
||||
def size(self) -> int:
|
||||
return len(self._items)
|
||||
|
||||
@property
|
||||
def is_empty(self) -> bool:
|
||||
return not self._items
|
||||
|
||||
@property
|
||||
def is_full(self) -> bool:
|
||||
return len(self._items) >= self._max_size
|
||||
|
||||
def is_due(self) -> bool:
|
||||
if not self._items:
|
||||
return False
|
||||
|
||||
if self._started_at is None:
|
||||
return False
|
||||
|
||||
elapsed = (
|
||||
time.monotonic()
|
||||
- self._started_at
|
||||
)
|
||||
|
||||
return elapsed >= self._flush_interval_seconds
|
||||
|
||||
def snapshot(self) -> list[BufferedCDR]:
|
||||
return list(self._items)
|
||||
|
||||
def clear(self) -> None:
|
||||
self._items.clear()
|
||||
self._started_at = None
|
||||
97
src/core/worker/clickhouse.py
Normal file
97
src/core/worker/clickhouse.py
Normal file
@@ -0,0 +1,97 @@
|
||||
from collections.abc import Sequence
|
||||
|
||||
import clickhouse_connect
|
||||
|
||||
from core.config import Settings
|
||||
from core.domain.models import NormalizedCDR
|
||||
|
||||
|
||||
class ClickHouseWriteError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class ClickHouseWriter:
|
||||
COLUMN_NAMES = [
|
||||
"cdr_id",
|
||||
"raw_id",
|
||||
"msisdn",
|
||||
"imsi",
|
||||
"service_type",
|
||||
"radio_access_type",
|
||||
"cell_id",
|
||||
"bytes_uploaded",
|
||||
"bytes_downloaded",
|
||||
"total_bytes",
|
||||
"duration_seconds",
|
||||
"session_start_time",
|
||||
"session_end_time",
|
||||
"source_ip",
|
||||
]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
settings: Settings,
|
||||
) -> None:
|
||||
self._settings = settings
|
||||
|
||||
self._client = clickhouse_connect.get_client(
|
||||
host=settings.clickhouse_host,
|
||||
port=settings.clickhouse_port,
|
||||
username=settings.clickhouse_user,
|
||||
password=settings.clickhouse_password,
|
||||
database=settings.clickhouse_database,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _to_row(
|
||||
cdr: NormalizedCDR,
|
||||
) -> list:
|
||||
return [
|
||||
cdr.cdr_id,
|
||||
cdr.raw_id,
|
||||
|
||||
cdr.msisdn,
|
||||
cdr.imsi,
|
||||
|
||||
cdr.service_type,
|
||||
cdr.radio_access_type,
|
||||
cdr.cell_id,
|
||||
|
||||
cdr.bytes_uploaded,
|
||||
cdr.bytes_downloaded,
|
||||
cdr.total_bytes,
|
||||
cdr.duration_seconds,
|
||||
|
||||
cdr.session_start_time,
|
||||
cdr.session_end_time,
|
||||
|
||||
str(cdr.source_ip),
|
||||
]
|
||||
|
||||
def insert(
|
||||
self,
|
||||
records: Sequence[NormalizedCDR],
|
||||
) -> None:
|
||||
if not records:
|
||||
return
|
||||
|
||||
rows = [
|
||||
self._to_row(record)
|
||||
for record in records
|
||||
]
|
||||
|
||||
try:
|
||||
self._client.insert(
|
||||
table="cdr_records",
|
||||
data=rows,
|
||||
column_names=self.COLUMN_NAMES,
|
||||
database=self._settings.clickhouse_database,
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
raise ClickHouseWriteError(
|
||||
f"ClickHouse batch insert failed: {exc}"
|
||||
) from exc
|
||||
|
||||
def close(self) -> None:
|
||||
self._client.close()
|
||||
@@ -1,8 +1,19 @@
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from confluent_kafka import Consumer, KafkaException, Message
|
||||
from confluent_kafka import (
|
||||
Consumer,
|
||||
KafkaException,
|
||||
Message,
|
||||
TopicPartition,
|
||||
)
|
||||
|
||||
from core.worker.batch import (
|
||||
BufferedCDR,
|
||||
KafkaPosition,
|
||||
MicroBatch,
|
||||
)
|
||||
from core.worker.clickhouse import ClickHouseWriter
|
||||
from core.config import Settings
|
||||
from core.domain.errors import (
|
||||
CDRProcessingError,
|
||||
@@ -48,6 +59,17 @@ class ProcessingWorker:
|
||||
|
||||
self._dlq = DLQPublisher(settings)
|
||||
|
||||
self._clickhouse = ClickHouseWriter(
|
||||
settings
|
||||
)
|
||||
|
||||
self._batch = MicroBatch(
|
||||
max_size=settings.batch_size,
|
||||
flush_interval_seconds=(
|
||||
settings.batch_flush_interval_seconds
|
||||
),
|
||||
)
|
||||
|
||||
self._running = False
|
||||
|
||||
def run(self) -> None:
|
||||
@@ -71,6 +93,7 @@ class ProcessingWorker:
|
||||
)
|
||||
|
||||
if message is None:
|
||||
self._flush_if_due()
|
||||
continue
|
||||
|
||||
if message.error():
|
||||
@@ -81,6 +104,7 @@ class ProcessingWorker:
|
||||
continue
|
||||
|
||||
self._handle_message(message)
|
||||
self._flush_if_due()
|
||||
|
||||
finally:
|
||||
self._shutdown()
|
||||
@@ -106,6 +130,9 @@ class ProcessingWorker:
|
||||
normalized = process_payload(payload)
|
||||
|
||||
except CDRProcessingError as exc:
|
||||
if not self._batch.is_empty:
|
||||
self._flush_batch()
|
||||
|
||||
self._route_to_dlq(
|
||||
message=message,
|
||||
original_payload=original_payload,
|
||||
@@ -124,6 +151,9 @@ class ProcessingWorker:
|
||||
detail=f"Unexpected processing error: {exc}",
|
||||
)
|
||||
|
||||
if not self._batch.is_empty:
|
||||
self._flush_batch()
|
||||
|
||||
self._route_to_dlq(
|
||||
message=message,
|
||||
original_payload=original_payload,
|
||||
@@ -132,20 +162,30 @@ class ProcessingWorker:
|
||||
|
||||
return
|
||||
|
||||
logger.info(
|
||||
(
|
||||
"Normalized CDR. "
|
||||
"raw_id=%s cdr_id=%s "
|
||||
"service_type=%s rat=%s"
|
||||
),
|
||||
normalized.raw_id,
|
||||
normalized.cdr_id,
|
||||
normalized.service_type,
|
||||
normalized.radio_access_type,
|
||||
position = KafkaPosition(
|
||||
topic=message.topic(),
|
||||
partition=message.partition(),
|
||||
offset=message.offset(),
|
||||
)
|
||||
|
||||
# Temporary until ClickHouse batching is implemented.
|
||||
self._commit(message)
|
||||
self._batch.add(
|
||||
BufferedCDR(
|
||||
cdr=normalized,
|
||||
position=position,
|
||||
)
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
(
|
||||
"Buffered normalized CDR. "
|
||||
"raw_id=%s batch_size=%s"
|
||||
),
|
||||
normalized.raw_id,
|
||||
self._batch.size,
|
||||
)
|
||||
|
||||
if self._batch.is_full:
|
||||
self._flush_batch()
|
||||
|
||||
def _route_to_dlq(
|
||||
self,
|
||||
@@ -202,6 +242,96 @@ class ProcessingWorker:
|
||||
message.offset(),
|
||||
)
|
||||
|
||||
def _flush_if_due(self) -> None:
|
||||
if self._batch.is_due():
|
||||
self._flush_batch()
|
||||
|
||||
def _flush_batch(self) -> None:
|
||||
items = self._batch.snapshot()
|
||||
|
||||
if not items:
|
||||
return
|
||||
|
||||
records = [
|
||||
item.cdr
|
||||
for item in items
|
||||
]
|
||||
|
||||
logger.info(
|
||||
"Flushing ClickHouse batch. size=%s",
|
||||
len(records),
|
||||
)
|
||||
|
||||
self._clickhouse.insert(
|
||||
records
|
||||
)
|
||||
|
||||
positions = [
|
||||
item.position
|
||||
for item in items
|
||||
]
|
||||
|
||||
self._commit_positions(
|
||||
positions
|
||||
)
|
||||
|
||||
self._batch.clear()
|
||||
|
||||
logger.info(
|
||||
"ClickHouse batch flushed successfully. size=%s",
|
||||
len(records),
|
||||
)
|
||||
|
||||
def _commit_positions(
|
||||
self,
|
||||
positions: list[KafkaPosition],
|
||||
) -> None:
|
||||
highest_offsets: dict[
|
||||
tuple[str, int],
|
||||
int,
|
||||
] = {}
|
||||
|
||||
for position in positions:
|
||||
key = (
|
||||
position.topic,
|
||||
position.partition,
|
||||
)
|
||||
|
||||
next_offset = (
|
||||
position.offset + 1
|
||||
)
|
||||
|
||||
current = highest_offsets.get(key)
|
||||
|
||||
if (
|
||||
current is None
|
||||
or next_offset > current
|
||||
):
|
||||
highest_offsets[key] = next_offset
|
||||
|
||||
offsets = [
|
||||
TopicPartition(
|
||||
topic,
|
||||
partition,
|
||||
offset,
|
||||
)
|
||||
for (
|
||||
topic,
|
||||
partition,
|
||||
), offset in highest_offsets.items()
|
||||
]
|
||||
|
||||
committed = self._consumer.commit(
|
||||
offsets=offsets,
|
||||
asynchronous=False,
|
||||
)
|
||||
|
||||
for partition in committed or []:
|
||||
if partition.error is not None:
|
||||
raise KafkaException(
|
||||
partition.error
|
||||
)
|
||||
|
||||
def stop(self) -> None:
|
||||
self._running = False
|
||||
|
||||
@@ -209,7 +339,20 @@ class ProcessingWorker:
|
||||
logger.info("Worker shutting down.")
|
||||
|
||||
try:
|
||||
self._dlq.close()
|
||||
if not self._batch.is_empty:
|
||||
logger.info(
|
||||
"Flushing remaining records before shutdown."
|
||||
)
|
||||
|
||||
self._flush_batch()
|
||||
|
||||
finally:
|
||||
self._consumer.close()
|
||||
try:
|
||||
self._dlq.close()
|
||||
|
||||
finally:
|
||||
try:
|
||||
self._clickhouse.close()
|
||||
|
||||
finally:
|
||||
self._consumer.close()
|
||||
|
||||
101
tests/unit/test_batch.py
Normal file
101
tests/unit/test_batch.py
Normal file
@@ -0,0 +1,101 @@
|
||||
from datetime import datetime, timezone
|
||||
from ipaddress import IPv4Address
|
||||
from uuid import uuid4
|
||||
|
||||
from core.domain.models import NormalizedCDR
|
||||
from core.worker.batch import (
|
||||
BufferedCDR,
|
||||
KafkaPosition,
|
||||
MicroBatch,
|
||||
)
|
||||
|
||||
|
||||
def normalized_cdr(
|
||||
raw_id: str,
|
||||
) -> NormalizedCDR:
|
||||
now = datetime.now(
|
||||
timezone.utc
|
||||
)
|
||||
|
||||
return NormalizedCDR(
|
||||
cdr_id=uuid4(),
|
||||
raw_id=raw_id,
|
||||
|
||||
msisdn="+19875551234",
|
||||
imsi="310410998877665",
|
||||
|
||||
service_type="4G_DATA",
|
||||
radio_access_type="LTE_4G",
|
||||
cell_id="4021_8812",
|
||||
|
||||
bytes_uploaded=100,
|
||||
bytes_downloaded=200,
|
||||
total_bytes=300,
|
||||
duration_seconds=300,
|
||||
|
||||
session_start_time=now,
|
||||
session_end_time=now,
|
||||
|
||||
source_ip=IPv4Address(
|
||||
"10.240.12.1"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def buffered_item(
|
||||
raw_id: str,
|
||||
offset: int,
|
||||
) -> BufferedCDR:
|
||||
return BufferedCDR(
|
||||
cdr=normalized_cdr(raw_id),
|
||||
position=KafkaPosition(
|
||||
topic="cdr.raw",
|
||||
partition=0,
|
||||
offset=offset,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_batch_flushes_when_full() -> None:
|
||||
batch = MicroBatch(
|
||||
max_size=2,
|
||||
flush_interval_seconds=10,
|
||||
)
|
||||
|
||||
batch.add(
|
||||
buffered_item(
|
||||
raw_id="raw-1",
|
||||
offset=1,
|
||||
)
|
||||
)
|
||||
|
||||
assert batch.is_full is False
|
||||
|
||||
batch.add(
|
||||
buffered_item(
|
||||
raw_id="raw-2",
|
||||
offset=2,
|
||||
)
|
||||
)
|
||||
|
||||
assert batch.is_full is True
|
||||
|
||||
|
||||
def test_batch_clear_resets_state() -> None:
|
||||
batch = MicroBatch(
|
||||
max_size=2,
|
||||
flush_interval_seconds=10,
|
||||
)
|
||||
|
||||
batch.add(
|
||||
buffered_item(
|
||||
raw_id="raw-1",
|
||||
offset=1,
|
||||
)
|
||||
)
|
||||
|
||||
batch.clear()
|
||||
|
||||
assert batch.size == 0
|
||||
assert batch.is_empty is True
|
||||
assert batch.is_due() is False
|
||||
Reference in New Issue
Block a user