collecteur: rattrapage par lots de l'historique et ingestion des alertes #111
Labels
No labels
Compat/Breaking
EC01
EC02
EC03
EC04
EC05
EC06
Kind/BDD
Kind/Back
Kind/Bug
Kind/CICD
Kind/Cloud
Kind/Contenu
Kind/Data
Kind/Documentation
Kind/Enhancement
Kind/Feature
Kind/Front
Kind/IA
Kind/Infra
Kind/Monitoring
Kind/Security
Kind/Testing
Portée/Post-jury
Priority
Critical
Priority
High
Priority
Low
Priority
Medium
Reviewed
Confirmed
Reviewed
Duplicate
Reviewed
Invalid
Reviewed
Won't Fix
Status
Abandoned
Status
Blocked
Status
Need More Info
ops/alerte
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
g2/enervision!111
Loading…
Reference in a new issue
No description provided.
Delete branch "lenaic/107-rattrapage-par-lots"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Ce que ça change
Le rattrapage par lots de l'historique
/readingset l'ingestion des alertes, encomplément de la relève à la minute du #33. Les deux écrivent sous leurs propres
préfixes bronze,
endpoint=readingsetendpoint=alerts, sans jamais toucher àendpoint=current: c'est ce que l'ADR 0005 prévoit, et c'est ce qui a permisd'é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.mddé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é :
Le pas vaut
fenêtre ÷ limit. Il se choisit donc par--pas-minutes, une fenêtren'est jamais tronquée, et le plafond de 1000 se lit comme une résolution minimale.
Le §6 est corrigé.
Un site n'est
okque 1,8 % du temps, relevé sur 280 observations : 69,6 %critical, 28,6 %degraded. Filtrer là-dessus condamnait le rattrapage à nepresque jamais tourner.
Le bon filtre est le capteur de consommation, pas
overall. Croisement del'état annoncé et de ce que
/readingsrend réellement, 84 observations :overallconsumptionconsumption: failingn'a jamais rien rendu, 77 fois sur 77. Et un sitedegradeddont ce capteur va bien rend des valeurs : l'ancien filtre lesjetait. 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: okne garantit rien. C'est le secondfilet 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/alertsn'accepte quesite_idet
severity, vérifié dans son schéma OpenAPI : ni bornes de temps, ni curseur.Il rend les alertes courantes.
collector.alertesest donc une collecte au fil del'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.endpoint=currentest resté vide pendant tous les essais : les préfixes ne setouchent pas.
Report d'un site en défaut, avec l'échéance que la source donne elle-même :
108 cas unitaires passent, ruff est propre.
Trois choses trouvées en chemin, hors périmètre
/etc/enervisionétait endrwx------ root:root. Le fichierminio.envétait bien en
0640 root:deploycomme l'ADR 0008 l'annonce, maisdeploynepouvait 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.envresterootseul.À porter dans le rôle Ansible
app, sinon une réinstallation le reperd.httpxetminione sont installés nulle part sur le serveur. L'ADR 0008prévoit
python3 -m collector.currentsous cron, mais rien n'installe lesdépendances. Il manque une tâche au rôle
app.collecte-current.shétait livrée non exécutable, en100644, alors quecron l'appelle directement. Passée en
100755ici.Les deux premiers points méritent leur ticket, ils bloquent la mise en service du
collecteur autant que du rattrapage.
Relecture
Ce qui suit le code
docs/data/etl-pipeline.mdcorrigé, §2 et §6, le modèle de pagination y était fauxservices/collector/README.mdcomplétéOù regarder en priorité
Le filtre de
collector/statut.py. C'est la seule décision de conception quin'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.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, pasruff checkL'exécution #715 échoue sur « Qualité du code Python ». Reproduit avec le ruff 0.16.5 de
requirements-dev.txt:ruff checkpasse bien — c'est vrai de la moitié de l'étape, qui estruff 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 prochainruff formatà la racine.)2. L'idempotence ne tient pas depuis la ligne de commande
main()ancre la grille surdatetime.now(UTC)tronqué à la minute :Rejouer la même commande une minute plus tard décale toutes les bornes, donc toutes les clés. En rejouant ton découpage :
Ce sont des fenêtres chevauchantes — exactement ce que le docstring de
fenetres()interdit. Etbronzeé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 decle_lot, et le critère 4 du #107. Les tests ne l'attrapent pas parce qu'ils passentDEBUT/FINen 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_atronqué au jour, ou à un multiple defenetre), ou accepter--depuis/--jusqu-aexplicites. Plus un test qui appellemain()deux fois avec une horloge décalée.3.
/sensors/statusinjoignable → code de sortie 0Un 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
erroret sortir en 0 ne se défend pas.test_un_etat_des_capteurs_illisible_arrete_toutvérifielots == []et jamais le code de sortie.À traiter aussi
critical», le code filtre surconsumptionet collecte des sitesdegraded. 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é depuis8d91991: il dit encore « saute les sites qui ne sont pasok», soit le filtre suroverallque ce commit a remplacé. Le README etstatut.pyont suivi, pas celui-ci.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:4renvoie à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 estterraform-etat-distant, le mode d'exécution est l'ADR 0008./etc/enervisionni l'installation dehttpx/miniosur le serveur. Le0710posé à 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
alertes.py:78alert_id: la même alerte sanstimestamplisible atterrit sous deux préfixesdt=/hour=à deux passes.alertes.py:56Passe.completeexigeignorees == 0, etignoreesmé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 —currenttranche l'inverse.backfill.py:311limiteest 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/:264Lot.a_rejouer(rien d'exploitable) etResultat.a_rejouer(not ecrit) divergent sur un échec d'écriture. En renommer un.backfill.py:44,alertes.py:37_appelerest privé et importé depuis.currentpar les deux nouveaux modules. Le remonter avecClientHTTPdans unhttp.pypartagé.stockage.py:21ecrire_json(..., content_type="application/gzip")— le nom ment maintenant.ecrire_objet?backfill.py:198gzip.compressinscrit un mtime : même contenu, octets différents à chaque passe. Sur unbronzeversionné, chaque rejeu crée une version pour rien →mtime=0.backfill.py:161start_time/end_timepartent en naïf, sansZ. Symétrique de la source et validé sur l'API réelle, maisstatut.pyprend 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.
Commentaire plus détaillé a part
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 bonneet 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 surnow()tronqué à la minute, mes cas de test passaient lesbornes 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=ethour=désignent une vraie journée civile au lieud'enjamber deux dates.
main()accepte maintenanthorloge,httpetdepot— une couture pour les tests, etla 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/statusillisible. Tu as raison aussi, journaliseren
erroret sortir en 0 ne se défend pas.Resultatporte unstatut_lu, distinctd'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 :
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-pipelineest corrigé. Tu as l'œil : le README etstatut.pyavaientsuivi le changement de filtre, pas lui.
docs/runbooks/collecteur.mdexiste, et il est dans l'index. Il porte les troisprogrammes, 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
appy pose le mode de/etc/enervision, l'environnement Python, le bit d'exécution des lanceurs, les lignes decrontab et les migrations. Tu avais raison de dire que le
0710posé à la main seraitreperdu : 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
mtimede gzip : même contenu, octets différents à chaque passe, donc une versioncréé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, partitionstable d'une passe à l'autre.
Le reste :
ignoreesséparé deperdues, une source qui produit une alerte malformée encontinu 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_rejouerdevienta_reprendre.ecrire_jsondevientecrire_objet. Et_appeleravecClientHTTPremontent dans unhttp.pypartagé — tu as raison, trois modules qui importent le privé du voisin, cen'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.pyle fait déjà.102 cas unitaires,
ruff check,ruff format --checket 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.
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
41743ccruff check+ruff format --checkrequirements-dev.txta2f817e, la tête dedevelop; aucun conflitLes trois blocages
ruff format/ mypy — corrigé, et la conséquence que je signalais (mypy jamais exécuté) est levée.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érifieecritures == 2 × len(cles): réécriture, pas accumulation. Le partitionnement est couvert aussi (/dt=2026-09-02/hour=00/)./sensors/statusillisible —statut_lusépare « rien à faire » de « je ne sais pas », le README suit, et le test passe parmain(). Cohérent aveccurrent.code_de_sortie.Les détails sont tous traités, dont les deux qui comptaient (
mtime=0, partitiondt=inconnu). Renommagea_reprendrepropre, aucun reste deecrire_jsonni de_appelerimporté chez le voisin. Et41743cc, poussé après ta réponse, est un bon réflexe : refuser unalert_idqui ne peut pas nommer un objet.À corriger dans le manuel — deux lignes
Le chemin du dépôt est faux.
app_repo_dirvaut/opt/enervision/.repo, et rien ne pose de code sous/opt/enervision/services. Or les lanceurs fontcd "${ENERVISION_RACINE:-/opt/enervision}/services/collector". Les deux commandes du manuel appellent le script par son chemin.reposans poserENERVISION_RACINE: avecset -eu, lecdé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_PYTHONmais pasENERVISION_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 surgroup_vars/all/vars.yml:16et sur le rôleapp, qui ne clone que dans.repo.Le tableau des codes de sortie contredit le code qu'il documente. Il annonce pour
currentetalertes« 0 = au moins un objet écrit », « 1 = bronze n'a rien reçu ».current.code_de_sortiefait0 if releves and all(r.ecrit …) else 1: un seul PUT refusé sur sept donne 1, et son docstring argumente longuement contreany. 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 que41743ccvient de poser suralert_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._SEGMENTexiste déjà, il suffit de l'appliquer aux deux.backfill.py:452—--fenetre-heures 0sort en trace d'appels.limite_pourne valide que le pas ;ancrerfaitecoule // 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=1journalisé) et lève plus tard dansfenetres(), non rattrapée. Vérifié en local sur les deux ; unif fenetre <= timedelta(0): raisedanslimite_pourcouvre le cas./etc/enervisionse reperdra autrement que tu ne le crois. Le rôleapppose déjà0750 root:deploy(tasks/main.yml:37-44) : ce n'était pas un manque. Ce qui l'a remis en0700 root:root, ce sontinfra/compose/minio/genere-identifiants.sh:25etmlflow/genere-identifiants.sh:217, tous deux eninstall -d -m 0700 -o root -g root. Ton manuel dit « un--tags apple 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ôleappet dans la #114.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.