lección 3
Día 2 - Pipeline de ingesta y la sorpresa de los duplicados
Construyes el pipeline de S3 a Redshift, descubres el bug de duplicados de Kafka, David te explica el por que, y Marcos te hace code review.
⏱ 50 min
### Miércoles, 9:00 AM - Manos a la obra
Tercer día. Hoy es el día de CONSTRUIR. Ayer exploraste, clasificaste y diseñaste. Hoy escribes el pipeline que mueve eventos de S3 a Redshift. Es la pieza fundamental: sin datos en el warehouse, Sandra no puede construir modelos y Lucía no tiene sus cohortes.
1#data-team | Miercoles 9:20 AM23Pablo Herrera 9:20 AM4?? Standup miercoles5- Marcos: pipeline billing EN REVIEW (PR abierta)6- Sandra: esperando eventos en Redshift para arrancar modelos7- Nuevo: pipeline de ingesta de eventos (objetivo: tener datos8 hoy para que Sandra pueda empezar mañana)910@nuevo como vas? Necesitas algo?1112Tu 9:22 AM13Todo bien! Ayer termine la exploracion y el DDL.14Hoy objetivo: pipeline funcional que cargue 1 semana de eventos.15Tengo una duda sobre la estrategia de dedup que me gustaria16resolver con Marcos o David.1718Marcos Delgado 9:24 AM19Te hago una call a las 10. 5 min.2021Sandra Vidal 9:25 AM22Avisa cuando tengas datos en la tabla! Necesito al menos 423semanas de histórico para las cohortes ??2425 ?? 2
El plan es simple en concepto pero tiene matices importantes en implementación. Necesitas: (1) leer archivos JSON Lines de S3, (2) parsear cada línea como un evento, (3) clasificar por categoría, (4) aplanar las propiedades frecuentes (task_id, board_id), (5) deduplicar por event_id, y (6) cargar en Redshift. En FreshMart hacías algo parecido con CSVs. Aquí la diferencia es el volumen y los duplicados.
1# ingest_events.py - Pipeline de ingesta de eventos TaskFlow2# Lee de S3 (local para desarrollo), limpia y carga a staging3import pandas as pd4import json5from pathlib import Path6from datetime import datetime, timedelta78# --- Configuracion ---9RAW_PATH = Path('datos/taskflow_events/') # Simula S3 en local10OUTPUT_PATH = Path('output/staging/')11OUTPUT_PATH.mkdir(parents=True, exist_ok=True)1213# Clasificacion de eventos (definición oficial v1)14CORE_EVENTS = {15 'task_created', 'task_completed', 'task_assigned',16 'comment_added', 'file_uploaded', 'board_created',17 'automation_created',18}19BROAD_ONLY = {20 'board_viewed', 'task_viewed', 'search_performed',21 'filter_applied', 'notification_clicked',22 'dashboard_viewed', 'report_exported',23}2425def classify_event(event_type: str) -> str:26 if event_type in CORE_EVENTS:27 return 'core'28 elif event_type in BROAD_ONLY:29 return 'broad'30 return 'system'3132def extract_property(properties: dict, key: str) -> str:33 """Extrae un campo del dict de propiedades, None si no existe."""34 if properties and isinstance(properties, dict):35 return properties.get(key)36 return None3738# --- Lectura ---39def read_events_for_date(date_str: str) -> pd.DataFrame:40 """Lee todos los eventos de un día desde la estructura S3."""41 all_events = []42 date_path = RAW_PATH / date_str.replace('-', '/')4344 if not date_path.exists():45 print(f" [WARN] No hay datos para {date_str}")46 return pd.DataFrame()4748 for jsonl_file in date_path.glob('*.jsonl'):49 with open(jsonl_file, 'r') as f:50 for line in f:51 try:52 event = json.loads(line.strip())53 all_events.append(event)54 except json.JSONDecodeError:55 continue # Lineas malformadas se descartan5657 return pd.DataFrame(all_events)5859# --- Transformacion ---60def transform_events(df: pd.DataFrame, source_file: str) -> pd.DataFrame:61 """Aplica clasificacion, aplanamiento y metadatos."""62 if df.empty:63 return df6465 df['event_category'] = df['event_type'].apply(classify_event)66 df['event_timestamp'] = pd.to_datetime(df['timestamp'])67 df['event_date'] = df['event_timestamp'].dt.date68 df['task_id'] = df['properties'].apply(69 lambda p: extract_property(p, 'task_id')70 )71 df['board_id'] = df['properties'].apply(72 lambda p: extract_property(p, 'board_id')73 )74 df['properties_json'] = df['properties'].apply(75 lambda p: json.dumps(p) if p else None76 )77 df['ingested_at'] = datetime.utcnow()78 df['source_file'] = source_file7980 # Seleccionar columnas finales (orden del DDL)81 columns = [82 'event_id', 'event_type', 'event_category',83 'event_timestamp', 'event_date',84 'user_id', 'workspace_id',85 'task_id', 'board_id', 'properties_json',86 'ingested_at', 'source_file',87 ]88 return df[columns]8990# --- Deduplicacion ---91def deduplicate(df: pd.DataFrame) -> pd.DataFrame:92 """Elimina duplicados por event_id, quedandose con el primero."""93 before = len(df)94 df = df.drop_duplicates(subset=['event_id'], keep='first')95 after = len(df)96 if before > after:97 print(f" [DEDUP] {before - after:,} duplicados eliminados")98 return df
Pipeline de ingesta: lectura, transformación, deduplicación
### 10:00 AM - La call con Marcos sobre duplicados
Marcos te llama a las 10 en punto. Sin preambulos:
1Marcos (call, 10:00 AM):23"Los duplicados. Si. Kafka usa at-least-once delivery por4defecto. Significa que si el producer no recibe ACK del5broker (por timeout de red, por ejemplo), reenvia el6mensaje. El consumer lo recibe dos veces.78Opciones para deduplicar:91. En el pipeline de ingesta (lo que estas haciendo)10 - DROP_DUPLICATES por event_id antes de cargar11 - Simple, funciona, pero si ejecutas dos veces el12 mismo batch puedes tener problemas13142. En Redshift con MERGE/UPSERT15 - Cargas todo en una staging temporal16 - Haces MERGE contra la tabla final por event_id17 - Mas robusto pero más lento18193. En dbt (dejar que Sandra lo haga downstream)20 - La tabla staging tiene duplicados21 - Sandra deduplica en el primer modelo22 - Mas simple para ti pero Sandra me mataria2324Mi recomendación: opcion 1 + un check de idempotencia.25Tu pipeline debe poder ejecutarse 2 veces sobre el mismo26día sin crear duplicados. Usa event_id como clave natural.2728Pregunta a David cuantos duplicados esperar. El sabe el29config exacto del producer."3031Tu: "Entendido. Opcion 1 con idempotencia. Le preguntó a32David el ratio esperado."3334Marcos: "Bien. Cuando tengas el PR avisame, lo reviso."
### 10:30 AM - Preguntarle a David
1Tu 10:32 AM (DM a David Martin)2Hola David! Estoy montando el pipeline de ingesta de eventos3y he visto duplicados en S3 (~0.25% de los eventos).4Marcos me dice que es el retry del producer. Sabes cual es5el config exacto? Quiero saber que ratio de duplicados6esperar para mi validación.78David Martin 10:48 AM9Si. El producer está configurado con:10- acks=all11- retries=312- retry.backoff.ms=10013- enable.idempotence=false1415El enable.idempotence=false es el problema. Con true Kafka16garantiza exactly-once a nivel de producer pero tiene overhead.17Lo deje en false cuando lo monte rápido.1819El ratio esperado es ~0.2-0.5% segun la carga de red. En20picos puede subir al 1%.2122Quieres que active idempotence? Puedo hacerlo pero necesito23coordinarlo con infra.2425Tu 10:52 AM26Por ahora no hace falta, deduplico en el pipeline. Pero si27en el futuro el volumen sube mucho seria bueno tenerlo.28Gracias por la info!2930David Martin 10:53 AM31np. Si necesitas saber que significa algun evento específico32preguntame, la documentación del tracking es... escasa.
Esa última frase de David -- "la documentación del tracking es escasa" -- es un eufemismo. No hay documentación. La semántica de cada evento la tiene David en la cabeza. Eso es deuda técnica que habrá que resolver eventualmente, pero no esta semana.
Consejo de senior: enable.idempotence=true en Kafka elimina duplicados a nivel de producer (Kafka le asigna un sequence number a cada mensaje y descarta reintentos). Es la solución "correcta" pero no siempre la puedes activar (requiere coordinacion con infra, puede impactar throughput). Mientras tanto, deduplicar en el pipeline por event_id es perfectamente válido. Lo importante es que tu pipeline sea IDEMPOTENTE: ejecutarlo 2 veces sobre el mismo input produce el mismo resultado.
### 11:00 AM - Ejecutar el pipeline: primera carga
1# --- Ejecucion principal ---2def run_ingestion(start_date: str, end_date: str):3 """Ingesta eventos para un rango de fechas."""4 print(f"=== Ingesta de eventos: {start_date} a {end_date} ===")56 start = datetime.strptime(start_date, '%Y-%m-%d')7 end = datetime.strptime(end_date, '%Y-%m-%d')89 all_dfs = []10 current = start11 while current <= end:12 date_str = current.strftime('%Y-%m-%d')13 print(f"\n Procesando {date_str}...")1415 df = read_events_for_date(date_str)16 if not df.empty:17 df = transform_events(df, f"s3://taskflow-events/raw/{date_str}/")18 df = deduplicate(df)19 all_dfs.append(df)20 print(f" -> {len(df):,} eventos validos")2122 current += timedelta(days=1)2324 if all_dfs:25 final = pd.concat(all_dfs, ignore_index=True)26 # Dedup final (por si un evento aparece en 2 archivos de días distintos)27 final = deduplicate(final)2829 # Guardar como parquet (simula COPY a Redshift)30 output_file = OUTPUT_PATH / 'stg_product_events.parquet'31 final.to_parquet(output_file, index=False)3233 print(f"\n=== RESULTADO ===")34 print(f"Eventos totales: {len(final):,}")35 print(f"Dias procesados: {(end - start).days + 1}")36 print(f"Usuarios únicos: {final['user_id'].nunique():,}")37 print(f"Categorias: {final['event_category'].value_counts().to_dict()}")38 print(f"Output: {output_file}")39 else:40 print("\n[ERROR] No se encontraron eventos en el rango")4142# Ejecutar para 1 semana de muestra43run_ingestion('2024-03-08', '2024-03-14')
Pipeline completo: procesa una semana y genera el staging
Ejecutas el pipeline. Resultado: 1.742.891 eventos válidos en 7 días, 14.322 usuarios únicos, 3.891 workspaces. Se eliminaron 4.127 duplicados (0.24%, consistente con lo que David dijo). El archivo parquet pesa 47MB. Si extrapolas a 6 meses serían ~45M eventos -- manejable en Redshift pero necesitarás particiones en el futuro.
### 14:00 PM - La PR y el code review de Marcos
Después de comer subes tu PR a GitHub. Titulo: "feat: pipeline de ingesta de eventos de producto". Descripción con el DDL, el script de ingesta, tests básicos y el output de la primera ejecución.
1GitHub PR #47 - feat: pipeline de ingesta de eventos de producto23Marcos Delgado (reviewer) - 14:4545src/ingest_events.py:6L42 - "Has pensado en que pasa si un archivo JSON tiene una linea7vacia al final? Ese try/except va a tragarsela pero el log no te8dira nada. Anade un contador de lineas malformadas y logealo al9final."1011L67 - "Bien la dedup por event_id. Una mejora: anade un assert12después de la dedup que verifique que no hay NULLs en event_id.13Si Kafka manda un evento sin ID (pasa), tu pipeline deberia14fallar ruidosamente, no tragar el NULL y crear un duplicado15potencial."1617L89 - "El extract_property está bien pero isinstance(properties,18dict) va a fallar si el campo es un string JSON en vez de un dict19parseado. Asegurate de que el json.loads ya se hizo antes."2021General: "Buen primer PR. La estructura es clara y el naming sigue22las convenciones. Los 3 comentarios son mejoras de robustez, no23bloqueantes. Apruebo con esos cambios. ??"2425Sandra Vidal (reviewer) - 15:022627"Los nombres de columnas están perfectos! stg_product_events con28prefijo de entidad... me haces feliz ??29Solo una cosa: en el DDL anade un COMMENT a la tabla y a las30columnas clave. En Redshift se puede con COMMENT ON TABLE.31Asi cualquiera que haga DESC tabla sabe que es cada campo."
El code review de Marcos es exactamente lo que esperabas: técnico, específico, y centrado en robustez. No te dice "está mal" sino "has pensado en este edge case?". Es el tipo de review que te hace MEJOR, no el que te hace sentir mal. Sandra se centra en documentación y naming -- su obsesión. Aplicas los cambios, Marcos aprueba, y mergeas.
Después del merge, Marcos te manda un DM minimalista como siempre:
1Marcos Delgado 15:15 PM (DM)2Mergeado. Buen primer pipeline.3Una cosa: la proxima vez que ingieras más de 1 semana,4piensa en particionado. Cargar 6 meses de golpe en un5solo archivo parquet no va a escalar.6No es urgente -- es para que lo tengas en mente.78Tu 15:18 PM9Anotado. Cuando llegue el momento de cargar el histórico10completo te preguntó el approach.1112Marcos Delgado 15:19 PM13??
Ese "??" de Marcos es histórico. Es la primera vez que le ves usar un emoji. Sandra luego te confesará que en 4 meses solo le ha visto usarlo 3 veces. Es su forma de decir "me caes bien". No lo analices demasiado.
Error común de junior: tomarse el code review como un ataque personal. Los 3 comentarios de Marcos no significan que tu código está mal -- significan que un senior con 8 años de experiencia ve edge cases que tú todavía no ves. Eso es EXACTAMENTE para lo que sirve el code review. Agradécelo en vez de frustrarte.
### 16:00 PM - El data sync con Producto
El miércoles a las 16:00 es el "Data sync con Producto": reunión de 30 minutos donde Lucía trae preguntas y el equipo de datos responde. Hoy es tu primera vez ahí.
1Lucia Fernandez (Meet, 16:05):2"Ey, tengo dos preguntas para está semana:341. Las cohortes de retención: cuando las tenemos? Necesito5 incluirlas en el deck para el board review del lunes.672. Pregunta nueva: alguien sabe cuantos usuarios usan la8 feature de automatizaciones? Es que estamos pensando en9 moverla al plan Business y necesito saber el impacto."1011Pablo: "Las cohortes son el proyecto de está semana. El12nuevo está montando el pipeline de ingesta -- hoy ha cargado13los primeros datos. Las cohortes las tenemos el jueves o14viernes. Sobre las automatizaciones, depende de los eventos."1516Tu: "Lucia, la pregunta de automatizaciones la puedo17responder rápido con los datos que ya tengo cargados.18Dame hasta mañana por la mañana y te doy un número."1920Lucia: "Ey, genial ?? Si puedes segmentarlo por plan21(free vs pro vs business) mejor."2223Sandra: "Ojo con esa segmentacion -- necesitas cruzar24eventos con la tabla de suscripciones que está en otro25schema. Hablamos después del sync."2627Tu: "Entendido, lo coordino con Sandra."
Fíjate lo que acaba de pasar: Lucía hizo una pregunta ad-hoc ("cuántos usan automatizaciones?") y tú te comprometiste a responderla mañana. Es una tarea EXTRA que no estaba en tu ticket. Pero es pequeña, rápida, y demuestra que el pipeline que estás construyendo SIRVE para responder preguntas reales. Pablo lo nota y asiente -- eso es proactividad bien dirigida.
Hay un matiz importante aquí: no te comprometiste a "hacer un análisis profundo de automatizaciones". Te comprometiste a "un número segmentado por plan". Son 30 minutos de trabajo. La proactividad funciona cuando la tarea es ACOTADA. Si Lucía hubiera pedido "un análisis completo del funnel de conversiones", la respuesta correcta sería "lo hablo con Pablo y lo priorizamos".
### 17:00 PM - Sandra te explica el JOIN con suscripciones
1Sandra Vidal 17:05 PM (DM)2Para lo de Lucia -- las suscripciones están en:3analytics.dim_workspaces45Campos relevantes:6- workspace_id (PK)7- workspace_plan: 'free', 'pro', 'business'8- workspace_created_at9- workspace_member_count10- workspace_is_active (bool)1112El JOIN es por workspace_id. Cada evento tiene workspace_id13así que puedes cruzar directamente.1415Pero OJO: un workspace puede cambiar de plan. La tabla16dim_workspaces tiene el plan ACTUAL. Si quieres el plan17en el momento del evento necesitas la tabla de historial:18analytics.fct_plan_changes1920Para la pregunta de Lucia con el plan actual es suficiente.21Solo ten cuidado al documentarlo ??2223Tu 17:10 PM24Entendido! Uso dim_workspaces para el plan actual y lo25documento como "plan a fecha de consulta, no plan en el26momento del evento". Gracias Sandra!2728Sandra Vidal 17:11 PM29Exacto. Ves, esto es lo que diferencia un número30"correcto" de un número "correcto Y documentado". ??
Cierras el día con buen sabor: pipeline mergeado, primera carga exitosa, Sandra contenta con tu naming, y un mini-compromiso con Lucía que vas a resolver mañana antes del standup. El miércoles ha sido productivo.
## ejercicios
Pipeline de ingesta con deduplicación
Implementa la función principal del pipeline: lee un archivo JSON Lines, transforma los eventos (clasificacion + aplanamiento), deduplica por event_id y devuelve un DataFrame limpio.
Validar el staging de eventos antes de cargar
Implementa una función validate_staging que ejecute checks de calidad sobre el DataFrame de staging: sin NULLs en campos obligatorios, sin duplicados de event_id, categorías válidas, timestamps en rango razonable y ratio de duplicados bajo control.
Responder la pregunta de Lucia: uso de automatizaciones
Lucia quiere saber cuantos usuarios usan automatizaciones, segmentado por plan. Escribe la query SQL que cruza eventos con la dimension de workspaces.
Test de idempotencia del pipeline
Escribe un test con pytest que verifique que ejecutar el pipeline 2 veces sobre el mismo input produce exactamente el mismo resultado (mismas filas, mismo conteo).
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...