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

Diagram
  1. FaultToleranceInterceptor.around() intercepte l'`InvocationContext`. Pour @Asynchronous, il bascule immédiatement sur un virtual thread.

  2. PolicyComposer lit les annotations résolues (annotations directes + surcharges MP Config) et construit la chaîne dans l’ordre canonique §2.5.

  3. Chaque moteur applique sa logique :

    1. RetryEngine boucle jusqu’à maxRetries avec delay + jitter aléatoire.

    2. TimeoutEngine lance la tentative sur un virtual thread et appelle Thread.join(Duration) ; à l’expiration, il lève TimeoutException et interrompt le worker.

    3. BulkheadEngine acquiert un Semaphore (mode sync) ou pousse en LinkedBlockingQueue bornée (mode async).

    4. CircuitBreakerEngine consulte/met à jour l’automate à trois états via LongAdder (compteurs lock-free).

    5. FallbackResolver résout fallbackMethod ou instancie le FallbackHandler<T> via lookup CDI.

  4. FtMetricsRecorder émet les métriques §9 (MP Metrics) et §10 (OpenTelemetry) à chaque transition.

Moteurs purs Java 25

Moteur Implémentation

RetryEngine

Boucle for bornée par maxRetries / maxDuration. Thread.sleep(Duration) pour le délai. ThreadLocalRandom pour le jitter (sans verrou).

TimeoutEngine

Thread worker = Thread.ofVirtual().start(task). worker.join(Duration.ofMillis(timeoutMs)). Si non terminé : worker.interrupt() et TimeoutException.

CircuitBreakerEngine

Automate finite-state : AtomicReference<State> pour la transition, LongAdder pour les compteurs succès/échec. Pas de verrou — CAS lock-free.

BulkheadEngine

Semaphore à value permis. Mode async : LinkedBlockingQueue<Runnable> bornée à waitingTaskQueue.

FallbackResolver

MethodHandle résolu une seule fois à la construction (via MethodHandles.privateLookupIn). Pas de Proxy.newProxyInstance.

PolicyComposer

switch sur types scellés (AnnotationPresence). Construit un PolicyChain immuable.

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

StateRegistryBean (@ApplicationScoped)

Map (BeanClass, Method) → CircuitBreakerState. Lecture/écriture via ConcurrentHashMap.computeIfAbsent.

BulkheadStateRegistryBean (@ApplicationScoped)

Map (BeanClass, Method) → BulkheadState (Semaphore + Queue).

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.

  • TimeoutEngineThread.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.

StructuredTaskScope (JEP 505, finalisé Java 25) est disponible mais l’implémentation actuelle utilise Thread.join(Duration) pour rester strictement compatible Java 21+. Une migration vers StructuredTaskScope est listée dans ROADMAP.md.

Pas de synchronized, pas de ThreadLocal

Le CLAUDE.md du dépôt exige strictement :

  • aucun synchronizedReentrantLock avec tryLock quand un verrou est inévitable ;

  • aucun ThreadLocalScopedValue (JEP 506, finalisé Java 25) pour le contexte d’invocation ;

  • aucun setAccessible(true)MethodHandles.privateLookupIn pour les fallbacks privés ;

  • aucun java.lang.reflect.ProxyMethodHandle directs.

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.

Compteurs lock-free

CircuitBreakerEngine utilise LongAdder pour les compteurs successCount et failureCount, et un AtomicReference<State> pour la transition d’état. Toutes les transitions passent par compareAndSet — pas de verrou, pas de blocage.

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

DiracFtMetricsRecorder

dirac-cdi-vauban — MP Metrics §9, compteurs ft.invocations.total, ft.retry.retries.total, etc.

OtelFtMetricsRecorder

heisenberg-cdi-vauban — OpenTelemetry §10, via GlobalOpenTelemetry. Déclaré requires static io.opentelemetry.api.

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.

Pour aller plus loin

  • Concepts — ordre de composition, états du CircuitBreaker.

  • Référence — annotations, clés MP Config, SPI publique.

  • TCK — exécution et exclusions.