feat(airflow): orchestre l'import de la Mock API

This commit is contained in:
Meryemel-gham
2026-09-22 15:47:01 +02:00
parent 6b3908d321
commit d86224a0f7
8 changed files with 127 additions and 17 deletions
+50
View File
@@ -0,0 +1,50 @@
"""DAG d'import périodique des données de l'API Mock EnerVision (issue #15).
Orchestre le pipeline existant `app.etl.mock_api_import` sans dupliquer sa logique ETL.
Chaque exécution traite l'intervalle horaire Airflow précédent.
Le pipeline backend reste responsable de la validation, de la normalisation, du suivi de la
qualité, de l'idempotence et du chargement dans PostgreSQL/TimescaleDB.
"""
from __future__ import annotations
from datetime import datetime, timedelta
from airflow.providers.standard.operators.bash import BashOperator
from airflow.sdk import DAG
# Le backend possède son propre environnement uv dans l'image Airflow (ADR 0008).
COMMANDE_BACKEND = "cd /opt/backend && env -u VIRTUAL_ENV uv run --no-sync python -m"
# L'API et le pipeline backend plafonnent une réponse à 1 000 lectures par site.
LIMITE_LECTURES = 60
# Deux reprises donnent trois tentatives au total. Même dans le pire cas, l'exécution reste
# inférieure au pas horaire du DAG.
NOMBRE_REPRISES = 2
DELAI_ENTRE_REPRISES = timedelta(minutes=2)
PLAFOND_PAR_TENTATIVE = timedelta(minutes=10)
with DAG(
dag_id="mock_api_import",
description="Importe chaque heure les données de l'API Mock dans site et reading.",
schedule="@hourly",
start_date=datetime(2026, 1, 1),
catchup=False,
# Deux exécutions simultanées pourraient demander et traiter le même intervalle.
max_active_runs=1,
tags=["etl", "mock-api"],
) as dag:
BashOperator(
task_id="import_mock_api",
bash_command=(
f"{COMMANDE_BACKEND} app.etl.mock_api_import "
'--start-time "{{ data_interval_start.isoformat() }}" '
'--end-time "{{ data_interval_end.isoformat() }}" '
f"--limit {LIMITE_LECTURES}"
),
retries=NOMBRE_REPRISES,
retry_delay=DELAI_ENTRE_REPRISES,
execution_timeout=PLAFOND_PAR_TENTATIVE,
)
+44 -1
View File
@@ -10,13 +10,20 @@ from airflow.sdk import BaseOperator
DAGS_FOLDER = Path(__file__).resolve().parent.parent / "dags"
DAG_IDS = ["ml_train", "ml_score", "alertes", "historical_import"]
DAG_IDS = [
"ml_train",
"ml_score",
"alertes",
"historical_import",
"mock_api_import",
]
TACHES = [
("ml_train", "train"),
("ml_score", "score"),
("alertes", "detection"),
("alertes", "recommandations"),
("historical_import", "import_historical"),
("mock_api_import", "import_mock_api"),
]
@@ -52,6 +59,10 @@ def test_historical_import_has_no_schedule(dagbag: DagBag) -> None:
assert dagbag.dags["historical_import"].schedule is None
def test_mock_api_import_runs_every_hour(dagbag: DagBag) -> None:
assert dagbag.dags["mock_api_import"].timetable.expression == "0 * * * *"
def test_ml_train_task_calls_the_training_module(dagbag: DagBag) -> None:
tache = dagbag.dags["ml_train"].get_task("train")
assert "enervision_ml.train" in tache.bash_command
@@ -84,6 +95,20 @@ def test_historical_import_uses_the_expected_source_files(dagbag: DagBag) -> Non
assert "--metadata /opt/data/raw/dataset_metadata.json" in commande
def test_mock_api_import_calls_the_existing_backend_module(dagbag: DagBag) -> None:
commande = dagbag.dags["mock_api_import"].get_task("import_mock_api").bash_command
assert "app.etl.mock_api_import" in commande
def test_mock_api_import_uses_the_airflow_data_interval(dagbag: DagBag) -> None:
commande = dagbag.dags["mock_api_import"].get_task("import_mock_api").bash_command
assert '--start-time "{{ data_interval_start.isoformat() }}"' in commande
assert '--end-time "{{ data_interval_end.isoformat() }}"' in commande
assert "--limit 60" in commande
@pytest.mark.parametrize("task_id", ["detection", "recommandations"])
def test_alertes_tasks_run_in_the_backend_environment(dagbag: DagBag, task_id: str) -> None:
# Le backend a son propre venv dans l'image, distinct de celui de ml/ (ADR 0008).
@@ -95,6 +120,12 @@ def test_historical_import_runs_in_the_backend_environment(dagbag: DagBag) -> No
assert "/opt/backend" in commande
def test_mock_api_import_runs_in_the_backend_environment(dagbag: DagBag) -> None:
commande = dagbag.dags["mock_api_import"].get_task("import_mock_api").bash_command
assert "/opt/backend" in commande
def test_alertes_generates_recommendations_after_detecting(dagbag: DagBag) -> None:
# `recommendation.alert_id` est une cle etrangere `NOT NULL` : la generation n'a rien a lire
# tant que la detection n'a pas ecrit.
@@ -136,6 +167,14 @@ def duree_au_pire(tache: BaseOperator) -> timedelta:
return (tache.retries + 1) * tache.execution_timeout + tache.retries * tache.retry_delay
def test_mock_api_import_worst_case_stays_below_its_hourly_step(
dagbag: DagBag,
) -> None:
tache = dagbag.dags["mock_api_import"].get_task("import_mock_api")
assert duree_au_pire(tache) < timedelta(hours=1)
def test_alertes_worst_case_stays_below_its_hourly_step(dagbag: DagBag) -> None:
# Les deux taches s'enchainent : c'est leur somme, reprises comprises, qui doit tenir dans le
# pas horaire, sinon `max_active_runs=1` fait attendre l'execution suivante.
@@ -159,6 +198,10 @@ def test_historical_import_retries_after_a_transient_failure(dagbag: DagBag) ->
assert dagbag.dags["historical_import"].get_task("import_historical").retries >= 1
def test_mock_api_import_retries_after_a_transient_failure(dagbag: DagBag) -> None:
assert dagbag.dags["mock_api_import"].get_task("import_mock_api").retries >= 1
@pytest.mark.parametrize(("dag_id", "task_id"), TACHES)
def test_tasks_never_resync_the_baked_environment(
dagbag: DagBag, dag_id: str, task_id: str