Pipelines de producción
ETL Pacientes
start
EmptyOp
extract_pg
PythonOp
transform
PythonOp
load_bq
BigQueryOp
end
EmptyOp
Métricas de Adherencia
check_quality
@task
branch
BranchOp
high_adh
low_adh_alert
join
NONE_FAILED
Export Informes Clínicos
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