lección 5
Día 3 (tarde) -- Implementar Spark Structured Streaming
Implementas el pipeline completo de streaming: Kafka a S3 silver, watermarks para late data, deduplicación y el sink a Redis para features.
⏱ 45 min
### Jueves, 14:30 -- De prototipo a pipeline real
Con el ADR aprobado y el feature store diseñado, llega el momento de escribir el pipeline completo de streaming. No es un prototipo de demostración -- es código que va a correr en producción, procesando 4.5 millones de transacciones al día, 24/7, sin perder una sola. Alberto te recuerda: "En banca, exactly-once no es un nice-to-have. Si procesamos una transacción dos veces, el saldo del cliente está mal. Si no la procesamos, el dinero desaparece."
El pipeline tiene 4 responsabilidades: (1) consumir de Kafka con exactly-once semantics, (2) parsear y validar contra el schema, (3) deduplicar transacciones con ID único, y (4) escribir a S3 silver particionado + alimentar el feature store hot.
1"""2Pipeline de Streaming NeoBank -- Producción3Job: neobank-txn-streaming-v24Cluster: EMR-streaming-001 (4 executors, 15GB RAM cada uno)5Input: Kafka MSK topic "transactions.completed"6Output: S3 silver (Parquet) + Redis (features hot)78Garantías:9- Exactly-once processing (checkpoints en S3)10- Tolerancia a late data (watermark 5 min)11- Deduplicación por transaction_id12- Auto-recovery en caso de fallo (restart desde checkpoint)13"""1415from pyspark.sql import SparkSession16from pyspark.sql.functions import (17 col, from_json, to_timestamp, current_timestamp,18 window, count, avg, approx_count_distinct,19 expr, lit, date_format, hour20)21from pyspark.sql.types import (22 StructType, StructField, StringType, DoubleType,23 TimestampType, BooleanType24)2526# === CONFIGURACIÓN ===27KAFKA_BROKERS = "b-1.neobank-msk.kafka.eu-west-1.amazonaws.com:9092"28KAFKA_TOPIC = "transactions.completed"29CHECKPOINT_BASE = "s3://neobank-checkpoints/streaming-v2/"30OUTPUT_SILVER = "s3://neobank-lake/silver/transactions/"31TRIGGER_INTERVAL = "30 seconds"32WATERMARK_DELAY = "5 minutes"3334# === SCHEMA ===35txn_schema = StructType([36 StructField("transaction_id", StringType(), False),37 StructField("account_id", StringType(), False),38 StructField("merchant_id", StringType(), False),39 StructField("amount", DoubleType(), False),40 StructField("currency", StringType(), False),41 StructField("timestamp", StringType(), False),42 StructField("channel", StringType(), True), # app, web, pos, atm43 StructField("country_code", StringType(), True),44 StructField("status", StringType(), False),45 StructField("merchant_category", StringType(), True),46])4748# === SPARK SESSION ===49spark = SparkSession.builder \50 .appName("neobank-txn-streaming-v2") \51 .config("spark.sql.streaming.stateStore.providerClass",52 "org.apache.spark.sql.execution.streaming.state.HDFSBackedStateStoreProvider") \53 .config("spark.sql.shuffle.partitions", "16") \54 .getOrCreate()5556spark.sparkContext.setLogLevel("WARN")5758# === PASO 1: LEER DE KAFKA ===59raw_stream = spark.readStream \60 .format("kafka") \61 .option("kafka.bootstrap.servers", KAFKA_BROKERS) \62 .option("subscribe", KAFKA_TOPIC) \63 .option("startingOffsets", "latest") \64 .option("maxOffsetsPerTrigger", 50000) \65 .option("kafka.session.timeout.ms", "30000") \66 .option("kafka.max.poll.records", "5000") \67 .load()6869# === PASO 2: PARSEAR Y VALIDAR ===70parsed = raw_stream \71 .selectExpr(72 "CAST(key AS STRING) as kafka_key",73 "CAST(value AS STRING) as json_str",74 "topic", "partition", "offset",75 "timestamp as kafka_timestamp"76 ) \77 .select(78 from_json(col("json_str"), txn_schema).alias("txn"),79 col("kafka_key"),80 col("kafka_timestamp"),81 col("partition").alias("kafka_partition"),82 col("offset").alias("kafka_offset"),83 ) \84 .select("txn.*", "kafka_key", "kafka_timestamp", "kafka_partition", "kafka_offset") \85 .withColumn("event_time", to_timestamp(col("timestamp"))) \86 .withColumn("processing_time", current_timestamp()) \87 .filter(col("status") == "completed") \88 .filter(col("amount") > 0) # Filtrar transacciones con amount 0 o negativo8990# === PASO 3: DEDUPLICACIÓN POR TRANSACTION_ID ===91# Spark Structured Streaming soporta dropDuplicates con watermark92deduplicated = parsed \93 .withWatermark("event_time", WATERMARK_DELAY) \94 .dropDuplicatesWithinWatermark(["transaction_id"])9596# === PASO 4A: ESCRIBIR A S3 SILVER (particionado por fecha y hora) ===97silver_query = deduplicated \98 .withColumn("date_partition", date_format(col("event_time"), "yyyy-MM-dd")) \99 .withColumn("hour_partition", hour(col("event_time"))) \100 .writeStream \101 .format("parquet") \102 .option("path", OUTPUT_SILVER) \103 .option("checkpointLocation", f"{CHECKPOINT_BASE}silver/") \104 .partitionBy("date_partition", "hour_partition") \105 .trigger(processingTime=TRIGGER_INTERVAL) \106 .outputMode("append") \107 .queryName("silver-writer") \108 .start()109110# === PASO 4B: CALCULAR FEATURES HOT ===111hot_features = deduplicated \112 .groupBy(113 col("account_id"),114 window(col("event_time"), "1 hour", "1 minute")115 ) \116 .agg(117 count("*").alias("txn_count_1h"),118 avg("amount").alias("avg_amount_1h"),119 approx_count_distinct("merchant_id").alias("distinct_merchants_1h"),120 )121122# Escribir features a consola (en producción: forEach a Redis)123features_query = hot_features.writeStream \124 .format("console") \125 .outputMode("update") \126 .trigger(processingTime=TRIGGER_INTERVAL) \127 .queryName("hot-features") \128 .start()129130print("=== PIPELINE DE STREAMING INICIADO ===")131print(f" Kafka: {KAFKA_TOPIC} @ {KAFKA_BROKERS}")132print(f" Output: {OUTPUT_SILVER}")133print(f" Trigger: cada {TRIGGER_INTERVAL}")134print(f" Watermark: {WATERMARK_DELAY}")135print(f" Deduplicación: por transaction_id dentro del watermark")136print(f" Queries activas: silver-writer, hot-features")137138# Esperar terminación (en producción corre indefinidamente)139# spark.streams.awaitAnyTermination()
Pipeline completo de streaming: Kafka -> parse -> dedup -> S3 silver + features hot
### Watermarks -- tolerancia a late data
En un sistema distribuido, los eventos no siempre llegan en orden. Un cliente puede hacer una compra a las 14:00 pero la confirmación del banco del merchant llega a las 14:03 (delay de red, retry del POS terminal, etc.). El watermark le dice a Spark: "acepta eventos hasta 5 minutos de retraso respecto al evento más reciente que has visto. Después de eso, descártalos." Es el trade-off entre completitud y latencia.
El watermark de 5 minutos es una decisión de negocio, no técnica. Alberto y Miguel acordaron que perder el 0.01% de eventos con más de 5 min de retraso es aceptable vs. esperar indefinidamente. Si el regulador exigiera 100% completitud, necesitaríamos un approach diferente (reconciliación batch posterior). Documenta siempre POR QUÉ elegiste un watermark específico.
### Deduplicación en streaming
Kafka garantiza at-least-once delivery por defecto. Eso significa que un mismo evento puede llegar más de una vez (por ejemplo, si el producer hace retry tras un timeout). En banca, procesar una transacción dos veces es inaceptable -- el cliente vería un cargo doble. La deduplicación por transaction_id dentro del watermark window resuelve esto:
1# Deduplicación en Spark Structured Streaming2# dropDuplicatesWithinWatermark mantiene estado solo dentro de la ventana del watermark3# Esto limita el uso de memoria (vs. dropDuplicates global que crece sin límite)45# SIN deduplicación (peligroso en producción):6# Si el mismo TXN-12345 llega 2 veces, se procesa 2 veces7# -> Saldo del cliente incorrecto8# -> Feature txn_count_1h inflada artificialmente9# -> Modelo de fraude puede generar false positive1011# CON deduplicación dentro de watermark:12deduplicated = parsed \13 .withWatermark("event_time", "5 minutes") \14 .dropDuplicatesWithinWatermark(["transaction_id"])1516# Spark mantiene los transaction_ids vistos en los últimos 5 minutos17# Si ve un duplicado dentro de esa ventana, lo descarta18# Después de 5 min, el estado se limpia automáticamente1920# ¿Por qué "within watermark" y no global?21# - Global: mantendría TODOS los IDs en memoria -> OOM con 4.5M txns/día22# - Within watermark: solo mantiene ~15K IDs (5 min * 52 evt/s = 15,600)23# - Memoria: ~15K * 36 bytes (UUID) = ~560 KB (trivial)2425print("Deduplicación configurada:")26print(f" Ventana de dedup: {5} minutos (alineada con watermark)")27print(f" IDs en memoria (estimado): ~{5*60*52:,} ({5*60*52*36/1024:.0f} KB)")28print(f" Duplicados esperados: <0.1% del tráfico total")
Deduplicación dentro del watermark -- exactly-once con memoria acotada
dropDuplicates (sin watermark) en Spark Streaming mantiene TODOS los IDs en estado -- crece indefinidamente hasta OOM. SIEMPRE usa dropDuplicatesWithinWatermark en producción. La diferencia entre un prototipo que funciona 1 hora y un sistema que corre 6 meses sin caerse está en estos detalles.
## ejercicios
Implementar lógica de deduplicación
Simula la deduplicación con ventana temporal: dado un stream de transacciones con duplicados, elimínalos basándote en transaction_id y una ventana de tiempo configurable.
Configurar watermarks según SLA
Dado un conjunto de requisitos de negocio (SLA de fraude, tolerancia a late data, volumen), calcula la configuración óptima de watermark y trigger interval.
Implementar Redis Sink para features
Implementa un RedisSink que escribe features calculadas por Spark a Redis con TTL configurable y manejo de errores de conexión.
Regístrate para guardar tu progreso.
## comentarios
Reporta erratas, ayuda a otros o comparte tu opinión. Sé constructivo.
Inicia sesión para comentar y responder.
cargando comentarios...