fix(etl): traite les retours de revue du DAG historique
Airflow / Lint et intégrité des DAGs (push) Successful in 57s
Airflow / Construction de l'image (push) Successful in 2m52s
SonarQube / build-back (push) Successful in 1m17s
SonarQube / test-ml (push) Failing after 1m50s
SonarQube / build-front (push) Successful in 9m44s
SonarQube / test-back (push) Failing after 55s
SonarQube / test-front (push) Failing after 5m3s
SonarQube / SonarQube (push) Skipped
Airflow / Lint et intégrité des DAGs (push) Successful in 57s
Airflow / Construction de l'image (push) Successful in 2m52s
SonarQube / build-back (push) Successful in 1m17s
SonarQube / test-ml (push) Failing after 1m50s
SonarQube / build-front (push) Successful in 9m44s
SonarQube / test-back (push) Failing after 55s
SonarQube / test-front (push) Failing after 5m3s
SonarQube / SonarQube (push) Skipped
This commit is contained in:
@@ -90,10 +90,13 @@ jobs:
|
||||
docker run --rm --network none enervision-airflow:ci
|
||||
bash -c "cd /opt/ml && env -u VIRTUAL_ENV uv run --no-sync python -m enervision_ml.train --help"
|
||||
|
||||
# `--help` valide que le module historique et ses dépendances sont présents dans
|
||||
# l'environnement backend embarqué, sans nécessiter PostgreSQL ni accès réseau.
|
||||
- name: Vérifie que l'import historique s'importe sans réseau
|
||||
# `--help` sort par argparse avant `get_settings()` : ni base ni secret requis, et
|
||||
# l'import des modules prouve que l'environnement /opt/backend est complet.
|
||||
# Les deux commandes du DAG `alertes` et la commande du DAG historique sont couvertes.
|
||||
- name: Vérifie que les trois commandes backend s'importent sans réseau
|
||||
run: >
|
||||
docker run --rm --network none enervision-airflow:ci
|
||||
bash -c "cd /opt/backend
|
||||
&& env -u VIRTUAL_ENV uv run --no-sync python -m app.detection.internal_alerts --help
|
||||
&& env -u VIRTUAL_ENV uv run --no-sync python -m app.cli generate-recommendations --help
|
||||
&& env -u VIRTUAL_ENV uv run --no-sync python -m app.etl.historical_import --help"
|
||||
+78
-320
@@ -53,332 +53,90 @@ Trois pièges sont documentés en tête du `docker-compose.yml`, ils ne se devin
|
||||
|
||||
### Airflow (issues #115, #116 et #119)
|
||||
|
||||
Trois services Airflow sont définis dans `docker-compose.yml`. Les `docker compose profiles` ne
|
||||
sont pas utilisés : le démarrage reste explicite via `make airflow-up` et Airflow ne fait pas
|
||||
partie de la boucle `make dev`.
|
||||
Trois services, `docker compose profiles` non utilisés (démarrage explicite via `make
|
||||
airflow-up`, pas dans `make dev`) :
|
||||
|
||||
| Service | Rôle | Points notables |
|
||||
|---|---|---|
|
||||
| `airflow-init` | Migre la base de métadonnées et crée le compte admin | Conteneur jetable (`restart: "no"`). `webserver` et `scheduler` attendent qu'il se termine avec succès |
|
||||
| `airflow-webserver` | Interface Airflow sur le port `8080` | Avec `LocalExecutor`, il n'exécute aucune tâche lui-même |
|
||||
| `airflow-scheduler` | Planifie et exécute les tâches | Les DAGs tournent en sous-processus avec `LocalExecutor` |
|
||||
| `airflow-init` | Migre la base de métadonnées, crée le compte admin | Conteneur jetable (`restart: "no"`), ne redémarre jamais. `webserver`/`scheduler` attendent qu'il se termine avec succès |
|
||||
| `airflow-webserver` | UI, port `8080` | `LocalExecutor` : n'exécute aucune tâche lui-même |
|
||||
| `airflow-scheduler` | Planifie et **exécute** les tâches (`LocalExecutor`) | Les DAGs y tournent en sous-processus (`uv run --no-sync python -m ...`), c'est lui qui a besoin du volume `airflow_ml_state` |
|
||||
|
||||
`LocalExecutor` exécute les tâches dans le scheduler et non dans le webserver.
|
||||
Construits depuis `etl/airflow/Dockerfile`, contexte `.` (racine du repo, pas `etl/airflow/`) :
|
||||
l'image doit pouvoir `COPY` les sources de `ml/` **et** de `apps/backend/` pour se synchroniser
|
||||
deux environnements Python **3.14** (`/opt/ml/.venv` et `/opt/backend/.venv`, `uv sync --locked` à
|
||||
la construction), distincts du Python 3.12 qui fait tourner Airflow lui-même. Les DAGs shellent
|
||||
vers ces venvs plutôt que d'importer LightGBM, MLflow ou SQLAlchemy dans le process Airflow.
|
||||
Le choix et ses contreparties sont dans
|
||||
l'[ADR 0008](../adr/0008-airflow-execute-le-code-du-backend.md).
|
||||
|
||||
Les services sont construits depuis :
|
||||
|
||||
```text
|
||||
etl/airflow/Dockerfile
|
||||
```
|
||||
|
||||
avec la racine du dépôt comme contexte Docker.
|
||||
|
||||
Airflow 2.10.4 fonctionne avec Python 3.12, tandis que le pipeline ML et le backend utilisent des
|
||||
dépendances Python 3.14.
|
||||
|
||||
L'image Airflow embarque donc deux environnements distincts :
|
||||
|
||||
```text
|
||||
/opt/ml/.venv
|
||||
/opt/backend/.venv
|
||||
```
|
||||
|
||||
Le premier contient le pipeline Machine Learning.
|
||||
|
||||
Le second contient le backend EnerVision utilisé par les DAGs `alertes` et
|
||||
`historical_import`.
|
||||
|
||||
Cette séparation évite d'installer directement LightGBM, MLflow ou les dépendances SQLAlchemy du
|
||||
backend dans l'environnement Python utilisé par Airflow.
|
||||
|
||||
Le choix est décrit dans
|
||||
[l'ADR 0008](../adr/0008-airflow-execute-le-code-du-backend.md).
|
||||
|
||||
Les DAGs actuellement présents sont :
|
||||
|
||||
| DAG | Planification | Ce qu'il exécute |
|
||||
| DAG | Planification | Ce qu'il lance, et où |
|
||||
|---|---|---|
|
||||
| `ml_train` | manuelle | `enervision_ml.train` dans `/opt/ml/.venv` |
|
||||
| `ml_score` | `0 * * * *` | `enervision_ml.score` dans `/opt/ml/.venv` |
|
||||
| `alertes` | `15 * * * *` | `app.detection.internal_alerts` puis `app.cli generate-recommendations` dans `/opt/backend/.venv` |
|
||||
| `historical_import` | manuelle | `app.etl.historical_import` dans `/opt/backend/.venv` |
|
||||
|
||||
#### Import historique
|
||||
|
||||
Le DAG :
|
||||
|
||||
```text
|
||||
historical_import
|
||||
```
|
||||
|
||||
est défini dans :
|
||||
|
||||
```text
|
||||
etl/airflow/dags/historical_import.py
|
||||
```
|
||||
|
||||
Il ne contient aucune logique d'import propre.
|
||||
|
||||
Il utilise un `BashOperator` pour exécuter le module backend existant :
|
||||
|
||||
```text
|
||||
app.etl.historical_import
|
||||
```
|
||||
|
||||
dans l'environnement :
|
||||
|
||||
```text
|
||||
/opt/backend/.venv
|
||||
```
|
||||
|
||||
Le flux est donc :
|
||||
|
||||
```text
|
||||
Airflow scheduler
|
||||
|
|
||||
v
|
||||
historical_import
|
||||
|
|
||||
v
|
||||
BashOperator
|
||||
|
|
||||
v
|
||||
app.etl.historical_import
|
||||
|
|
||||
v
|
||||
PostgreSQL / TimescaleDB
|
||||
```
|
||||
|
||||
Les fichiers historiques locaux sont montés en lecture seule dans les services Airflow :
|
||||
|
||||
```text
|
||||
./data/raw:/opt/data/raw:ro
|
||||
```
|
||||
|
||||
Les chemins utilisés depuis le conteneur sont :
|
||||
|
||||
```text
|
||||
/opt/data/raw/all_sites_combined.csv
|
||||
/opt/data/raw/dataset_metadata.json
|
||||
```
|
||||
|
||||
Le montage en lecture seule empêche les traitements Airflow de modifier les fichiers sources.
|
||||
|
||||
Le dataset historique sert uniquement à initialiser les données de l'environnement.
|
||||
|
||||
Le DAG utilise donc :
|
||||
|
||||
```text
|
||||
schedule = None
|
||||
catchup = False
|
||||
max_active_runs = 1
|
||||
```
|
||||
|
||||
Il est déclenché manuellement.
|
||||
|
||||
`max_active_runs = 1` empêche deux imports du même dataset de s'exécuter simultanément.
|
||||
|
||||
La tâche possède également :
|
||||
|
||||
```text
|
||||
retries = 1
|
||||
retry_delay = 2 minutes
|
||||
execution_timeout = 30 minutes
|
||||
```
|
||||
|
||||
Le traitement historique étant idempotent, une reprise après une erreur transitoire ne doit pas
|
||||
créer de doublons.
|
||||
|
||||
Deux exécutions manuelles successives ont été validées avec succès.
|
||||
|
||||
Après les deux exécutions, PostgreSQL/TimescaleDB contenait toujours :
|
||||
|
||||
```text
|
||||
122647
|
||||
```
|
||||
|
||||
lectures avec :
|
||||
|
||||
```text
|
||||
source = "csv"
|
||||
```
|
||||
|
||||
La deuxième exécution n'a donc pas dupliqué les lectures historiques.
|
||||
|
||||
#### DAG alertes
|
||||
|
||||
Le DAG `alertes` est planifié à la quinzième minute de chaque heure.
|
||||
|
||||
La règle `anomaly` compare une lecture à la `prediction` du même instant, que `ml_score` écrit à
|
||||
l'heure pile. Le décalage laisse donc du temps au scoring pour terminer.
|
||||
|
||||
Aucune dépendance Airflow explicite n'est cependant déclarée entre `ml_score` et `alertes`.
|
||||
Quatre règles de détection sur cinq ne dépendent pas du modèle, et l'absence d'un modèle entraîné
|
||||
ne doit pas empêcher les autres alertes d'être produites.
|
||||
|
||||
Les deux tâches du DAG `alertes` s'enchaînent :
|
||||
|
||||
```text
|
||||
detection
|
||||
|
|
||||
v
|
||||
recommandations
|
||||
```
|
||||
|
||||
`recommendation.alert_id` étant une clé étrangère `NOT NULL`, la génération des recommandations
|
||||
est exécutée après la détection.
|
||||
|
||||
Les traitements sont idempotents en base grâce aux contraintes :
|
||||
|
||||
```text
|
||||
uq_alert_source_reference
|
||||
uq_recommendation_alert_rule
|
||||
```
|
||||
|
||||
Chaque tâche possède deux tentatives, deux minutes d'attente entre les tentatives et un plafond
|
||||
de cinq minutes par tentative.
|
||||
|
||||
#### DAGs ML
|
||||
|
||||
`ml_train` reste manuel.
|
||||
|
||||
Réentraîner le modèle est coûteux et `train.py` remplace actuellement le modèle existant sans
|
||||
comparer automatiquement les métriques du nouveau modèle avec celles du précédent.
|
||||
|
||||
Tant que ce mécanisme de sélection n'existe pas, le réentraînement reste déclenché humainement.
|
||||
|
||||
`ml_score` est planifié toutes les heures et réutilise le modèle produit par `ml_train`.
|
||||
|
||||
Il utilise :
|
||||
|
||||
```text
|
||||
max_active_runs = 1
|
||||
retries = 2
|
||||
execution_timeout = 30 minutes
|
||||
```
|
||||
|
||||
Deux scorings ne peuvent donc pas s'exécuter simultanément sur les mêmes données.
|
||||
|
||||
#### Configuration Airflow
|
||||
|
||||
`airflow-init` s'appuie sur l'entrypoint de l'image Airflow avec :
|
||||
|
||||
```text
|
||||
_AIRFLOW_DB_MIGRATE
|
||||
_AIRFLOW_WWW_USER_*
|
||||
```
|
||||
|
||||
Une migration de la base Airflow qui échoue fait échouer `airflow-init`.
|
||||
|
||||
Le `webserver` et le `scheduler` dépendent du succès de ce service et ne démarrent donc pas sur
|
||||
une base de métadonnées non initialisée.
|
||||
|
||||
Les variables Airflow sont fournies depuis le fichier `.env`.
|
||||
|
||||
Les secrets ne sont pas passés dans les arguments des processus.
|
||||
|
||||
Les variables `AIRFLOW_*` ne sont volontairement pas déclarées avec `${VAR:?}` dans le bloc
|
||||
commun de Docker Compose : Compose interpole le fichier complet même lorsqu'un seul service est
|
||||
démarré.
|
||||
|
||||
La validation des secrets nécessaires est réalisée par `airflow-init`.
|
||||
|
||||
Le scheduler reçoit également les variables nécessaires aux traitements backend :
|
||||
|
||||
```text
|
||||
DATABASE_URL
|
||||
APP_SECRET_KEY
|
||||
```
|
||||
|
||||
`APP_SECRET_KEY` est alimentée par :
|
||||
|
||||
```text
|
||||
AIRFLOW_APP_SECRET_KEY
|
||||
```
|
||||
|
||||
Cette clé est distincte de celle utilisée par l'API EnerVision.
|
||||
|
||||
#### Base de métadonnées Airflow
|
||||
|
||||
Airflow utilise une base PostgreSQL dédiée :
|
||||
|
||||
```text
|
||||
airflow
|
||||
```
|
||||
|
||||
Elle est créée lors de l'initialisation de PostgreSQL par :
|
||||
|
||||
```text
|
||||
db/init/120-airflow-database.sql
|
||||
```
|
||||
|
||||
Sur un volume `pgdata` déjà existant, les scripts de `docker-entrypoint-initdb.d` ne sont pas
|
||||
rejoués automatiquement.
|
||||
|
||||
Dans ce cas, la base peut être créée manuellement une fois :
|
||||
|
||||
```powershell
|
||||
docker compose exec db psql -U enervision -d enervision -c "CREATE DATABASE airflow;"
|
||||
```
|
||||
|
||||
#### CI Airflow
|
||||
|
||||
Le workflow :
|
||||
|
||||
```text
|
||||
.github/workflows/airflow.yml
|
||||
```
|
||||
|
||||
utilise Python 3.12 via :
|
||||
|
||||
```text
|
||||
etl/airflow/.python-version
|
||||
```
|
||||
|
||||
Il vérifie :
|
||||
|
||||
```text
|
||||
formatage Ruff
|
||||
analyse statique Ruff
|
||||
tests d'intégrité des DAGs
|
||||
construction de l'image Airflow
|
||||
```
|
||||
|
||||
La construction de l'image embarque :
|
||||
|
||||
```text
|
||||
ml/
|
||||
apps/backend/
|
||||
```
|
||||
|
||||
Une modification de ces composants peut donc casser l'image Airflow.
|
||||
|
||||
La CI vérifie également sans accès réseau que les commandes utilisées par les DAGs sont
|
||||
importables depuis leurs environnements respectifs.
|
||||
|
||||
Pour l'import historique, elle exécute notamment :
|
||||
|
||||
```text
|
||||
python -m app.etl.historical_import --help
|
||||
```
|
||||
|
||||
depuis `/opt/backend`.
|
||||
|
||||
Cette vérification permet de détecter une dépendance backend manquante ou un environnement Docker
|
||||
incomplet sans avoir besoin de démarrer PostgreSQL.
|
||||
|
||||
#### Dépendance système LightGBM
|
||||
|
||||
`libgomp1` est installé explicitement dans l'image Airflow.
|
||||
|
||||
LightGBM dépend de cette bibliothèque OpenMP.
|
||||
|
||||
Sans elle, l'image Docker pourrait être construite correctement mais l'import de LightGBM
|
||||
échouerait au moment de l'exécution avec une erreur liée à :
|
||||
|
||||
```text
|
||||
libgomp.so.1
|
||||
```
|
||||
| `ml_train` | manuelle | `enervision_ml.train`, dans `/opt/ml/.venv` |
|
||||
| `ml_score` | `0 * * * *` | `enervision_ml.score`, dans `/opt/ml/.venv` |
|
||||
| `alertes` | `15 * * * *` | `app.detection.internal_alerts` puis `app.cli generate-recommendations`, dans `/opt/backend/.venv` |
|
||||
| `historical_import` | manuelle | `app.etl.historical_import`, dans `/opt/backend/.venv` ; les fichiers de `data/raw` sont montés en lecture seule dans `/opt/data/raw` |
|
||||
|
||||
Le DAG `historical_import` réutilise le pipeline historique existant sans dupliquer sa logique.
|
||||
Il reste manuel, car le dataset sert à initialiser l'environnement. Le montage
|
||||
`./data/raw:/opt/data/raw:ro` permet au scheduler de lire les fichiers CSV/JSON sans pouvoir les
|
||||
modifier.
|
||||
|
||||
**Pourquoi `alertes` tourne à la quinzième minute.** Sa règle `anomaly` compare une lecture à la
|
||||
`prediction` du même instant, que `ml_score` écrit à l'heure pile. Le décalage laisse le scoring
|
||||
finir. Aucune dépendance n'est déclarée entre les deux DAGs pour autant, ni `ExternalTaskSensor` ni
|
||||
tâche greffée : quatre règles de détection sur cinq ne touchent pas au modèle, et un modèle jamais
|
||||
entraîné ne doit pas priver le parc de ses alertes. Le décalage est donc une convention et non une
|
||||
garantie : le plafond de `ml_score` est de 30 minutes, et un scoring qui déborde de `:15` prive
|
||||
`anomaly` de la `prediction` de l'heure, qu'elle ne retrouvera au passage suivant que si sa fenêtre
|
||||
la couvre encore. Les quatre autres règles ne s'en aperçoivent pas.
|
||||
|
||||
Ses deux tâches s'enchaînent en revanche (`recommendation.alert_id` est une clé étrangère `NOT
|
||||
NULL`), et toutes deux sont rejouables sans risque : l'idempotence est portée par la base,
|
||||
`uq_alert_source_reference` et `uq_recommendation_alert_rule`. Chacune a 2 tentatives, 2 minutes
|
||||
d'attente entre elles et un plafond de 5 minutes **par tentative** : au pire, reprises comprises,
|
||||
l'enchaînement occupe 38 minutes, ce qui le garde sous le pas horaire qu'un `max_active_runs=1`
|
||||
rend contraignant.
|
||||
|
||||
`airflow-init` s'appuie sur l'entrypoint de l'image (`_AIRFLOW_DB_MIGRATE`,
|
||||
`_AIRFLOW_WWW_USER_*`) plutôt que sur un script maison : l'entrypoint porte le code de sortie, une
|
||||
migration ratée (typiquement la base `airflow` absente, cf. ci-dessous) fait échouer le service et
|
||||
`webserver`/`scheduler` ne démarrent pas sur une base non migrée. Le mot de passe du compte admin
|
||||
passe par l'environnement, jamais par `argv` (ni `ps`, ni `docker compose config`).
|
||||
|
||||
Les variables `AIRFLOW_*` ne sont volontairement pas en `${VAR:?}` : Compose interpole le fichier
|
||||
entier avant de filtrer les services, une variable requise manquante casserait `make db-up`,
|
||||
`make dev`... pour tout poste dont le `.env` est antérieur. Elles valent `${VAR:-}` et c'est
|
||||
`airflow-init` qui refuse de démarrer (clé Fernet, clé Flask, mot de passe ou
|
||||
`AIRFLOW_APP_SECRET_KEY` vides).
|
||||
|
||||
Le conteneur reçoit deux variables du backend en plus de `ML_DATABASE_URL` : `DATABASE_URL`, en
|
||||
dialecte asyncpg, et `APP_SECRET_KEY`, alimentée par `AIRFLOW_APP_SECRET_KEY`. Cette dernière est
|
||||
**délibérément différente** de celle de l'API. La configuration du backend refuse de se construire
|
||||
sans clé, mais la détection ne signe ni ne vérifie aucun jeton : un Airflow compromis, qui permet
|
||||
déjà d'exécuter du code depuis son interface, ne doit pas livrer par-dessus la clé de signature
|
||||
des JWT.
|
||||
|
||||
**Pourquoi `ml_train` est manuel.** Réentraîner est coûteux et sa cadence n'est pas une décision
|
||||
prise. Surtout, `train.py` écrase le modèle sans comparer ses métriques à celles de l'ancien : un
|
||||
cron déploierait silencieusement un modèle dégradé. Tant que ce garde-fou n'existe pas, le
|
||||
déclenchement reste humain. `ml_score`, lui, est planifié à l'heure, avec `max_active_runs=1`
|
||||
(pas deux scorings simultanés dans `prediction`), 2 tentatives et un plafond de 30 minutes.
|
||||
|
||||
CI : `.github/workflows/airflow.yml` (Python 3.12 via `etl/airflow/.python-version`) lance lint et
|
||||
tests d'intégrité des DAGs, et construit l'image (elle `COPY` `ml/` et `apps/backend/`, une
|
||||
modification de l'un ou de l'autre peut donc la casser, d'où leurs chemins dans les déclencheurs)
|
||||
avant de vérifier que les deux environnements s'y importent sans réseau.
|
||||
|
||||
Piège à connaître : sur un volume `pgdata` déjà peuplé (poste de dev existant plutôt que premier
|
||||
`make db-up`), `db/init/120-airflow-database.sql` ne se rejoue pas (PostgreSQL n'exécute
|
||||
`docker-entrypoint-initdb.d/` que sur un volume vide). Créer la base `airflow` à la main une fois :
|
||||
`docker compose exec db psql -U $POSTGRES_USER -d $POSTGRES_DB -c "CREATE DATABASE airflow;"`.
|
||||
|
||||
`libgomp1` est installé explicitement dans l'image (`apt-get`, en root) : l'image Airflow de base
|
||||
est minimale et n'embarque pas la runtime OpenMP dont LightGBM a besoin, sans quoi l'erreur
|
||||
(`OSError: libgomp.so.1`) n'apparaît qu'à la première tâche réellement exécutée, pas à la
|
||||
construction de l'image.
|
||||
|
||||
## Machine cible, exécution Docker
|
||||
|
||||
|
||||
+12
-186
@@ -639,7 +639,7 @@ La suite backend complète a également été validée avec une couverture supé
|
||||
|
||||
## Suite du pipeline Data
|
||||
|
||||
Deux sources de données sont prises en charge par la logique ETL du backend :
|
||||
Deux sources de données sont maintenant prises en charge :
|
||||
|
||||
```text
|
||||
Dataset CSV/JSON
|
||||
@@ -661,192 +661,18 @@ mock_api_import.py
|
||||
API Mock
|
||||
```
|
||||
|
||||
La logique d'extraction, de validation, de transformation et de chargement est disponible pour
|
||||
les deux sources de données du MVP.
|
||||
La logique d'extraction, de transformation et de chargement est donc disponible pour les deux sources de données du MVP.
|
||||
|
||||
Airflow tourne réellement dans `etl/airflow/` et orchestre désormais quatre DAGs :
|
||||
Airflow tourne désormais réellement (`etl/airflow/`, `make airflow-up`) et orchestre le pipeline
|
||||
ML (`ml_train`/`ml_score`, issue #115), la détection d'alertes et la génération des
|
||||
recommandations (`alertes`, issue #116), ainsi que l'import historique
|
||||
(`historical_import`, issue #119).
|
||||
|
||||
```text
|
||||
ml_train
|
||||
ml_score
|
||||
alertes
|
||||
historical_import
|
||||
```
|
||||
Le DAG `historical_import` est déclenché manuellement. Il exécute
|
||||
`app.etl.historical_import` avec les fichiers montés en lecture seule depuis `data/raw` vers
|
||||
`/opt/data/raw`. L'orchestration de l'import API Mock et la réconciliation globale des deux
|
||||
sources restent couvertes par l'issue #15.
|
||||
|
||||
Les DAGs `ml_train` et `ml_score` orchestrent le pipeline Machine Learning (issue #115).
|
||||
Airflow permet de planifier les traitements, gérer leur ordre d'exécution, suivre leur état et remonter les erreurs. Il ne remplace pas la logique ETL Python existante : les scripts actuels restent responsables de l'extraction, de la validation, de la transformation et du chargement. `etl/airflow/dags/ml_train.py`, `ml_score.py` et `alertes.py` et `historical_import.py` montrent le patron retenu (des `BashOperator` qui invoquent le script tel quel, dans l'environnement `uv` que l'image embarque pour lui).
|
||||
|
||||
Le DAG `alertes` orchestre la détection des alertes et la génération des recommandations
|
||||
(issue #116).
|
||||
|
||||
Le DAG `historical_import` orchestre l'import du dataset historique CSV/JSON (issue #119).
|
||||
|
||||
### Orchestration de l'import historique
|
||||
|
||||
Le DAG historique est défini dans :
|
||||
|
||||
```text
|
||||
etl/airflow/dags/historical_import.py
|
||||
```
|
||||
|
||||
Il ne réimplémente aucune logique ETL. Il déclenche directement le module existant :
|
||||
|
||||
```text
|
||||
app.etl.historical_import
|
||||
```
|
||||
|
||||
Le flux d'exécution est le suivant :
|
||||
|
||||
```text
|
||||
data/raw/
|
||||
├── all_sites_combined.csv
|
||||
└── dataset_metadata.json
|
||||
|
|
||||
v
|
||||
Airflow
|
||||
|
|
||||
v
|
||||
DAG historical_import
|
||||
|
|
||||
v
|
||||
BashOperator
|
||||
|
|
||||
v
|
||||
app.etl.historical_import
|
||||
|
|
||||
v
|
||||
PostgreSQL / TimescaleDB
|
||||
|
|
||||
+--> dataset
|
||||
+--> site
|
||||
+--> reading
|
||||
```
|
||||
|
||||
Les fichiers historiques locaux sont montés dans les conteneurs Airflow en lecture seule :
|
||||
|
||||
```text
|
||||
./data/raw:/opt/data/raw:ro
|
||||
```
|
||||
|
||||
Le DAG utilise les chemins suivants :
|
||||
|
||||
```text
|
||||
/opt/data/raw/all_sites_combined.csv
|
||||
/opt/data/raw/dataset_metadata.json
|
||||
```
|
||||
|
||||
Le montage en lecture seule évite qu'un traitement Airflow puisse modifier les fichiers sources.
|
||||
|
||||
Le backend est déjà embarqué dans l'image Airflow dans son propre environnement Python :
|
||||
|
||||
```text
|
||||
/opt/backend/.venv
|
||||
```
|
||||
|
||||
Le DAG utilise un `BashOperator` avec le même principe que le DAG `alertes` :
|
||||
|
||||
```text
|
||||
cd /opt/backend
|
||||
env -u VIRTUAL_ENV
|
||||
uv run --no-sync python -m app.etl.historical_import
|
||||
```
|
||||
|
||||
Airflow reste ainsi responsable de l'orchestration tandis que le backend reste responsable de
|
||||
l'extraction, de la validation, de la transformation et du chargement.
|
||||
|
||||
### Planification
|
||||
|
||||
Le dataset historique sert à initialiser l'environnement et n'est pas une source périodique.
|
||||
|
||||
Le DAG est donc configuré avec :
|
||||
|
||||
```text
|
||||
schedule = None
|
||||
catchup = False
|
||||
max_active_runs = 1
|
||||
```
|
||||
|
||||
Le déclenchement est manuel depuis l'interface Airflow ou avec la CLI.
|
||||
|
||||
`max_active_runs = 1` empêche deux imports historiques de s'exécuter simultanément.
|
||||
|
||||
La tâche `import_historical` définit également :
|
||||
|
||||
```text
|
||||
retries = 1
|
||||
retry_delay = 2 minutes
|
||||
execution_timeout = 30 minutes
|
||||
```
|
||||
|
||||
Le retry permet de reprendre le traitement après une erreur transitoire, notamment une
|
||||
indisponibilité temporaire de PostgreSQL.
|
||||
|
||||
Le pipeline historique étant idempotent, une nouvelle exécution ne doit pas dupliquer les mesures
|
||||
déjà présentes.
|
||||
|
||||
### Déclenchement et suivi
|
||||
|
||||
Le DAG peut être déclenché avec :
|
||||
|
||||
```powershell
|
||||
docker compose exec airflow-scheduler airflow dags trigger historical_import
|
||||
```
|
||||
|
||||
Les exécutions peuvent être consultées avec :
|
||||
|
||||
```powershell
|
||||
docker compose exec airflow-scheduler airflow dags list-runs -d historical_import
|
||||
```
|
||||
|
||||
### Validation de l'orchestration
|
||||
|
||||
L'orchestration a été validée localement avec Docker Compose et le `LocalExecutor` Airflow.
|
||||
|
||||
Le scheduler détecte les quatre DAGs :
|
||||
|
||||
```text
|
||||
alertes
|
||||
historical_import
|
||||
ml_score
|
||||
ml_train
|
||||
```
|
||||
|
||||
Deux exécutions manuelles successives du DAG `historical_import` ont été réalisées.
|
||||
|
||||
Les deux exécutions se sont terminées avec :
|
||||
|
||||
```text
|
||||
state = success
|
||||
```
|
||||
|
||||
Les exécutions ont été traitées séquentiellement.
|
||||
|
||||
Après les deux exécutions, PostgreSQL/TimescaleDB contenait toujours :
|
||||
|
||||
```text
|
||||
122647
|
||||
```
|
||||
|
||||
lectures historiques avec :
|
||||
|
||||
```text
|
||||
source = "csv"
|
||||
```
|
||||
|
||||
Le nombre de lectures n'a donc pas doublé après la seconde exécution.
|
||||
|
||||
Cette validation confirme que l'orchestration Airflow réutilise correctement
|
||||
`app.etl.historical_import` et conserve l'idempotence du pipeline historique.
|
||||
|
||||
### Suite
|
||||
|
||||
L'import de l'API Mock est déjà disponible côté backend avec :
|
||||
|
||||
```text
|
||||
app.etl.mock_api_import
|
||||
```
|
||||
|
||||
Son orchestration Airflow ainsi que la réconciliation globale entre les sources historique et
|
||||
API Mock restent à compléter dans l'issue #15.
|
||||
|
||||
Airflow ne remplace pas les pipelines Python existants : il orchestre leur exécution, leur
|
||||
planification, les reprises sur erreur et leur suivi.
|
||||
Le pipeline Data servira ensuite à préparer les données nécessaires au modèle de Machine Learning.
|
||||
|
||||
@@ -18,6 +18,8 @@ COMMANDE_BACKEND = "cd /opt/backend && env -u VIRTUAL_ENV uv run --no-sync pytho
|
||||
|
||||
CSV_PATH = "/opt/data/raw/all_sites_combined.csv"
|
||||
METADATA_PATH = "/opt/data/raw/dataset_metadata.json"
|
||||
SOURCE_TIMEZONE = "UTC"
|
||||
BATCH_SIZE = 1000
|
||||
|
||||
with DAG(
|
||||
dag_id="historical_import",
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
"""Tests d'integrite des DAGs : s'importent sans erreur et ont la structure attendue.
|
||||
|
||||
Les tests ne lancent pas reellement les traitements : ils valident uniquement la definition
|
||||
des DAGs et leurs commandes.
|
||||
"""
|
||||
"""Tests d'integrite des DAGs : s'importent sans erreur, structure attendue. Pas d'execution
|
||||
reelle des taches (ca reclamerait le conteneur avec `uv`/`enervision_ml`), juste la definition."""
|
||||
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
@@ -14,7 +11,6 @@ from airflow.models.dagbag import DagBag
|
||||
DAGS_FOLDER = Path(__file__).resolve().parent.parent / "dags"
|
||||
|
||||
DAG_IDS = ["ml_train", "ml_score", "alertes", "historical_import"]
|
||||
|
||||
TACHES = [
|
||||
("ml_train", "train"),
|
||||
("ml_score", "score"),
|
||||
@@ -42,10 +38,13 @@ def test_ml_train_has_no_schedule(dagbag: DagBag) -> None:
|
||||
|
||||
|
||||
def test_ml_score_runs_every_hour(dagbag: DagBag) -> None:
|
||||
# `@hourly` est un alias Airflow pour ce cron, c'est sous cette forme que `.summary` le rend.
|
||||
assert dagbag.dags["ml_score"].timetable.summary == "0 * * * *"
|
||||
|
||||
|
||||
def test_alertes_runs_after_the_hourly_scoring(dagbag: DagBag) -> None:
|
||||
# Le decalage n'est pas cosmetique : la regle `anomaly` compare une lecture a la `prediction`
|
||||
# du meme instant, que `ml_score` ecrit a l'heure pile.
|
||||
assert dagbag.dags["alertes"].timetable.summary == "15 * * * *"
|
||||
|
||||
|
||||
@@ -87,6 +86,7 @@ def test_historical_import_uses_the_expected_source_files(dagbag: DagBag) -> Non
|
||||
|
||||
@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).
|
||||
assert "/opt/backend" in dagbag.dags["alertes"].get_task(task_id).bash_command
|
||||
|
||||
|
||||
@@ -96,6 +96,8 @@ def test_historical_import_runs_in_the_backend_environment(dagbag: DagBag) -> No
|
||||
|
||||
|
||||
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.
|
||||
assert dagbag.dags["alertes"].get_task("detection").downstream_task_ids == {"recommandations"}
|
||||
|
||||
|
||||
@@ -110,11 +112,14 @@ def test_ml_score_reuses_the_model_path_written_by_ml_train(dagbag: DagBag) -> N
|
||||
|
||||
@pytest.mark.parametrize("dag_id", DAG_IDS)
|
||||
def test_no_two_runs_of_a_dag_overlap(dagbag: DagBag, dag_id: str) -> None:
|
||||
# Deux entrainements ecriraient le meme fichier modele, deux scorings inseriraient en meme
|
||||
# temps dans `prediction`, deux detections analyseraient la meme fenetre.
|
||||
assert dagbag.dags[dag_id].max_active_runs == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("dag_id", "task_id"), TACHES)
|
||||
def test_every_task_has_an_execution_timeout(dagbag: DagBag, dag_id: str, task_id: str) -> None:
|
||||
# Sans plafond, une connexion pendue immobilise un slot du scheduler indefiniment.
|
||||
assert dagbag.dags[dag_id].get_task(task_id).execution_timeout is not None
|
||||
|
||||
|
||||
@@ -125,11 +130,15 @@ def test_ml_score_execution_timeout_stays_below_its_hourly_step(dagbag: DagBag)
|
||||
|
||||
|
||||
def duree_au_pire(tache: BaseOperator) -> timedelta:
|
||||
# `execution_timeout` plafonne une tentative, pas la tache : deux reprises occupent trois
|
||||
# plafonds et deux delais d'attente.
|
||||
assert tache.execution_timeout is not None
|
||||
return (tache.retries + 1) * tache.execution_timeout + tache.retries * tache.retry_delay
|
||||
|
||||
|
||||
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.
|
||||
taches = [
|
||||
dagbag.dags["alertes"].get_task(task_id) for task_id in ("detection", "recommandations")
|
||||
]
|
||||
@@ -142,6 +151,7 @@ def test_ml_score_retries_after_a_transient_failure(dagbag: DagBag) -> None:
|
||||
|
||||
@pytest.mark.parametrize("task_id", ["detection", "recommandations"])
|
||||
def test_alertes_retries_after_a_transient_failure(dagbag: DagBag, task_id: str) -> None:
|
||||
# Les deux commandes sont idempotentes en base, une reprise ne duplique rien.
|
||||
assert dagbag.dags["alertes"].get_task(task_id).retries >= 1
|
||||
|
||||
|
||||
@@ -153,4 +163,5 @@ def test_historical_import_retries_after_a_transient_failure(dagbag: DagBag) ->
|
||||
def test_tasks_never_resync_the_baked_environment(
|
||||
dagbag: DagBag, dag_id: str, task_id: str
|
||||
) -> None:
|
||||
# Sans `--no-sync`, `uv run` reconstruit le projet a chaque execution.
|
||||
assert "--no-sync" in dagbag.dags[dag_id].get_task(task_id).bash_command
|
||||
|
||||
Reference in New Issue
Block a user