fix(etl): borne le DAG alertes sur son pire cas et couvre sa seconde commande en CI
Airflow / Lint et intégrité des DAGs (push) Successful in 56s
Airflow / Construction de l'image (push) Successful in 3m20s

`execution_timeout` plafonne une tentative, pas la tâche. Avec deux reprises, quinze
minutes par tentative autorisaient quarante-neuf minutes par tâche et quatre-vingt-dix-huit
pour l'enchaînement, quand le commentaire annonçait une somme tenant sous le pas horaire.
Le plafond passe à cinq minutes, ce qui borne le pire cas à trente-huit minutes, et le test
d'intégrité calcule désormais ce pire cas plutôt que la somme des plafonds : reprises et
délais d'attente compris, c'est la durée qu'un `max_active_runs=1` fait payer à l'exécution
suivante.

La CI vérifie aussi `app.cli generate-recommendations --help` sans réseau. C'est la seconde
commande du DAG, et son import tire FastAPI, les repositories et les services, donc une part
de l'environnement `/opt/backend` que la détection seule ne touche pas.

`10-infra.md` nomme enfin ce que le décalage de quinze minutes ne garantit pas : le plafond
de `ml_score` valant trente minutes, un scoring qui déborde prive la règle `anomaly` de la
prédiction de l'heure, qu'elle ne retrouvera au passage suivant que si sa fenêtre la couvre
encore.
This commit is contained in:
Johan LEROY
2026-09-21 13:28:23 +02:00
parent 306c5a52e5
commit f8d08c8686
4 changed files with 34 additions and 17 deletions
+3 -3
View File
@@ -26,9 +26,9 @@ COMMANDE_BACKEND = "cd /opt/backend && env -u VIRTUAL_ENV uv run --no-sync pytho
# `uq_alert_source_reference` et `uq_recommendation_alert_rule`) : reprendre ne duplique rien.
TENTATIVES = 2
DELAI_ENTRE_TENTATIVES = timedelta(minutes=2)
# La somme des deux plafonds reste sous le pas horaire : une exécution pendue ne doit pas
# empiéter sur la suivante.
PLAFOND_PAR_TACHE = timedelta(minutes=15)
# `execution_timeout` vaut par tentative : c'est le pire cas des deux tâches enchaînées, reprises
# et délais compris, qui doit tenir sous le pas horaire. Les tests d'intégrité en font le calcul.
PLAFOND_PAR_TACHE = timedelta(minutes=5)
with DAG(
dag_id="alertes",
+14 -7
View File
@@ -5,6 +5,7 @@ from datetime import timedelta
from pathlib import Path
import pytest
from airflow.models.baseoperator import BaseOperator
from airflow.models.dagbag import DagBag
DAGS_FOLDER = Path(__file__).resolve().parent.parent / "dags"
@@ -106,14 +107,20 @@ def test_ml_score_execution_timeout_stays_below_its_hourly_step(dagbag: DagBag)
assert timeout < timedelta(hours=1)
def test_alertes_execution_timeouts_stay_below_its_hourly_step(dagbag: DagBag) -> None:
# Les deux taches s'enchainent : c'est leur somme qui doit tenir dans le pas horaire.
plafonds = [
dagbag.dags["alertes"].get_task(task_id).execution_timeout
for task_id in ("detection", "recommandations")
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")
]
assert all(plafond is not None for plafond in plafonds)
assert sum(plafonds, timedelta()) < timedelta(hours=1)
assert sum((duree_au_pire(tache) for tache in taches), timedelta()) < timedelta(hours=1)
def test_ml_score_retries_after_a_transient_failure(dagbag: DagBag) -> None: