collector : une passe de rattrapage pauvre n'efface plus une passe riche #162

Merged
lenaic merged 3 commits from lenaic/107-rattrapage-ne-degrade-pas into develop 2026-09-07 13:25:44 +00:00
Owner

collector : une passe de rattrapage pauvre n'efface plus une passe riche

Ce qui a été mesuré aujourd'hui

La source applique l'état de panne courant d'un site à tout son historique.
La même URL, la même fenêtre, appelée trois fois à vingt secondes d'écart :

passe 1 :  0 / 100 points avec valeur
passe 2 : 21 / 100
passe 3 :  0 / 100

C'est pour cette raison que docs/runbooks/collecteur.md conseille de relancer
le rattrapage plusieurs fois.

Le défaut

L'écriture visait la même clé sans rien comparer. Une passe rendant 2 points
remplaçait donc tranquillement une passe qui en avait rendu 22, et le conseil
« relancer jusqu'à ce que la liste se vide » supposait une amélioration monotone
que le code ne garantissait pas.

Le versionnement du seau gardait bien l'ancienne version, récupérable par
version_id. Ça ne suffit pas : la zone argent lit la version courante, et
personne ne va parcourir des versions à la main pour retrouver la bonne.

Le correctif

Le protocole Depot gagne une lecture, metadonnees_objet, et l'objet porte
désormais points-avec-valeur — écrit pour être relu par la passe suivante. Un
stat suffit à comparer, sans télécharger ni décompresser.

Une comparaison impossible laisse écrire : clé absente, dépôt qui ne répond
pas, objet antérieur à cette métadonnée, ou dépôt qui ne sait pas lire. On
préfère une écriture de trop à une passe utile perdue.

Sur les deux cas existants qui changent

« L'idempotence tient à la clé » et « la même commande rejouée n'en crée pas
d'autres » restent vraies et restent vérifiées. C'est le compteur d'écritures
qui décrivait un effet de bord, et qui vaut maintenant un de moins quand la
seconde passe n'apporte rien. Cinq cas neufs couvrent le garde-fou, dont celui
qui motive tout le reste : une passe plus pauvre ne remplace pas celle en place.

Contexte utile

Six passes sur 30 jours ont été jouées aujourd'hui : ~5 fenêtres écrites sur
420 par passe, 0,0 % de points avec valeur
. La source ne rend pas son
historique en ce moment — aucun site sain sur 8 essais en 96 secondes. Le
rattrapage n'est donc pas la voie pour l'historique d'entraînement ; ce correctif
sert le jour où la source se rétablit.

Preuve

pytest tests/unit : 601 tests, 0 échec. ruff, ruff format,
mypy --strict sur le collecteur : propres.

Suite des #107 et #111.

# collector : une passe de rattrapage pauvre n'efface plus une passe riche ## Ce qui a été mesuré aujourd'hui La source applique l'état de panne **courant** d'un site à tout son historique. La même URL, la même fenêtre, appelée trois fois à vingt secondes d'écart : ``` passe 1 : 0 / 100 points avec valeur passe 2 : 21 / 100 passe 3 : 0 / 100 ``` C'est pour cette raison que `docs/runbooks/collecteur.md` conseille de relancer le rattrapage plusieurs fois. ## Le défaut L'écriture visait la même clé **sans rien comparer**. Une passe rendant 2 points remplaçait donc tranquillement une passe qui en avait rendu 22, et le conseil « relancer jusqu'à ce que la liste se vide » supposait une amélioration monotone que le code ne garantissait pas. Le versionnement du seau gardait bien l'ancienne version, récupérable par `version_id`. **Ça ne suffit pas** : la zone argent lit la version *courante*, et personne ne va parcourir des versions à la main pour retrouver la bonne. ## Le correctif Le protocole `Depot` gagne une lecture, `metadonnees_objet`, et l'objet porte désormais `points-avec-valeur` — écrit pour être relu par la passe suivante. Un `stat` suffit à comparer, sans télécharger ni décompresser. **Une comparaison impossible laisse écrire** : clé absente, dépôt qui ne répond pas, objet antérieur à cette métadonnée, ou dépôt qui ne sait pas lire. On préfère une écriture de trop à une passe utile perdue. ## Sur les deux cas existants qui changent « L'idempotence tient à la clé » et « la même commande rejouée n'en crée pas d'autres » **restent vraies et restent vérifiées**. C'est le compteur d'écritures qui décrivait un effet de bord, et qui vaut maintenant un de moins quand la seconde passe n'apporte rien. Cinq cas neufs couvrent le garde-fou, dont celui qui motive tout le reste : *une passe plus pauvre ne remplace pas celle en place*. ## Contexte utile Six passes sur 30 jours ont été jouées aujourd'hui : **~5 fenêtres écrites sur 420 par passe, 0,0 % de points avec valeur**. La source ne rend pas son historique en ce moment — aucun site sain sur 8 essais en 96 secondes. Le rattrapage n'est donc pas la voie pour l'historique d'entraînement ; ce correctif sert le jour où la source se rétablit. ## Preuve `pytest tests/unit` : **601 tests, 0 échec**. `ruff`, `ruff format`, `mypy --strict` sur le collecteur : propres. Suite des #107 et #111.
collector: une passe de rattrapage pauvre n'efface plus une passe riche
All checks were successful
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 19s
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 4s
Intégration / Aucun secret commité (pull_request) Successful in 2s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 3m20s
7403d887ea
La source applique l'état de panne COURANT d'un site à tout son historique.
Mesuré le 06/09 : la même URL, la même fenêtre, appelée trois fois à vingt
secondes d'écart, rend 0 puis 21 puis 0 points sur 100. C'est pour ça que le
manuel dit de relancer le rattrapage plusieurs fois.

Sauf que l'écriture visait la même clé sans rien comparer. Une passe rendant
2 points remplaçait donc tranquillement une passe qui en avait rendu 22, et le
conseil « relancer jusqu'à ce que la liste se vide » supposait une amélioration
monotone que le code ne garantissait pas. Le versionnement du seau gardait bien
l'ancienne version, mais la zone argent lit la version COURANTE : personne ne va
parcourir des `version_id` à la main pour retrouver la bonne.

Le protocole `Depot` gagne donc une lecture, `metadonnees_objet`, et l'objet
porte désormais `points-avec-valeur` — écrit pour être relu par la passe
suivante. Un `stat` suffit à comparer, sans télécharger ni décompresser.

Une comparaison impossible laisse écrire : clé absente, dépôt qui ne répond
pas, objet antérieur à cette métadonnée, ou dépôt qui ne sait pas lire. On
préfère une écriture de trop à une passe utile perdue.

Deux cas existants changent d'assertion, pas d'intention. « L'idempotence tient
à la clé » et « la même commande rejouée n'en crée pas d'autres » restent vraies
et restent vérifiées ; c'est le compteur d'écritures qui décrivait un effet de
bord, et qui vaut maintenant un de moins quand la seconde passe n'apporte rien.
lenaic requested review from olivier 2026-09-07 07:06:38 +00:00
lenaic self-assigned this 2026-09-07 07:24:54 +00:00
collector: une fenêtre conservée n'est pas une fenêtre à reprendre (#107)
Some checks failed
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 20s
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 4s
Intégration / Aucun secret commité (pull_request) Successful in 2s
Intégration / Python — qualité, tests et dépendances (pull_request) Has been cancelled
Infra Ansible / Playbooks Ansible valides (pull_request) Successful in 1m23s
7f83b8ced7
Le garde-fou rendait la fenêtre avec `ecrit=False`, ce que `a_reprendre`
lit comme « pas d'objet en bronze ». Conséquence : une fenêtre gardée
parce que bronze la portait déjà, et mieux, retournait dans la liste
« à rejouer » et `code_de_sortie` valait 1.

Une seconde exécution de la même commande sortait donc en 1 alors
qu'elle n'avait plus rien à faire — mesuré, `[0, 1]` sur deux passes
identiques. Le manuel annonce 0 pour « tout ce qui était collectable est
écrit » et conseille de « relancer jusqu'à ce que la liste se vide » :
cette liste ne se vidait jamais, et chaque cron nocturne retombant sur
un bronze complet aurait échoué.

`Lot` gagne donc `conserve`, qui dit « bronze l'a, et mieux » là où
`ecrit` faux dit seulement « rien n'a été écrit ». Les deux divergent, et
c'est la distinction que `a_reprendre` demandait. Le résumé les compte à
part : « 0/420 fenêtres écrites, 420 déjà en place » ne se lit plus comme
un échec.

Deux points de solidité au passage. La lecture des métadonnées était le
seul appel au dépôt hors `try` : `collecter_fenetre` promet de ne jamais
lever et `rattraper` l'appelle sans filet, donc une exception y perdait
toutes les fenêtres restantes, pas une. Et `int()` ne rattrapait que
`ValueError` là où une métadonnée non textuelle donne `TypeError`.

`pytest tests/unit` : 604 tests, 0 échec. `ruff`, `ruff format`, `mypy
--strict` sur les trois paquets typés : propres.
Merge branch 'develop' into lenaic/107-rattrapage-ne-degrade-pas
All checks were successful
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 33s
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 6s
Intégration / Workflows — lint et audit de sécurité (pull_request) Successful in 17s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 5m21s
d9484771a0
olivier approved these changes 2026-09-07 13:25:39 +00:00
Dismissed
olivier left a comment

Revue

Le défaut est réel et bien cerné, et le correctif est le bon : comparer avant
d'écrire, sur une métadonnée qu'un stat suffit à lire, et laisser écrire dès
que la comparaison est impossible. Les cinq cas neufs décrivent le garde-fou, et
la reprise des deux cas existants est honnête — c'est bien le compteur
d'écritures qui décrivait un effet de bord, pas l'idempotence.

Un défaut bloquant, corrigé sur la branche (7f83b8c)

Le garde-fou rendait la fenêtre avec ecrit=False, or a_reprendre lit ce
drapeau comme « pas d'objet en bronze » :

return [lot for lot in self.lots if not lot.ecrit]   # avant

Une fenêtre gardée parce que bronze la portait déjà, et mieux, retournait donc
dans la liste « à rejouer », et code_de_sortie valait 1. Mesuré en ajoutant
l'assertion manquante au cas bout-en-bout existant, sur le code de la branche :

assert codes == [0, 0]
E       assert [0, 1] == [0, 0]

Deux exécutions de la même commande, la seconde sortait en 1 alors qu'elle
n'avait plus rien à faire, et la liste « à rejouer » ressortait entière. Le
manuel annonce 0 pour « tout ce qui était collectable est écrit » et conseille
de « relancer jusqu'à ce que la liste se vide » : cette liste ne se vidait plus
jamais. La demande cherchait à rendre ce conseil sûr ; en l'état elle le rendait
sans fin.

Lot gagne donc conserve, qui dit « bronze l'a, et mieux » là où ecrit faux
dit seulement « rien n'a été écrit ». Les deux divergent — comme ils divergent
déjà sur un échec d'écriture, ce que la docstring de a_reprendre notait — et
c'est précisément la distinction qu'elle demandait. Le résumé les compte à part :
0/420 fenêtres écrites, 420 déjà en place ne se lit plus comme un échec.

Une précision, parce que le message de ce commit va trop loin : il parle d'un
« cron nocturne » qui aurait échoué. C'est faux, et vars.yml le dit
explicitement — rattrapage-readings.sh n'est pas planifié et ne le sera pas.
La portée réelle est la relance à la main, celle que le manuel décrit, et le
code de sortie sur lequel on s'appuie pour savoir s'il reste du travail.

Deux points de solidité, dans le même commit

  • lire(cle) était le seul appel au dépôt hors try. collecter_fenetre
    promet de ne jamais lever et rattraper l'appelle sans filet : une exception
    échappée là n'aurait pas perdu une fenêtre mais toutes celles qui restaient.
    L'adaptateur MinIO rattrape de son côté, donc rien ne casse en production —
    mais le protocole Depot autorise n'importe quel autre dépôt.
  • int(brut) ne rattrapait que ValueError ; une métadonnée non textuelle donne
    TypeError. Le typage du protocole l'exclut, un dépôt tiers non.

Une remarque, pour plus tard et pas pour ici

La clé n'encode que la fenêtre, jamais le pas (cles.py). Un rattrapage relancé
avec un --pas-minutes plus fin vise donc la même clé, et si la source lui rend
autant de points avec valeur qu'à la passe grossière, le >= garde l'objet
grossier. Source rétablie, la passe fine en rend plus et gagne ; c'est le cas
dégradé qui pourrait surprendre. Comparer « points avec valeur » reste le bon
arbitre — c'est la grandeur qui porte le produit — donc je ne touche à rien,
mais ça vaut peut-être une ligne au manuel le jour où quelqu'un relance en plus
fin.

Vérification

Le manuel a été complété : le garde-fou y est décrit, et la lecture de
0/420 écrites, 420 déjà en place avec un code de sortie 0 y est explicite.

Rejoué sur la branche fusionnée avec develop : pytest tests/unit 918 tests
0 échec, ruff, ruff format et mypy --strict propres sur les six paquets
typés. En 3.14 et non en 3.12 — le serveur est inatteignable d'ici — donc c'est
la chaîne qui reste l'arbitre, et elle est verte.

Pour mémoire : la première passe de chaîne sur 7f83b8c avait été annulée en
vol, pas mise en échec. La fusion de la #166 dans develop a recalculé la
référence de la demande, et le groupe de concurrence du workflow annule la passe
la plus ancienne. Les quatre autres paliers étaient verts.

## Revue Le défaut est réel et bien cerné, et le correctif est le bon : comparer avant d'écrire, sur une métadonnée qu'un `stat` suffit à lire, et laisser écrire dès que la comparaison est impossible. Les cinq cas neufs décrivent le garde-fou, et la reprise des deux cas existants est honnête — c'est bien le compteur d'écritures qui décrivait un effet de bord, pas l'idempotence. ### Un défaut bloquant, corrigé sur la branche (7f83b8c) Le garde-fou rendait la fenêtre avec `ecrit=False`, or `a_reprendre` lit ce drapeau comme « pas d'objet en bronze » : ```python return [lot for lot in self.lots if not lot.ecrit] # avant ``` Une fenêtre gardée *parce que* bronze la portait déjà, et mieux, retournait donc dans la liste « à rejouer », et `code_de_sortie` valait 1. Mesuré en ajoutant l'assertion manquante au cas bout-en-bout existant, sur le code de la branche : ``` assert codes == [0, 0] E assert [0, 1] == [0, 0] ``` Deux exécutions de la même commande, la seconde sortait en 1 alors qu'elle n'avait plus rien à faire, et la liste « à rejouer » ressortait entière. Le manuel annonce 0 pour « tout ce qui était collectable est écrit » et conseille de « relancer jusqu'à ce que la liste se vide » : cette liste ne se vidait plus jamais. La demande cherchait à rendre ce conseil sûr ; en l'état elle le rendait sans fin. `Lot` gagne donc `conserve`, qui dit « bronze l'a, et mieux » là où `ecrit` faux dit seulement « rien n'a été écrit ». Les deux divergent — comme ils divergent déjà sur un échec d'écriture, ce que la docstring de `a_reprendre` notait — et c'est précisément la distinction qu'elle demandait. Le résumé les compte à part : `0/420 fenêtres écrites, 420 déjà en place` ne se lit plus comme un échec. Une précision, parce que le message de ce commit va trop loin : il parle d'un « cron nocturne » qui aurait échoué. C'est faux, et `vars.yml` le dit explicitement — `rattrapage-readings.sh` n'est pas planifié et ne le sera pas. La portée réelle est la relance à la main, celle que le manuel décrit, et le code de sortie sur lequel on s'appuie pour savoir s'il reste du travail. ### Deux points de solidité, dans le même commit - `lire(cle)` était le seul appel au dépôt hors `try`. `collecter_fenetre` promet de ne jamais lever et `rattraper` l'appelle sans filet : une exception échappée là n'aurait pas perdu une fenêtre mais toutes celles qui restaient. L'adaptateur MinIO rattrape de son côté, donc rien ne casse en production — mais le protocole `Depot` autorise n'importe quel autre dépôt. - `int(brut)` ne rattrapait que `ValueError` ; une métadonnée non textuelle donne `TypeError`. Le typage du protocole l'exclut, un dépôt tiers non. ### Une remarque, pour plus tard et pas pour ici La clé n'encode que la fenêtre, jamais le pas (`cles.py`). Un rattrapage relancé avec un `--pas-minutes` plus fin vise donc la même clé, et si la source lui rend autant de points *avec valeur* qu'à la passe grossière, le `>=` garde l'objet grossier. Source rétablie, la passe fine en rend plus et gagne ; c'est le cas dégradé qui pourrait surprendre. Comparer « points avec valeur » reste le bon arbitre — c'est la grandeur qui porte le produit — donc je ne touche à rien, mais ça vaut peut-être une ligne au manuel le jour où quelqu'un relance en plus fin. ### Vérification Le manuel a été complété : le garde-fou y est décrit, et la lecture de `0/420 écrites, 420 déjà en place` avec un code de sortie 0 y est explicite. Rejoué sur la branche fusionnée avec develop : `pytest tests/unit` 918 tests 0 échec, `ruff`, `ruff format` et `mypy --strict` propres sur les six paquets typés. En 3.14 et non en 3.12 — le serveur est inatteignable d'ici — donc c'est la chaîne qui reste l'arbitre, et elle est verte. Pour mémoire : la première passe de chaîne sur 7f83b8c avait été *annulée* en vol, pas mise en échec. La fusion de la #166 dans develop a recalculé la référence de la demande, et le groupe de concurrence du workflow annule la passe la plus ancienne. Les quatre autres paliers étaient verts.
olivier approved these changes 2026-09-07 13:25:41 +00:00
lenaic merged commit 25840e251d into develop 2026-09-07 13:25:44 +00:00
lenaic deleted branch lenaic/107-rattrapage-ne-degrade-pas 2026-09-07 13:25:44 +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!162
No description provided.