collecteur: rattrapage par lots de l'historique et ingestion des alertes #111

Merged
lenaic merged 7 commits from lenaic/107-rattrapage-par-lots into develop 2026-09-03 12:28:03 +00:00
Owner

Ce que ça change

Le rattrapage par lots de l'historique /readings et l'ingestion des alertes, en
complément de la relève à la minute du #33. Les deux écrivent sous leurs propres
préfixes bronze, endpoint=readings et endpoint=alerts, sans jamais toucher à
endpoint=current : c'est ce que l'ADR 0005 prévoit, et c'est ce qui a permis
d'écrire ce module en parallèle du collecteur.

C'est ce qui donne au modèle de quoi s'entraîner sans attendre des semaines de
collecte en direct. L'historique de la source remonte à plus d'un an.

Closes #107

Trois mesures qui ont changé la conception

L'API ne tronque pas, elle synthétise. Le §6 de docs/data/etl-pipeline.md
décrivait un modèle de troncature : une fenêtre revenant avec 1000 lectures
signalait que le plafond était atteint et que sa fin était perdue. C'est faux, et
je l'ai mesuré :

fenêtre 6 h, limit 1000 -> 1000 points espacés de 21,6 s
fenêtre 6 h, limit    6 ->    6 points espacés de  1 h
fenêtre 1 h, limit 1000 -> 1000 points espacés de  3,6 s

Le pas vaut fenêtre ÷ limit. Il se choisit donc par --pas-minutes, une fenêtre
n'est jamais tronquée, et le plafond de 1000 se lit comme une résolution minimale.
Le §6 est corrigé.

Un site n'est ok que 1,8 % du temps, relevé sur 280 observations : 69,6 %
critical, 28,6 % degraded. Filtrer là-dessus condamnait le rattrapage à ne
presque jamais tourner.

Le bon filtre est le capteur de consommation, pas overall. Croisement de
l'état annoncé et de ce que /readings rend réellement, 84 observations :

overall consumption des valeurs ? n
critical failing non 70
degraded failing non 7
critical ok non 3
ok ok oui 2
degraded ok oui 2

consumption: failing n'a jamais rien rendu, 77 fois sur 77. Et un site
degraded dont ce capteur va bien rend des valeurs : l'ancien filtre les
jetait. L'effet est mesuré, sur dix passes visant 49 fenêtres : 2 fenêtres
écrites avec l'ancien critère, 9 avec celui-ci.

L'inverse n'est pas vrai, consumption: ok ne garantit rien. C'est le second
filet qui rattrape : une fenêtre revenue sans aucune valeur est reportée, jamais
écrite comme vérité ni jetée.

Les alertes ne se rattrapent pas. GET /api/v1/alerts n'accepte que site_id
et severity, vérifié dans son schéma OpenAPI : ni bornes de temps, ni curseur.
Il rend les alertes courantes. collector.alertes est donc une collecte au fil de
l'eau, à la cadence de la relève, et son idempotence tient à la clé, qui porte
l'alert_id.

Preuve

Jouée sur le serveur, contre l'API et MinIO réels, sous deploy.

rattrapage terminé : 2/14 fenêtres écrites, 33 points avec valeur (9,8 %),
                     0 site reporté

source=mock_api/endpoint=readings/site_id=SITE005/dt=2026-08-27/hour=10/
    20260827T100400Z_20260828T100400Z.json.gz   935 o
source=mock_api/endpoint=alerts/site_id=SITE001/dt=2026-09-03/hour=09/
    ALR-SITE001-1788429916.json                 502 o

endpoint=current est resté vide pendant tous les essais : les préfixes ne se
touchent pas.

Report d'un site en défaut, avec l'échéance que la source donne elle-même :

WARNING site=SITE002 reporté : capteurs en critical,
        rétablissement 2026-09-03T10:04:49.445Z

108 cas unitaires passent, ruff est propre.

Trois choses trouvées en chemin, hors périmètre

  1. /etc/enervision était en drwx------ root:root. Le fichier minio.env
    était bien en 0640 root:deploy comme l'ADR 0008 l'annonce, mais deploy ne
    pouvait pas traverser le répertoire : le collecteur du #33 serait sorti en
    code 2 à chaque minute sans jamais rien collecter. Corrigé en 0710 root:deploy,
    qui donne la traversée sans la lecture — mlflow.env reste root seul.
    À porter dans le rôle Ansible app, sinon une réinstallation le reperd.
  2. httpx et minio ne sont installés nulle part sur le serveur. L'ADR 0008
    prévoit python3 -m collector.current sous cron, mais rien n'installe les
    dépendances. Il manque une tâche au rôle app.
  3. collecte-current.sh était livrée non exécutable, en 100644, alors que
    cron l'appelle directement. Passée en 100755 ici.

Les deux premiers points méritent leur ticket, ils bloquent la mise en service du
collecteur autant que du rattrapage.

Relecture

  • Un pair a relu et laissé un commentaire, même court
  • Ses remarques sont traitées, ou une réponse explique pourquoi elles ne le sont pas

Ce qui suit le code

  • docs/data/etl-pipeline.md corrigé, §2 et §6, le modèle de pagination y était faux
  • services/collector/README.md complété

Où regarder en priorité

Le filtre de collector/statut.py. C'est la seule décision de conception qui
n'est pas évidente, et c'est celle qui décide combien de données on obtient. Le
tableau des 84 observations est dans le docstring de EtatSite.collectable.

## Ce que ça change Le rattrapage par lots de l'historique `/readings` et l'ingestion des alertes, en complément de la relève à la minute du #33. Les deux écrivent sous leurs propres préfixes bronze, `endpoint=readings` et `endpoint=alerts`, sans jamais toucher à `endpoint=current` : c'est ce que l'ADR 0005 prévoit, et c'est ce qui a permis d'écrire ce module en parallèle du collecteur. C'est ce qui donne au modèle de quoi s'entraîner sans attendre des semaines de collecte en direct. L'historique de la source remonte à plus d'un an. Closes #107 ## Trois mesures qui ont changé la conception **L'API ne tronque pas, elle synthétise.** Le §6 de `docs/data/etl-pipeline.md` décrivait un modèle de troncature : une fenêtre revenant avec 1000 lectures signalait que le plafond était atteint et que sa fin était perdue. C'est faux, et je l'ai mesuré : ``` fenêtre 6 h, limit 1000 -> 1000 points espacés de 21,6 s fenêtre 6 h, limit 6 -> 6 points espacés de 1 h fenêtre 1 h, limit 1000 -> 1000 points espacés de 3,6 s ``` Le pas vaut `fenêtre ÷ limit`. Il se choisit donc par `--pas-minutes`, une fenêtre n'est jamais tronquée, et le plafond de 1000 se lit comme une résolution minimale. Le §6 est corrigé. **Un site n'est `ok` que 1,8 % du temps**, relevé sur 280 observations : 69,6 % `critical`, 28,6 % `degraded`. Filtrer là-dessus condamnait le rattrapage à ne presque jamais tourner. **Le bon filtre est le capteur de consommation, pas `overall`.** Croisement de l'état annoncé et de ce que `/readings` rend réellement, 84 observations : | `overall` | `consumption` | des valeurs ? | n | |---|---|---|---| | critical | failing | non | 70 | | degraded | failing | non | 7 | | critical | ok | non | 3 | | ok | ok | oui | 2 | | degraded | ok | oui | 2 | `consumption: failing` n'a jamais rien rendu, 77 fois sur 77. Et un site `degraded` dont ce capteur va bien **rend des valeurs** : l'ancien filtre les jetait. L'effet est mesuré, sur dix passes visant 49 fenêtres : 2 fenêtres écrites avec l'ancien critère, 9 avec celui-ci. L'inverse n'est pas vrai, `consumption: ok` ne garantit rien. C'est le second filet qui rattrape : une fenêtre revenue sans aucune valeur est reportée, jamais écrite comme vérité ni jetée. **Les alertes ne se rattrapent pas.** `GET /api/v1/alerts` n'accepte que `site_id` et `severity`, vérifié dans son schéma OpenAPI : ni bornes de temps, ni curseur. Il rend les alertes courantes. `collector.alertes` est donc une collecte au fil de l'eau, à la cadence de la relève, et son idempotence tient à la clé, qui porte l'`alert_id`. ## Preuve Jouée sur le serveur, contre l'API et MinIO réels, sous `deploy`. ``` rattrapage terminé : 2/14 fenêtres écrites, 33 points avec valeur (9,8 %), 0 site reporté source=mock_api/endpoint=readings/site_id=SITE005/dt=2026-08-27/hour=10/ 20260827T100400Z_20260828T100400Z.json.gz 935 o source=mock_api/endpoint=alerts/site_id=SITE001/dt=2026-09-03/hour=09/ ALR-SITE001-1788429916.json 502 o ``` `endpoint=current` est resté vide pendant tous les essais : les préfixes ne se touchent pas. Report d'un site en défaut, avec l'échéance que la source donne elle-même : ``` WARNING site=SITE002 reporté : capteurs en critical, rétablissement 2026-09-03T10:04:49.445Z ``` 108 cas unitaires passent, ruff est propre. ## Trois choses trouvées en chemin, hors périmètre 1. **`/etc/enervision` était en `drwx------ root:root`.** Le fichier `minio.env` était bien en `0640 root:deploy` comme l'ADR 0008 l'annonce, mais `deploy` ne pouvait pas traverser le répertoire : le collecteur du #33 serait sorti en code 2 à chaque minute sans jamais rien collecter. Corrigé en `0710 root:deploy`, qui donne la traversée sans la lecture — `mlflow.env` reste `root` seul. **À porter dans le rôle Ansible `app`**, sinon une réinstallation le reperd. 2. **`httpx` et `minio` ne sont installés nulle part sur le serveur.** L'ADR 0008 prévoit `python3 -m collector.current` sous cron, mais rien n'installe les dépendances. Il manque une tâche au rôle `app`. 3. **`collecte-current.sh` était livrée non exécutable**, en `100644`, alors que cron l'appelle directement. Passée en `100755` ici. Les deux premiers points méritent leur ticket, ils bloquent la mise en service du collecteur autant que du rattrapage. ## Relecture - [ ] Un pair a relu et laissé un commentaire, même court - [ ] Ses remarques sont traitées, ou une réponse explique pourquoi elles ne le sont pas ## Ce qui suit le code - [x] `docs/data/etl-pipeline.md` corrigé, §2 et §6, le modèle de pagination y était faux - [x] `services/collector/README.md` complété ## Où regarder en priorité Le filtre de `collector/statut.py`. C'est la seule décision de conception qui n'est pas évidente, et c'est celle qui décide combien de données on obtient. Le tableau des 84 observations est dans le docstring de `EtatSite.collectable`.
lenaic self-assigned this 2026-09-03 11:09:38 +00:00
Complète collector.current par deux modules qui écrivent sous leurs propres
préfixes bronze, sans jamais toucher aux siens (ADR 0005).

collector.backfill remonte l'historique /readings par fenêtres. Le modèle de
pagination de la source a été mesuré et non supposé : elle synthétise
exactement `limit` points répartis sur la fenêtre demandée, elle ne tronque
jamais. Le pas vaut donc fenêtre ÷ limit et se choisit par --pas-minutes ; le
plafond de 1000 se lit comme une résolution minimale. La spécification du §6
décrivait un modèle de troncature, elle est corrigée.

L'état de panne courant d'un site s'applique à tout son historique : le module
lit /api/v1/sensors/status avant tout et saute les sites qui ne sont pas ok, en
retenant le failing_until que la source donne elle-même. Une fenêtre revenue
sans aucune valeur est reportée, jamais écrite comme vérité ni jetée.

collector.alertes est une collecte au fil de l'eau et non un lot : /api/v1/alerts
n'accepte ni bornes de temps ni curseur, il rend les alertes courantes. Son
idempotence tient à la clé, qui porte l'alert_id.

L'écriture accepte désormais un content_type, sans quoi un .json.gz serait
annoncé comme du JSON nu.

28 cas unitaires ajoutés, 88 au total sur le collecteur.
collecteur: filtre le rattrapage sur le capteur de consommation
Some checks failed
Intégration / Qualité du code Python (pull_request) Failing after 30s
Intégration / Tests unitaires et couverture (pull_request) Successful in 57s
Intégration / Dépendances du tableau de bord (pull_request) Successful in 14s
Intégration / Images épinglées par version (pull_request) Successful in 3s
Intégration / Tableau de bord (pull_request) Successful in 20s
Intégration / Aucun secret commité (pull_request) Successful in 2s
Intégration / Dépendances Python et inventaire applicatif (pull_request) Successful in 1m25s
8d91991dc2
Le filtre portait sur « overall », il porte désormais sur le capteur
« consumption ». Décision mesurée, pas supposée.

Croisement de l'état annoncé et de ce que /readings rend réellement, 84
observations : « consumption: failing » n'a jamais rien rendu, 77 fois sur 77.
À l'inverse un site « degraded » dont ce capteur va bien rend des valeurs, et
l'ancien filtre les jetait.

Le coût de l'ancien critère se chiffre : un site n'est « ok » que 1,8 % du
temps, contre 28,6 % de « degraded ». Sur dix passes visant 49 fenêtres,
l'ancien filtre écrivait 2 fenêtres, celui-ci en écrit 9.

« consumption: ok » ne garantit rien pour autant, trois cas sur sept n'ont
rien rendu : c'est le report des fenêtres vides qui les rattrape.

4 cas unitaires ajoutés, 92 au total sur le collecteur.
collecteur: mise en forme ruff
All checks were successful
Intégration / Qualité du code Python (pull_request) Successful in 49s
Intégration / Tests unitaires et couverture (pull_request) Successful in 56s
Intégration / Dépendances du tableau de bord (pull_request) Successful in 12s
Intégration / Images épinglées par version (pull_request) Successful in 3s
Intégration / Tableau de bord (pull_request) Successful in 20s
Intégration / Aucun secret commité (pull_request) Successful in 3s
Intégration / Dépendances Python et inventaire applicatif (pull_request) Successful in 1m21s
21119c35b1
`ruff format --check` est bloquant dans la chaîne, et je n'avais joué que
`ruff check`. Deux fichiers reformatés, aucun changement de comportement.
collecteur: les lanceurs acceptent un interpréteur en paramètre
All checks were successful
Intégration / Qualité du code Python (pull_request) Successful in 50s
Intégration / Tests unitaires et couverture (pull_request) Successful in 57s
Intégration / Dépendances du tableau de bord (pull_request) Successful in 13s
Intégration / Images épinglées par version (pull_request) Successful in 3s
Intégration / Tableau de bord (pull_request) Successful in 18s
Intégration / Aucun secret commité (pull_request) Successful in 3s
Intégration / Dépendances Python et inventaire applicatif (pull_request) Successful in 1m24s
d5ff09620d
Le rôle Ansible du #113 pose un environnement Python isolé sur le serveur et
le nomme par ENERVISION_PYTHON. Sans cette variable, les lanceurs appellent le
python3 du système, où les dépendances ne sont pas installées et ne peuvent
pas l'être (PEP 668).
Member

Revue — relecture complète

Beau travail, et la démarche « mesuré, pas supposé » est le vrai apport : la correction du §6 invalide une spec que tout le monde aurait suivie, et le croisement sur 84 observations transforme une intuition de filtre en décision chiffrée avec son coût (2 fenêtres contre 9). Le second filet — fenêtre vide reportée, ni écrite comme vérité ni jetée — est le bon arbitrage, et il est bien testé.

Trois choses bloquent la fusion, dont une qui touche la propriété centrale de la PR. Le reste est de la doc et des détails.


1. La chaîne est rouge — ruff format, pas ruff check

L'exécution #715 échoue sur « Qualité du code Python ». Reproduit avec le ruff 0.16.5 de requirements-dev.txt :

backfill.py:240   journal.info(...) tient sur une ligne
statut.py:129     l'expression conditionnelle de capteur_conso
2 files would be reformatted

ruff check passe bien — c'est vrai de la moitié de l'étape, qui est ruff check … && ruff format --check …. Et comme elle échoue, mypy n'a jamais tourné : son état est inconnu, pas vert.

ruff format services packages, puis nouveau push. (Les deux fichiers de test sont aussi non formatés : hors périmètre CI, mais ils bougeront au prochain ruff format à la racine.)

2. L'idempotence ne tient pas depuis la ligne de commande

main() ancre la grille sur datetime.now(UTC) tronqué à la minute :

jusqu_a = datetime.now(UTC).replace(second=0, microsecond=0)
depuis  = jusqu_a - timedelta(days=options.jours)

Rejouer la même commande une minute plus tard décale toutes les bornes, donc toutes les clés. En rejouant ton découpage :

passe 1 : …/dt=2026-09-01/hour=12/20260901T120000Z_20260902T120000Z.json.gz
passe 2 : …/dt=2026-09-01/hour=12/20260901T120100Z_20260902T120100Z.json.gz

clés communes : 0 / 2     objets en bronze après les deux passes : 4

Ce sont des fenêtres chevauchantes — exactement ce que le docstring de fenetres() interdit. Et bronze étant versionné, rien ne le rattrape en aval. Ça contredit le README (« Relancer la même commande réécrit les mêmes objets »), le docstring de cle_lot, et le critère 4 du #107. Les tests ne l'attrapent pas parce qu'ils passent DEBUT/FIN en dur : main() n'est jamais exercé.

Effet de bord : avec une ancre à la minute, dt=/hour= d'une fenêtre de 24 h tombent à une heure quelconque et la fenêtre enjambe deux journées civiles — le partitionnement Hive de l'ADR 0005 perd son sens.

→ Caler l'ancre sur une frontière stable (jusqu_a tronqué au jour, ou à un multiple de fenetre), ou accepter --depuis/--jusqu-a explicites. Plus un test qui appelle main() deux fois avec une horloge décalée.

3. /sensors/status injoignable → code de sortie 0

journal.error("état des capteurs illisible (%s) : rien n'est rattrapé", )
return Resultat(reportes=[...])            # lots == []
...
return 0 if not resultat.a_rejouer else 1  # [] est faux → 0

Un cron nocturne qui n'a rien collecté sort en succès. Ça contredit ton propre docstring (« code de sortie strict ») et surtout current.code_de_sortie, qui tranche explicitement l'inverse : « Une passe vide vaut un échec ».

Le tableau du README documente ce 0, donc c'est peut-être délibéré — mais journaliser en error et sortir en 0 ne se défend pas. test_un_etat_des_capteurs_illisible_arrete_tout vérifie lots == [] et jamais le code de sortie.


À traiter aussi

  • Critère 7 du #107 non servi, et preuve du double passage absente. Le ticket demande une exécution sur un an et sept sites avec le taux mesuré, et en preuve « le décompte d'objets bronze identique après le second passage ». La section Preuve montre 2/14 fenêtres — et ce second passage aurait justement révélé le point 2.
  • L'écart au critère 1 n'est pas porté au ticket. Le critère dit « saute ceux qui sont critical », le code filtre sur consumption et collecte des sites degraded. L'écart est justifié et c'est le meilleur morceau de la PR, mais le #107 est ouvert avec ses critères d'origine : coller le tableau des 84 observations en commentaire et amender le critère.
  • docs/data/etl-pipeline.md §6 est périmé depuis 8d91991 : il dit encore « saute les sites qui ne sont pas ok », soit le filtre sur overall que ce commit a remplacé. Le README et statut.py ont suivi, pas celui-ci.
  • Rien dans docs/runbooks/, et deux renvois faux. La PR ajoute deux gestes d'exploitation (un cron à la minute de plus, un rattrapage nocturne) ; la définition de terminé exige le manuel. rattrapage-readings.sh:4 renvoie à docs/runbooks/collecteur.md, qui n'existe pas (lien mort hérité du #33), et cite « l'ADR 0007 pour le mode d'exécution » — l'ADR 0007 est terraform-etat-distant, le mode d'exécution est l'ADR 0008.
  • Tes deux constats bloquants n'ont pas de ticket. Aucune des issues ouvertes ne couvre le mode de /etc/enervision ni l'installation de httpx/minio sur le serveur. Le 0710 posé à la main n'existe nulle part dans le dépôt : il sera reperdu au prochain Ansible. Le #64 est voisin, pas identique. Je peux les ouvrir si tu veux.

Détails mineurs

Quoi
alertes.py:78 Le repli sur l'heure de réception casse la dédup que tu protèges juste au-dessus en refusant d'inventer un alert_id : la même alerte sans timestamp lisible atterrit sous deux préfixes dt=/hour= à deux passes.
alertes.py:56 Passe.complete exige ignorees == 0, et ignorees mélange l'échec d'écriture (vraie perte) et l'alerte malformée par la source. Une alerte malformée persistante ferait sortir cron en 1 chaque minutecurrent tranche l'inverse.
backfill.py:311 limite est calculée une fois et appliquée à la dernière fenêtre raccourcie : elle revient à un pas plus fin que --pas-minutes, ce que le module dit refuser de faire en silence.
backfill.py:120 / :264 Deux sens de « à rejouer » : Lot.a_rejouer (rien d'exploitable) et Resultat.a_rejouer (not ecrit) divergent sur un échec d'écriture. En renommer un.
backfill.py:44, alertes.py:37 _appeler est privé et importé depuis .current par les deux nouveaux modules. Le remonter avec ClientHTTP dans un http.py partagé.
stockage.py:21 ecrire_json(..., content_type="application/gzip") — le nom ment maintenant. ecrire_objet ?
backfill.py:198 gzip.compress inscrit un mtime : même contenu, octets différents à chaque passe. Sur un bronze versionné, chaque rejeu crée une version pour rien → mtime=0.
backfill.py:161 start_time/end_time partent en naïf, sans Z. Symétrique de la source et validé sur l'API réelle, mais statut.py prend soin de documenter ce piège pour les valeurs entrantes — autant le faire pour les sortantes.

Pour débloquer : les points 1, 2 et 3, plus la preuve du double passage. La doc et les tickets sont rapides ; le reste peut suivre.

Relu par Olivier. Vérifications faites : chaîne #715 rejouée localement au même ruff, découpage des fenêtres rejoué, critères du #107 repris un à un.

## Revue — relecture complète Beau travail, et la démarche « mesuré, pas supposé » est le vrai apport : la correction du §6 invalide une spec que tout le monde aurait suivie, et le croisement sur 84 observations transforme une intuition de filtre en décision chiffrée avec son coût (2 fenêtres contre 9). Le second filet — fenêtre vide reportée, ni écrite comme vérité ni jetée — est le bon arbitrage, et il est bien testé. **Trois choses bloquent la fusion**, dont une qui touche la propriété centrale de la PR. Le reste est de la doc et des détails. --- ### 1. La chaîne est rouge — `ruff format`, pas `ruff check` L'exécution #715 échoue sur « Qualité du code Python ». Reproduit avec le ruff 0.16.5 de `requirements-dev.txt` : ``` backfill.py:240 journal.info(...) tient sur une ligne statut.py:129 l'expression conditionnelle de capteur_conso 2 files would be reformatted ``` `ruff check` passe bien — c'est vrai de la moitié de l'étape, qui est `ruff check … && ruff format --check …`. Et **comme elle échoue, mypy n'a jamais tourné** : son état est inconnu, pas vert. → `ruff format services packages`, puis nouveau push. (Les deux fichiers de test sont aussi non formatés : hors périmètre CI, mais ils bougeront au prochain `ruff format` à la racine.) ### 2. L'idempotence ne tient pas depuis la ligne de commande `main()` ancre la grille sur `datetime.now(UTC)` tronqué **à la minute** : ```python jusqu_a = datetime.now(UTC).replace(second=0, microsecond=0) depuis = jusqu_a - timedelta(days=options.jours) ``` Rejouer la même commande une minute plus tard décale toutes les bornes, donc toutes les clés. En rejouant ton découpage : ``` passe 1 : …/dt=2026-09-01/hour=12/20260901T120000Z_20260902T120000Z.json.gz passe 2 : …/dt=2026-09-01/hour=12/20260901T120100Z_20260902T120100Z.json.gz clés communes : 0 / 2 objets en bronze après les deux passes : 4 ``` Ce sont des fenêtres **chevauchantes** — exactement ce que le docstring de `fenetres()` interdit. Et `bronze` étant versionné, rien ne le rattrape en aval. Ça contredit le README (« Relancer la même commande réécrit les mêmes objets »), le docstring de `cle_lot`, et le critère 4 du #107. Les tests ne l'attrapent pas parce qu'ils passent `DEBUT`/`FIN` en dur : `main()` n'est jamais exercé. Effet de bord : avec une ancre à la minute, `dt=`/`hour=` d'une fenêtre de 24 h tombent à une heure quelconque et la fenêtre enjambe deux journées civiles — le partitionnement Hive de l'ADR 0005 perd son sens. → Caler l'ancre sur une frontière stable (`jusqu_a` tronqué au jour, ou à un multiple de `fenetre`), ou accepter `--depuis`/`--jusqu-a` explicites. Plus un test qui appelle `main()` deux fois avec une horloge décalée. ### 3. `/sensors/status` injoignable → code de sortie 0 ```python journal.error("état des capteurs illisible (%s) : rien n'est rattrapé", …) return Resultat(reportes=[...]) # lots == [] ... return 0 if not resultat.a_rejouer else 1 # [] est faux → 0 ``` Un cron nocturne qui n'a rien collecté sort en succès. Ça contredit ton propre docstring (« code de sortie strict ») et surtout `current.code_de_sortie`, qui tranche explicitement l'inverse : « Une passe vide vaut un échec ». Le tableau du README documente ce 0, donc c'est peut-être délibéré — mais journaliser en `error` et sortir en 0 ne se défend pas. `test_un_etat_des_capteurs_illisible_arrete_tout` vérifie `lots == []` et jamais le code de sortie. --- ### À traiter aussi - **Critère 7 du #107 non servi, et preuve du double passage absente.** Le ticket demande une exécution sur un an et sept sites avec le taux mesuré, et en preuve « le décompte d'objets bronze identique après le second passage ». La section Preuve montre 2/14 fenêtres — et ce second passage aurait justement révélé le point 2. - **L'écart au critère 1 n'est pas porté au ticket.** Le critère dit « saute ceux qui sont `critical` », le code filtre sur `consumption` et collecte des sites `degraded`. L'écart est justifié et c'est le meilleur morceau de la PR, mais le #107 est ouvert avec ses critères d'origine : coller le tableau des 84 observations en commentaire et amender le critère. - **`docs/data/etl-pipeline.md` §6 est périmé depuis 8d91991** : il dit encore « saute les sites qui ne sont pas `ok` », soit le filtre sur `overall` que ce commit a remplacé. Le README et `statut.py` ont suivi, pas celui-ci. - **Rien dans `docs/runbooks/`, et deux renvois faux.** La PR ajoute deux gestes d'exploitation (un cron à la minute de plus, un rattrapage nocturne) ; la définition de terminé exige le manuel. `rattrapage-readings.sh:4` renvoie à `docs/runbooks/collecteur.md`, **qui n'existe pas** (lien mort hérité du #33), et cite « l'ADR 0007 pour le mode d'exécution » — l'ADR 0007 est `terraform-etat-distant`, le mode d'exécution est l'**ADR 0008**. - **Tes deux constats bloquants n'ont pas de ticket.** Aucune des issues ouvertes ne couvre le mode de `/etc/enervision` ni l'installation de `httpx`/`minio` sur le serveur. Le `0710` posé à la main n'existe nulle part dans le dépôt : il sera reperdu au prochain Ansible. Le #64 est voisin, pas identique. Je peux les ouvrir si tu veux. --- ### Détails mineurs | Où | Quoi | |---|---| | `alertes.py:78` | Le repli sur l'heure de réception casse la dédup que tu protèges juste au-dessus en refusant d'inventer un `alert_id` : la même alerte sans `timestamp` lisible atterrit sous deux préfixes `dt=`/`hour=` à deux passes. | | `alertes.py:56` | `Passe.complete` exige `ignorees == 0`, et `ignorees` mélange l'échec d'écriture (vraie perte) et l'alerte malformée par la source. Une alerte malformée persistante ferait sortir cron en 1 **chaque minute** — `current` tranche l'inverse. | | `backfill.py:311` | `limite` est calculée une fois et appliquée à la dernière fenêtre raccourcie : elle revient à un pas plus fin que `--pas-minutes`, ce que le module dit refuser de faire en silence. | | `backfill.py:120` / `:264` | Deux sens de « à rejouer » : `Lot.a_rejouer` (rien d'exploitable) et `Resultat.a_rejouer` (`not ecrit`) divergent sur un échec d'écriture. En renommer un. | | `backfill.py:44`, `alertes.py:37` | `_appeler` est privé et importé depuis `.current` par les deux nouveaux modules. Le remonter avec `ClientHTTP` dans un `http.py` partagé. | | `stockage.py:21` | `ecrire_json(..., content_type="application/gzip")` — le nom ment maintenant. `ecrire_objet` ? | | `backfill.py:198` | `gzip.compress` inscrit un mtime : même contenu, octets différents à chaque passe. Sur un `bronze` versionné, chaque rejeu crée une version pour rien → `mtime=0`. | | `backfill.py:161` | `start_time`/`end_time` partent en naïf, sans `Z`. Symétrique de la source et validé sur l'API réelle, mais `statut.py` prend soin de documenter ce piège pour les valeurs entrantes — autant le faire pour les sortantes. | --- **Pour débloquer :** les points 1, 2 et 3, plus la preuve du double passage. La doc et les tickets sont rapides ; le reste peut suivre. *Relu par Olivier. Vérifications faites : chaîne #715 rejouée localement au même ruff, découpage des fenêtres rejoué, critères du #107 repris un à un.*
olivier requested changes 2026-09-03 11:27:46 +00:00
Dismissed
olivier left a comment

Commentaire plus détaillé a part

Commentaire plus détaillé a part
collecteur: retours de relecture de la #111
All checks were successful
Intégration / Qualité du code Python (pull_request) Successful in 55s
Intégration / Tests unitaires et couverture (pull_request) Successful in 59s
Intégration / Dépendances du tableau de bord (pull_request) Successful in 13s
Intégration / Images épinglées par version (pull_request) Successful in 3s
Intégration / Tableau de bord (pull_request) Successful in 20s
Intégration / Aucun secret commité (pull_request) Successful in 2s
Intégration / Dépendances Python et inventaire applicatif (pull_request) Successful in 1m27s
fd4890c81c
Olivier a relu et trouvé un vrai défaut, plus deux corrections de fond et une
série de détails. Tout est traité.

L'IDEMPOTENCE NE TENAIT PAS DEPUIS LA LIGNE DE COMMANDE. main() calait la
grille sur l'heure courante tronquée à la minute : rejouer la même commande une
minute plus tard décalait toutes les bornes, donc toutes les clés, et produisait
deux jeux d'objets chevauchants au lieu de réécrire les mêmes. Exactement ce que
le docstring de fenetres() interdit, et ce que le README promettait. Les cas de
test ne l'attrapaient pas parce qu'ils passaient les bornes en dur et
n'exerçaient jamais main(). Les bornes sont désormais ancrées sur un multiple
entier de la fenêtre depuis l'époque Unix, ce qui règle au passage le
partitionnement : avec une fenêtre de 24 h elles tombent à minuit UTC, au lieu
d'enjamber deux journées civiles.

UN ÉTAT DES CAPTEURS ILLISIBLE SORT DÉSORMAIS EN 1. Journaliser en « error »
puis rendre 0 laissait un cron nocturne qui n'a rien collecté passer pour un
succès.

GZIP N'INSCRIT PLUS L'HEURE DE COMPRESSION. Même contenu, mêmes octets : sur un
seau versionné comme bronze, chaque rejeu créait sinon une version pour rien, et
l'idempotence de la clé se reperdait par la porte de derrière.

LES ALERTES À L'HORODATAGE ILLISIBLE ne retombent plus sur l'heure de réception,
qui change à chaque passe et cassait la déduplication protégée trois lignes plus
haut. Elles vont sous dt=inconnu. Et le décompte sépare l'alerte malformée par
la source, qui ne doit pas faire sonner cron chaque minute, de l'alerte perdue à
l'écriture, qui est une vraie perte.

La limite est recalculée par fenêtre : la dernière, plus courte, recevait la
limite des fenêtres pleines et donc un pas plus fin que celui demandé.

Le client HTTP et l'appel qui ne lève jamais remontent dans http.py : trois
modules importaient un _appeler privé chez leur voisin. Depot.ecrire_json
devient ecrire_objet, le nom mentait depuis qu'il accepte un content_type.
Resultat.a_rejouer devient a_reprendre, il divergeait de Lot.a_rejouer sur un
échec d'écriture.

Documentation : le §6 d'etl-pipeline décrivait encore le filtre sur « overall »
remplacé depuis, le renvoi à l'ADR 0007 pointait sur Terraform au lieu du mode
d'exécution, et docs/runbooks/collecteur.md — cité par deux lanceurs — n'existait
pas. Il existe.

9 cas unitaires ajoutés, 102 au total. ruff check, ruff format --check et mypy
passent.
collecteur: note l'hypothèse de fuseau sur les bornes sortantes
All checks were successful
Intégration / Qualité du code Python (pull_request) Successful in 54s
Intégration / Tests unitaires et couverture (pull_request) Successful in 56s
Intégration / Dépendances du tableau de bord (pull_request) Successful in 14s
Intégration / Images épinglées par version (pull_request) Successful in 3s
Intégration / Tableau de bord (pull_request) Successful in 22s
Intégration / Aucun secret commité (pull_request) Successful in 3s
Intégration / Dépendances Python et inventaire applicatif (pull_request) Successful in 1m25s
f3499274fa
Dernier point mineur de la relecture d'Olivier. cles.py documente déjà
l'hypothèse pour les horodatages entrants ; elle vaut aussi pour ceux qu'on
envoie, et une hypothèse tacite des deux côtés en fait une évidence pour
personne.
Author
Owner

Merci, c'est une relecture qui a servi. Tout est traité, y compris les détails.

Les trois points bloquants

1. ruff format. Corrigé et poussé pendant que tu écrivais. Ta lecture est la bonne
et je l'avais ratée : comme l'étape échouait, mypy n'avait jamais tourné. Il tourne
maintenant, sur dix fichiers, sans erreur.

2. L'idempotence depuis la ligne de commande. Tu as raison, et c'est un vrai défaut.
main() calait la grille sur now() tronqué à la minute, mes cas de test passaient les
bornes en dur et n'exerçaient jamais le chemin que cron emprunte. C'est exactement là
que la propriété que cette demande met en avant se cassait.

Les bornes sont désormais ancrées sur un multiple entier de la fenêtre depuis l'époque
Unix. Ça règle aussi l'effet de bord que tu signales : avec une fenêtre de 24 h elles
tombent à minuit UTC, donc dt= et hour= désignent une vraie journée civile au lieu
d'enjamber deux dates.

main() accepte maintenant horloge, http et depot — une couture pour les tests, et
la seule façon de vérifier cette propriété là où elle vit. Trois cas ajoutés, dont celui
que tu demandes : deux appels à main() avec une horloge décalée, mêmes clés.

3. Le code de sortie sur /sensors/status illisible. Tu as raison aussi, journaliser
en error et sortir en 0 ne se défend pas. Resultat porte un statut_lu, distinct
d'une liste de reports vide : ne rien avoir à faire et ne pas savoir quoi faire sont deux
situations différentes. Le tableau du README disait 0, il dit 1.

La preuve du double passage

Jouée sur le serveur. Elle a d'abord semblé rouge, et le pourquoi vaut d'être dit : mon
premier script comparait un décompte brut, or deux sites en panne étaient redevenus sains
entre les passes et la seconde a légitimement collecté des couples site-fenêtre que la
première avait sautés. Ce sont de nouvelles clés, pas des doublons. Comparé sur les
ensembles de clés :

passe 1 · bornes 2026-08-31T00:00:00.000Z à 2026-09-03T00:00:00.000Z
passe 2 · bornes 2026-08-31T00:00:00.000Z à 2026-09-03T00:00:00.000Z
         4 fenêtres écrites, 1 seule clé nouvelle

clés de la passe 1 disparues après la passe 2 : 0

Trois écritures sur quatre réécrites en place, et surtout des bornes identiques — la
chose même qui était cassée. La clé nouvelle vient du site redevenu sain.

Les points « à traiter aussi »

Le critère 1 est amendé au #107, avec le tableau des 84 observations en commentaire.
Tu as raison sur le principe : un écart justifié qui ne vit que dans une demande de fusion
disparaît à la fusion. J'ai précisé le critère 4 dans la foulée — il disait « la même
fenêtre », il dit « la même commande », qui est ce qui compte pour cron.

Le §6 d'etl-pipeline est corrigé. Tu as l'œil : le README et statut.py avaient
suivi le changement de filtre, pas lui.

docs/runbooks/collecteur.md existe, et il est dans l'index. Il porte les trois
programmes, comment vérifier que ça tourne, comment lire les codes de sortie — qui ne
disent pas la même chose d'un programme à l'autre — et pourquoi un rattrapage demande
plusieurs passes. Le renvoi à l'ADR 0007 pointait bien sur Terraform, c'est 0008.

Tes deux constats ont leur ticket : le #113, et la demande #114 qui le livre.
Ouverts pendant que tu relisais, d'où le télescopage. Le rôle app y pose le mode de
/etc/enervision, l'environnement Python, le bit d'exécution des lanceurs, les lignes de
crontab et les migrations. Tu avais raison de dire que le 0710 posé à la main serait
reperdu : il l'aurait été au premier --tags app.

Les détails

Tous traités, et deux d'entre eux étaient meilleurs que des détails.

Le mtime de gzip : même contenu, octets différents à chaque passe, donc une version
créée pour rien sur un seau versionné. L'idempotence se reperdait par la porte de
derrière. mtime=0, avec un cas de test.

Le repli des alertes sur l'heure de réception cassait bien la déduplication que je
protège trois lignes plus haut. Elles vont sous dt=inconnu/hour=inconnu, partition
stable d'une passe à l'autre.

Le reste : ignorees séparé de perdues, une source qui produit une alerte malformée en
continu ne fait plus sonner cron chaque minute. La limite est recalculée par fenêtre, la
dernière recevait celle des fenêtres pleines. Resultat.a_rejouer devient a_reprendre.
ecrire_json devient ecrire_objet. Et _appeler avec ClientHTTP remontent dans un
http.py partagé — tu as raison, trois modules qui importent le privé du voisin, ce
n'est plus un privé.

Le seul que j'ai traité par un commentaire plutôt que par du code est l'horodatage naïf
en sortie : c'est symétrique de ce que la source rend, et vérifié sur l'API réelle, mais
l'hypothèse méritait d'être écrite des deux côtés comme cles.py le fait déjà.

102 cas unitaires, ruff check, ruff format --check et mypy passent.

Le critère 7 du #107 reste ouvert et je le laisse ouvert : ce qui a été joué porte sur
trois jours, pas un an. Le taux, lui, est connu et il est bas — le simulateur bascule ses
capteurs toutes les trente secondes, une passe n'attrape que les sites sains à cet
instant. Le rattrapage d'un an demandera plusieurs dizaines de passes, et c'est ce que le
manuel dit de faire.

Merci, c'est une relecture qui a servi. Tout est traité, y compris les détails. ### Les trois points bloquants **1. `ruff format`.** Corrigé et poussé pendant que tu écrivais. Ta lecture est la bonne et je l'avais ratée : comme l'étape échouait, mypy n'avait jamais tourné. Il tourne maintenant, sur dix fichiers, sans erreur. **2. L'idempotence depuis la ligne de commande. Tu as raison, et c'est un vrai défaut.** `main()` calait la grille sur `now()` tronqué à la minute, mes cas de test passaient les bornes en dur et n'exerçaient jamais le chemin que cron emprunte. C'est exactement là que la propriété que cette demande met en avant se cassait. Les bornes sont désormais ancrées sur un multiple entier de la fenêtre depuis l'époque Unix. Ça règle aussi l'effet de bord que tu signales : avec une fenêtre de 24 h elles tombent à minuit UTC, donc `dt=` et `hour=` désignent une vraie journée civile au lieu d'enjamber deux dates. `main()` accepte maintenant `horloge`, `http` et `depot` — une couture pour les tests, et la seule façon de vérifier cette propriété là où elle vit. Trois cas ajoutés, dont celui que tu demandes : deux appels à `main()` avec une horloge décalée, mêmes clés. **3. Le code de sortie sur `/sensors/status` illisible.** Tu as raison aussi, journaliser en `error` et sortir en 0 ne se défend pas. `Resultat` porte un `statut_lu`, distinct d'une liste de reports vide : ne rien avoir à faire et ne pas savoir quoi faire sont deux situations différentes. Le tableau du README disait 0, il dit 1. ### La preuve du double passage Jouée sur le serveur. Elle a d'abord semblé rouge, et le pourquoi vaut d'être dit : mon premier script comparait un décompte brut, or deux sites en panne étaient redevenus sains entre les passes et la seconde a légitimement collecté des couples site-fenêtre que la première avait sautés. Ce sont de nouvelles clés, pas des doublons. Comparé sur les ensembles de clés : ``` passe 1 · bornes 2026-08-31T00:00:00.000Z à 2026-09-03T00:00:00.000Z passe 2 · bornes 2026-08-31T00:00:00.000Z à 2026-09-03T00:00:00.000Z 4 fenêtres écrites, 1 seule clé nouvelle clés de la passe 1 disparues après la passe 2 : 0 ``` Trois écritures sur quatre réécrites en place, et surtout des **bornes identiques** — la chose même qui était cassée. La clé nouvelle vient du site redevenu sain. ### Les points « à traiter aussi » **Le critère 1 est amendé au #107**, avec le tableau des 84 observations en commentaire. Tu as raison sur le principe : un écart justifié qui ne vit que dans une demande de fusion disparaît à la fusion. J'ai précisé le critère 4 dans la foulée — il disait « la même fenêtre », il dit « la même commande », qui est ce qui compte pour cron. **Le §6 d'`etl-pipeline` est corrigé.** Tu as l'œil : le README et `statut.py` avaient suivi le changement de filtre, pas lui. **`docs/runbooks/collecteur.md` existe**, et il est dans l'index. Il porte les trois programmes, comment vérifier que ça tourne, comment lire les codes de sortie — qui ne disent pas la même chose d'un programme à l'autre — et pourquoi un rattrapage demande plusieurs passes. Le renvoi à l'ADR 0007 pointait bien sur Terraform, c'est 0008. **Tes deux constats ont leur ticket** : le **#113**, et la demande **#114** qui le livre. Ouverts pendant que tu relisais, d'où le télescopage. Le rôle `app` y pose le mode de `/etc/enervision`, l'environnement Python, le bit d'exécution des lanceurs, les lignes de crontab et les migrations. Tu avais raison de dire que le `0710` posé à la main serait reperdu : il l'aurait été au premier `--tags app`. ### Les détails Tous traités, et deux d'entre eux étaient meilleurs que des détails. **Le `mtime` de gzip** : même contenu, octets différents à chaque passe, donc une version créée pour rien sur un seau versionné. L'idempotence se reperdait par la porte de derrière. `mtime=0`, avec un cas de test. **Le repli des alertes sur l'heure de réception** cassait bien la déduplication que je protège trois lignes plus haut. Elles vont sous `dt=inconnu/hour=inconnu`, partition stable d'une passe à l'autre. Le reste : `ignorees` séparé de `perdues`, une source qui produit une alerte malformée en continu ne fait plus sonner cron chaque minute. La limite est recalculée par fenêtre, la dernière recevait celle des fenêtres pleines. `Resultat.a_rejouer` devient `a_reprendre`. `ecrire_json` devient `ecrire_objet`. Et `_appeler` avec `ClientHTTP` remontent dans un `http.py` partagé — tu as raison, trois modules qui importent le privé du voisin, ce n'est plus un privé. Le seul que j'ai traité par un commentaire plutôt que par du code est l'horodatage naïf en sortie : c'est symétrique de ce que la source rend, et vérifié sur l'API réelle, mais l'hypothèse méritait d'être écrite des deux côtés comme `cles.py` le fait déjà. **102 cas unitaires**, `ruff check`, `ruff format --check` et mypy passent. Le critère 7 du #107 reste ouvert et je le laisse ouvert : ce qui a été joué porte sur trois jours, pas un an. Le taux, lui, est connu et il est bas — le simulateur bascule ses capteurs toutes les trente secondes, une passe n'attrape que les sites sains à cet instant. Le rattrapage d'un an demandera plusieurs dizaines de passes, et c'est ce que le manuel dit de faire.
collecteur: refuse un identifiant d'alerte qui ne peut pas nommer un objet
All checks were successful
Intégration / Qualité du code Python (pull_request) Successful in 54s
Intégration / Tests unitaires et couverture (pull_request) Successful in 56s
Intégration / Dépendances du tableau de bord (pull_request) Successful in 14s
Intégration / Images épinglées par version (pull_request) Successful in 3s
Intégration / Tableau de bord (pull_request) Successful in 19s
Intégration / Aucun secret commité (pull_request) Successful in 2s
Intégration / Dépendances Python et inventaire applicatif (pull_request) Successful in 1m28s
41743cccd5
Relecture critique avant de proposer la fusion. L'alert_id devient un segment
de chemin dans la clé bronze : un « / » y créerait une arborescence là où on
attend un objet, et « .. » sortirait du préfixe. La source rend
« ALR-SITE001-1788429204 », mais rien ne le garantit et une clé mal formée
s'écrirait silencieusement ailleurs.

« . » et « .. » sont écartés à part : ils passent la classe de caractères et
désignent pourtant un répertoire.
Member

Revue — feu vert

Les trois points bloquants sont levés, la chaîne est verte, ça peut partir dans develop.

Le point 2 est la meilleure des trois réponses : l'ancrage sur la grille est expliqué là où il vit, et les cas ajoutés exercent enfin le chemin que cron emprunte. C'était exactement le trou.

Ce que j'ai rejoué moi-même

Contrôle Résultat
Chaîne sur 41743cc 7 tâches vertes (exécution 165)
ruff check + ruff format --check propres, au ruff 0.16.5 de requirements-dev.txt
mypy strict 10 fichiers, 0 erreur — il tourne enfin
pytest + couverture 175 cas dont 103 sur le collecteur ; 87,3 % global (seuil 70), 90 % zones sensibles (seuil 85)
Base assise sur a2f817e, la tête de develop ; aucun conflit

Les trois blocages

  1. ruff format / mypy — corrigé, et la conséquence que je signalais (mypy jamais exécuté) est levée.
  2. Idempotence en ligne de commandeancrer() cale les bornes sur un multiple de la fenêtre depuis l'époque Unix. test_deux_lancements_a_une_minute_d_ecart… compare les ensembles de clés et vérifie ecritures == 2 × len(cles) : réécriture, pas accumulation. Le partitionnement est couvert aussi (/dt=2026-09-02/hour=00/).
  3. Code de sortie sur /sensors/status illisiblestatut_lu sépare « rien à faire » de « je ne sais pas », le README suit, et le test passe par main(). Cohérent avec current.code_de_sortie.

Les détails sont tous traités, dont les deux qui comptaient (mtime=0, partition dt=inconnu). Renommage a_reprendre propre, aucun reste de ecrire_json ni de _appeler importé chez le voisin. Et 41743cc, poussé après ta réponse, est un bon réflexe : refuser un alert_id qui ne peut pas nommer un objet.


À corriger dans le manuel — deux lignes

Le chemin du dépôt est faux. app_repo_dir vaut /opt/enervision/.repo, et rien ne pose de code sous /opt/enervision/services. Or les lanceurs font cd "${ENERVISION_RACINE:-/opt/enervision}/services/collector". Les deux commandes du manuel appellent le script par son chemin .repo sans poser ENERVISION_RACINE : avec set -eu, le cd échoue et le script meurt. Les en-têtes des trois lanceurs donnent une troisième variante.

Ça dépasse cette demande : la tâche crontab de la #114 pose ENERVISION_PYTHON mais pas ENERVISION_RACINE, et pointe sur {{ app_repo_dir }}/… — le collecteur mourrait chaque minute. Le correctif le plus solide est ici, dans les lanceurs : dériver la racine de $0 (cd "$(dirname "$0")/..") au lieu de la coder en dur. Non vérifié sur la machine, le SSH m'a été refusé ; la démonstration tient sur group_vars/all/vars.yml:16 et sur le rôle app, qui ne clone que dans .repo.

Le tableau des codes de sortie contredit le code qu'il documente. Il annonce pour current et alertes « 0 = au moins un objet écrit », « 1 = bronze n'a rien reçu ». current.code_de_sortie fait 0 if releves and all(r.ecrit …) else 1 : un seul PUT refusé sur sept donne 1, et son docstring argumente longuement contre any. Le README du service le dit juste. C'est le document qu'on lit à 3 h du matin qui dit l'inverse des deux autres.

Points de code, non bloquants

  • alertes.py:106site_id échappe à la validation que 41743cc vient de poser sur alert_id. Même payload, même confiance, même interpolation dans la clé : cle_alerte({…, "site_id": "a/b"}) rend …/site_id=a/b/dt=…. Pas une sortie de seau — une clé S3 est une chaîne opaque — mais une partition Hive cassée pour le lecteur DuckDB, et un vrai .. pour tout outil qui recopie le seau sur un système de fichiers. _SEGMENT existe déjà, il suffit de l'appliquer aux deux.
  • backfill.py:452--fenetre-heures 0 sort en trace d'appels. limite_pour ne valide que le pas ; ancrer fait ecoule // timedelta(0)ZeroDivisionError, code 1 au lieu du 2 réservé aux réglages impossibles. Une fenêtre négative passe aussi le contrôle initial (limit=1 journalisé) et lève plus tard dans fenetres(), non rattrapée. Vérifié en local sur les deux ; un if fenetre <= timedelta(0): raise dans limite_pour couvre le cas.
  • /etc/enervision se reperdra autrement que tu ne le crois. Le rôle app pose déjà 0750 root:deploy (tasks/main.yml:37-44) : ce n'était pas un manque. Ce qui l'a remis en 0700 root:root, ce sont infra/compose/minio/genere-identifiants.sh:25 et mlflow/genere-identifiants.sh:217, tous deux en install -d -m 0700 -o root -g root. Ton manuel dit « un --tags app le rétablit » : vrai, mais la prochaine rotation d'identifiants le recasse. À porter dans la #113 plutôt qu'ici.

Le critère 7 laissé ouvert avec son motif écrit me va, et l'amendement du critère 1 sur le #107 est exactement ce qu'il fallait : un écart justifié qui ne vit que dans une demande de fusion disparaît à la fusion.

Relu par Olivier. Vérifications : chaîne 165 rejouée localement au même outillage, tests et couverture rejoués, main() exercé sur les cas limites, chemins de déploiement repris dans le rôle app et dans la #114.

## Revue — feu vert **Les trois points bloquants sont levés, la chaîne est verte, ça peut partir dans `develop`.** Le point 2 est la meilleure des trois réponses : l'ancrage sur la grille est expliqué là où il vit, et les cas ajoutés exercent enfin le chemin que cron emprunte. C'était exactement le trou. ### Ce que j'ai rejoué moi-même | Contrôle | Résultat | |---|---| | Chaîne sur `41743cc` | 7 tâches vertes (exécution 165) | | `ruff check` + `ruff format --check` | propres, au ruff 0.16.5 de `requirements-dev.txt` | | mypy strict | 10 fichiers, 0 erreur — il tourne enfin | | pytest + couverture | 175 cas dont 103 sur le collecteur ; 87,3 % global (seuil 70), 90 % zones sensibles (seuil 85) | | Base | assise sur `a2f817e`, la tête de `develop` ; aucun conflit | ### Les trois blocages 1. **`ruff format` / mypy** — corrigé, et la conséquence que je signalais (mypy jamais exécuté) est levée. 2. **Idempotence en ligne de commande** — `ancrer()` cale les bornes sur un multiple de la fenêtre depuis l'époque Unix. `test_deux_lancements_a_une_minute_d_ecart…` compare les *ensembles* de clés et vérifie `ecritures == 2 × len(cles)` : réécriture, pas accumulation. Le partitionnement est couvert aussi (`/dt=2026-09-02/hour=00/`). 3. **Code de sortie sur `/sensors/status` illisible** — `statut_lu` sépare « rien à faire » de « je ne sais pas », le README suit, et le test passe par `main()`. Cohérent avec `current.code_de_sortie`. Les détails sont tous traités, dont les deux qui comptaient (`mtime=0`, partition `dt=inconnu`). Renommage `a_reprendre` propre, aucun reste de `ecrire_json` ni de `_appeler` importé chez le voisin. Et `41743cc`, poussé après ta réponse, est un bon réflexe : refuser un `alert_id` qui ne peut pas nommer un objet. --- ### À corriger dans le manuel — deux lignes **Le chemin du dépôt est faux.** `app_repo_dir` vaut `/opt/enervision/.repo`, et rien ne pose de code sous `/opt/enervision/services`. Or les lanceurs font `cd "${ENERVISION_RACINE:-/opt/enervision}/services/collector"`. Les deux commandes du manuel appellent le script par son chemin `.repo` **sans** poser `ENERVISION_RACINE` : avec `set -eu`, le `cd` échoue et le script meurt. Les en-têtes des trois lanceurs donnent une troisième variante. Ça dépasse cette demande : la tâche crontab de la **#114** pose `ENERVISION_PYTHON` mais pas `ENERVISION_RACINE`, et pointe sur `{{ app_repo_dir }}/…` — le collecteur mourrait chaque minute. Le correctif le plus solide est ici, dans les lanceurs : dériver la racine de `$0` (`cd "$(dirname "$0")/.."`) au lieu de la coder en dur. Non vérifié sur la machine, le SSH m'a été refusé ; la démonstration tient sur `group_vars/all/vars.yml:16` et sur le rôle `app`, qui ne clone que dans `.repo`. **Le tableau des codes de sortie contredit le code qu'il documente.** Il annonce pour `current` et `alertes` « 0 = au moins un objet écrit », « 1 = bronze n'a rien reçu ». `current.code_de_sortie` fait `0 if releves and all(r.ecrit …) else 1` : un seul PUT refusé sur sept donne **1**, et son docstring argumente longuement contre `any`. Le README du service le dit juste. C'est le document qu'on lit à 3 h du matin qui dit l'inverse des deux autres. ### Points de code, non bloquants - **`alertes.py:106` — `site_id` échappe à la validation que `41743cc` vient de poser sur `alert_id`.** Même payload, même confiance, même interpolation dans la clé : `cle_alerte({…, "site_id": "a/b"})` rend `…/site_id=a/b/dt=…`. Pas une sortie de seau — une clé S3 est une chaîne opaque — mais une partition Hive cassée pour le lecteur DuckDB, et un vrai `..` pour tout outil qui recopie le seau sur un système de fichiers. `_SEGMENT` existe déjà, il suffit de l'appliquer aux deux. - **`backfill.py:452` — `--fenetre-heures 0` sort en trace d'appels.** `limite_pour` ne valide que le pas ; `ancrer` fait `ecoule // timedelta(0)` → `ZeroDivisionError`, code 1 au lieu du 2 réservé aux réglages impossibles. Une fenêtre négative passe aussi le contrôle initial (`limit=1` journalisé) et lève plus tard dans `fenetres()`, non rattrapée. Vérifié en local sur les deux ; un `if fenetre <= timedelta(0): raise` dans `limite_pour` couvre le cas. - **`/etc/enervision` se reperdra autrement que tu ne le crois.** Le rôle `app` pose déjà `0750 root:deploy` (`tasks/main.yml:37-44`) : ce n'était pas un manque. Ce qui l'a remis en `0700 root:root`, ce sont `infra/compose/minio/genere-identifiants.sh:25` et `mlflow/genere-identifiants.sh:217`, tous deux en `install -d -m 0700 -o root -g root`. Ton manuel dit « un `--tags app` le rétablit » : vrai, mais la prochaine rotation d'identifiants le recasse. À porter dans la #113 plutôt qu'ici. --- Le critère 7 laissé ouvert avec son motif écrit me va, et l'amendement du critère 1 sur le #107 est exactement ce qu'il fallait : un écart justifié qui ne vit que dans une demande de fusion disparaît à la fusion. *Relu par Olivier. Vérifications : chaîne 165 rejouée localement au même outillage, tests et couverture rejoués, `main()` exercé sur les cas limites, chemins de déploiement repris dans le rôle `app` et dans la #114.*
olivier approved these changes 2026-09-03 12:25:52 +00:00
olivier left a comment

Feu vert. Les trois points bloquants sont levés et vérifiés, la chaîne est verte sur 41743cc. Détail en commentaire à part (#issuecomment-1461) : deux corrections de manuel et trois points de code, aucun ne bloque la fusion.

Feu vert. Les trois points bloquants sont levés et vérifiés, la chaîne est verte sur 41743cc. Détail en commentaire à part (#issuecomment-1461) : deux corrections de manuel et trois points de code, aucun ne bloque la fusion.
lenaic merged commit e0395db2cb into develop 2026-09-03 12:28:03 +00:00
lenaic deleted branch lenaic/107-rattrapage-par-lots 2026-09-03 12:28:03 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
g2/enervision!111
No description provided.