From 19c38fe571f19d6393efe9bd868694c4d07aef1f Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Mon, 21 Sep 2026 09:45:25 +0200 Subject: [PATCH] fix(backend): decoupe l'insertion des recommandations en lots et remet les docs a jour `create_missing()` construisait un seul `INSERT ... VALUES` pour la totalite des propositions. Avec quatre colonnes par ligne et le plafond asyncpg de 32 767 parametres, la route echouait au-dela de 8 191 recommandations par appel, cas devenu realiste maintenant que la detection interne (#104) alimente `alert` en continu. L'insertion passe par des lots de `TAILLE_DE_LOT` lignes, sur le patron de `app/etl/historical_import.py`. L'ADR 0006, `20-backend.md` et la description de la PR annoncaient qu'aucune source n'alimentait `alert` et que #104 n'etait pas commencee. #104 est livree sur `dev` depuis la #113 : les phrases sont corrigees plutot que laissees a vieillir dans un ADR. --- .../app/repositories/recommendation.py | 26 +++++++++++-------- .../tests/repositories/test_recommendation.py | 17 ++++++++++++ .../0006-moteur-de-regles-dans-le-backend.md | 10 ++++--- docs/architecture/20-backend.md | 4 ++- docs/architecture/40-data.md | 5 ++-- 5 files changed, 45 insertions(+), 17 deletions(-) diff --git a/apps/backend/app/repositories/recommendation.py b/apps/backend/app/repositories/recommendation.py index 8b07349..144957b 100644 --- a/apps/backend/app/repositories/recommendation.py +++ b/apps/backend/app/repositories/recommendation.py @@ -16,6 +16,9 @@ class NouvelleRecommandation: rule_reference: str +TAILLE_DE_LOT = 1000 + + class RecommendationRepository: def __init__(self, session: AsyncSession) -> None: self._session = session @@ -34,14 +37,15 @@ class RecommendationRepository: # Pourquoi : l'idempotence est déléguée à `uq_recommendation_alert_rule` plutôt qu'à une # lecture préalable, qui laisserait une fenêtre entre le contrôle et l'insertion. async def create_missing(self, nouvelles: Sequence[NouvelleRecommandation]) -> int: - if not nouvelles: - return 0 - - requete = ( - insert(Recommendation) - .values([asdict(nouvelle) for nouvelle in nouvelles]) - .on_conflict_do_nothing(constraint="uq_recommendation_alert_rule") - .returning(Recommendation.recommendation_id) - ) - creees = (await self._session.scalars(requete)).all() - return len(creees) + creees = 0 + # Piège : asyncpg plafonne une requête à 32 767 paramètres, soit 8 191 lignes de quatre + # colonnes. Au-delà de ce seuil un `INSERT` d'un seul tenant échouerait. + for debut in range(0, len(nouvelles), TAILLE_DE_LOT): + requete = ( + insert(Recommendation) + .values([asdict(nouvelle) for nouvelle in nouvelles[debut : debut + TAILLE_DE_LOT]]) + .on_conflict_do_nothing(constraint="uq_recommendation_alert_rule") + .returning(Recommendation.recommendation_id) + ) + creees += len((await self._session.scalars(requete)).all()) + return creees diff --git a/apps/backend/tests/repositories/test_recommendation.py b/apps/backend/tests/repositories/test_recommendation.py index 64b3b5f..6585878 100644 --- a/apps/backend/tests/repositories/test_recommendation.py +++ b/apps/backend/tests/repositories/test_recommendation.py @@ -5,6 +5,7 @@ import pytest from sqlalchemy.ext.asyncio import AsyncSession from app.models.energy import Alert, Recommendation, Site +from app.repositories import recommendation as module_recommendation from app.repositories.recommendation import NouvelleRecommandation, RecommendationRepository pytestmark = pytest.mark.integration @@ -123,3 +124,19 @@ async def test_create_missing_returns_zero_without_any_proposal(session: AsyncSe creees = await RecommendationRepository(session).create_missing([]) assert creees == 0 + + +async def test_create_missing_inserts_every_proposal_across_several_batches( + session: AsyncSession, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(module_recommendation, "TAILLE_DE_LOT", 2) + depot = RecommendationRepository(session) + alert_id = await creer_alerte(session) + propositions = [nouvelle(alert_id, f"regle-{index}-v1") for index in range(5)] + + creees = await depot.create_missing(propositions) + enregistrees = [r for r in await depot.list_all() if r.alert_id == alert_id] + await session.rollback() + + assert creees == 5 + assert len(enregistrees) == 5 diff --git a/docs/adr/0006-moteur-de-regles-dans-le-backend.md b/docs/adr/0006-moteur-de-regles-dans-le-backend.md index 794825e..4f23dce 100644 --- a/docs/adr/0006-moteur-de-regles-dans-le-backend.md +++ b/docs/adr/0006-moteur-de-regles-dans-le-backend.md @@ -57,9 +57,13 @@ n'oblige à exposer un port pour régénérer des recommandations. fenêtre entre le contrôle et l'insertion. Corollaire : `rule_reference` est une clé fonctionnelle. Une règle dont le sens change prend une référence `-v2` ; renommer une référence livrée ferait réapparaître ses recommandations à côté des anciennes. -- **Le moteur ne produira rien tant que `alert` restera vide.** Aucun code ne produit aujourd'hui - de ligne d'alerte : ni détection interne (#104), ni ingestion de l'API Mock `/alerts`. La chaîne - s'allume d'elle-même le jour où l'une des deux existe, sans retoucher le moteur. +- **Le moteur est branché sur la détection interne, et sur elle seule.** `alert` est alimentée + par `app/detection/internal_alerts.py` (#104), lancée à la main comme `enervision_ml.score` ; + l'ingestion de l'API Mock `/alerts` reste à faire. Le rapport de génération est donc à zéro tant + que la détection n'a pas tourné, sans que le moteur soit à retoucher. +- **L'insertion est découpée en lots.** `create_missing()` écrit par paquets de `TAILLE_DE_LOT` + lignes : asyncpg plafonne une requête à 32 767 paramètres, soit 8 191 lignes de quatre colonnes, + et la détection interne peut alimenter `alert` au fil de l'eau. - Si le projet devait un jour pondérer les recommandations par un score appris, la décision serait à rouvrir : le moteur redeviendrait consommateur du pipeline ML. diff --git a/docs/architecture/20-backend.md b/docs/architecture/20-backend.md index c45b134..c6189a6 100644 --- a/docs/architecture/20-backend.md +++ b/docs/architecture/20-backend.md @@ -206,7 +206,9 @@ le rapport rendu distingue `recommendations_created` de `already_present`. Le m disponible hors HTTP par `python -m app.cli generate-recommendations` (cible `make recommendations`), sur le patron de `make ml-score`. Le choix de loger le moteur dans le backend plutôt que dans `ml/` est justifié par l'[ADR 0006](../adr/0006-moteur-de-regles-dans-le-backend.md). -Tant qu'aucune source n'alimente `alert`, la route est fonctionnelle mais rend un rapport à zéro. +Les alertes traitées sont celles qu'écrit la détection interne (#104, section ci-dessous) : la +génération ne rend donc de recommandations qu'une fois la détection passée. L'insertion est +découpée en lots de `TAILLE_DE_LOT` lignes, asyncpg plafonnant une requête à 32 767 paramètres. `GET /readings` reprend le même gabarit mais s'en écarte sur un point : `reading` est l'hypertable, donc la seule table métier pouvant porter des années d'historique, ce que `docs/architecture/ diff --git a/docs/architecture/40-data.md b/docs/architecture/40-data.md index b02fd13..88724e9 100644 --- a/docs/architecture/40-data.md +++ b/docs/architecture/40-data.md @@ -226,8 +226,9 @@ et ne sont pas considérées comme des alertes actuelles. Les lignes de `recommendation` sont écrites par le moteur de règles du backend (`app/services/recommendation_rules.py`), déclenché par `POST /api/v1/recommendations/generate` -ou par `make recommendations`. Le couple `(alert_id, rule_reference)` est unique : rejouer le -moteur sur les mêmes alertes n'ajoute aucune ligne. +ou par `make recommendations`, à partir des alertes déjà en base. Le couple +`(alert_id, rule_reference)` est unique : rejouer le moteur sur les mêmes alertes n'ajoute aucune +ligne. ### Relations entre les tables