fix(coeur): la garde de divergence compare aussi la charge, sur les trois types de slot - #282
Open
gplanchat wants to merge 2 commits into
Open
fix(coeur): la garde de divergence compare aussi la charge, sur les trois types de slot#282gplanchat wants to merge 2 commits into
gplanchat wants to merge 2 commits into
Conversation
DUR042 ne comparait que le nom de l'activité au slot. La charge ne traversait jamais la comparaison : un replay qui recalculait un payload différent voyait le journal servir l'ancien résultat, la charge fraîche partir à la poubelle, et l'exécution se terminer en succès en ayant menti sur ce qu'elle avait demandé. Mesuré par mutation sur une maquette d'agent, avant correctif : neuf charges calculées, trois journalisées, six divergences avalées sans un mot, suite verte. `activityPayloadForSlot(): ?array` rejoint le port, et la comparaison passe par une empreinte canonique calculée **des deux côtés au même endroit** — c'est ce qui la rend symétrique entre un côté enregistré qui a fait l'aller-retour JSON du magasin et un côté frais qui ne l'a pas fait. Ce que la garde ne voit volontairement pas, parce qu'un faux positif arrête une exécution saine et coûte plus cher que le trou qu'il bouche : - un objet dont le journal ne retient rien — DTO à propriétés privées, le style de la maison — rend la même empreinte des deux côtés ; - l'ordre des clés d'un objet JSON, qui n'est pas de l'information ; l'ordre d'une liste, lui, en est ; - une charge inencodable (ressource, NAN) désarme la garde ; - une histoire écrite avant ce changement n'a rien à comparer. Le message nomme l'octet où les deux empreintes divergent et montre une fenêtre autour : sur une charge d'agent de 4 ko, deux préfixes tronqués se ressemblaient trait pour trait et n'apprenaient rien. Hors périmètre : les slots Nexus et workflow enfant comparent toujours leur seule identité. Le trou y est le même, et l'enjeu plus grand côté Nexus, où le doublon part chez un tiers. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
gplanchat
force-pushed
the
fix/dur042-compare-aussi-la-charge
branch
from
September 4, 2026 18:18
9fa4ada to
823d40e
Compare
La garde de charge ne couvrait que les activités. Les deux autres types de slot
avaient le même trou : l'identité comparée, la charge jamais.
Une seule méthode pour les trois — `refusePayloadDivergence()` — comme
`refuseDivergence()` tient déjà l'identité des trois. Ce qui identifie un slot
n'étant pas de même nature partout, le type et l'identité viennent de
l'appelant : un nom pour une activité, un type pour un enfant, un triplet pour
Nexus.
Aucun champ ajouté à aucun événement : les trois charges étaient déjà sur le
fil. Le désenveloppage, lui, diffère aux trois endroits, et c'est là qu'une
erreur se serait cachée :
- activité — `Payloads` d'un élément portant l'enveloppe TemporalActivityScheduleInput ;
- Nexus — un `Payload` **nu**, l'enveloppe ayant été retirée du tampon (tâche 1.1) ;
- enfant — `Payloads` d'un élément portant l'input nu.
Les fixtures sont écrites d'après ce que le tampon produit, pas d'après le
voisin : celle de NexusSlotDivergenceTest est antérieure au retrait de
l'enveloppe et porte encore `{operationId, payload}`.
Nexus n'est gardé que côté pont Temporal, et c'est structurel : le backend
journal refuse ces opérations par construction (DUR036) et son événement ne
porte que le site d'appel.
`fromEvents()` permet de fabriquer un historique Temporal synthétique sans
serveur — ce qui lève au passage la réserve de la PR sur le chemin Temporal des
activités, désormais mesuré lui aussi.
Vérifié par mutation : neutraliser la garde partagée fait tomber exactement les
sept tests qui l'éprouvent, sur les trois types de slot.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Le trou
DUR042 ne comparait que le nom de l'activité au slot :
La charge ne traversait jamais la comparaison. Un replay qui recalcule un payload différent voit le journal servir l'ancien résultat, la charge fraîche partir à la poubelle, et l'exécution se terminer en succès en ayant menti sur ce qu'elle avait demandé.
Mesuré, pas supposé
Mutation injectée dans le client de modèle de la maquette d'agent — une charge qui diverge entre deux passes de rejeu du même run, identique d'un run à l'autre :
uniqid()dans la chargeSonde posée sur les charges calculées dans
DurableModelClient::request(), avant correctif : 12 charges calculées pour 3 journalisées —1+1+2+2+3+3sur les six passes de rejeu. Sans mutation, ces douze empreintes se réduisent à trois valeurs distinctes ; sous mutation, elles sont douze, donc neuf divergences avalées sans un mot.Le correctif
activityPayloadForSlot(): ?arrayrejointWorkflowHistorySourceInterface, implémenté par les deux backends. Aucun champ ajouté aux événements :ActivityScheduledporte déjà la charge, et l'entrée Temporal aussi — les histoires anciennes restent donc lisibles, ce que la garde exige d'elle-même depuis DUR042.L'empreinte canonique est calculée des deux côtés au même endroit, dans
ExecutionContext. C'est le point important : le côté enregistré a fait l'aller-retour JSON du magasin, le côté frais non. Deux canonicaliseurs séparés auraient dérivé, et la dérive se serait vue en production sous forme de faux positifs irreproductibles d'un backend à l'autre.Ce que la garde ne voit pas, volontairement
Un faux positif arrête une exécution saine ; il coûte plus cher que le trou qu'il bouche. Donc :
{}à l'aller contre[]au retour faisait diverger à chaque reprise toute exécution portant un DTO ;NAN) désarme la garde plutôt que d'accuser ce qu'elle ne sait pas lire ;Le message
Sur une charge d'agent de 4 ko, deux préfixes tronqués se ressemblaient trait pour trait. Le message nomme donc l'octet de divergence et montre une fenêtre autour :
Il dit aussi que la cause est du code non déterministe, pas un écart de version — l'ancien message renvoyait vers
ChangePoint, qui n'y peut rien.Vérification
tests/unit/Durable/Replay/ActivityPayloadDivergenceTest.php— les deux moitiés de la règle, ce qui doit être refusé et tout ce qui doit continuer de passer.Illuminate\Cache\*absentes de ce vendor, présentes surmain. Zéro régression.main. Psalm : 1, la même.cs:check: 0.Les trois formes de fil
La garde relit trois entrées d'appel, et elles ne se valent pas — c'est là qu'une erreur de désenveloppage se cacherait :
Payloadsd'un élément, portant l'enveloppeTemporalActivityScheduleInputPayloadnu — l'enveloppe{operationId, payload}a été retirée du tampon (tâche 1.1)Payloadsd'un élément, portant l'input nuLes fixtures sont écrites d'après ce que
TemporalWorkflowCommandBufferproduit, pas d'après le voisin : celle deNexusSlotDivergenceTestest antérieure au retrait de l'enveloppe et porte encore l'ancienne forme. Ne pas aligner les nouvelles dessus.TemporalExecutionHistory::fromEvents()fabrique un historique synthétique sans serveur — ce qui lève la réserve que portait la première version de cette PR : le chemin Temporal des activités est désormais mesuré lui aussi, au même titre que Nexus et l'enfant.Nexus n'est gardé que côté pont, et c'est structurel : le backend journal refuse ces opérations par construction (DUR036), et son
NexusOperationScheduledne porte que le site d'appel — jamais la charge.EventStoreHistorySource::nexusOperationPayloadForSlot()rend doncnull, avec la raison écrite.Une garde, trois slots
refusePayloadDivergence()est unique et prend son type de slot de l'appelant — exactement commerefuseDivergence()tient déjà l'identité des trois. Ce qui identifie un slot n'est pas de même nature partout : un nom pour une activité, un type pour un enfant, un triplet pour Nexus, et le message le dit dans les termes du slot concerné.Aucun champ ajouté à aucun événement, sur les trois : les charges étaient déjà sur le fil.
Note de performance
activityPayloadForSlot()fait sa propre passe sur le flux, comme ses voisines. Le cheminactivity()passe donc de trois à quatre balayages par appel. C'est le motif existant, pas un nouveau — mais il reste quadratique sur une exécution longue.