customercore / src /streaming /bronze_consumer.py
Saibalaji Namburi
fix(lint): resolve ruff code formatting and unused import warnings
15c00a9
Raw
History Blame Contribute Delete
5.86 kB
"""
src/streaming/bronze_consumer.py
Reads from all 4 Redpanda topics and writes raw messages to MinIO
as the Bronze layer (unmodified, append-only Parquet files).
Bronze = raw data exactly as received. Nothing is changed.
If something goes wrong downstream, we always have the original.
Run: python -m src.streaming.bronze_consumer --batch-size 1000
Ctrl+C to stop gracefully.
"""
import argparse
import io
import json
import signal
import time
from collections import defaultdict
from datetime import datetime, timezone
import boto3
import pyarrow as pa
import pyarrow.parquet as pq
from botocore.client import Config
from confluent_kafka import Consumer, KafkaError
from tqdm import tqdm
# ── Config ────────────────────────────────────────────────────
BROKER = "localhost:9092"
GROUP_ID = "bronze-consumer-group"
TOPICS = ["support-tickets", "billing-events", "product-events", "incident-events"]
BUCKET = "customercore-lake"
TOPIC_TO_PREFIX = {
"support-tickets": "bronze/tickets",
"billing-events": "bronze/billing",
"product-events": "bronze/product",
"incident-events": "bronze/incidents",
}
MINIO_ENDPOINT = "http://localhost:9000"
MINIO_ACCESS_KEY = "minioadmin"
MINIO_SECRET_KEY = "minioadmin"
running = True
def signal_handler(sig, frame):
global running
print("\n[STOP] Graceful shutdown triggered...")
running = False
signal.signal(signal.SIGINT, signal_handler)
def get_s3():
return boto3.client(
"s3",
endpoint_url=MINIO_ENDPOINT,
aws_access_key_id=MINIO_ACCESS_KEY,
aws_secret_access_key=MINIO_SECRET_KEY,
config=Config(signature_version="s3v4"),
region_name="us-east-1",
)
def write_batch_to_bronze(s3, topic: str, records: list[dict]) -> str:
"""Write a batch of raw records to MinIO as a Parquet file."""
prefix = TOPIC_TO_PREFIX[topic]
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S_%f")
key = f"{prefix}/batch_{timestamp}.parquet"
# Convert to Arrow table — all values as strings to preserve raw format
rows = [{"raw_json": json.dumps(r), "ingested_at": datetime.now(timezone.utc).isoformat()} for r in records]
table = pa.table({
"raw_json": pa.array([r["raw_json"] for r in rows], type=pa.string()),
"ingested_at": pa.array([r["ingested_at"] for r in rows], type=pa.string()),
})
buf = io.BytesIO()
pq.write_table(table, buf)
buf.seek(0)
s3.put_object(Bucket=BUCKET, Key=key, Body=buf.getvalue())
return key
def main(batch_size: int = 500, timeout_seconds: int = 30):
print("=" * 60)
print("CustomerCore Bronze Consumer")
print(f"Topics : {', '.join(TOPICS)}")
print(f"Sink : MinIO s3://{BUCKET}/bronze/")
print(f"Batch : {batch_size} messages per Parquet file")
print("Press Ctrl+C to stop gracefully")
print("=" * 60)
consumer = Consumer({
"bootstrap.servers": BROKER,
"group.id": GROUP_ID,
"auto.offset.reset": "earliest",
"enable.auto.commit": True,
})
consumer.subscribe(TOPICS)
s3 = get_s3()
buffers: dict[str, list] = defaultdict(list)
total_written = 0
files_written = 0
start = time.time()
last_message_time = time.time()
print(f"\nListening for messages (will auto-stop after {timeout_seconds}s silence)...\n")
pbar = tqdm(unit="msg", desc="Consumed", bar_format="{desc}: {n_fmt} msgs | Files: {postfix}")
pbar.set_postfix_str("0")
try:
while running:
msg = consumer.poll(timeout=1.0)
# Auto-stop if silent for timeout_seconds
if time.time() - last_message_time > timeout_seconds:
print(f"\n[INFO] No messages for {timeout_seconds}s — flushing and stopping.")
break
if msg is None:
continue
if msg.error():
if msg.error().code() != KafkaError._PARTITION_EOF:
print(f"[ERROR] {msg.error()}")
continue
last_message_time = time.time()
topic = msg.topic()
try:
value = json.loads(msg.value().decode())
buffers[topic].append(value)
total_written += 1
pbar.update(1)
except json.JSONDecodeError:
continue
# Flush buffer when batch is full
if len(buffers[topic]) >= batch_size:
key = write_batch_to_bronze(s3, topic, buffers[topic])
files_written += 1
pbar.set_postfix_str(str(files_written))
buffers[topic] = []
# Flush remaining partial batches
print("\nFlushing remaining buffers...")
for topic, records in buffers.items():
if records:
key = write_batch_to_bronze(s3, topic, records)
files_written += 1
print(f" [FLUSHED] {len(records)} records -> {key}")
finally:
pbar.close()
consumer.close()
elapsed = time.time() - start
print(f"\n{'=' * 60}")
print(f" Total consumed : {total_written:,} messages")
print(f" Parquet files : {files_written}")
print(f" Duration : {elapsed:.1f}s")
print(f" Sink : s3://{BUCKET}/bronze/")
print(" Browse MinIO : http://localhost:9001")
print(f"{'=' * 60}")
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--batch-size", type=int, default=500, help="Messages per Parquet file")
parser.add_argument("--timeout", type=int, default=30, help="Stop after N seconds of silence")
args = parser.parse_args()
main(batch_size=args.batch_size, timeout_seconds=args.timeout)