Skip to content

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
mainfrom
fix/dur042-compare-aussi-la-charge
Open

fix(coeur): la garde de divergence compare aussi la charge, sur les trois types de slot#282
gplanchat wants to merge 2 commits into
mainfrom
fix/dur042-compare-aussi-la-charge

Conversation

@gplanchat

@gplanchat gplanchat commented Sep 4, 2026

Copy link
Copy Markdown
Owner

Le trou

DUR042 ne comparait que le nom de l'activité au slot :

$this->refuseDivergence('activity', $slotIndex, $this->historySource->activityNameForSlot($slotIndex), $requested);

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 :

avant après
sans mutation vert vert (aucun faux positif)
uniqid() dans la charge rouge rouge
charge divergente au rejeu vert ← le trou rouge

Sonde posée sur les charges calculées dans DurableModelClient::request(), avant correctif : 12 charges calculées pour 3 journalisées1+1+2+2+3+3 sur 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(): ?array rejoint WorkflowHistorySourceInterface, implémenté par les deux backends. Aucun champ ajouté aux événements : ActivityScheduled porte 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 :

  • 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. Vérifié avant d'être écrit : sans cette normalisation, {} à l'aller contre [] au retour faisait diverger à chaque reprise toute exécution portant un DTO ;
  • l'ordre des clés d'un objet JSON n'est pas de l'information ; l'ordre d'une liste, lui, en est, et une liste réordonnée diverge ;
  • une charge inencodable (ressource, NAN) désarme la garde plutôt que d'accuser ce qu'elle ne sait pas lire ;
  • une histoire écrite avant ce changement n'a rien à comparer et passe inchangée.

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 :

payload changed at byte 4072. History recorded …"model":"mistral-small-latest","nonce":1}},
code scheduled …"model":"mistral-small-latest","nonce":2}} (4075 and 4075 bytes)

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

  • 9 tests neufs dans 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.
  • Suite complète : 13 erreurs avant, 13 après, toutes Illuminate\Cache\* absentes de ce vendor, présentes sur main. Zéro régression.
  • PHPStan : 2 erreurs, les mêmes que 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 :

slot forme sur le fil
activité Payloads d'un élément, portant l'enveloppe TemporalActivityScheduleInput
Nexus un Payload nu — l'enveloppe {operationId, payload} a é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 TemporalWorkflowCommandBuffer produit, pas d'après le voisin : celle de NexusSlotDivergenceTest est 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 NexusOperationScheduled ne porte que le site d'appel — jamais la charge. EventStoreHistorySource::nexusOperationPayloadForSlot() rend donc null, avec la raison écrite.

Une garde, trois slots

refusePayloadDivergence() est unique et prend son type de slot de l'appelant — exactement comme refuseDivergence() 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 chemin activity() 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.

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
gplanchat force-pushed the fix/dur042-compare-aussi-la-charge branch from 9fa4ada to 823d40e Compare September 4, 2026 18:18
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>
@gplanchat gplanchat changed the title fix(coeur): la garde de divergence compare aussi la charge de l'activité fix(coeur): la garde de divergence compare aussi la charge, sur les trois types de slot Sep 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant