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-streaming4Cluster: 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") \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 .option("checkpointLocation", f"{CHECKPOINT_BASE}features/") \126 .outputMode("update") \127 .trigger(processingTime=TRIGGER_INTERVAL) \128 .queryName("hot-features") \129 .start()130131# Cada query necesita SU checkpoint, en su propio directorio. Dos queries132# compartiendo checkpoint se pisan los offsets y pierden datos en silencio.133134print("=== PIPELINE DE STREAMING INICIADO ===")135print(f" Kafka: {KAFKA_TOPIC} @ {KAFKA_BROKERS}")136print(f" Output: {OUTPUT_SILVER}")137print(f" Trigger: cada {TRIGGER_INTERVAL}")138print(f" Watermark: {WATERMARK_DELAY}")139print(f" Deduplicación: por transaction_id dentro del watermark")140print(f" Queries activas: silver-writer, hot-features")141142# Esta linea bloquea hasta que alguna query termine o falle. Es lo que143# mantiene vivo el job: sin ella, el proceso llega al final del fichero,144# se cierra, y se lleva por delante los dos streams.145spark.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.
Y el aviso simétrico: dropDuplicatesWithinWatermark NO es exactly-once frente a reintentos tardíos. Acota la memoria a cambio de una ventana de protección finita. Si el sistema que te envía los eventos reintenta más tarde que tu watermark, necesitas además una clave única en el destino.
1Alberto (revisando tu PR, 17:40):2"Bien el dropDuplicatesWithinWatermark. Pero quiero que digas3en voz alta lo que este código NO cubre, porque mañana te lo4va a preguntar alguien.56Tu watermark son 5 minutos. Eso significa que si el mismo7transaction_id vuelve a los 6 minutos, tu dedup ya lo ha8olvidado y lo deja pasar. Y los reintentos de los sistemas9de pagos no son de 5 minutos: el POS reintenta a los 15, el10adquirente a la hora, y una reconciliación nocturna puede11reenviarte el día entero.1213Así que ten claro que tienes DOS niveles:1415 - Dentro del watermark: Spark. Barato, en memoria, automático.16 - Fuera del watermark: la tabla de destino. Un MERGE contra17 transaction_id, o un índice único que reviente. Más caro,18 pero es el único que aguanta un reintento de 15 minutos.1920Hoy solo tienes el primero. Apúntalo en el ADR como riesgo21conocido y seguimos."
Alberto señala el hueco del dedup: los reintentos tardíos
Regístrate para guardar tu progreso.