---
name: datos-etl
description: >
  Ingeniería de datos y ETL: diseño de pipelines, calidad de datos, evolución de esquemas,
  CDC (change data capture), validación de datos, linaje de datos, procesamiento batch vs
  streaming, patrones dbt, patrones de data lake.
version: "1.0.1"
evolved: true
evolved-from: "1.0.0"
evolved-at: "2026-05-05"
evolved-by: "aprender"
evolved-note: "Gotcha de la sesión SIGM 2026-05-05 (L9): seeds con check constraint fecha_vencimiento >= fecha_emision violan al usar emision única para todas las tuplas"
herramientasPermitidas: [Read, Grep]
evolvable: true  # default para skill estandar
exclusiones:
  - "No cargar para queries SQL de aplicación (ORM, endpoints de API) — para SQL de aplicación cargar `postgresql-experto` o `sql-optimizacion`."
  - "No cargar para pipelines de ML/AI (feature engineering, entrenamiento de modelos) — este skill cubre ingeniería de datos operacional, no MLOps."
  - "No cargar para migraciones de esquema de base de datos de aplicación (Alembic, Flyway) — para migraciones cargar `postgresql-experto` o `deprecacion-migracion`."
  - "No cargar para arquitectura de microservicios con mensajería (Kafka, RabbitMQ como event bus) — para event-driven cargar `event-driven`."
---
# Ingeniería de Datos y ETL — Guía de Producción

## Cuándo NO cargar

- La tarea es queries SQL para endpoints de aplicación (ORM, reportes ad-hoc): cargar `postgresql-experto` o `sql-optimizacion`.
- El pipeline es de ML/AI (feature engineering, entrenamiento, serving de modelos): este skill cubre datos operacionales, no MLOps.
- La tarea es migración de esquema de base de datos de aplicación (Alembic, Flyway): cargar `postgresql-experto` o `deprecacion-migracion`.
- El uso de Kafka/RabbitMQ es como event bus de microservicios, no como ingesta de datos: cargar `event-driven`.

## Principios Fundamentales

Un pipeline de datos es software de producción: necesita tests, monitoreo, versioning y manejo de errores.

**Reglas de oro:**
1. Los pipelines fallan de forma predecible: idempotencia es obligatoria.
2. Un dato sin linaje es un dato sin confianza.
3. La calidad de datos se valida en la entrada, no al final.
4. Schema-on-write para datos de alta integridad; schema-on-read sólo para exploración.

---

## Diseño de Pipelines — Estructura por Capas (Medallion)

```
Fuentes        Bronze (raw)      Silver (limpio)     Gold (negocio)
─────────   →  ────────────  →  ──────────────── →  ──────────────
CRM             Replica 1:1      Deduplicado          KPIs diarios
ERP             Sin transforms    Normalizado          Reportes
APIs            Auditado          Validado             ML features
Archivos        Inmutable         Enriquecido          Dashboards
```

### Bronze es SIEMPRE inmutable

```python
class IngestionBronze:
    def ingestar(self, fuente: str, datos: list[dict]) -> str:
        ahora = datetime.now(timezone.utc)
        ruta = (
            f"bronze/{fuente}/"
            f"anio={ahora.year}/mes={ahora.month:02d}/dia={ahora.day:02d}/"
            f"{ahora.isoformat()}.parquet"
        )
        df = pd.DataFrame(datos)
        df["_ingestado_en"] = ahora.isoformat()
        df["_fuente"] = fuente
        df.to_parquet(ruta, compression="snappy", index=False)
        return ruta
```

---

## Idempotencia en Pipelines

NUNCA insertar sin deduplicación. Usar patrón DELETE+INSERT por partición:

```python
def procesar_pedidos(fecha: date) -> None:
    """Idempotente: ejecutar múltiples veces produce el mismo resultado."""
    pedidos = extraer_pedidos(fecha)
    pedidos_transformados = [transformar(p) for p in pedidos]

    with destino.transaccion() as tx:
        tx.execute(
            "DELETE FROM silver.pedidos WHERE fecha_pedido = :fecha",
            {"fecha": fecha},
        )
        tx.insertar_lote("silver.pedidos", pedidos_transformados)
```

---

## Batch vs Streaming — Cuándo Usar Cada Uno

| Criterio | Batch | Streaming |
|----------|-------|-----------|
| Latencia aceptable | > 1 minuto | < 1 minuto |
| Volumen por ejecución | GB a TB | KB a MB por evento |
| Complejidad de estado | Baja | Alta (windows, joins) |
| Tolerancia a fallos | Reintentar batch completo | At-least-once / exactly-once |
| Costo infraestructura | Bajo (periódico) | Alto (siempre activo) |
| Casos de uso | Reportes nocturnos, ETL histórico | Alertas, fraude, dashboards live |

---

## Implementaciones Completas

Para validación con Great Expectations, CDC con Debezium (Kafka), evolución de
esquemas (Avro), patrones dbt (modelos incrementales), y linaje de datos, ver
[recursos/implementaciones-completas.md](recursos/implementaciones-completas.md).

---

## Checklist de Revisión — ETL y Datos

- [ ] Todo pipeline es idempotente: re-ejecutar produce el mismo resultado.
- [ ] La capa Bronze es inmutable y no se modifica tras la ingesta.
- [ ] Las validaciones de calidad de datos ocurren ANTES de cargar a Silver.
- [ ] Los esquemas nuevos son backward-compatible (campos nuevos con default o nullable).
- [ ] El linaje está registrado para cada transformación (origen, destino, conteos).
- [ ] Los pipelines tienen tests unitarios para las funciones de transformación.
- [ ] Los errores de registro individual van a DLQ, no detienen el pipeline completo.
- [ ] Las métricas de calidad (% rechazados, % nulos) se exponen a Grafana/Prometheus.
- [ ] Los modelos dbt tienen tests de `not_null`, `unique` y `accepted_values` en columnas clave.

## Gotchas / Errores comunes no obvios

**Pipeline idempotente con `DELETE + INSERT` falla silenciosamente cuando la partición es incorrecta**: si el `DELETE` usa una cláusula `WHERE fecha_pedido = :fecha` pero los datos tienen timestamps con zona horaria y la comparación trunca la hora, el DELETE borra más registros de los esperados (o ninguno). Causa: la idempotencia depende de que el predicado del DELETE sea exactamente el mismo que identifica los datos del INSERT. Fix: verificar que la columna de partición tiene el mismo tipo y granularidad en fuente y destino. Para columnas con timestamp, usar `DATE_TRUNC('day', timestamp_col)` explícitamente en ambos lados.

**La capa Bronze "inmutable" se modifica porque el mismo script de ingesta sobreescribe el archivo Parquet existente**: el script usa `df.to_parquet(ruta)` donde `ruta` incluye la fecha pero no la hora — si se ejecuta dos veces el mismo día, sobrescribe la ingesta anterior. Causa: la "inmutabilidad" de Bronze depende de que las rutas incluyan suficiente granularidad temporal para ser únicas por ejecución. Fix: incluir el timestamp ISO completo (con hora y minutos) en la ruta de Bronze: `f"bronze/{fuente}/{ahora.isoformat()}.parquet"`. Si se necesita reejecutar, crear una nueva partición con sufijo `_rerun_N`.

**Validación con Great Expectations pasa en staging pero falla en producción con el mismo schema**: los expectations se definen sobre una muestra de datos de staging que puede no tener todos los valores posibles del dominio. Causa: un expectation de tipo `expect_column_values_to_be_in_set(["A","B","C"])` falla en producción cuando aparece un valor "D" válido que no existía en el sample de staging. Fix: para columnas con dominio abierto, usar `expect_column_values_to_not_be_null` en lugar de un set cerrado. Reservar sets cerrados solo para columnas con dominio verdaderamente finito y controlado (estados de máquina de estado, flags booleanos).

**El job de Spark/pandas falla en producción por OOM aunque funcionó bien en staging con datos de muestra**: el procesamiento en memoria de un DataFrame completo escala linealmente con el volumen de datos. En staging con 10K registros todo cabe en RAM; en producción con 10M registros el proceso muere. Causa: operaciones como `groupby + apply` con funciones Python puras no se pueden paralelizar ni distribuir automáticamente. Fix: para transformaciones sobre datasets grandes, usar procesamiento por chunks (`chunksize` en pandas) o migrar a Spark/DuckDB para operaciones distribuidas. Medir el uso de memoria en staging con el volumen máximo esperado en producción, no con la muestra de desarrollo.

**Seeds que simulan data histórica con check constraints de fechas relativas violan el constraint si se usa una sola fecha de emisión**: cuando un seed crea facturas/pagos demo con una mezcla de estatus (vencidas, vigentes, pagadas) y la tabla tiene check constraint del tipo `fecha_vencimiento >= fecha_emision`, generar `fecha_emision = '2025-12-10'` y luego asignar `fecha_vencimiento = '2025-10-31'` para vencidas viola el constraint. Caso real (SIGM 2026-05-04): seed `009-datos-operativos-demo.sql` con 500 facturas demo donde `n%10=0` representaba "vencidas" pero la fecha_emision común era posterior. Causa: la lógica genera dimensiones derivadas (vencimiento) sin re-derivar las fuente (emisión). Fix: separar fechas POR ESTATUS antes de generar tuplas:
```sql
-- Vencidas: emision en pasado profundo, vencimiento ya pasó
CASE WHEN n % 10 = 0 THEN DATE '2025-09-30'
     ELSE DATE '2025-12-10' + (n % 3)
END AS fecha_emision,
CASE WHEN n % 10 = 0 THEN DATE '2025-10-31'
     ELSE DATE '2026-01-31'
END AS fecha_vencimiento,
```
Generalización: cualquier check constraint que relacione columnas debe respetarse al SEEDEAR, no solo en INSERT productivo.
