Heisenberg sépare strictement les automates purs (heisenberg-core) de l’intégration CDI (heisenberg-cdi-vauban). Cette page décrit la séquence d’invocation, le rôle de chaque moteur, la gestion d’état partagée pour @CircuitBreaker et @Bulkhead, et les invariants de threading exigés par les virtual threads.
Séparation heisenberg-core / heisenberg-cdi-vauban
heisenberg-core est du Java pur — pas de CDI, pas d'`@Inject`. Il s’utilise hors container, par exemple dans un test JUnit 5 pour vérifier la logique d’un RetryEngine sans déployer l’application.
heisenberg-cdi-vauban est la couche d’intégration : un unique @Interceptor CDI (FaultToleranceInterceptor) orchestre les moteurs, une Build Compatible Extension (HeisenbergExtension) le déclare au démarrage, deux beans @ApplicationScoped (StateRegistryBean, BulkheadStateRegistryBean) maintiennent l’état partagé.
Pipeline d’invocation
-
FaultToleranceInterceptor.around()intercepte l'`InvocationContext`. Pour@Asynchronous, il bascule immédiatement sur un virtual thread. -
PolicyComposerlit les annotations résolues (annotations directes + surcharges MP Config) et construit la chaîne dans l’ordre canonique §2.5. -
Chaque moteur applique sa logique :
-
RetryEngineboucle jusqu’àmaxRetriesavecdelay + jitteraléatoire. -
TimeoutEnginelance la tentative sur un virtual thread et appelleThread.join(Duration); à l’expiration, il lèveTimeoutExceptionet interrompt le worker. -
BulkheadEngineacquiert unSemaphore(mode sync) ou pousse enLinkedBlockingQueuebornée (mode async). -
CircuitBreakerEngineconsulte/met à jour l’automate à trois états viaLongAdder(compteurs lock-free). -
FallbackResolverrésoutfallbackMethodou instancie leFallbackHandler<T>via lookup CDI.
-
-
FtMetricsRecorderémet les métriques §9 (MP Metrics) et §10 (OpenTelemetry) à chaque transition.
Moteurs purs Java 25
| Moteur | Implémentation |
|---|---|
|
Boucle |
|
|
|
Automate finite-state : |
|
|
|
|
|
|
StateRegistry partagé
@CircuitBreaker et @Bulkhead ont besoin d’un état partagé entre toutes les invocations d’une méthode donnée — qu’elle soit appelée depuis un bean @RequestScoped, @ApplicationScoped ou @Dependent. La spec MP FT §5.4 / §9.6 le précise : l’état est attaché à la signature de méthode, pas à l’instance.
| Bean | Rôle |
|---|---|
|
Map |
|
Map |
Conséquence : un bean @RequestScoped recréé à chaque requête partage son @CircuitBreaker avec les requêtes précédentes — c’est intentionnel et conforme.
Modèle de threading
Virtual threads partout
Toutes les politiques qui ont besoin de bloquer (@Timeout, @Asynchronous, @Bulkhead en mode async) utilisent Thread.ofVirtual(). Aucun pool de threads plateforme n’est créé par Heisenberg.
-
TimeoutEngine—Thread.ofVirtual().start(task)+join(Duration). Le coût mémoire est de l’ordre de quelques Ko par tentative (stack pinning compris). -
AsyncEngine(intégré à l’intercepteur) —Thread.ofVirtual().start(…)pour exécuter la chaîne complète. -
BulkheadEngine(async) — la file est traitée par des virtual threads créés à la demande.
|
|
Pas de synchronized, pas de ThreadLocal
Le CLAUDE.md du dépôt exige strictement :
-
aucun
synchronized—ReentrantLockavectryLockquand un verrou est inévitable ; -
aucun
ThreadLocal—ScopedValue(JEP 506, finalisé Java 25) pour le contexte d’invocation ; -
aucun
setAccessible(true)—MethodHandles.privateLookupInpour les fallbacks privés ; -
aucun
java.lang.reflect.Proxy—MethodHandledirects.
Cette discipline garantit que les virtual threads ne sont jamais pinned sur un thread porteur — sinon le coût mémoire des @Asynchronous explose sous charge.
BCE Vauban : HeisenbergExtension
L’orchestration au démarrage est portée par io.vidocq.heisenberg.cdi.HeisenbergExtension, une Build Compatible Extension CDI 4.1 :
// Pseudo-déclaration — la BCE véritable utilise les hooks @Discovery,
// @Enhancement, @Registration et @Synthesis de CDI 4.1 Lite.
@Discovery
public void declareInterceptor(ScannedTypes types) {
// Déclare FaultToleranceInterceptor.class
}
@Validation
public void validateAnnotations(BeanInfo bean) {
// Vérifie que les méthodes @Asynchronous retournent CompletionStage|Future,
// que @Bulkhead.value >= 1, que @Fallback.value et fallbackMethod sont exclusifs, etc.
// Lève FaultToleranceDefinitionException si écart.
}
@Synthesis
public void synthesizeStateRegistry(SyntheticComponents synth) {
// Synthétise StateRegistryBean et BulkheadStateRegistryBean si absents.
}
La validation au démarrage est cruciale : toute annotation mal formée (@Asynchronous void m(), @Bulkhead(value=0), @Fallback avec deux paramètres concurrents) provoque l’arrêt immédiat de l’application — pas d’erreur runtime tardive.
Observabilité optionnelle
La SPI io.vidocq.heisenberg.api.FtMetricsRecorder est implémentée en parallèle par deux recorders :
| Recorder | Module |
|---|---|
|
|
|
|
Le FaultToleranceInterceptor injecte @Any Instance<FtMetricsRecorder>. Si plusieurs recorders sont présents, MetricsRecorderResolver.resolve() les agrège dans un CompositeFtMetricsRecorder (fan-out) — cela évite AmbiguousResolutionException et permet de publier MP Metrics et OTel simultanément.
Si aucun recorder n’est présent (OpenTelemetry absent, Dirac absent), une instance NoopFtMetricsRecorder est utilisée — coût d’invocation négligeable. Le script run-tck-no-observability.sh valide ce chemin sans observabilité.
Sealed types et pattern matching
L’API publique utilise des types scellés pour exposer un modèle exhaustif au compilateur.
public sealed interface PolicyResult<T>
permits PolicyResult.Success, PolicyResult.Failure, PolicyResult.Fallback {
record Success<T>(T value) implements PolicyResult<T> {}
record Failure<T>(Throwable error) implements PolicyResult<T> {}
record Fallback<T>(T value, Throwable suppressedError) implements PolicyResult<T> {}
}
PolicyComposer utilise switch exhaustif dessus — la spec MP FT 4.1 est figée, donc l’exhaustivité statique est appropriée.