docs(etl): documente l'orchestration de l'import historique

This commit is contained in:
Meryemel-gham
2026-09-21 14:41:38 +02:00
parent ae4082c584
commit cb4df846cb
4 changed files with 506 additions and 75 deletions
+2 -2
View File
@@ -21,7 +21,7 @@ Ce que la documentation apporte à chacun : [docs/architecture/00-vue-ensemble.m
| Backend | FastAPI, Python 3.14 | `apps/backend` | Initialise |
| Frontend | Angular 22, Node 24 LTS | `apps/frontend` | Tableau de bord |
| Base | PostgreSQL 17 + TimescaleDB | `db` | Initialise |
| ETL | Apache Airflow | `etl/airflow` | Trois DAGs |
| ETL | Apache Airflow | `etl/airflow` | Quatre DAGs |
| Infra | Terraform (k3s single-node) | `infra/terraform` | Initialise |
| Reverse proxy | Nginx, TLS | `infra/proxy` | En place |
| CI/CD | GitHub Actions | `.github/workflows` | Backend en place |
@@ -48,7 +48,7 @@ L'etat detaille de chaque brique et les vues d'architecture sont dans
│ ├── migrations/ Migrations SQL versionnees
│ └── seeds/ Jeux de donnees de reference
├── etl/airflow/
│ ├── dags/ DAGs d'orchestration (pipeline ML, alertes)
│ ├── dags/ DAGs d'orchestration (pipeline ML, alertes, import historique)
│ ├── plugins/ Operateurs et hooks maison
│ ├── include/ Requetes SQL et ressources des DAGs
│ └── tests/ Tests d'integrite des DAGs
+5 -4
View File
@@ -70,10 +70,11 @@ Le lien `front -.-> api` reste en pointillé : le frontend appelle bien une API,
intercepteur répond à sa place tant que les endpoints n'existent pas. Voir
[30-frontend.md](30-frontend.md).
Le lien `airflow --> db` est maintenant en trait plein : trois DAGs tournent, deux pour
Le lien `airflow --> db` est maintenant en trait plein : quatre DAGs tournent, deux pour
l'entraînement et le scoring du modèle ML (issue #115), un pour la détection d'alertes et la
génération des recommandations (issue #116), cf. plus bas et [20-backend.md](20-backend.md). Le
reste du périmètre Airflow envisagé (ingestion, issues #15/#16) reste en pointillé, non construit.
génération des recommandations (issue #116), et `historical_import` pour l'ingestion du dataset
historique (issue #119). L'orchestration de l'import API Mock et la réconciliation globale des
deux sources restent à compléter dans l'issue #15.
Le lien `prom -.-> api` de même : l'API expose bien `/metrics` au format Prometheus, mais aucun
collecteur ne vient le lire.
@@ -88,7 +89,7 @@ collecteur ne vient le lire.
| ML | LightGBM, MLflow | `ml` | `En cours` | Pipeline d'entraînement et de scoring (`enervision_ml.train`/`.score`, features par lags/moyennes glissantes partagées entre les deux, baseline de persistance saisonnière, suivi MLflow local), exposé en lecture via `GET /predictions`, orchestré par Airflow (`ml_train`/`ml_score`). Voir [ADR 0005](../adr/0005-modele-prediction-lightgbm.md) et [ML-START.md](../ML-START.md). Surveillance de dérive (EC06, #44/#45) pas encore construite |
| Infra | Docker Compose, Nginx, Terraform, k3s single-node | `infra`, `docker-compose.prod.yml` | `En cours` | Reverse proxy et overlay de déploiement écrits et validés, jamais lancés sur le serveur ([ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md)). Module d'installation k3s jamais appliqué, aucune ressource Kubernetes déclarée |
| Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | `Cible` | Rien, hors le `/metrics` exposé par l'API |
| ETL | Apache Airflow | `etl/airflow` | `En cours` | Webserver + scheduler (LocalExecutor) tournent via docker-compose, base de métadonnées Postgres dédiée. Trois DAGs en sous-processus `uv run` : `ml_train` manuel et `ml_score` `@hourly` pour le pipeline ML (issue #115), `alertes` à `15 * * * *` pour la détection et les recommandations (issue #116, [ADR 0008](../adr/0008-airflow-execute-le-code-du-backend.md)). L'ingestion (issues #15/#16) n'a pas encore de DAG |
| ETL | Apache Airflow | `etl/airflow` | `En cours` | Webserver + scheduler (LocalExecutor) tournent via docker-compose, base de métadonnées Postgres dédiée. Quatre DAGs en sous-processus `uv run` : `ml_train`, `ml_score`, `alertes` et `historical_import`. Le DAG historique orchestre `app.etl.historical_import` et charge `dataset`, `site` et `reading`. L'orchestration API Mock reste à compléter dans #15 |
| CI/CD | GitHub Actions | `.github/workflows` | `En cours` | 5 workflows, 16 jobs : lint, typage, tests avec seuil de couverture bloquant, tests d'intégration sur TimescaleDB réel, audit de dépendances, SAST Bandit, quality gate SonarCloud, intégrité des DAGs Airflow. Détail dans [50-cicd.md](50-cicd.md). **Aucun job de déploiement** (#21) |
## Flux bout en bout
+312 -64
View File
@@ -51,86 +51,334 @@ Trois pièges sont documentés en tête du `docker-compose.yml`, ils ne se devin
- `LocalExecutor` exécute les tâches comme sous-processus du **scheduler**, jamais du webserver :
c'est le scheduler qui a besoin du volume `airflow_ml_state` (modèle, magasin MLflow).
### Airflow (issues #115 et #116)
### Airflow (issues #115, #116 et #119)
Trois services, `docker compose profiles` non utilisés (démarrage explicite via `make
airflow-up`, pas dans `make dev`) :
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`.
| Service | Rôle | Points notables |
|---|---|---|
| `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` |
| `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` |
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).
`LocalExecutor` exécute les tâches dans le scheduler et non dans le webserver.
| DAG | Planification | Ce qu'il lance, et où |
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 |
|---|---|---|
| `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` |
| `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` |
**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.
#### Import historique
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.
Le DAG :
`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`).
```text
historical_import
```
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).
est défini dans :
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.
```text
etl/airflow/dags/historical_import.py
```
**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.
Il ne contient aucune logique d'import propre.
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.
Il utilise un `BashOperator` pour exécuter le module backend existant :
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;"`.
```text
app.etl.historical_import
```
`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.
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
```
## Machine cible, exécution Docker
+187 -5
View File
@@ -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 maintenant prises en charge :
Deux sources de données sont prises en charge par la logique ETL du backend :
```text
Dataset CSV/JSON
@@ -661,10 +661,192 @@ mock_api_import.py
API Mock
```
La logique d'extraction, de transformation et de chargement est donc disponible pour les deux sources de données du MVP.
La logique d'extraction, de validation, de transformation et de chargement est disponible pour
les deux sources de données du MVP.
Airflow tourne désormais réellement (`etl/airflow/`, `make airflow-up`) et orchestre le pipeline ML (`ml_train`/`ml_score`, issue #115) ainsi que la détection d'alertes et la génération des recommandations (`alertes`, issue #116). Il n'orchestre pas encore ces deux imports : `historical_import.py` et `mock_api_import.py` (normalisation et chargement micro-batch, issues #15/#16) restent à faire.
Airflow tourne réellement dans `etl/airflow/` et orchestre désormais quatre DAGs :
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` montrent le patron retenu (des `BashOperator` qui invoquent le script tel quel, dans l'environnement `uv` que l'image embarque pour lui).
```text
ml_train
ml_score
alertes
historical_import
```
Le pipeline Data servira ensuite à préparer les données nécessaires au modèle de Machine Learning.
Les DAGs `ml_train` et `ml_score` orchestrent le pipeline Machine Learning (issue #115).
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.