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.
⏱ 60 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)272829LIMITE_LOTE = 500_000303132def extract_playbacks(since: datetime) -> pd.DataFrame:33 """Extrae reproducciones nuevas desde el watermark."""34 query = """35 SELECT36 playback_id,37 user_id,38 content_id,39 started_at,40 ended_at,41 watch_seconds,42 completed,43 created_at44 FROM playbacks45 WHERE created_at > %(since)s46 ORDER BY created_at ASC47 LIMIT %(limite)s48 """49 logger.info(f"Extrayendo playbacks desde {since.isoformat()}")50 with psycopg2.connect(config.pg_connection_string) as conn:51 df = pd.read_sql(query, conn, params={"since": since, "limite": LIMITE_LOTE})5253 # EL BORDE DEL LOTE: aqui se pierden filas sin que nadie se entere.54 #55 # El LIMIT corta por numero de filas, no por instante. Si en el segundo56 # 03:00:07 se insertaron 40 reproducciones y el corte cae en la numero 12,57 # el lote se lleva 12 y deja 28 dentro de la base. Pero la marca de agua58 # avanza a 03:00:07 (es el max del lote), y manana la consulta pide59 # created_at > 03:00:07. Esas 28 filas no las va a pedir nadie nunca mas.60 # No hay error, no hay aviso: simplemente no estan.61 #62 # El instinto es cambiar el > por un >=, y con esta carga es peor: el63 # fichero se nombra por el rango del lote, asi que las 12 filas del solape64 # se escribirian otra vez en un fichero nuevo de la misma particion. Se65 # cambia perder filas por contarlas dos veces, que encima se ve menos.66 #67 # La solucion es no partir un instante en dos: si el lote llego al tope,68 # se suelta el ultimo instante ENTERO y se lo lleva el lote siguiente69 # completo. Se procesan menos filas hoy y ninguna se queda a medias.70 if len(df) == LIMITE_LOTE:71 corte = df['created_at'].max()72 df = df[df['created_at'] < corte]73 logger.warning(74 f"Lote al tope ({LIMITE_LOTE:,}): se deja el instante {corte} "75 f"para la proxima ejecucion. Quedan {len(df):,} filas."76 )77 # Caso limite: si TODAS las filas del lote comparten el mismo instante,78 # esto vacia el DataFrame y el pipeline no avanzaria nunca. Con 500.00079 # filas en un segundo no pasa aqui, pero el dia que pase hay que verlo80 # en el log y no en la metrica de Marta.81 if df.empty:82 raise RuntimeError(83 f"{LIMITE_LOTE:,} filas con el mismo created_at ({corte}): "84 "sube LIMITE_LOTE o trocea la extraccion por rango de fechas."85 )8687 logger.info(f"Extraídas {len(df):,} filas de playbacks")88 return df899091def extract_users() -> pd.DataFrame:92 """Extrae tabla completa de usuarios (dimensión, ~20K filas)."""93 query = """94 SELECT user_id, email, username, plan, country,95 registered_at, last_login_at, device_preference96 FROM users97 """98 with psycopg2.connect(config.pg_connection_string) as conn:99 df = pd.read_sql(query, conn)100 logger.info(f"Extraídos {len(df):,} usuarios")101 return df102103104def extract_content() -> pd.DataFrame:105 """Extrae catálogo completo de contenido (dimensión, ~1200 filas)."""106 query = """107 SELECT content_id, title, content_type, genre,108 duration_seconds, release_year, director,109 rating_imdb, license_cost_eur, added_at, is_premium110 FROM content111 """112 # OJO CON LA UNIDAD. En el fichero del caso la columna se llama113 # duration_MINUTES, no duration_seconds. Si copias esta consulta contra el114 # fichero sin darte cuenta, cualquier calculo que compare el tiempo visto115 # (segundos) con la duracion del titulo sale multiplicado o dividido por 60 --116 # y eso es exactamente la tasa de finalizacion que calculas el miercoles por117 # la tarde para Sofia. Comprueba la unidad ANTES de dividir, siempre.118 with psycopg2.connect(config.pg_connection_string) as conn:119 df = pd.read_sql(query, conn)120 logger.info(f"Extraídos {len(df):,} títulos de contenido")121 return df
Extracción incremental con tope de lote y corte por instante completo.
## 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__)78# Cuanto hay que ver para que cuente como una vision y no como un clic.9#10# Va aqui, en un solo sitio, porque manana el DAU se calcula filtrando por la11# columna que sale de esta constante. Un umbral repetido a mano en cada consulta12# es un umbral que dentro de dos meses vale 30 en unas y 120 en otras, y entonces13# el mismo DAU da dos numeros segun quien lo calcule.14#15# Y el numero no se elige por intuicion: se mira cuanta gente cae en la frontera.16# En el extracto de este caso, entre los 30 y los 120 segundos hay 79 sesiones de17# 18.492, asi que aqui da igual cual sea. Saber que da igual vale mas que acertar18# por suerte.19UMBRAL_SESION_SIGNIFICATIVA = 120 # segundos202122def transform_playbacks(23 df: pd.DataFrame,24 content_durations: dict | None = None, # {content_id: duracion_en_SEGUNDOS}25) -> pd.DataFrame:26 """Transforma playbacks raw en formato silver limpio."""27 if df.empty:28 return df2930 initial_count = len(df)3132 # 1. Parsear timestamps con timezone. VA PRIMERO: la deduplicacion de abajo33 # necesita el instante ya como fecha, no como texto. Y created_at tambien34 # se convierte, aunque no se use en los calculos: de esa columna sale la35 # marca de agua al final del pipeline, y si llega como texto (que es lo que36 # pasa al leer de un CSV en local) el .max().isoformat() del paso 4 revienta37 # DESPUES de haber escrito ya en S3.38 df['started_at'] = pd.to_datetime(df['started_at'], utc=True)39 df['ended_at'] = pd.to_datetime(df['ended_at'], utc=True, errors='coerce')40 df['created_at'] = pd.to_datetime(df['created_at'], utc=True)4142 # 1 bis. Sin usuario, sin contenido o sin instante de inicio no se puede43 # atribuir la reproduccion a nada. Y sin instante, ademas, event_date44 # sale a nulo y la carga escribe una particion llamada "event_date=NaT":45 # basura que aparece en el lake seis meses despues y nadie sabe de donde46 # salio.47 antes = len(df)48 df = df[df['user_id'].notna() & df['content_id'].notna() & df['started_at'].notna()]49 if len(df) < antes:50 logger.warning(f"Descartadas {antes - len(df)} filas sin usuario, contenido o inicio")5152 # 2. Deduplicar.53 #54 # OJO con la clave: deduplicar por el identificador de la fila no quita55 # nada, porque por definicion es unico. Los duplicados que este caso tiene56 # (~4% en iOS) son el MISMO evento mandado dos veces por el reproductor, y57 # llegan con identificadores distintos. Lo que los define es la sesion, el58 # tipo de evento y el instante -- y como el reintento llega con unos59 # segundos de diferencia, hay que redondear el instante antes de agrupar.60 #61 # El margen de 10 s hay que ajustarlo mirando el fichero: si los duplicados62 # son exactos, sobra; si el reintento llega un minuto despues, se queda corto.63 #64 # Y que columnas identifican "el mismo evento" depende de la fuente:65 # - En el fichero de eventos del caso: la sesion y el tipo de evento.66 # - En la tabla playbacks del esquema de Daniel, que no tiene ninguna de67 # las dos: usuario y contenido. Es una clave peor -- un usuario que ve68 # dos veces el mismo titulo en el mismo intervalo de 10 s cuenta como69 # uno -- pero a 10 s de margen eso no pasa en la vida real, y es lo70 # que se puede hacer con lo que hay.71 # Se decide mirando las columnas, no suponiendo.72 if len(df):73 antes = len(df)74 claves = [c for c in ('playback_session_id', 'event_type') if c in df.columns]75 if not claves:76 claves = ['user_id', 'content_id']77 logger.warning(78 f"Sin sesion ni tipo de evento: deduplicando por {claves} + instante"79 )80 df['_dedup'] = df['started_at'].dt.floor('10s').astype(str)81 for col in claves:82 df['_dedup'] = df['_dedup'] + '|' + df[col].astype(str)83 df = df.drop_duplicates(subset=['_dedup'], keep='first').drop(columns=['_dedup'])84 if len(df) < antes:85 logger.info(f"Duplicados eliminados: {antes - len(df)}")8687 # 3. Validar watch_seconds (no negativo, no mayor que 24h)88 df['watch_seconds'] = pd.to_numeric(89 df['watch_seconds'], errors='coerce'90 )91 # OJO: aqui NO se hace .fillna(0).92 #93 # Convertir "no se" en "cero" pierde la diferencia, y aqui cuesta dinero:94 # una fila con watch_seconds nulo saldria como reproduccion de CERO segundos,95 # pasaria el filtro de abajo (cero es >= cero) y entraria en el denominador96 # de la tasa de finalizacion y del tiempo medio por sesion -- dos de las cinco97 # metricas que van a la reunion del viernes. Un punado de nulos convertidos en98 # ceros baja la media sin que nadie sepa por que.99 #100 # Un nulo se rellena cuando sabes con que. Si no lo sabes, se queda nulo y101 # quien calcule la media decide que hacer con el.102 # Fuera lo imposible, pero SIN arrastrar lo desconocido: los nulos se quedan.103 df = df[df['watch_seconds'].isna() | df['watch_seconds'].between(0, 86400)]104 # Entero que admite nulos: si se dejara en float, el tipo cambia en el Parquet.105 df['watch_seconds'] = df['watch_seconds'].astype('Int64')106 sin_tiempo = int(df['watch_seconds'].isna().sum())107 if sin_tiempo:108 logger.warning(f"{sin_tiempo} reproducciones sin tiempo de visionado: quedan a nulo")109110 # 4. Calcular campos derivados111 #112 # Guardamos y particionamos por fecha UTC: asi el lake es estable y no cambia113 # si algun dia la empresa cambia de sitio. Pero "un dia" para el negocio es un114 # dia en Madrid, y la diferencia no es teorica: todo lo que pasa entre las 00:00115 # y la 01:00 peninsular en invierno (y hasta las 02:00 en verano) cae en el dia116 # ANTERIOR en UTC. El DAU que Marta ensena el viernes sale de aqui.117 #118 # Se dejan las dos columnas: una para guardar, otra para contar.119 df['event_date'] = df['started_at'].dt.date120 df['event_date_local'] = df['started_at'].dt.tz_convert('Europe/Madrid').dt.date121 df['event_hour'] = df['started_at'].dt.hour122 df['day_of_week'] = df['started_at'].dt.dayofweek # 0=lunes123 df['is_weekend'] = df['day_of_week'].isin([5, 6])124125 # 5. Que fraccion del titulo se vio.126 #127 # Sin esta columna, "finalizacion" solo puede salir del flag completed, que128 # es un si/no puesto por el reproductor. El ratio dice cuanto se vio de129 # verdad, y es lo que el miercoles permite responder a Sofia si las peliculas130 # largas se terminan o se abandonan a la mitad.131 #132 # Se calcula AQUI y no al consultar porque necesita la duracion del titulo,133 # que vive en otra tabla: hacerlo una vez en la carga ahorra un JOIN en cada134 # consulta posterior. Y si no se conoce la duracion, el ratio es NULO --135 # nunca cero, que significaria "no vio nada".136 if content_durations:137 duracion = df['content_id'].map(content_durations)138 else:139 duracion = pd.Series(pd.NA, index=df.index, dtype='Float64')140 df['completion_ratio'] = (df['watch_seconds'] / duracion.where(duracion > 0)).round(3)141 # Tope en 1.5: rebobinar y volver a ver un trozo da ratios por encima de 1,142 # y eso es real. Un ratio de 40 no: es una duracion mal puesta en el catalogo.143 df['completion_ratio'] = df['completion_ratio'].clip(upper=1.5)144145 # 6. Categorizar la sesion.146 #147 # Estos nombres son los que van a usar las consultas del resto de la semana148 # (el jueves se filtra session_category = 'bounce' para no inflar el DAU),149 # asi que se escriben una vez aqui y no se vuelven a inventar.150 # Si no se sabe el tiempo, la categoria es 'unknown', no 'bounce'.151 # El .fillna(False) no es decorativo: con watch_seconds nulo, la comparacion152 # devuelve nulo (no False), y np.select no sabe que hacer con un nulo.153 condiciones = [154 (df['watch_seconds'] < 30).fillna(False),155 df['watch_seconds'].between(30, 300, inclusive='left').fillna(False),156 df['watch_seconds'].between(300, 1800, inclusive='left').fillna(False),157 df['watch_seconds'].between(1800, 7200, inclusive='left').fillna(False),158 (df['watch_seconds'] >= 7200).fillna(False),159 ]160 etiquetas = ['bounce', 'short', 'medium', 'long', 'binge']161 df['session_category'] = np.select(condiciones, etiquetas, default='unknown')162163 # 6. Flag de sesión significativa (> 2 min de watch).164 # Si no sabemos el tiempo, no sabemos si fue significativa: nulo, no "no".165 # (Con dtype boolean de pandas, el nulo se propaga solo.)166 # El umbral va en una constante (arriba del fichero) y no repetido en cada167 # consulta. Manana el dia 3 calcula el DAU filtrando por esta columna, no168 # escribiendo otra vez un numero: el dia que alguien decida que son 45169 # segundos se cambia aqui y no en siete consultas de dos lecciones.170 df['is_meaningful'] = (df['watch_seconds'] > UMBRAL_SESION_SIGNIFICATIVA).astype('boolean')171 df.loc[df['watch_seconds'].isna(), 'is_meaningful'] = pd.NA172173 final_count = len(df)174 dropped = initial_count - final_count175 if dropped > 0:176 logger.warning(f"Descartadas {dropped} filas inválidas ({dropped/initial_count*100:.1f}%)")177178 logger.info(f"Transformación completada: {final_count:,} filas válidas")179 return df.reset_index(drop=True)180181182def transform_users(df: pd.DataFrame) -> pd.DataFrame:183 """Limpia y enriquece la dimensión de usuarios."""184 df['registered_at'] = pd.to_datetime(df['registered_at'], utc=True)185 df['last_login_at'] = pd.to_datetime(df['last_login_at'], utc=True, errors='coerce')186187 # NO se guarda la antiguedad en dias.188 #189 # Seria (ahora - registered_at).dt.days, y eso depende de CUANDO corre el190 # pipeline. Guardarlo en el lake lo vuelve irreproducible: reprocesar los191 # datos de la semana pasada daria valores distintos de los que habia la192 # semana pasada.193 #194 # Regla: en el lake no se guarda nada que dependa del momento de ejecucion.195 # Se guarda la fecha de alta -- que es un hecho y no cambia -- y la antiguedad196 # se calcula AL CONSULTAR. Una linea menos aqui y un problema menos en seis meses.197198 # Cohorte mensual de registro (esta si: sale de la fecha de alta, no del reloj)199 df['registration_cohort'] = df['registered_at'].dt.to_period('M').astype(str)200201 # Estandarizar plan202 df['plan'] = df['plan'].fillna('free').str.lower().str.strip()203204 return df
Transformación defensiva: dedup, validación, campos derivados para analytics.
## Volver a lanzarlo tiene que ser gratis
Antes de escribir nada en S3, una pregunta que decide el diseño de la carga: si lanzo el pipeline dos veces con los mismos datos de entrada, ¿el resultado es el mismo? Esto no es teoría. El cron sale a las 03:00; una noche la réplica va lenta, el proceso muere a mitad, y a las 03:40 lo relanzas desde tu portátil. Si la segunda pasada no deja el lake exactamente como lo habría dejado la primera, acabas de duplicar el DAU de ese día -- y no lo vas a descubrir hasta que Marta pregunte por un pico raro en la reunión del viernes.
Un proceso con esa propiedad se llama idempotente, y en un lake de ficheros se consigue con una regla sencilla: el nombre del fichero que escribes tiene que salir del LOTE, no del reloj. Si el fichero se llama por el rango de datos que trae, relanzar el mismo lote reescribe ese fichero y sólo ese: lanzarlo diez veces deja lo mismo que lanzarlo una. Si el nombre lleva la hora de ejecución -- o un número que sube -- cada pasada deja un fichero nuevo al lado del anterior y los datos se cuentan dos veces. Es una decisión de tres caracteres en un nombre, y decide si el pipeline se puede relanzar sin pensar o hay que entrar a borrar a mano a las cuatro de la mañana.
Ojo con confundirlo con la limpieza de duplicados que acabas de escribir: esa mira DENTRO del lote y quita los reintentos del reproductor de iOS. No te protege de correr el pipeline dos veces, porque en la segunda pasada el lote vuelve a venir limpio y el duplicado está en el lake, no en el DataFrame. Son dos problemas distintos y cada uno se arregla en su sitio. Y la forma de comprobar que lo tienes bien es tonta y funciona: lanza el proceso dos veces seguidas con la misma fecha y cuenta las filas. Si el número cambia, no es idempotente, por muy bien que se lea el código.
## 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 = None,19 lote_desde=None,20 lote_hasta=None,21):22 """Escribe un DataFrame como Parquet en S3, opcionalmente particionado.2324 lote_desde / lote_hasta: rango del lote que se esta cargando. Se usan para25 nombrar el fichero cuando hay particion, para que dos ejecuciones no se26 pisen. Obligatorios si se pasa partition_col.27 """28 if df.empty:29 logger.warning(f"DataFrame vacío, no se escribe en {prefix}")30 return3132 if partition_col and partition_col in df.columns:33 # Una partición por valor único.34 #35 # OJO CON EL NOMBRE DEL FICHERO: aquí es donde se pierden datos.36 # Si se llama siempre "data.parquet", cada ejecución REESCRIBE entera37 # la partición de esa fecha con lo que traiga en ese momento. Y como la38 # marca de agua avanza por created_at (cuándo se insertó la fila) y la39 # partición se hace por event_date (cuándo empezó la reproducción), que40 # no son lo mismo, la ejecución de mañana vuelve a tocar la partición de41 # hoy y borra lo que escribió la de hoy. Resultado: cada noche se pierden42 # las reproducciones de entre medianoche y las 03:00, para siempre y sin43 # un solo error en el log.44 #45 # El nombre sale del RANGO DEL LOTE, no del reloj: dos ejecuciones46 # distintas escriben ficheros distintos (nadie pisa a nadie) y relanzar47 # el MISMO lote reescribe el suyo y sólo el suyo -- que es lo que hace48 # que la carga se pueda repetir sin pensar.49 marca = f"{lote_desde:%Y%m%dT%H%M%S}-{lote_hasta:%Y%m%dT%H%M%S}"50 for value, partition_df in df.groupby(partition_col):51 key = f"{prefix}/{partition_col}={value}/part-{marca}.parquet"52 _write_single_parquet(partition_df, s3_client, key)53 else:54 # Las dimensiones (usuarios, catálogo) se extraen ENTERAS cada noche, así55 # que aquí el nombre fijo es lo correcto: cada ejecución sustituye la foto56 # anterior. Si se les pusiera nombre por lote, el lake acumularía una copia57 # del padrón por ejecución y los cruces multiplicarían filas. Son dos58 # escrituras con dos semánticas distintas.59 key = f"{prefix}/data.parquet"60 _write_single_parquet(df, s3_client, key)616263def _write_single_parquet(df: pd.DataFrame, s3_client, key: str):64 """Escribe un único archivo Parquet a S3."""65 table = pa.Table.from_pandas(df)66 buffer = BytesIO()67 pq.write_table(table, buffer, compression='snappy')68 buffer.seek(0)6970 s3_client.put_object(71 Bucket=config.s3_bucket,72 Key=key,73 Body=buffer.getvalue(),74 ContentType='application/octet-stream'75 )76 size_mb = buffer.getbuffer().nbytes / (1024 * 1024)77 logger.info(f"Escrito s3://{config.s3_bucket}/{key} ({size_mb:.2f} MB)")787980def contar_filas_del_lake() -> int:81 """Cuenta lo que hay en el lake, sin tocar el fichero de Metabase.8283 Conexion en memoria y de usar y tirar: lee el Parquet de S3 directamente,84 asi que no abre ni bloquea nada.85 """86 con = duckdb.connect(":memory:")87 con.execute("INSTALL httpfs; LOAD httpfs;")88 con.execute(f"SET s3_region = '{config.s3_region}';")89 total = con.execute(90 f"SELECT COUNT(*) FROM read_parquet('s3://{config.s3_bucket}/silver/playbacks/**/*.parquet')"91 ).fetchone()[0]92 con.close()93 return total949596def setup_duckdb_analytics():97 """Crea las vistas del lake. SE EJECUTA UNA VEZ, a mano, en el montaje.9899 No forma parte del pipeline nocturno, y esto es la decision del dia 1: si el100 script de cada noche abriera este fichero mientras Metabase lo tiene abierto,101 uno de los dos se queda fuera. Las vistas apuntan al Parquet de S3, asi que102 los datos nuevos se ven sin tocar el fichero: no hay nada que refrescar.103 Metabase lo abre en modo lectura.104 """105 con = duckdb.connect(config.duckdb_path)106107 # Instalar y cargar extensión para S3108 con.execute("INSTALL httpfs; LOAD httpfs;")109 con.execute(f"SET s3_region = '{config.s3_region}';")110111 # Crear vistas sobre los archivos Parquet en S3112 bucket = config.s3_bucket113 con.execute(f"""114 CREATE OR REPLACE VIEW silver_playbacks AS115 SELECT * FROM read_parquet(116 's3://{bucket}/silver/playbacks/event_date=*/*.parquet',117 hive_partitioning = true118 );119 """)120 con.execute(f"""121 CREATE OR REPLACE VIEW dim_users AS122 SELECT * FROM read_parquet('s3://{bucket}/silver/users/data.parquet');123 """)124 con.execute(f"""125 CREATE OR REPLACE VIEW dim_content AS126 SELECT * FROM read_parquet('s3://{bucket}/silver/content/data.parquet');127 """)128129 # Y las tres que hacen falta manana para hablar de dinero y de crecimiento.130 # No pasan por la transformacion porque no vienen de la base de datos: los131 # cobros salen de una exportacion de la pasarela de pago y el gasto de132 # marketing lo mantiene alguien a mano en una hoja. Se leen tal cual y se133 # limpian al consultar.134 #135 # Ponerlas aqui, y no manana, es la diferencia entre que la leccion de136 # metricas empiece calculando o empiece buscando de donde sale cada tabla.137 for nombre, fichero in [138 ('suscripciones', 'suscripciones.csv'),139 ('pagos', 'pagos.csv'),140 ('marketing', 'gasto_marketing_semanal.csv'),141 ]:142 con.execute(f"""143 CREATE OR REPLACE VIEW {nombre} AS144 SELECT * FROM read_csv_auto('s3://{bucket}/raw/{fichero}');145 """)146147 # Verificar que funciona148 count = con.execute("SELECT COUNT(*) FROM silver_playbacks").fetchone()[0]149 logger.info(f"DuckDB configurado. silver_playbacks: {count:,} filas")150151 con.close()152 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, timezone67import pandas as pd # hace falta para pd.Timestamp() de mas abajo8from .config import config9from .extract import extract_playbacks, extract_users, extract_content, get_watermark10from .transform import transform_playbacks, transform_users11from .load import write_parquet_to_s3, contar_filas_del_lake1213logger = logging.getLogger(__name__)141516def run_daily_pipeline():17 """Pipeline diario completo: extract -> transform -> load."""18 start_time = datetime.now(timezone.utc)19 logger.info(f"=== Pipeline iniciado: {start_time.isoformat()} ===")2021 s3 = boto3.client('s3', region_name=config.s3_region)2223 try:24 # 1. EXTRACT25 watermark = get_watermark(s3, 'playbacks')26 logger.info(f"Watermark playbacks: {watermark.isoformat()}")2728 playbacks_raw = extract_playbacks(since=watermark)29 users_raw = extract_users()30 content_raw = extract_content()3132 if playbacks_raw.empty:33 logger.info("No hay nuevos playbacks. Pipeline finalizado.")34 return {"status": "no_new_data", "duration_seconds": 0}3536 # 2. TRANSFORM37 # Las duraciones del catalogo entran aqui para poder calcular que38 # fraccion de cada titulo se vio. OJO CON LA UNIDAD: el ratio divide39 # segundos entre segundos. Si la columna del origen viniera en minutos40 # (es lo que pasa con el fichero del caso), hay que multiplicar por 6041 # ANTES de construir el diccionario, no despues de ver los numeros.42 duraciones = dict(43 zip(content_raw['content_id'], content_raw['duration_seconds'])44 )45 playbacks_clean = transform_playbacks(playbacks_raw, content_durations=duraciones)46 users_clean = transform_users(users_raw)4748 # 3. LOAD a S3.49 # Al escribir los playbacks se pasa el RANGO del lote: de ahi sale el50 # nombre del fichero, para que dos ejecuciones no se pisen y relanzar51 # el mismo lote sea inofensivo.52 write_parquet_to_s3(53 playbacks_clean, s3,54 prefix=f"{config.silver_prefix}/playbacks",55 partition_col='event_date',56 lote_desde=pd.Timestamp(watermark),57 lote_hasta=playbacks_clean['created_at'].max(),58 )59 # Las dimensiones van sin rango: se reescriben enteras cada noche.60 write_parquet_to_s3(users_clean, s3, prefix=f"{config.silver_prefix}/users")61 write_parquet_to_s3(content_raw, s3, prefix=f"{config.silver_prefix}/content")6263 # 4. Actualizar watermark64 new_watermark = playbacks_clean['created_at'].max().isoformat()65 _update_watermark(s3, 'playbacks', new_watermark)6667 # 5. Comprobar que el lake se lee. NO se toca el fichero .duckdb de68 # Metabase: las vistas apuntan al Parquet de S3, asi que los datos69 # nuevos aparecen solos y no hay nada que refrescar. Se abre una70 # conexion en memoria, de usar y tirar, para contar lo que hay.71 total_rows = contar_filas_del_lake()7273 duration = (datetime.now(timezone.utc) - start_time).total_seconds()74 result = {75 "status": "success",76 "new_playbacks": len(playbacks_clean),77 "total_rows_in_lake": total_rows,78 "duration_seconds": round(duration, 1),79 }80 logger.info(f"=== Pipeline completado: {result} ===")81 return result8283 except Exception as e:84 logger.error(f"Pipeline FALLIDO: {e}", exc_info=True)85 # NO devolvemos un diccionario de error: lo relanzamos.86 #87 # Si aqui se devuelve un dict con status "error", el proceso termina con88 # codigo de salida CERO. Para cron, para el sistema y para cualquier cosa89 # que mire si el trabajo fue bien, la ejecucion ha sido un exito. El unico90 # rastro es una linea en un log que nadie abre hasta que algo se nota --91 # normalmente el jueves por la tarde, mirando un panel con los datos de92 # anteayer, una hora antes de preparar la reunion.93 #94 # Capturar una excepcion y seguir como si nada no es gestionar un error:95 # es esconderlo. La primera regla de un pipeline nocturno es que alguien96 # se entere de que ha fallado ANTES que la persona que usa el dato.97 raise9899100def _update_watermark(s3_client, table_name: str, timestamp_iso: str):101 """Actualiza el watermark en S3."""102 try:103 obj = s3_client.get_object(Bucket=config.s3_bucket, Key=config.watermark_key)104 watermarks = json.loads(obj['Body'].read())105 except s3_client.exceptions.NoSuchKey:106 watermarks = {}107108 watermarks[table_name] = timestamp_iso109 s3_client.put_object(110 Bucket=config.s3_bucket,111 Key=config.watermark_key,112 Body=json.dumps(watermarks, indent=2).encode(),113 )
Pipeline completo con logging, watermarks y error handling. Listo para cron.
## Configuración de cron
1# cron/crontab.txt2#3# UN SOLO trabajo, no tres. Las metricas van DETRAS del pipeline en la misma4# orden, no media hora despues y cruzando los dedos: si una noche el pipeline5# tarda mas de lo normal (red lenta, lote grande, la replica ocupada con el6# backup), unas metricas lanzadas por reloj se calculan sobre un lake a medias7# y nadie se entera.8#9# Y si algo falla, un correo. No hace falta monitorizacion sofisticada en una10# empresa de 30 personas: hace falta que alguien se entere antes que Marta.110 3 * * * cd /opt/streamvibe-data && venv/bin/python -m etl.daily >> /var/log/streamvibe-etl.log 2>&1 || echo "El ETL de StreamVibe ha fallado, mira /var/log/streamvibe-etl.log" | mail -s "[ETL] fallo" tu@streamvibe.es1213# Copia de seguridad: NO se copia el fichero .duckdb.14#15# Ese fichero solo contiene tres VISTAS sobre el Parquet de S3 -- unos kilobytes16# de definiciones, cero datos. Copiarlo cada noche no protege nada. Los datos17# estan en S3, y ahi lo que protege es el versionado del bucket, que guarda las18# versiones anteriores de un fichero sobrescrito o borrado por error:19#20# aws s3api put-bucket-versioning --bucket streamvibe-data-lake \21# --versioning-configuration Status=Enabled22#23# Ojo: el versionado guarda TODAS las versiones antiguas y se pagan, asi que va24# siempre con una regla de ciclo de vida que las expire a los 30 dias.
Un solo job: el pipeline y, si ha ido bien, las métricas. Con aviso por correo si falla.
La carga histórica y la diaria NO son la misma cosa, y confundirlas te deja sin datos el viernes. Haz la cuenta: la extracción lleva un LIMIT de 500.000 filas (bien puesto, para no saturar la réplica), y el histórico son unos 3,8 millones. Eso son ocho lotes. Si dejas que se cargue "solo" a razón de un lote por noche, con la primera ejecución en la madrugada del miércoles el lake no estará completo hasta la semana siguiente -- y la reunión con Meridian es el viernes. El histórico se lanza a mano, hoy, y se deja correr hasta que no queden filas: mide cuánto tarda el primer lote y multiplica. El cron de las 03:00 es para lo de después, unos pocos miles de filas y nadie mirando.
1# etl/daily.py -- lo que lanza cron: un lote y, si ha ido bien, las metricas2from .pipeline import run_daily_pipeline3from ..analytics.metrics import recalcular_metricas45resultado = run_daily_pipeline()6if resultado["status"] == "success":7 recalcular_metricas()8910# etl/backfill.py -- la carga historica: se lanza A MANO, hoy, una sola vez11def backfill(max_lotes: int = 50):12 """Carga el historico entero, lote a lote, sin parar hasta terminar."""13 for n in range(1, max_lotes + 1):14 resultado = run_daily_pipeline()15 if resultado["status"] == "no_new_data":16 logger.info(f"Historico completo tras {n - 1} lotes.")17 recalcular_metricas() # una sola vez, al final18 return19 logger.info(f"Lote {n} cargado ({resultado['rows_loaded']:,} filas). Sigo.")20 raise RuntimeError(21 f"Sigue habiendo datos tras {max_lotes} lotes: revisa la marca de agua"22 )2324# El tope no es adorno: si el pipeline fallara devolviendo un error en vez de25# levantarlo, un bucle sin tope reintentaria para siempre contra la replica que26# Daniel pidio no saturar. Con la excepcion relanzada, el bucle se corta solo;27# el tope es el cinturon por si acaso.
Dos puntos de entrada distintos: el diario que lanza cron, y el histórico que lanzas tú hoy.
## 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_playbacks567# Helper: una fila completa. Si un test omite una columna que la transformacion8# toca, el test revienta con KeyError antes de llegar al assert -- y entonces no9# esta probando lo que crees que prueba, solo que no explota.10def fila(**kwargs):11 base = {12 'playback_session_id': 'sess_1',13 'event_type': 'play',14 'user_id': 10,15 'content_id': 100,16 'started_at': '2024-03-15T20:00:00Z',17 'ended_at': '2024-03-15T20:30:00Z',18 'watch_seconds': 1000,19 'completed': False,20 'created_at': '2024-03-15T21:00:00Z',21 }22 base.update(kwargs)23 return base242526def test_deduplication():27 """El mismo evento mandado dos veces (reintento de iOS) se queda en uno."""28 df = pd.DataFrame([29 fila(playback_session_id='s1'),30 fila(playback_session_id='s1'), # duplicado31 fila(playback_session_id='s2', user_id=20),32 fila(playback_session_id='s3', user_id=30),33 ])34 result = transform_playbacks(df)35 assert len(result) == 3363738def test_invalid_watch_seconds_filtered():39 """watch_seconds negativos o > 24h se descartan."""40 df = pd.DataFrame([41 fila(playback_session_id='s1', watch_seconds=-5), # imposible42 fila(playback_session_id='s2', watch_seconds=90000), # > 24h43 fila(playback_session_id='s3', watch_seconds=1000), # valido44 ])45 result = transform_playbacks(df)46 assert len(result) == 147 assert result.iloc[0]['watch_seconds'] == 1000484950def test_watch_seconds_desconocido_no_es_cero():51 """Un tiempo desconocido se queda a NULO, no se convierte en cero.5253 Es el test que protege las metricas del viernes: si el nulo pasara a cero,54 esa fila entraria en el denominador del tiempo medio y de la tasa de55 finalizacion, bajando la media sin que nadie sepa por que.56 """57 df = pd.DataFrame([58 fila(playback_session_id='s1', watch_seconds=None),59 fila(playback_session_id='s2', watch_seconds=600),60 ])61 result = transform_playbacks(df)62 assert len(result) == 2 # la fila NO se descarta63 assert result['watch_seconds'].isna().sum() == 1 # sigue siendo desconocida64 assert pd.isna(result.loc[result['watch_seconds'].isna(), 'is_meaningful'].iloc[0])656667def test_null_user_filtered():68 """Registros sin user_id, sin contenido o sin inicio se descartan."""69 df = pd.DataFrame([70 fila(playback_session_id='s1', user_id=None),71 fila(playback_session_id='s2', content_id=None),72 fila(playback_session_id='s3', started_at=None),73 fila(playback_session_id='s4', user_id=20),74 ])75 result = transform_playbacks(df)76 assert len(result) == 177 assert result.iloc[0]['user_id'] == 20787980def test_una_sola_fila():81 """Un lote de una fila no debe romper nada (caso borde tipico)."""82 result = transform_playbacks(pd.DataFrame([fila()]))83 assert len(result) == 1848586def test_empty_dataframe():87 """Un DataFrame vacío retorna vacío sin error."""88 df = pd.DataFrame()89 result = transform_playbacks(df)90 assert len(result) == 0919293def test_cargar_el_mismo_lote_dos_veces_no_cambia_el_lake(tmp_path):94 """El test que habria cazado el peor fallo de este pipeline.9596 Cargar dos veces el mismo lote tiene que dejar el lake IGUAL. Si el nombre97 del fichero de salida fuera fijo ("data.parquet"), la segunda carga98 reescribiria la particion y se perderian las filas que la primera habia99 dejado ahi. Con el nombre derivado del rango del lote, la segunda pasada100 reescribe su propio fichero y nada mas.101 """102 df = transform_playbacks(pd.DataFrame([103 fila(playback_session_id='s1'),104 fila(playback_session_id='s2', user_id=20),105 ]))106 desde = pd.Timestamp('2024-03-15T03:00:00Z')107 hasta = pd.Timestamp('2024-03-16T03:00:00Z')108109 escribir_local(df, tmp_path, 'event_date', desde, hasta)110 filas_primera = contar_filas(tmp_path)111 escribir_local(df, tmp_path, 'event_date', desde, hasta) # mismo lote otra vez112 assert contar_filas(tmp_path) == filas_primera
Tests que sí pasan, y dos nuevos: el del tiempo desconocido y el de la carga repetida.
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 los 21 días de reproducciones reales listos para calcular métricas mañana
## comentarios
Reporta erratas, ayuda a otros o comparte tu opinión. Sé constructivo.
Inicia sesión para comentar y responder.
cargando comentarios...
Regístrate para guardar tu progreso.