lección 3
Día 2 -- Pipeline ETL: PostgreSQL a S3 a DuckDB
Implementas el pipeline completo: extraer de PostgreSQL, transformar y limpiar, cargar como Parquet en S3 y configurar DuckDB para consultas analíticas.
⏱ 45 min
### Martes, 8:00 AM -- Hoy se escribe código
Ahora vamos a usar los datasets crudos de StreamVibe. Guárdalos en `datos/`: `reproducciones_eventos.parquet` (`event_id, event_type, playback_session_id, user_id, content_id, timestamp, device_type, properties`; con ~4% de duplicados en iOS, ~0.5% de `user_id` nulos y ~8% de filas huérfanas), `usuarios.csv` (`user_id, email, display_name, signup_date, signup_source, country, language_pref, created_at`), `catalogo.csv` (`content_id, title, content_type, genre_primary, genre_secondary, country_of_origin, language_original, release_year, duration_minutes, season_count, episode_count, date_added, is_original, maturity_rating, description`) y `suscripciones.csv` (`subscription_id, user_id, plan_type, status, started_at, ended_at, monthly_amount_eur, payment_method, cancel_reason, trial_started_at, trial_ended_at`). En la lección el extract lee de una réplica PostgreSQL con `pd.read_sql`; para ejecutarlo en tu máquina sustituye esa fuente por la lectura de estos ficheros con `pd.read_parquet("datos/reproducciones_eventos.parquet")` y `pd.read_csv("datos/usuarios.csv")`. Los nombres de columna del esquema PG del contexto son ilustrativos: usa los reales de los ficheros.
Ayer fue estrategia, diseño y comunicación. Hoy toca construir. Daniel te envió el connection string de la réplica a las 22:00 de anoche con un mensaje: "Ahí lo tienes. Recuerda: solo lectura, y si ves que tarda más de 10 segundos cualquier query, córtala. La réplica comparte IOPs con el backup nocturno." Típico CTO protector de su infra. Respetas eso -- en FlashRoute tú eras igual con tu pipeline de ETAs.
El pipeline que vas a implementar tiene la estructura clásica Extract-Transform-Load que has construido decenas de veces. Pero con una diferencia: está vez no hay arquitecto que te diga cómo, ni tech lead que revise tu PR, ni estándar corporativo que seguir. Tú decides la estructura del código, los patrones de error handling, el formato de logs y la cadencia de ejecución.
### Estructura del proyecto
1streamvibe-data/2├── etl/3│ ├── __init__.py4│ ├── config.py # Configuración (env vars, paths, constantes)5│ ├── extract.py # Lectura de PostgreSQL réplica6│ ├── transform.py # Limpieza, dedup, enriquecimiento7│ ├── load.py # Escritura a S3 + refresh DuckDB8│ └── pipeline.py # Orquestador: extract -> transform -> load9├── analytics/10│ ├── __init__.py11│ ├── metrics.py # Cálculo de DAU, MAU, retention, etc.12│ └── content.py # Análisis de rendimiento de contenido13├── utils/14│ ├── __init__.py15│ ├── logging_config.py # Setup de logging estructurado16│ └── watermark.py # Gestión de watermarks incrementales17├── tests/18│ ├── test_extract.py19│ ├── test_transform.py20│ └── test_metrics.py21├── cron/22│ └── crontab.txt # Definición de jobs cron23├── requirements.txt24├── .env.example25└── README.md
Estructura limpia y mantenible. Un senior organiza su código desde el día 1.
### Paso 1: Configuración y extracción
La extracción es incremental: cada ejecución lee solo los registros nuevos desde la última extracción exitosa. Guardas un watermark (último timestamp procesado) en un archivo JSON en S3. Si el watermark no existe (primera ejecución), procesas todos los datos históricos.
1# etl/config.py2import os3from dataclasses import dataclass45@dataclass(frozen=True)6class Config:7 """Configuración centralizada del pipeline."""8 # PostgreSQL réplica (solo lectura)9 pg_host: str = os.getenv("PG_REPLICA_HOST", "replica.streamvibe.internal")10 pg_port: int = int(os.getenv("PG_REPLICA_PORT", "5432"))11 pg_database: str = os.getenv("PG_DATABASE", "streamvibe")12 pg_user: str = os.getenv("PG_USER", "data_reader")13 pg_password: str = os.getenv("PG_PASSWORD", "")1415 # S316 s3_bucket: str = os.getenv("S3_BUCKET", "streamvibe-data-lake")17 s3_region: str = os.getenv("AWS_REGION", "eu-west-1")1819 # Paths20 raw_prefix: str = "raw"21 silver_prefix: str = "silver"22 gold_prefix: str = "gold"23 watermark_key: str = "metadata/watermarks.json"2425 # DuckDB26 duckdb_path: str = os.getenv("DUCKDB_PATH", "/data/streamvibe.duckdb")2728 @property29 def pg_connection_string(self) -> str:30 return (31 f"host={self.pg_host} port={self.pg_port} "32 f"dbname={self.pg_database} user={self.pg_user} "33 f"password={self.pg_password}"34 )3536config = Config()
Configuración via environment variables -- patrón 12-factor apps.
1# etl/extract.py2import psycopg23import pandas as pd4import logging5from datetime import datetime, timezone6from .config import config78logger = logging.getLogger(__name__)91011def get_watermark(s3_client, table_name: str) -> datetime:12 """Lee el último timestamp procesado para una tabla."""13 import json14 try:15 obj = s3_client.get_object(16 Bucket=config.s3_bucket,17 Key=config.watermark_key18 )19 watermarks = json.loads(obj['Body'].read())20 ts = watermarks.get(table_name)21 if ts:22 return datetime.fromisoformat(ts)23 except s3_client.exceptions.NoSuchKey:24 pass25 # Primera ejecución: todo el histórico26 return datetime(2023, 1, 1, tzinfo=timezone.utc)272829def extract_playbacks(since: datetime) -> pd.DataFrame:30 """Extrae reproducciones nuevas desde el watermark."""31 query = """32 SELECT33 playback_id,34 user_id,35 content_id,36 started_at,37 ended_at,38 watch_seconds,39 completed,40 created_at41 FROM playbacks42 WHERE created_at > %(since)s43 ORDER BY created_at ASC44 LIMIT 50000045 """46 logger.info(f"Extrayendo playbacks desde {since.isoformat()}")47 with psycopg2.connect(config.pg_connection_string) as conn:48 df = pd.read_sql(query, conn, params={"since": since})4950 logger.info(f"Extraídas {len(df):,} filas de playbacks")51 return df525354def extract_users() -> pd.DataFrame:55 """Extrae tabla completa de usuarios (dimensión, ~42K filas)."""56 query = """57 SELECT user_id, email, username, plan, country,58 registered_at, last_login_at, device_preference59 FROM users60 """61 with psycopg2.connect(config.pg_connection_string) as conn:62 df = pd.read_sql(query, conn)63 logger.info(f"Extraídos {len(df):,} usuarios")64 return df656667def extract_content() -> pd.DataFrame:68 """Extrae catálogo completo de contenido (dimensión, ~1200 filas)."""69 query = """70 SELECT content_id, title, content_type, genre,71 duration_seconds, release_year, director,72 rating_imdb, license_cost_eur, added_at, is_premium73 FROM content74 """75 with psycopg2.connect(config.pg_connection_string) as conn:76 df = pd.read_sql(query, conn)77 logger.info(f"Extraídos {len(df):,} títulos de contenido")78 return df
Extracción incremental con LIMIT para no saturar la réplica. Patrón defensivo.
### Paso 2: Transformación -- de datos crudos a datos limpios
La transformación aplica las lecciones de FreshMart (datos siempre sucios), TaskFlow (eventos duplicados) y NeoBank (validación estricta). Cada fila que entra se deduplica, válida y enriquece antes de escribirse en la zona silver.
1# etl/transform.py2import pandas as pd3import numpy as np4import logging56logger = logging.getLogger(__name__)789def transform_playbacks(df: pd.DataFrame) -> pd.DataFrame:10 """Transforma playbacks raw en formato silver limpio."""11 if df.empty:12 return df1314 initial_count = len(df)1516 # 1. Deduplicar por playback_id17 df = df.drop_duplicates(subset=['playback_id'], keep='first')1819 # 2. Parsear timestamps con timezone20 df['started_at'] = pd.to_datetime(df['started_at'], utc=True)21 df['ended_at'] = pd.to_datetime(df['ended_at'], utc=True, errors='coerce')2223 # 3. Validar watch_seconds (no negativo, no mayor que 24h)24 df['watch_seconds'] = pd.to_numeric(25 df['watch_seconds'], errors='coerce'26 ).fillna(0).astype(int)27 df = df[(df['watch_seconds'] >= 0) & (df['watch_seconds'] <= 86400)]2829 # 4. Calcular campos derivados30 df['event_date'] = df['started_at'].dt.date31 df['event_hour'] = df['started_at'].dt.hour32 df['day_of_week'] = df['started_at'].dt.dayofweek # 0=lunes33 df['is_weekend'] = df['day_of_week'].isin([5, 6])3435 # 5. Categorizar duración de sesión36 df['session_category'] = pd.cut(37 df['watch_seconds'],38 bins=[0, 300, 1200, 3600, 7200, 86400],39 labels=['< 5 min', '5-20 min', '20-60 min', '1-2 horas', '> 2 horas']40 )4142 # 6. Flag de sesión significativa (> 2 min de watch)43 df['is_meaningful'] = df['watch_seconds'] > 1204445 final_count = len(df)46 dropped = initial_count - final_count47 if dropped > 0:48 logger.warning(f"Descartadas {dropped} filas inválidas ({dropped/initial_count*100:.1f}%)")4950 logger.info(f"Transformación completada: {final_count:,} filas válidas")51 return df.reset_index(drop=True)525354def transform_users(df: pd.DataFrame) -> pd.DataFrame:55 """Limpia y enriquece la dimensión de usuarios."""56 df['registered_at'] = pd.to_datetime(df['registered_at'], utc=True)57 df['last_login_at'] = pd.to_datetime(df['last_login_at'], utc=True, errors='coerce')5859 # Calcular antigüedad en días60 now = pd.Timestamp.now(tz='UTC')61 df['days_since_registration'] = (now - df['registered_at']).dt.days6263 # Cohorte mensual de registro64 df['registration_cohort'] = df['registered_at'].dt.to_period('M').astype(str)6566 # Estandarizar plan67 df['plan'] = df['plan'].fillna('free').str.lower().str.strip()6869 return df
Transformación defensiva: dedup, validación, campos derivados para analytics.
### Paso 3: Carga -- Parquet a S3 y setup de DuckDB
La carga escribe Parquet particionado por fecha en S3 (zona silver) y luego configura DuckDB para leer esos archivos directamente. DuckDB con la extensión httpfs puede hacer queries sobre Parquet en S3 sin descargar nada localmente -- es básicamente un Athena gratuito.
1# etl/load.py2import boto33import duckdb4import pandas as pd5import pyarrow as pa6import pyarrow.parquet as pq7import logging8from io import BytesIO9from .config import config1011logger = logging.getLogger(__name__)121314def write_parquet_to_s3(15 df: pd.DataFrame,16 s3_client,17 prefix: str,18 partition_col: str = None19):20 """Escribe un DataFrame como Parquet en S3, opcionalmente particionado."""21 if df.empty:22 logger.warning(f"DataFrame vacío, no se escribe en {prefix}")23 return2425 if partition_col and partition_col in df.columns:26 # Escribir una partición por valor único27 for value, partition_df in df.groupby(partition_col):28 key = f"{prefix}/{partition_col}={value}/data.parquet"29 _write_single_parquet(partition_df, s3_client, key)30 else:31 key = f"{prefix}/data.parquet"32 _write_single_parquet(df, s3_client, key)333435def _write_single_parquet(df: pd.DataFrame, s3_client, key: str):36 """Escribe un único archivo Parquet a S3."""37 table = pa.Table.from_pandas(df)38 buffer = BytesIO()39 pq.write_table(table, buffer, compression='snappy')40 buffer.seek(0)4142 s3_client.put_object(43 Bucket=config.s3_bucket,44 Key=key,45 Body=buffer.getvalue(),46 ContentType='application/octet-stream'47 )48 size_mb = buffer.getbuffer().nbytes / (1024 * 1024)49 logger.info(f"Escrito s3://{config.s3_bucket}/{key} ({size_mb:.2f} MB)")505152def setup_duckdb_analytics():53 """Configura DuckDB para leer del lake en S3."""54 con = duckdb.connect(config.duckdb_path)5556 # Instalar y cargar extensión para S357 con.execute("INSTALL httpfs; LOAD httpfs;")58 con.execute(f"SET s3_region = '{config.s3_region}';")5960 # Crear vistas sobre los archivos Parquet en S361 bucket = config.s3_bucket62 con.execute(f"""63 CREATE OR REPLACE VIEW silver_playbacks AS64 SELECT * FROM read_parquet(65 's3://{bucket}/silver/playbacks/event_date=*/data.parquet',66 hive_partitioning = true67 );68 """)69 con.execute(f"""70 CREATE OR REPLACE VIEW dim_users AS71 SELECT * FROM read_parquet('s3://{bucket}/silver/users/data.parquet');72 """)73 con.execute(f"""74 CREATE OR REPLACE VIEW dim_content AS75 SELECT * FROM read_parquet('s3://{bucket}/silver/content/data.parquet');76 """)7778 # Verificar que funciona79 count = con.execute("SELECT COUNT(*) FROM silver_playbacks").fetchone()[0]80 logger.info(f"DuckDB configurado. silver_playbacks: {count:,} filas")8182 con.close()83 return count
DuckDB lee Parquet de S3 directamente via vistas -- como un Athena gratuito y local.
### El orquestador: uniendo todo
1# etl/pipeline.py2import boto33import json4import logging5from datetime import datetime, timezone6from .config import config7from .extract import extract_playbacks, extract_users, extract_content, get_watermark8from .transform import transform_playbacks, transform_users9from .load import write_parquet_to_s3, setup_duckdb_analytics1011logger = logging.getLogger(__name__)121314def run_daily_pipeline():15 """Pipeline diario completo: extract -> transform -> load."""16 start_time = datetime.now(timezone.utc)17 logger.info(f"=== Pipeline iniciado: {start_time.isoformat()} ===")1819 s3 = boto3.client('s3', region_name=config.s3_region)2021 try:22 # 1. EXTRACT23 watermark = get_watermark(s3, 'playbacks')24 logger.info(f"Watermark playbacks: {watermark.isoformat()}")2526 playbacks_raw = extract_playbacks(since=watermark)27 users_raw = extract_users()28 content_raw = extract_content()2930 if playbacks_raw.empty:31 logger.info("No hay nuevos playbacks. Pipeline finalizado.")32 return {"status": "no_new_data", "duration_seconds": 0}3334 # 2. TRANSFORM35 playbacks_clean = transform_playbacks(playbacks_raw)36 users_clean = transform_users(users_raw)3738 # 3. LOAD a S339 write_parquet_to_s3(40 playbacks_clean, s3,41 prefix=f"{config.silver_prefix}/playbacks",42 partition_col='event_date'43 )44 write_parquet_to_s3(users_clean, s3, prefix=f"{config.silver_prefix}/users")45 write_parquet_to_s3(content_raw, s3, prefix=f"{config.silver_prefix}/content")4647 # 4. Actualizar watermark48 new_watermark = playbacks_clean['created_at'].max().isoformat()49 _update_watermark(s3, 'playbacks', new_watermark)5051 # 5. Refresh DuckDB52 total_rows = setup_duckdb_analytics()5354 duration = (datetime.now(timezone.utc) - start_time).total_seconds()55 result = {56 "status": "success",57 "new_playbacks": len(playbacks_clean),58 "total_rows_in_lake": total_rows,59 "duration_seconds": round(duration, 1),60 }61 logger.info(f"=== Pipeline completado: {result} ===")62 return result6364 except Exception as e:65 logger.error(f"Pipeline FALLIDO: {e}", exc_info=True)66 return {"status": "error", "error": str(e)}676869def _update_watermark(s3_client, table_name: str, timestamp_iso: str):70 """Actualiza el watermark en S3."""71 try:72 obj = s3_client.get_object(Bucket=config.s3_bucket, Key=config.watermark_key)73 watermarks = json.loads(obj['Body'].read())74 except s3_client.exceptions.NoSuchKey:75 watermarks = {}7677 watermarks[table_name] = timestamp_iso78 s3_client.put_object(79 Bucket=config.s3_bucket,80 Key=config.watermark_key,81 Body=json.dumps(watermarks, indent=2).encode(),82 )
Pipeline completo con logging, watermarks y error handling. Listo para cron.
### Configuración de cron
1# cron/crontab.txt2# Pipeline diario: extrae datos nuevos a las 03:00 (mínimo tráfico en la réplica)30 3 * * * cd /opt/streamvibe-data && /opt/streamvibe-data/venv/bin/python -m etl.pipeline >> /var/log/streamvibe-etl.log 2>&145# Recalcular métricas gold: a las 03:30 (después de que el pipeline silver termine)630 3 * * * cd /opt/streamvibe-data && /opt/streamvibe-data/venv/bin/python -m analytics.metrics >> /var/log/streamvibe-metrics.log 2>&178# Backup del DuckDB: a las 04:0090 4 * * * cp /data/streamvibe.duckdb /data/backups/streamvibe_$(date +\%Y\%m\%d).duckdb
Tres jobs cron: ETL a las 3, métricas a las 3:30, backup a las 4. Simple y robusto.
La primera ejecución del pipeline procesará los 3 millones de filas históricas. Tardará 5-8 minutos. Las siguientes (incrementales) tardarán 10-30 segundos con ~7K filas nuevas. El LIMIT de 500K en la extracción asegura que nunca saturamos la réplica ni la memoria de la EC2 micro.
### Testing del pipeline -- no despliegas sin probar
En NeoBank aprendiste que un pipeline sin tests es una bomba de relojería. No necesitas un framework complejo de testing para 3 scripts -- pero sí necesitas validaciones básicas que te digan si algo va mal antes de que Marta mire el dashboard y vea números absurdos.
1# tests/test_transform.py2import pandas as pd3import pytest4from etl.transform import transform_playbacks567def test_deduplication():8 """Los playbacks duplicados se eliminan."""9 df = pd.DataFrame({10 'playback_id': [1, 1, 2, 3],11 'user_id': [10, 10, 20, 30],12 'content_id': [100, 100, 200, 300],13 'started_at': ['2024-03-15'] * 4,14 'watch_seconds': [100, 100, 200, 300],15 'completed': [False, False, True, False],16 'created_at': ['2024-03-15'] * 4,17 })18 result = transform_playbacks(df)19 assert len(result) == 3 # Uno de los duplicados eliminado202122def test_invalid_watch_seconds_filtered():23 """watch_seconds negativos o > 24h se descartan."""24 df = pd.DataFrame({25 'playback_id': [1, 2, 3],26 'user_id': [10, 20, 30],27 'content_id': [100, 200, 300],28 'started_at': ['2024-03-15'] * 3,29 'watch_seconds': [-5, 90000, 1000], # Negativo, > 24h, válido30 'completed': [False, False, True],31 'created_at': ['2024-03-15'] * 3,32 })33 result = transform_playbacks(df)34 assert len(result) == 1 # Solo el de 1000s es válido35 assert result.iloc[0]['watch_seconds'] == 1000363738def test_null_user_filtered():39 """Registros sin user_id se descartan."""40 df = pd.DataFrame({41 'playback_id': [1, 2],42 'user_id': [None, 20],43 'content_id': [100, 200],44 'started_at': ['2024-03-15'] * 2,45 'watch_seconds': [100, 200],46 'completed': [False, True],47 'created_at': ['2024-03-15'] * 2,48 })49 result = transform_playbacks(df)50 assert len(result) == 151 assert result.iloc[0]['user_id'] == 20525354def test_empty_dataframe():55 """Un DataFrame vacío retorna vacío sin error."""56 df = pd.DataFrame()57 result = transform_playbacks(df)58 assert len(result) == 0
Tests básicos pero esenciales. Si algo rompe la transformación, te enteras antes de producción.
Un patrón que aprendiste en FlashRoute: SIEMPRE prueba tu pipeline con datos vacíos, datos con un solo registro y datos con duplicados. Estos tres edge cases cubren el 80% de los bugs en ETL. El otro 20% son timezones (siempre UTC) y tipos numéricos que llegan como strings.
### Resumen del Día 2
- Implementaste pipeline ETL completo: extract (PG réplica) -> transform (Pandas) -> load (S3 Parquet)
- Configuraste DuckDB para leer directamente de S3 con vistas sobre Parquet
- Diseñaste extracción incremental con watermarks (no reprocesas datos ya cargados)
- Ejecutaste la primera carga histórica: 3M filas en ~6 minutos
- Configuraste cron para ejecución automática diaria a las 03:00
- El lake tiene datos reales de 14 meses listos para calcular métricas mañana
## ejercicios
Implementar extracción incremental con watermarks
Escribe la función de extracción que lee datos nuevos de PostgreSQL usando un sistema de watermarks almacenado en S3. Debe manejar la primera ejecución (sin watermark previo) y extracciones incrementales posteriores.
Pipeline de transformación con validación
Implementa la función de transformación que limpia, válida y enriquece los datos de reproducciones. Incluye deduplicación, validación de rangos, cálculo de campos derivados y generación de un reporte de calidad.
Configurar DuckDB como motor analítico sobre S3
Implementa la configuración de DuckDB para leer Parquet directamente de S3, crear vistas analíticas y verificar que todo funciona con una query de prueba.
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...