Skip to content

B2 and B3 — the profiler stops instrumenting production, and its profile serialises - #275

Open
gplanchat wants to merge 2 commits into
mainfrom
fix/profileur-en-production
Open

B2 and B3 — the profiler stops instrumenting production, and its profile serialises#275
gplanchat wants to merge 2 commits into
mainfrom
fix/profileur-en-production

Conversation

@gplanchat

@gplanchat gplanchat commented Sep 3, 2026

Copy link
Copy Markdown
Owner

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 in load(). This is not neutral plumbing: the service it aliases onto WorkflowExecutionObserverInterface is injected into ExecutionRuntime, ExecutionEngine and ActivityMessageProcessor. 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 on kernel.request — an event messenger:consume never fires. In a worker, the timeline grew for as long as the process lived.

Both halves are handled together:

  • Outside kernel.debug, nothing of the profiler is registered and observation falls back to NullWorkflowExecutionObserver. Doing nothing is a real behaviour here, not a signature filler — an execution nobody watches runs the same way, like NullLogger. It is also what FrameworkBundle does, loading its collectors from separate files under a condition.
  • In debug, the trace gains the kernel.reset tag, which hands it to services_resetter — the mechanism that actually clears state between two messages of a worker. The reset() 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->data is 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_SUBSTITUTE settles, 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

Case What it keeps
closure, resource, anonymous object holding a closure the profile stays storable whatever is observed
ordinary payload with ints, floats, booleans, null, list, nested array the barrier distorts nothing — a JSON round trip turning a list into an object would silently break the keys the template reads
binary payload the panel does not go blank
kernel.reset tag, reset method a debug worker stays bounded
outside debug: no definition tagged data_collector, no trace production stops paying
in debug: the observer is indeed the trace regression net

Verification

main this branch
unit suite 1073 tests, 4 errors 1084 tests, 4 errors
PHPStan 2 errors 2 errors

The 4 errors are pre-existing and identical (illuminate/cache missing 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 in src/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 pulled durable.execution_trace from the container in production will no longer find it. It does not have to resurrect the profiler for that — implementing WorkflowExecutionObserverInterface and 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, so durable.execution_trace no longer exists outside debug — but DurableExtension:668 still referenced it by a bare Reference in the Temporal-native branch. A production application with a temporal.dsn raised ServiceNotFoundException at compile time; a Sylius shop no longer booted. The target constructor declares ?DurableExecutionTrace = null, so the reference now carries NULL_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, what Durable\Observation\RecordedDetails already 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 to of(), and DiagnoseExecutionCommand, which had no barrier at all, uses it too.

Three failure modes, each observed in execution before being fixed:

  • json_encode calls the payload's jsonSerialize(), i.e. business code, which can throw. No flag covers this. The exception reached collect()kernel.response — where the original defect only broke saveProfile() on kernel.terminate, with the response already sent. The fix made the failure earlier and user-visible.
  • Beyond 512 levels of nesting, json_decode returns null, assigned to $this->data which the parent types array|Data: TypeError. The barrier is therefore applied key by key.
  • Without JSON_PRESERVE_ZERO_FRACTION, a float of integral value comes back as int, and the timeline bounds that declare float lie about their type.

Recursion, by contrast, survives: JSON_PARTIAL_OUTPUT_ON_ERROR truncates it and returns the rest. That case is now tested so it stops being assumed broken.

The escape hatch prescribed by UPGRADE.md did not work. Aliasing WorkflowExecutionObserverInterface onto your own service was overwritten by the extension's setAlias(): the application's services.yaml definitions 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.reset does not fire on Temporal transports, which return no envelope — B2 is only closed for the bus-driven half; the overlap between that tag and ResetDurableProfilerListener, which the review did not settle; and the inverted coupling that makes all of this possible — Bridge/Temporal type-hints a bundle class its composer.json does not require.

gplanchat and others added 2 commits September 4, 2026 00:07
…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>
@gplanchat gplanchat changed the title B2 et B3 — le profileur cesse d'instrumenter la production, et son profil se sérialise B2 and B3 — the profiler stops instrumenting production, and its profile serialises 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