Saltar al contenido

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 | Miércoles 9:20
2
3Pablo Herrera 9:20
4📋 Standup miércoles
5- Marcos: pipeline billing EN REVIEW (PR abierta)
6- Sandra: esperando eventos en Redshift para arrancar modelos
7- Nuevo: pipeline de ingesta de eventos (objetivo: tener datos
8 hoy para que Sandra pueda empezar mañana)
9
10@nuevo como vas? ¿Necesitas algo?
11
12Tú 9:22
13Todo bien! Ayer terminé la exploración y el DDL.
14Hoy objetivo: pipeline funcional que cargue 1 semana de eventos.
15Tengo una duda sobre la estrategia de dedup que me gustaría
16resolver con Marcos o David.
17
18Marcos Delgado 9:24
19Te hago una call a las 10. 5 min.
20
21Sandra Vidal 9:25
22Avisa cuando tengas datos en la tabla! Necesito al menos 4
23semanas de histórico para las cohortes 📊
24
25 📊 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 TaskFlow
2# Lee de S3 (local para desarrollo), limpia y carga a staging
3import pandas as pd
4import json
5from pathlib import Path
6from datetime import datetime, timedelta
7
8# --- Configuracion ---
9RAW_PATH = Path('datos/taskflow_events/') # Simula S3 en local
10OUTPUT_PATH = Path('output/staging/')
11OUTPUT_PATH.mkdir(parents=True, exist_ok=True)
12
13# 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}
19CONSUMO = {
20 'board_viewed', 'task_viewed', 'search_performed',
21 'filter_applied', 'notification_clicked',
22 'dashboard_viewed', 'report_exported',
23}
24
25def classify_event(event_type: str) -> str:
26 if event_type in CORE_EVENTS:
27 return 'core'
28 elif event_type in CONSUMO:
29 return 'consumo'
30 return 'system'
31
32def 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 None
37
38# --- 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('-', '/')
43
44 if not date_path.exists():
45 print(f" [WARN] No hay datos para {date_str}")
46 return pd.DataFrame()
47
48 for jsonl_file in sorted(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 descartan
56
57 return pd.DataFrame(all_events)
58
59# --- Transformacion ---
60def transform_events(df: pd.DataFrame, source_file: str) -> pd.DataFrame:
61 """Aplica clasificacion, aplanamiento y metadatos."""
62 if df.empty:
63 return df
64
65 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.normalize()
68 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 None
76 )
77 df['ingested_at'] = pd.Timestamp('2024-10-09 09:00:00', tz='UTC')
78 df['source_file'] = source_file
79
80 # 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]
89
90# --- Deduplicacion ---
91def deduplicate(df: pd.DataFrame) -> pd.DataFrame:
92 """Se queda con la primera aparicion de cada event_id (la mas antigua)."""
93 before = len(df)
94 df = df.sort_values('event_timestamp').drop_duplicates(
95 subset=['event_id'], keep='first'
96 ).copy()
97 after = len(df)
98 if before > after:
99 print(f" [DEDUP] {before - after:,} duplicados eliminados")
100 return df

Pipeline de ingesta: lectura, transformación, deduplicación

## 10:00 - La call con Marcos sobre duplicados

Marcos te llama a las 10 en punto. Sin preambulos:

1Marcos (call, 10:00 AM):
2
3"Los duplicados. Sí. Kafka usa at-least-once delivery por
4defecto. Significa que si el producer no recibe ACK del
5broker (por timeout de red, por ejemplo), reenvía el
6mensaje. El consumer lo recibe dos veces.
7
8Opciones para deduplicar:
91. En el pipeline de ingesta (lo que estás haciendo)
10 - DROP_DUPLICATES por event_id antes de cargar
11 - Simple, funciona, pero si ejecutas dos veces el
12 mismo batch puedes tener problemas
13
142. En Redshift con MERGE/UPSERT
15 - Cargas todo en una staging temporal
16 - Haces MERGE contra la tabla final por event_id
17 - Más robusto pero más lento
18
193. En dbt (dejar que Sandra lo haga downstream)
20 - La tabla staging tiene duplicados
21 - Sandra deduplica en el primer modelo
22 - Más simple para ti pero Sandra me mataría
23
24Mi recomendación: opción 1 + un check de idempotencia.
25Tu pipeline debe poder ejecutarse 2 veces sobre el mismo
26día sin crear duplicados. Usa event_id como clave natural.
27
28Pregunta a David cuántos duplicados esperar. Él sabe el
29config exacto del producer."
30
31Tú: "Entendido. Opción 1 con idempotencia. Le pregunto a
32David el ratio esperado."
33
34Marcos: "Bien. Cuando tengas el PR avísame, lo reviso."

## 10:30 - Preguntarle a David

1Tú 10:32 AM (DM a David Martín)
2Hola David! Estoy montando el pipeline de ingesta de eventos
3y he visto duplicados en S3 (~3% de los eventos).
4Marcos me dice que es el retry del producer. Sabes cuál es
5el config exacto? Quiero saber qué ratio de duplicados
6esperar para mi validación.
7
8David Martín 10:48
9Sí. El producer está configurado con:
10- acks=all
11- retries=3
12- retry.backoff.ms=100
13- enable.idempotence=false
14
15El enable.idempotence=false es el problema. Con true Kafka
16garantiza exactly-once a nivel de producer pero tiene overhead.
17Lo dejé en false cuando lo monté rápido.
18
19El ratio esperado es de un 3% según la carga de red. En
20picos puede subir al 5%.
21
22¿Quieres que active idempotence? Puedo hacerlo pero necesito
23coordinarlo con infra.
24
25Tú 10:52
26Por ahora no hace falta, deduplico en el pipeline. Pero si
27en el futuro el volumen sube mucho sería bueno tenerlo.
28Gracias por la info!
29
30David Martín 10:53
31np. Si necesitas saber qué significa algún evento específico
32pregúntame, 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 - 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} ===")
5
6 start = datetime.strptime(start_date, '%Y-%m-%d')
7 end = datetime.strptime(end_date, '%Y-%m-%d')
8
9 all_dfs = []
10 current = start
11 while current <= end:
12 date_str = current.strftime('%Y-%m-%d')
13 print(f"\n Procesando {date_str}...")
14
15 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")
21
22 current += timedelta(days=1)
23
24 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)
28
29 # Guardar como parquet (simula COPY a Redshift)
30 output_file = OUTPUT_PATH / 'stg_product_events.parquet'
31 final.to_parquet(output_file, index=False)
32
33 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")
41
42# Ejecutar para 1 semana de muestra
43run_ingestion('2024-10-02', '2024-10-08')

Pipeline completo: procesa una semana y genera el staging

Ejecutas el pipeline. Resultado: 126.742 eventos válidos de los 130.740 del fichero, 8.500 usuarios únicos, 1.944 workspaces. Se eliminaron 3.998 duplicados (3,06%, consistente con lo que David dijo). El archivo parquet pesa 5MB. Con siete meses cabe de sobra en Redshift; cuando el volumen crezca necesitarás particiones.

## 14:00 - 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 producto
2
3Marcos Delgado (reviewer) - 14:45
4
5src/ingest_events.py:
6L42 - "Has pensado en qué pasa si un archivo JSON tiene una línea
7vacía al final? Ese try/except va a tragársela pero el log no te
8dirá nada. Añade un contador de líneas malformadas y logealo al
9final."
10
11L67 - "Bien la dedup por event_id. Una mejora: añade un assert
12después de la dedup que verifique que no hay NULLs en event_id.
13Si Kafka manda un evento sin ID (pasa), tu pipeline debería
14fallar ruidosamente, no tragar el NULL y crear un duplicado
15potencial."
16
17L89 - "El extract_property está bien pero isinstance(properties,
18dict) va a fallar si el campo es un string JSON en vez de un dict
19parseado. Asegúrate de que el json.loads ya se hizo antes."
20
21General: "Buen primer PR. La estructura es clara y el naming sigue
22las convenciones. Los 3 comentarios son mejoras de robustez, no
23bloqueantes. Apruebo con esos cambios. 👍"
24
25Sandra Vidal (reviewer) - 15:02
26
27"Los nombres de columnas están perfectos! stg_product_events con
28prefijo de entidad... me haces feliz 📊
29Solo una cosa: en el DDL añade un COMMENT a la tabla y a las
30columnas clave. En Redshift se puede con COMMENT ON TABLE.
31Así cualquiera que haga DESC tabla sabe qué 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 (DM)
2Mergeado. Buen primer pipeline.
3Una cosa: la próxima vez que ingieras más de 1 semana,
4piensa en particionado. Cargar 6 meses de golpe en un
5solo archivo parquet no va a escalar.
6No es urgente — es para que lo tengas en mente.
7
8Tú 15:18
9Anotado. Cuando llegue el momento de cargar el histórico
10completo te pregunto el approach.
11
12Marcos Delgado 15:19
13👍

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 - 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í.

1Lucía Fernández (Meet, 16:05):
2"Ey, tengo dos preguntas para esta semana:
3
41. Las cohortes de retención: ¿cuándo las tenemos? Necesito
5 incluirlas en el deck para el board review del lunes.
6
72. Pregunta nueva: alguien sabe ¿cuántos usuarios usan la
8 feature de automatizaciones? Es que estamos pensando en
9 moverla al plan Business y necesito saber el impacto."
10
11Pablo: "Las cohortes son el proyecto de esta semana. El
12nuevo está montando el pipeline de ingesta — hoy ha cargado
13los primeros datos. Las cohortes las tenemos el jueves o
14viernes. Sobre las automatizaciones, depende de los eventos."
15
16Tú: "Lucía, la pregunta de automatizaciones la puedo
17responder rápido con los datos que ya tengo cargados.
18Dame hasta mañana por la mañana y te doy un número."
19
20Lucía: "Ey, genial 🚀 Si puedes segmentarlo por plan
21(free vs pro vs business) mejor."
22
23Sandra: "Ojo con esa segmentación — necesitas cruzar
24eventos con la tabla de suscripciones que está en otro
25schema. Hablamos después del sync."
26
27Tú: "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 - Sandra te explica el JOIN con suscripciones

1Sandra Vidal 17:05 (DM)
2Para lo de Lucía — las suscripciones están en:
3analytics.dim_workspaces
4
5Campos relevantes:
6- workspace_id (PK)
7- workspace_plan: 'free', 'pro', 'business'
8- workspace_created_at
9- workspace_member_count
10- workspace_is_active (bool)
11
12El JOIN es por workspace_id. Cada evento tiene workspace_id
13así que puedes cruzar directamente.
14
15Pero OJO: un workspace puede cambiar de plan. La tabla
16dim_workspaces tiene el plan ACTUAL. Si quieres el plan
17en el momento del evento necesitas la tabla de historial:
18analytics.fct_plan_changes
19
20Para la pregunta de Lucía con el plan actual es suficiente.
21Solo ten cuidado al documentarlo 📊
22
23Tú 17:10
24Entendido! Uso dim_workspaces para el plan actual y lo
25documento como "plan a fecha de consulta, no plan en el
26momento del evento". Gracias Sandra!
27
28Sandra Vidal 17:11
29Exacto. Ves, esto es lo que diferencia un número
30"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.

Regístrate para guardar tu progreso.