B2 and B3 — the profiler stops instrumenting production, and its profile serialises - #275
Open
gplanchat wants to merge 2 commits into
Open
B2 and B3 — the profiler stops instrumenting production, and its profile serialises#275gplanchat wants to merge 2 commits into
gplanchat wants to merge 2 commits into
Conversation
…profil se sérialise Deux défauts de la même surface, l'un cachant l'autre. **Le profileur tournait en production.** `registerProfiler()` était appelé sans condition, et l'observateur qu'il aliase est injecté dans ExecutionRuntime, ExecutionEngine et ActivityMessageProcessor : il passait sur le chemin chaud de chaque exécution, pour alimenter une page que personne ne sert en production. Hors kernel.debug, rien de tout cela n'est plus enregistré et l'observation retombe sur NullWorkflowExecutionObserver — ne rien faire est ici un comportement réel, pas un bouche-trou de signature : une exécution que personne ne regarde s'exécute pareil. **Sa trace n'était jamais vidée dans un worker.** ResetDurableProfilerListener écoute kernel.request, que `messenger:consume` ne déclenche jamais ; la timeline grossissait tant que le processus vivait. Le tag `kernel.reset` la confie à services_resetter, qui est ce qui vide entre deux messages. La méthode reset() existait déjà — il ne manquait que la déclaration. **Une charge utile non sérialisable cassait le profil entier.** Le collecteur rangeait les charges utiles brutes et __serialize() les rendait telles quelles : une closure dans une charge utile ne fait pas tomber le panneau Durable, elle fait tomber le profil de la requête, panneaux des autres bundles compris. Une barrière au seul endroit où $this->data est constitué ramène tout à des scalaires et des tableaux — la conversion que le gabarit fait de toute façon pour afficher. JSON_INVALID_UTF8_SUBSTITUTE règle au passage un second défaut du même endroit : un journal porte des octets, pas forcément du texte valide, et le gabarit affichait un vide là où il y avait une charge utile. Un cas garde la forme : une charge utile ordinaire — entiers, flottants, booléens, null, liste, tableau imbriqué — traverse la barrière à l'identique, sinon un aller-retour JSON casserait en silence les clés que le gabarit lit. Le placement de l'objet nul dans le cœur est validé par CoreDependsOnNoHostTest, garde à jeu de données sur chaque fichier de src/Durable qui vérifie qu'aucun n'importe un hôte ni un pont. Suite unit : 1084 tests contre 1073 sur main, mêmes 4 erreurs d'environnement. PHPStan : 2 erreurs, exactement la base de main, aucun diagnostic nouveau. Refs: B2 et B3 de documentation/audit/ Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ockage rejoint le cœur Trois défauts que la relecture croisée a trouvés dans le correctif lui-même. **Le conteneur de production ne compilait plus.** `registerProfiler()` devenu conditionnel, `durable.execution_trace` n'existe plus hors debug — mais `DurableExtension:668` le référençait encore par une référence nue dans la branche Temporal native. Une application de production avec un `temporal.dsn` levait `ServiceNotFoundException` à la compilation ; une boutique Sylius ne démarrait plus. Le constructeur cible déclare pourtant `?DurableExecutionTrace = null` : la référence passe en `NULL_ON_INVALID_REFERENCE`. Huit relectures indépendantes ont atteint ce défaut, trois en compilant réellement. Aucun test ne pouvait le voir : tous chargeaient `load([[]])`, sans DSN, donc sans jamais construire la branche fautive, et aucun n'appelait de passe de compilation. `testHorsDebugUneApplicationTemporaleCompileEncore` charge la configuration Temporale et fait tourner `CheckExceptionOnInvalidReferenceBehaviorPass` — en déclarant en synthétique, à chaque tour, le service manquant que la passe signale, jusqu'à convergence. Ce qui reste est la liste de ce que le bundle attend de FrameworkBundle ; qu'un service **à nous** s'y trouve est le bug. **La barrière de sérialisation était un doublon, et elle mordait plus fort que le défaut.** `DurableDataCollector::storable()` refaisait, en privé et en statique, ce que `Durable\Observation\RecordedDetails` fait pour toutes les surfaces d'observation depuis qu'un `json_encode` sans tolérance a rendu un dépliant vide dans le back-office Sylius. Son docbloc dit déjà que « c'est à cet endroit-ci que la dégradation se décide, pour toutes les surfaces à la fois » : la variante qui rend une structure plutôt qu'un texte y rejoint `of()`. Trois modes de panne, chacun constaté en exécution avant d'être corrigé : - `json_encode` appelle le `jsonSerialize()` de la charge utile, donc du code métier, qui peut lever. Aucun drapeau ne couvre ce cas. L'exception remontait à `collect()` — `kernel.response` — là où le défaut d'origine ne cassait que `saveProfile()`, sur `kernel.terminate`, réponse déjà partie. Le correctif rendait la panne plus précoce et visible de l'utilisateur. - Au-delà de 512 niveaux, `json_decode` rend `null`, affecté à `$this->data` typée `array|Data` chez le parent : `TypeError`. La barrière s'applique donc clé par clé — la charge utile pathologique disparaît seule, le panneau tient. - Sans `JSON_PRESERVE_ZERO_FRACTION`, un `float` de valeur entière revient `int`, et les bornes de frise qui se déclarent `float` mentent sur leur type. La récursion, elle, survit : `JSON_PARTIAL_OUTPUT_ON_ERROR` la coupe et rend le reste. Le cas est testé pour qu'on cesse de le croire cassé. `DiagnoseExecutionCommand` — le frère non traité — déversait `payload()` sans barrière : il lit un journal de production, et une charge utile inencodable y faisait tomber le diagnostic qu'on était venu chercher. Même appel. **L'échappatoire prescrite par UPGRADE.md ne fonctionnait pas.** Aliaser `WorkflowExecutionObserverInterface` sur son propre service était écrasé par le `setAlias()` de l'extension : les définitions du `services.yaml` de l'application existent déjà quand l'extension se charge. L'alias respecte désormais ce que l'application a déclaré, en debug comme en production. Restent ouverts, hors périmètre : `kernel.reset` ne se déclenche pas sur les transports Temporal, qui ne rendent aucune enveloppe — B2 n'est donc fermé que pour la moitié pilotée par le bus ; le double emploi entre ce tag et `ResetDurableProfilerListener`, dont la relecture n'a pas tranché ; et le couplage inversé qui rend tout ceci possible — `Bridge/Temporal` type-hinte une classe du bundle que son `composer.json` ne requiert pas. 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.
Two audit findings on the same surface, one hiding the other.
B2 — the profiler ran in production, and leaked in workers
registerProfiler()was called unconditionally, first thing inload(). This is not neutral plumbing: the service it aliases ontoWorkflowExecutionObserverInterfaceis injected intoExecutionRuntime,ExecutionEngineandActivityMessageProcessor. It therefore sat on the hot path of every execution, feeding a page nobody serves in production.And its trace was only cleared by
ResetDurableProfilerListener, which listens onkernel.request— an eventmessenger:consumenever fires. In a worker, the timeline grew for as long as the process lived.Both halves are handled together:
kernel.debug, nothing of the profiler is registered and observation falls back toNullWorkflowExecutionObserver. Doing nothing is a real behaviour here, not a signature filler — an execution nobody watches runs the same way, likeNullLogger. It is also what FrameworkBundle does, loading its collectors from separate files under a condition.kernel.resettag, which hands it toservices_resetter— the mechanism that actually clears state between two messages of a worker. Thereset()method already existed; only the declaration was missing.B3 — an unserialisable payload broke the whole profile
The collector stored raw payloads and
__serialize()returned them as-is. A closure in a payload therefore does not bring down the Durable panel: it brings down the request's profile, other bundles' panels included. RED reproduced exactly:Exception: Serialization of 'Closure' is not allowed.A barrier at the single point where
$this->datais assembled reduces everything to scalars and arrays — which is the conversion the template performs anyway in order to display (|json_encode). Nothing shown is lost, and one point covers every payload source, present and future.JSON_INVALID_UTF8_SUBSTITUTEsettles, in the same gesture, a second defect at the same place, noted separately in the audit: a journal carries bytes, not necessarily valid text, and the template rendered an empty area where there was a payload.What the tests pin down
null, list, nested arraykernel.resettag,resetmethoddata_collector, no traceVerification
mainunitsuiteThe 4 errors are pre-existing and identical (
illuminate/cachemissing on this machine). PHPStan is strictly at baseline this time: no new diagnostic.Eleven more tests for ten cases written: the eleventh comes from
CoreDependsOnNoHostTest, a data-provider guard over every file insrc/Durable/, which gains an entry for the null object and confirms it imports neither host nor bridge. The placement is validated by a guard the repository already had.Break
Behavioural, and documented in
UPGRADE.md: an application that pulleddurable.execution_tracefrom the container in production will no longer find it. It does not have to resurrect the profiler for that — implementingWorkflowExecutionObserverInterfaceand aliasing the interface onto its own service is cheaper and does not accumulate a timeline for nobody's screen.Addendum — 2026-09-04, after cross-review
This branch broke the production container, and CI was green. Twelve review axes blocked it; eight reached the same defect, three by actually compiling a container.
registerProfiler()became conditional, sodurable.execution_traceno longer exists outside debug — butDurableExtension:668still referenced it by a bareReferencein the Temporal-native branch. A production application with atemporal.dsnraisedServiceNotFoundExceptionat compile time; a Sylius shop no longer booted. The target constructor declares?DurableExecutionTrace = null, so the reference now carriesNULL_ON_INVALID_REFERENCE.No test could see it: all of them loaded
load([[]]), without a DSN, so the offending branch was never built, and none of them ran a compiler pass. There is now one that does.The serialisation barrier was a duplicate, and it bit harder than the defect.
DurableDataCollector::storable()re-implemented, privately, whatDurable\Observation\RecordedDetailsalready does for every observation surface — a class whose docblock states that "this is where degradation is decided, for all surfaces at once". The variant returning a structure rather than text now sits next toof(), andDiagnoseExecutionCommand, which had no barrier at all, uses it too.Three failure modes, each observed in execution before being fixed:
json_encodecalls the payload'sjsonSerialize(), i.e. business code, which can throw. No flag covers this. The exception reachedcollect()—kernel.response— where the original defect only brokesaveProfile()onkernel.terminate, with the response already sent. The fix made the failure earlier and user-visible.json_decodereturnsnull, assigned to$this->datawhich the parent typesarray|Data:TypeError. The barrier is therefore applied key by key.JSON_PRESERVE_ZERO_FRACTION, afloatof integral value comes back asint, and the timeline bounds that declarefloatlie about their type.Recursion, by contrast, survives:
JSON_PARTIAL_OUTPUT_ON_ERRORtruncates it and returns the rest. That case is now tested so it stops being assumed broken.The escape hatch prescribed by
UPGRADE.mddid not work. AliasingWorkflowExecutionObserverInterfaceonto your own service was overwritten by the extension'ssetAlias(): the application'sservices.yamldefinitions already exist when the extension loads. The alias now respects what the application declared, in debug as in production.Still open, out of scope:
kernel.resetdoes not fire on Temporal transports, which return no envelope — B2 is only closed for the bus-driven half; the overlap between that tag andResetDurableProfilerListener, which the review did not settle; and the inverted coupling that makes all of this possible —Bridge/Temporaltype-hints a bundle class itscomposer.jsondoes not require.