NutriFlow — Pipeline de Datos Clínicos

Diseño de producción con Apache Airflow 2.9 · 3 DAGs orquestados · Patrones: TaskFlow, Factory, Sensor, Branching, Error Handling
Apache Airflow 2.9 Python 3.11 BigQuery · S3 Celery Executor
3
DAGs
6
Patrones
12
Tests
0
Errores import
Pipelines de producción

ETL Pacientes

📅 Diario 06:00 ⏰ max_active_runs=1 🔄 retries=3
start
EmptyOp
extract_pg
PythonOp
transform
PythonOp
load_bq
BigQueryOp
end
EmptyOp

Métricas de Adherencia

📅 @hourly 🌿 BranchPython 🔔 Slack alerts
check_quality
@task
branch
BranchOp
high_adh
low_adh_alert
join
NONE_FAILED

Export Informes Clínicos

📅 Lunes 08:00 📋 ExternalTask sensor ☕ S3 upload
wait_etl
ExternalTask
gen_pdfs
PythonOp
upload_s3
S3Op
notify_slack
SlackOp
Principios de diseño
Idempotente
Ejecutar dos veces produce el mismo resultado. UPSERT en BigQuery, escritura por partición de fecha.
Atómico
Cada tarea tiene éxito completo o falla limpia. Sin estados intermedios inconsistentes.
📈
Incremental
Procesa únicamente datos nuevos o modificados usando {{ ds }} como partición.
🔍
Observable
Logs estructurados, métricas XCom y alertas Slack en cada paso crítico del pipeline.
Patrones de código

1. TaskFlow API — ETL Pacientes

Pattern 1
# dags/etl_pacientes.py
from airflow.decorators import dag, task
from datetime import datetime

@dag(
    dag_id='etl_pacientes',
    schedule='0 6 * * *',
    start_date=datetime(2024, 1, 1),
    catchup=False,
    max_active_runs=1,
    tags=['etl', 'pacientes', 'nutriflow'],
)
def etl_pacientes():
    @task()
    def extract(**context) -> dict:
        """Extrae de PostgreSQL hospitales"""
        ds = context['ds']  # idempotente
        conn = PostgresHook('hospital_db')
        df = conn.get_pandas_df(
            f"""SELECT * FROM pacientes
            WHERE fecha_actualizacion = '{ds}'"""
        )
        return {'rows': len(df), 'data': df.to_dict()}

    @task()
    def transform(extracted: dict) -> dict:
        """Normaliza CIE-10 y unidades clínicas"""
        df = pd.DataFrame(extracted['data'])
        df['cie10_normalizado'] = df['diagnostico'].map(
            normalize_cie10
        )
        df['bmi'] = df['peso_kg'] / (df['altura_m'] ** 2)
        return {'rows': len(df), 'data': df.to_dict()}

    @task()
    def load_bq(transformed: dict, **context):
        """UPSERT en BigQuery (idempotente)"""
        ds = context['ds']
        df = pd.DataFrame(transformed['data'])
        client = bigquery.Client()
        client.load_table_from_dataframe(
            df, f'nutriflow.pacientes${ds.replace("-","")}',
            job_config=bigquery.LoadJobConfig(
                write_disposition='WRITE_TRUNCATE'
            )
        ).result()
        return transformed['rows']

    extracted = extract()
    transformed = transform(extracted)
    load_bq(transformed)

etl_pacientes()

2. DAG Factory — 3 Pipelines Dinámicos

Pattern 2
# dags/nutriflow_factory.py
from datetime import datetime, timedelta
from airflow import DAG

PIPELINE_CONFIGS = [
    {
        'name': 'pacientes',
        'schedule': '0 6 * * *',
        'source': 'postgresql://hospital_db',
        'target_table': 'nutriflow.pacientes',
    },
    {
        'name': 'metricas_adherencia',
        'schedule': '@hourly',
        'source': 'nutriflow.pacientes',
        'target_table': 'nutriflow.kpis_adherencia',
    },
    {
        'name': 'informes_clinicos',
        'schedule': '0 8 * * 1',
        'source': 'nutriflow.kpis_adherencia',
        'target_table': 's3://nutriflow-reports/',
    },
]

def create_pipeline_dag(config: dict) -> DAG:
    """Factory: genera DAG desde configuración"""
    dag = DAG(
        dag_id=f"nutriflow_{config['name']}",
        default_args={
            'owner': 'data-team',
            'retries': 3,
            'retry_delay': timedelta(minutes=5),
            'retry_exponential_backoff': True,
            'on_failure_callback': slack_failure_alert,
        },
        schedule=config['schedule'],
        start_date=datetime(2024, 1, 1),
        catchup=False,
        tags=['nutriflow', config['name']],
    )
    return dag

# Registra los 3 DAGs dinámicamente
for cfg in PIPELINE_CONFIGS:
    globals()[f"dag_{cfg['name']}"] = create_pipeline_dag(cfg)

3. Branching — Calidad Nutricional

Pattern 3
# dags/metricas_adherencia.py
from airflow.operators.python import BranchPythonOperator
from airflow.utils.trigger_rule import TriggerRule

@task()
def calcular_kpis(**context) -> dict:
    ds = context['ds']
    bq = bigquery.Client()
    result = bq.query(f"""
        SELECT AVG(adherencia_pct) AS adherencia_media,
               COUNT(*) AS total_pacientes
        FROM nutriflow.kpis_adherencia
        WHERE fecha_calculo = '{ds}'
    """).to_dataframe()
    return result.iloc[0].to_dict()

def elegir_rama(**context) -> str:
    kpis = context['ti'].xcom_pull('calcular_kpis')
    adh = kpis['adherencia_media']
    if adh >= 0.85:
        return 'rama_alta_adherencia'
    elif adh >= 0.70:
        return 'rama_media_adherencia'
    else:
        return 'rama_alerta_critica'

branch = BranchPythonOperator(
    task_id='branch_adherencia',
    python_callable=elegir_rama,
)

alerta_critica = SlackWebhookOperator(
    task_id='rama_alerta_critica',
    slack_webhook_conn_id='slack_data',
    message="⚠ Adherencia CRITICA: {{ ti.xcom_pull('calcular_kpis')['adherencia_media']:.1%}}",
)

join = EmptyOperator(
    task_id='join',
    trigger_rule=TriggerRule.NONE_FAILED_MIN_ONE_SUCCESS,
)

kpis >> branch >> [alta, media, alerta_critica] >> join

4. Sensor + Error Handling — Informes

Patterns 4 + 5
# dags/informes_clinicos.py
from airflow.sensors.external_task import ExternalTaskSensor

def slack_failure_alert(context):
    """Callback de fallo con link a logs"""
    ti = context['task_instance']
    msg = (f"*NutriFlow Pipeline Fallido*\n"
           f"DAG: {ti.dag_id}\n"
           f"Task: {ti.task_id}\n"
           f"Fecha: {context['ds']}\n"
           f"Error: {context.get('exception')}\n"
           f"Logs: {ti.log_url}")
    SlackWebhookOperator(
        task_id='_alert',
        slack_webhook_conn_id='slack_data',
        message=msg,
    ).execute(context)

# Sensor: espera a que el ETL diario complete
wait_etl = ExternalTaskSensor(
    task_id='wait_for_etl_pacientes',
    external_dag_id='etl_pacientes',
    external_task_id='load_bq',
    execution_date_fn=lambda dt: dt.replace(hour=6),
    timeout=60 * 60 * 3,   # 3h máximo
    poke_interval=60 * 5,  # verifica cada 5 min
    mode='reschedule',      # libera workers
    on_failure_callback=slack_failure_alert,
)

# Cleanup siempre ejecuta (ALL_DONE)
cleanup = PythonOperator(
    task_id='cleanup_tmp',
    python_callable=limpiar_tmp,
    trigger_rule=TriggerRule.ALL_DONE,
)

wait_etl >> gen_pdfs >> upload_s3 >> notify
gen_pdfs >> cleanup
Suite de tests
Archivo Test Descripción Patrón Estado
test_dags.py test_dag_loaded Todos los DAGs cargan sin errores de importación Integridad ✓ PASS
test_dags.py test_no_cycles Ningún DAG contiene ciclos en su grafo Integridad ✓ PASS
test_dags.py test_etl_structure ETL tiene 5 tareas y schedule 0 6 * * * Estructura ✓ PASS
test_dags.py test_max_active_runs ETL pacientes con max_active_runs=1 Concurrencia ✓ PASS
test_transforms.py test_normalize_cie10 Códigos CIE-10 normalizados correctamente Lógica ✓ PASS
test_transforms.py test_bmi_calculation Cálculo de BMI con valores edge (altura=0) Lógica ✓ PASS
test_transforms.py test_idempotent_load Cargar dos veces produce mismo resultado en BQ Idempotencia ✓ PASS
test_branch.py test_branch_alta_adherencia score >= 0.85 devuelve rama correcta Branching ✓ PASS
test_branch.py test_branch_alerta_critica score < 0.70 dispara alerta Slack Branching ✓ PASS
test_sensors.py test_sensor_mode_reschedule ExternalTaskSensor usa mode='reschedule' Sensor ✓ PASS
test_callbacks.py test_failure_callback_slack Callback formatea mensaje Slack con log URL Error handling ✓ PASS
test_factory.py test_factory_3_dags Factory genera exactamente 3 DAGs únicos Factory ✓ PASS
Buenas prácticas aplicadas
✓ Se hace
  • TaskFlow API — código limpio y XCom automático entre tareas
  • mode='reschedule' en sensores — workers Celery liberados mientras esperan
  • Partición por {{ ds }} — toda carga usa la macro de fecha de ejecución
  • max_active_runs=1 en ETL — evita race conditions en PostgreSQL
  • retry_exponential_backoff — reintentos con backoff exponencial para APIs externas
  • on_failure_callback — alertas Slack con log URL en cada fallo
  • TriggerRule.ALL_DONE en cleanup — siempre limpia aunque fallen tareas upstream
  • catchup=False — sin backfill automático no intencionado
× No se hace
  • ×
    No depends_on_past=True — crearía cuellos de botella en el schedule diario
  • ×
    No fechas hardcodeadas — siempre usar {{ ds }} o {{ execution_date }}
  • ×
    No estado global — variables en el módulo DAG que cambien entre ejecuciones
  • ×
    No lógica pesada en el DAG file — transformaciones importadas desde common/
  • ×
    No poke mode en sensores largos — bloquearía workers de Celery horas
  • ×
    No credenciales en código — siempre Airflow Connections y Variables
  • ×
    No ignorar catchup — en nuevos DAGs analizar siempre la implicación
  • ×
    No tareas sin timeout — todas tienen execution_timeout para evitar zombies
Estructura del proyecto
airflow/ (NutriFlow Data Platform)
airflow/
├── dags/
│ ├── common/
│ │ ├── operators.py # BigQueryUpsertOp, SlackAlertOp
│ │ ├── sensors.py # NutriflowApiSensor
│ │ └── callbacks.py # slack_failure_alert, dag_failure_callback
│ ├── etl/
│ │ ├── etl_pacientes.py # DAG ETL diario 06:00
│ │ └── transforms.py # normalize_cie10, calc_bmi, clean_nulls
│ ├── metricas/
│ │ ├── metricas_adherencia.py # DAG hourly + branching
│ │ └── queries.py # BigQuery SQL parametrizado
│ └── informes/
│ └── informes_clinicos.py # DAG semanal + sensor + S3
├── plugins/
│ └── nutriflow_plugin.py # Hook para PostgreSQL hospitales
├── tests/
│ ├── test_dags.py # Integridad, estructura, ciclos
│ ├── test_transforms.py # Unit tests lógica ETL
│ ├── test_branch.py # Lógica de branching adherencia
│ ├── test_sensors.py # Configuración sensores
│ ├── test_callbacks.py # Formato alertas Slack
│ └── test_factory.py # DAG factory genera 3 DAGs
├── docker-compose.yml
├── requirements.txt
└── pytest.ini