Spaces:
Running
Running
| """ | |
| 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) | |