CI i CD reaccionen a esdeveniments de codi: un push, una PR, un tag. Però al mòdul 4 va quedar una decisió pendent de complir: el scoring batch de churn s'ha d'executar cada dilluns a les 06:00 per alimentar la campanya de retenció de màrqueting, i a dia d'avui continua sent "algú llança score_batch.py els dilluns". Cap commit no dispara això — ho dispara el calendari. I no n'hi ha prou amb programar-ho: cal reintentar-ho si el remote de dades falla a les 06:01, avisar algú si falla de debò, i poder respondre el dimarts la pregunta "va córrer el scoring ahir i amb quin resultat?". Això és orquestració, i aquesta lliçó la resol amb Prefect: entendràs què afegeix un orquestrador sobre cron i sobre dvc repro, compararàs les opcions del mercat, i construiràs el flow real del scoring setmanal de CineClick amb reintents, timeout, alertes i schedule.

Contingut

  1. Què és un orquestrador i què afegeix sobre cron i dvc repro
  2. Panorama d'orquestradors i per què Prefect per a CineClick
  3. Conceptes de Prefect: flow, task, deployment, schedule, work pool
  4. El flow real: flows/scoring_setmanal.py
  5. Fallades: retries amb backoff, timeout i alertes
  6. Programar el dilluns a les 06:00
  7. Esbós del segon flow: reentrenament
  8. Orquestrador vs. CI/CD: què va a cada lloc

Què és un orquestrador i què afegeix sobre cron i dvc repro

La temptació immediata és un crontab: 0 6 * * 1 python scripts/score_batch.py. Funciona… fins a la primera matinada en què el remote de DVC triga a respondre, l'script mor, ningú no se n'assabenta, i màrqueting llança la campanya del dilluns amb el parquet de la setmana passada. Cron llança processos; no els opera. La diferència és a tot el que passa quan alguna cosa no va bé:

Necessitat cron dvc repro a mà Orquestrador
Planificació (calendari) No
Dependències entre passos No (un script monolític) Sí (el DAG del dvc.yaml)
Reintents automàtics amb espera No No Sí, per task i configurable
Timeout per pas No No
Alertes en fallar Només si et muntes l'email de cron No Sí (Slack, webhook, email)
Visibilitat històrica ("va córrer dilluns?") Grep al syslog, amb sort No UI amb cada execució, estat i logs
Reexecutar només el que ha fallat No Parcial (caceja stages)
Pas de dades entre passos Fitxers i fe Fitxers declarats Valors de retorn de funcions

Ull a la segona columna: dvc repro ja ens dona un DAG reproduïble (mòdul 2) i no el llençarem. DVC sap quins passos depenen de quines dades; el que no sap és quan executar-se, reintentar si la xarxa parpelleja ni avisar si falla. Orquestrador i DVC són complementaris: l'orquestrador és el despertador, el supervisor i el megàfon; DVC (o les nostres funcions Python) és la feina en si. De fet, l'esbós de reentrenament del final d'aquesta lliçó embolcalla dvc repro dins d'una task de Prefect.

Panorama d'orquestradors i per què Prefect per a CineClick

Orquestrador Filosofia Fort en Quan triar-lo
Airflow El veterà: DAGs declarats, scheduler central, ecosistema enorme d'operadors Equips de dades grans amb centenars de DAGs i necessitat d'integracions (BigQuery, Spark…) Ja hi ha Airflow a l'empresa, o el volum/varietat de pipelines ho justifica
Prefect Pythònic: un flow és una funció decorada; la infraestructura mínima és un procés Equips petits que volen orquestrar codi Python existent sense reescriure'l Pocs pipelines, equip Python, ganes de pagar poca infraestructura
Dagster Orientat a assets de dades: declares quines dades produeixes, no només quines tasques corren Llinatge i qualitat de dades com a ciutadans de primera; bon tipatge El catàleg de dades i el seu llinatge són el centre del problema
Kubeflow Pipelines Natiu de Kubernetes: cada pas és un contenidor ML pesat sobre K8s: GPUs, entrenament distribuït, aïllament per pas Plataforma ML corporativa sobre Kubernetes amb molts equips

L'elecció per a CineClick ja va quedar compromesa al mòdul 1: Prefect. La justificació, ara que sabem què cal orquestrar, s'aguanta sola:

  • Volum mínim: dos pipelines (scoring setmanal i, més endavant, reentrenament). Aixecar un Airflow amb el seu scheduler, la seva base de dades i els seus workers per a dos flows és matar mosques a canonades.
  • És Python del que ja tenim: score_batch.py existeix i funciona; amb Prefect, convertir-lo en flow és afegir decoradors, no reescriure'l en un altre paradigma.
  • Infraestructura gairebé nul·la: un worker de Prefect és un procés Python; pot córrer en un pod petit del clúster que ja operem. Amb Prefect Cloud (o un servidor Prefect self-hosted lleuger) tenim UI, schedules i alertes sense administrar gairebé res.
  • Kubeflow quedaria justificat si entrenéssim distribuït amb GPUs — i la GPU va quedar descartada per escrit a 04-05.

Conceptes de Prefect: flow, task, deployment, schedule, work pool

Prefect té més peces de les que necessitem; aquestes cinc basten per a tot el que farem:

  • Task: una funció Python decorada amb @task. És la unitat de reintent, timeout i logging. Regla pràctica: una task = un pas que voldries reintentar o veure a la UI per separat.
  • Flow: una funció decorada amb @flow que crida tasks (i opcionalment altres flows). Defineix l'ordre i les dependències — implícites: si la task B rep el resultat de l'A, B espera l'A.
  • Deployment: un flow registrat al servidor de Prefect amb la seva configuració: des d'on s'executa, amb quins paràmetres i amb quin schedule. És el que converteix "una funció al meu repo" en "una cosa que el servidor sap llançar".
  • Schedule: el calendari del deployment; farem servir una expressió cron.
  • Work pool i worker: el work pool és la cua on el servidor deixa la feina pendent; el worker és el procés (en el nostre cas, de tipus process: un simple procés Python en un pod) que escolta aquesta cua i executa els flows. Aquesta separació permet que el servidor no executi res ell mateix: només coordina.
flowchart LR
    S[Servidor Prefect<br/>schedules + estat + UI] -->|"dilluns 06:00: hi ha feina"| WP[(Work pool<br/>cineclick-pool)]
    WP -->|la recull| W[Worker 'process'<br/>pod al clúster]
    W -->|executa| F[flow scoring_setmanal]
    F -->|estats i logs| S

El flow real: flows/scoring_setmanal.py

Traduïm la decisió de 04-01 (scoring batch setmanal per a la campanya de retenció) a un flow amb sis tasks. Cada task reutilitza peces que ja existeixen: el dvc pull del mòdul 2, l'esquema_clients de pandera de 05-01, el champion del registry del mòdul 3 i la predicció vectoritzada de 04-05.

# flows/scoring_setmanal.py
"""Scoring batch setmanal de churn per a la campanya de retenció.
Programat: dilluns 06:00. Sortida: data/prediccions_churn.parquet"""
import hashlib
import subprocess
from datetime import datetime

import mlflow
import pandas as pd
import requests
from prefect import flow, task, get_run_logger

from cineclick_churn.features import construir_features
from cineclick_churn.validacio import esquema_clients

RUTA_DADES = "data/clients_churn.csv"
RUTA_SORTIDA = "data/prediccions_churn.parquet"
URI_CHAMPION = "models:/churn-cineclick@champion"
WEBHOOK_MARKETING = "https://hooks.slack.com/services/XXX/simulat"


@task(retries=3, retry_delay_seconds=[30, 120, 300], timeout_seconds=600)
def descarregar_dades_fresques() -> pd.DataFrame:
    """dvc pull del dataset. Fràgil per xarxa: 3 reintents amb backoff."""
    subprocess.run(["dvc", "pull", RUTA_DADES], check=True)
    return pd.read_csv(RUTA_DADES)


@task  # sense retries: si les dades són invàlides, reintentar no les arregla
def validar_dades(df: pd.DataFrame) -> pd.DataFrame:
    """El mateix esquema pandera de la CI, ara sobre les dades completes."""
    return esquema_clients.validate(df, lazy=True)


@task(retries=2, retry_delay_seconds=60, timeout_seconds=300)
def carregar_champion():
    """Carrega models:/churn-cineclick@champion. Fràgil per xarxa (registry)."""
    return mlflow.sklearn.load_model(URI_CHAMPION)


@task
def predir_lot(model, df: pd.DataFrame) -> pd.DataFrame:
    """Predicció vectoritzada sobre tot el dataframe de cop
    (la lliçó 04-05: res d'iterar files, ~370x més ràpid)."""
    X = construir_features(df.drop(columns=["abandonament"], errors="ignore"))
    probas = model.predict_proba(X)[:, 1]
    return pd.DataFrame({
        "id_client": df["id_client"],
        "probabilitat_abandonament": probas,
        "data_scoring": datetime.now().date().isoformat(),
    })


@task
def escriure_prediccions(prediccions: pd.DataFrame) -> str:
    prediccions.to_parquet(RUTA_SORTIDA, index=False)
    return RUTA_SORTIDA


@task(retries=3, retry_delay_seconds=30)
def notificar_marketing(ruta: str, n_clients: int, n_risc: int) -> None:
    """Webhook (Slack simulat): màrqueting sap que el fitxer està a punt."""
    requests.post(WEBHOOK_MARKETING, json={
        "text": (f"Scoring setmanal de churn a punt: {ruta} — "
                 f"{n_clients} clients, {n_risc} en risc (p >= 0.5).")
    }, timeout=10).raise_for_status()


@flow(name="scoring-setmanal-churn", log_prints=True)
def scoring_setmanal():
    logger = get_run_logger()

    df = descarregar_dades_fresques()
    df = validar_dades(df)
    model = carregar_champion()
    prediccions = predir_lot(model, df)
    ruta = escriure_prediccions(prediccions)

    n_risc = int((prediccions["probabilitat_abandonament"] >= 0.5).sum())
    logger.info("Scoring completat: %s files, %s en risc", len(prediccions), n_risc)
    notificar_marketing(ruta, len(prediccions), n_risc)


if __name__ == "__main__":
    scoring_setmanal()   # executable en local tal qual, sense servidor

Punts que mereixen aturar-s'hi:

  • El flow es llegeix com el procés: descarregar → validar → carregar el champion → predir → escriure → notificar. Les dependències no es declaren en YAML: surten del fet que cada task consumeix el retorn de l'anterior. Si validar_dades falla, res del que ve després no s'executa.
  • Els reintents no són uniformes, i això és deliberat. descarregar_dades_fresques i carregar_champion fallen per xarxa — reintentar té sentit. validar_dades falla perquè les dades estan malament — reintentar la mateixa validació sobre les mateixes dades és perdre temps i enterrar l'alerta; ha de fallar ràpid i alt.
  • Reutilització total: construir_features i esquema_clients són exactament els mateixos objectes que fan servir l'API online, la CI i l'entrenament. El scoring batch no té la seva pròpia lògica de features ni el seu propi contracte de dades — aquella va ser la batalla guanyada al mòdul 3.
  • Executable en local: python flows/scoring_setmanal.py corre el flow complet sense servidor ni worker. Depurar un flow de Prefect és depurar una funció Python — una de les raons de l'elecció.

Fallades: retries amb backoff, timeout i alertes

Anatomia dels reintents

@task(retries=3, retry_delay_seconds=[30, 120, 300], timeout_seconds=600)
  • retries=3: fins a 4 intents en total (l'original més 3).
  • retry_delay_seconds=[30, 120, 300]: espera creixent (backoff) — 30 s, després 2 min, després 5 min. Una fallada transitòria de xarxa sol resoldre's sola; martellejar cada segon només afegeix càrrega al servei que ja està patint.
  • timeout_seconds=600: si el dvc pull es queda penjat (pitjor que fallar: no avisa), al cap de 10 minuts Prefect mata l'intent i compta com a fallada — que al seu torn dispara el reintent següent.

I quan falla de debò

Suposa que el dilluns el remote magatzem està caigut una hora sencera. Els 4 intents s'esgoten (06:00, 06:10 amb timeout + esperes…), la task queda en estat Failed, el flow sencer queda Failed, i passen tres coses:

  1. Estat visible: l'execució apareix en vermell a la UI de Prefect, amb el traceback de cada intent i els seus temps. El dimarts no cal reconstruir què va passar — està escrit.
  2. Alerta: una automation de Prefect ("quan un flow run de scoring-setmanal-churn entri en Failed → notifica al canal #alertes-ml") avisa l'equip. La configuració és declarativa al servidor; no cal programar res al flow. Distingeix-la de notificar_marketing: aquella task és part del procés de negoci (avisar que el parquet està a punt); l'automation és operació (avisar que alguna cosa s'ha trencat).
  3. Reexecució manual barata: arreglat el remote, un clic a "Retry" a la UI (o prefect flow-run retry <id>) rellança l'execució. No cal esperar el dilluns següent ni recordar els arguments.

La conseqüència per a màrqueting també està controlada: com que la notificació només s'envia al final, no reben avís d'un parquet que no existeix. Sense notícia = sense fitxer nou; amb l'alerta interna, l'equip pot avisar màrqueting del retard proactivament.

Programar el dilluns a les 06:00

Amb Prefect 2/3, el deployment es declara des del mateix flow i se serveix amb un worker. Primer, el work pool (una vegada):

prefect work-pool create cineclick-pool --type process

Després, registrar el deployment amb el seu schedule — l'expressió cron 0 6 * * 1 es llegeix: minut 0, hora 6, qualsevol dia del mes, qualsevol mes, dia de la setmana 1 (dilluns):

# flows/desplegar_flows.py
from scoring_setmanal import scoring_setmanal

if __name__ == "__main__":
    scoring_setmanal.deploy(
        name="scoring-setmanal-dilluns",
        work_pool_name="cineclick-pool",
        cron="0 6 * * 1",              # dilluns 06:00
        # la zona horària del schedule es fixa a Europe/Madrid al
        # servidor; sense ella, "06:00" seria UTC i a l'estiu arribaria a les 08:00
    )

I un worker escoltant el pool (en producció, un pod petit del clúster amb el repo instal·lat; en desenvolupament, el teu terminal):

prefect worker start --pool cineclick-pool

Amb això, la decisió presa a 04-01 — "scoring setmanal, dilluns a les 06:00" — per fi es compleix sola: el servidor crea l'execució a l'hora programada, el worker la recull, i si ningú no està mirant… precisament per a això hi ha els estats i les alertes.

Esbós del segon flow: reentrenament

El scoring no és l'únic procés recurrent a l'horitzó: tard o d'hora el model s'haurà de reentrenar amb dades fresques. El flow és estructuralment semblant i reutilitza encara més — embolcalla el pipeline DVC del mòdul 2 sencer:

# flows/reentrenament.py — ESBÓS, es completa al mòdul 6
from prefect import flow, task
import subprocess


@task(retries=2, retry_delay_seconds=60)
def dades_fresques():
    subprocess.run(["dvc", "pull"], check=True)


@task(timeout_seconds=3600)
def reproduir_pipeline():
    """dvc repro executa preparar_dades -> construir_features ->
    entrenar -> avaluar, amb la seva cache d'stages intacta."""
    subprocess.run(["dvc", "repro"], check=True)


@task
def registrar_challenger():
    """Llegeix metrics.json; si supera els llindars de 05-01, registra la
    nova versió a MLflow i li posa l'àlies @challenger.
    MAI @champion: la promoció segueix el flux humà de 05-02."""
    ...


@flow(name="reentrenament-churn")
def reentrenament():
    dades_fresques()
    reproduir_pipeline()
    registrar_challenger()

Fixa't en el que aquest esbós decideix i en el que no decideix. Decideix el com: DVC fa la feina pesada (amb la seva cache d'stages: si les dades no han canviat, preparar_dades ni s'executa), i el resultat arriba com a màxim a @challenger — la promoció a @champion manté l'aprovació humana de 05-02, també quan l'entrenament és automàtic. El que no decideix és el quan: cada mes? Quan la deriva de dades superi un llindar? Quan el rendiment caigui? Aquesta política és matèria de la lliçó 06-03, quan tinguem el monitoratge que la informa. De moment, el flow existeix i es pot llançar a mà des de la UI — que ja és infinitament millor que "la Laura el llança des del seu portàtil".

Orquestrador vs. CI/CD: què va a cada lloc

Amb tres eines d'automatització en joc (CI, CD, orquestrador) és fàcil posar les coses on no toca. El criteri de repartiment és el disparador:

Procés Disparador Eina Per què
Tests de codi/dades/model Push, PR GitHub Actions (CI) Esdeveniment de codi; efímer; per commit
Build i desplegament de la imatge Tag v*.*.* GitHub Actions (CD) Esdeveniment de codi; lliurament de software
Scoring batch setmanal Calendari (dilluns 06:00) Prefect Procés de dades recurrent; necessita retries, estat i alertes
Reentrenament Calendari o senyal de monitoratge (06-03) Prefect Procés de dades llarg, amb dependències i reintents
Promoció de model a @champion Decisió humana Script + revisió (05-02) No és ni esdeveniment de codi ni calendari: és una decisió

La regla mnemotècnica: GitHub Actions respon a "el codi ha canviat"; Prefect respon a "toca processar dades". Els runners d'Actions són efímers i anònims — perfectes per a tests, pèssims per a un procés de negoci l'historial d'execucions del qual vols consultar d'aquí a tres mesos. Podries forçar un schedule: a Actions per al scoring (existeix), però perdries reintents per pas, timeout per pas, reexecució selectiva i la UI d'estats — és a dir, tot el que demanàvem al principi de la lliçó.

Errors Comuns i Consells

  • Un flow d'una sola task gegant. Si scoring_setmanal fos una única task que ho fa tot, una fallada del webhook al final obligaria a repetir també la descàrrega i la predicció. La granularitat de les tasks defineix la granularitat dels reintents: separa el que és fràgil (xarxa) del que és determinista (càlcul).
  • Retries a tot arreu "per si de cas". Reintentar una validació de dades fallida retarda l'alerta 10 minuts per arribar al mateix error. Reintenta el que és transitori (xarxa, serveis externs); falla ràpid en el que és determinista.
  • Oblidar la zona horària del schedule. 0 6 * * 1 en UTC són les 07:00 o les 08:00 a Madrid segons l'època de l'any — i màrqueting esperant des de les 06:00. Fixa explícitament Europe/Madrid al schedule del deployment.
  • Lògica de negoci dins del flow en lloc del paquet. Si predir_lot reimplementés les features "perquè era més còmode", tindríem la tercera còpia de la lògica que el mòdul 3 va unificar. Les tasks orquestren; el paquet cineclick_churn implementa.
  • Confondre la notificació de negoci amb l'alerta de fallada. notificar_marketing avisa de l'èxit a un consumidor; l'automation de Prefect avisa de la fallada a l'equip. Si barreges totes dues a la mateixa task, una fallada del scoring pot acabar notificant "fitxer a punt" a màrqueting o deixant l'equip sense alerta.
  • No provar el flow en local abans de desplegar-lo. El flow és una funció: python flows/scoring_setmanal.py l'executa sencer. Depurar mirant els logs del worker al servidor és la manera lenta.
  • Consell: dona al flow un nom estable i descriptiu (scoring-setmanal-churn) des del primer dia. Les automations, els dashboards i les converses d'incidents pengen d'aquest nom; reanomenar-lo després trenca més del que sembla.

Exercicis

Exercici 1

Màrqueting demana que, a més del parquet, el scoring escrigui un CSV amb només els 500 clients de més probabilitat d'abandonament (data/top_risc.csv), i que si aquest pas extra falla, el flow es consideri igualment exitós (el parquet és el que és crític; el CSV és cortesia). Escriu la task i la seva integració al flow. Pista: investiga return_state=True o la gestió d'excepcions dins del flow.

Exercici 2

El dilluns a les 06:00 el servidor de MLflow està caigut per un manteniment no avisat i torna a les 06:20. Amb el flow tal com està escrit, el scoring de dilluns es completa o falla? Justifica-ho calculant la línia temporal dels intents de carregar_champion (retries=2, retry_delay_seconds=60, timeout_seconds=300) suposant que cada intent contra el servidor caigut falla al cap d'uns ~10 segons i que les tasks prèvies han acabat a les 06:05.

Exercici 3

Un company proposa moure la CI a l'orquestrador: "així ho tenim tot a Prefect, un flow ci que corre ruff i pytest quan algú faci push". Dona tres raons concretes per les quals els tests de la lliçó 05-01 encaixen millor a GitHub Actions que a Prefect.

Solucions

Exercici 1

@task(retries=1, retry_delay_seconds=30)
def escriure_top_risc(prediccions: pd.DataFrame, n: int = 500) -> str:
    top = prediccions.nlargest(n, "probabilitat_abandonament")
    top.to_csv("data/top_risc.csv", index=False)
    return "data/top_risc.csv"

I al flow, després d'escriure_prediccions:

    estat = escriure_top_risc(prediccions, return_state=True)
    if estat.is_failed():
        logger.warning("top_risc.csv no generat; el parquet SÍ que està disponible")

Amb return_state=True la crida retorna l'estat en lloc de propagar l'excepció: la task apareix en vermell a la UI (visibilitat intacta) però el flow continua i acaba en èxit. És la manera idiomàtica de marcar un pas com a "desitjable però no crític". L'alternativa d'un try/except al voltant funcionaria, però l'estat del flux quedaria menys fidel a la UI.

Exercici 2

Es completa. Línia temporal: tasks prèvies llestes a les 06:05. Intent 1 de carregar_champion ≈ 06:05:00, falla al cap d'uns ~10 s (06:05:10). Espera 60 s → intent 2 ≈ 06:06:10, falla (06:06:20). Espera 60 s → intent 3 (l'últim: retries=2 = 3 intents en total) ≈ 06:07:20, falla (06:07:30). Resultat: els tres intents s'esgoten a les 06:07:30 i el servidor no torna fins a les 06:20 — el flow falla, l'automation alerta, i a les 06:20+ algú (o un clic a Retry) el rellança. Moralitat doble: (a) calcula la finestra total que cobreixen els teus reintents (aquí ~2,5 minuts, insuficient per a un manteniment de 20); per a dependències amb caigudes llargues conegudes, un backoff més llarg ([60, 300, 900]) cobreix ~21 minuts; (b) fins i tot quan els reintents no basten, el sistema ha degradat : estat visible, alerta emesa, reexecució d'un clic — compareu-ho amb el cron silenciós de l'inici.

Exercici 3

(1) Disparador: la CI reacciona a push/PR, un esdeveniment que GitHub emet de manera nativa cap a Actions; portar-ho a Prefect exigiria muntar webhooks i perdria la integració amb el flux de revisió. (2) Bloqueig del merge: el valor de la CI és marcar la PR en verd/vermell i bloquejar el merge (branch protection); Prefect no participa en aquest mecanisme — tindries tests que corren però no protegeixen res. (3) Entorn efímer per commit: cada run d'Actions parteix d'un runner net i fa checkout del commit exacte de la PR, que és just el que vols per validar codi; el worker de Prefect és un procés persistent amb una versió del repo instal·lada, pensat per a processos de dades, no per provar N branques concurrents. És la taula de la lliçó en acció: esdeveniment de codi → Actions; procés de dades recurrent → Prefect.

Conclusió

L'últim procés manual de CineClick ja es mou sol: el flow scoring-setmanal-churn (a flows/scoring_setmanal.py) descarrega les dades amb dvc pull, les valida amb el mateix esquema pandera de la CI, carrega models:/churn-cineclick@champion, prediu en lot vectoritzat, escriu data/prediccions_churn.parquet i avisa màrqueting — cada dilluns a les 06:00 (0 6 * * 1, Europe/Madrid) via deployment sobre el work pool cineclick-pool, amb reintents amb backoff on hi ha xarxa, timeout on hi pot haver penjades, i alerta a l'equip quan falla de debò. Vam triar Prefect davant d'Airflow, Dagster i Kubeflow pel que CineClick és: dos pipelines, un equip petit i codi Python que ja funcionava. I ha quedat repartit el mapa de l'automatització: Actions per a esdeveniments de codi, Prefect per a processos de dades, i les decisions — la promoció a @champion, fins i tot des del flow de reentrenament esbossat — per a les persones. El mòdul ja té CI, CD i orquestració; en queda la peça més delicada. Perquè tot el que hem construït desplega el model nou de cop després de staging, i les mètriques offline — per bones que siguin — no garanteixen el que farà amb trànsit real. L'última lliçó del mòdul ensenya a deixar anar un model amb xarxa: shadow, canary i A/B.

© Copyright 2026. Tots els drets reservats