Heisenberg strictly separates pure automata (heisenberg-core) from the CDI integration (heisenberg-cdi-vauban). This page describes the invocation sequence, the role of each engine, the shared state management for @CircuitBreaker and @Bulkhead, and the threading invariants required by virtual threads.
heisenberg-core / heisenberg-cdi-vauban split
heisenberg-core is plain Java — no CDI, no @Inject. It is usable outside a container, for instance in a JUnit 5 test to verify a `RetryEngine’s logic without deploying the application.
heisenberg-cdi-vauban is the integration layer: a single CDI @Interceptor (FaultToleranceInterceptor) orchestrates the engines, a Build Compatible Extension (HeisenbergExtension) declares it at startup, two @ApplicationScoped beans (StateRegistryBean, BulkheadStateRegistryBean) maintain the shared state.
Invocation pipeline
-
FaultToleranceInterceptor.around()intercepts theInvocationContext. For@Asynchronous, it immediately switches onto a virtual thread. -
PolicyComposerreads the resolved annotations (direct annotations + MP Config overrides) and builds the chain in canonical §2.5 order. -
Each engine applies its logic:
-
RetryEngineloops up tomaxRetrieswithdelay + jitterrandomness. -
TimeoutEngineruns the attempt on a virtual thread and callsThread.join(Duration); on expiry, it raisesTimeoutExceptionand interrupts the worker. -
BulkheadEngineacquires aSemaphore(sync mode) or pushes onto a boundedLinkedBlockingQueue(async mode). -
CircuitBreakerEnginereads/updates the three-state machine viaLongAdder(lock-free counters). -
FallbackResolverresolvesfallbackMethodor instantiates theFallbackHandler<T>through CDI lookup.
-
-
FtMetricsRecorderemits §9 (MP Metrics) and §10 (OpenTelemetry) metrics on every transition.
Pure Java 25 engines
| Engine | Implementation |
|---|---|
|
|
|
|
|
Finite-state machine: |
|
|
|
|
|
|
Shared StateRegistry
@CircuitBreaker and @Bulkhead need state shared across all invocations of a given method — whether it is called from a @RequestScoped, @ApplicationScoped or @Dependent bean. MP FT spec §5.4 / §9.6 explicitly states that the state is attached to the method signature, not to the instance.
| Bean | Role |
|---|---|
|
Map |
|
Map |
Consequence: a @RequestScoped bean recreated on every request shares its @CircuitBreaker with previous requests — this is intentional and spec-compliant.
Threading model
Virtual threads everywhere
Every policy that needs to block (@Timeout, @Asynchronous, @Bulkhead async mode) uses Thread.ofVirtual(). No platform thread pool is created by Heisenberg.
-
TimeoutEngine—Thread.ofVirtual().start(task)+join(Duration). Memory cost is in the order of a few kB per attempt (stack pinning included). -
AsyncEngine(built into the interceptor) —Thread.ofVirtual().start(…)to run the full chain. -
BulkheadEngine(async) — the queue is drained by virtual threads created on demand.
|
|
No synchronized, no ThreadLocal
The repo CLAUDE.md mandates strictly:
-
no
synchronized—ReentrantLockwithtryLockwhen a lock is unavoidable; -
no
ThreadLocal—ScopedValue(JEP 506, finalised Java 25) for the invocation context; -
no
setAccessible(true)—MethodHandles.privateLookupInfor private fallbacks; -
no
java.lang.reflect.Proxy— directMethodHandle.
This discipline guarantees that virtual threads are never pinned to their carrier thread — otherwise the memory cost of @Asynchronous explodes under load.
Vauban BCE: HeisenbergExtension
Startup orchestration is carried by io.vidocq.heisenberg.cdi.HeisenbergExtension, a CDI 4.1 Build Compatible Extension:
// Pseudo-declaration — the real BCE uses the @Discovery,
// @Enhancement, @Registration and @Synthesis hooks of CDI 4.1 Lite.
@Discovery
public void declareInterceptor(ScannedTypes types) {
// Declares FaultToleranceInterceptor.class
}
@Validation
public void validateAnnotations(BeanInfo bean) {
// Verifies that @Asynchronous methods return CompletionStage|Future,
// that @Bulkhead.value >= 1, that @Fallback.value and fallbackMethod are exclusive, etc.
// Raises FaultToleranceDefinitionException if not.
}
@Synthesis
public void synthesizeStateRegistry(SyntheticComponents synth) {
// Synthesises StateRegistryBean and BulkheadStateRegistryBean if absent.
}
Startup validation is crucial: any malformed annotation (@Asynchronous void m(), @Bulkhead(value=0), @Fallback with two conflicting parameters) immediately aborts application startup — no late runtime surprise.
Optional observability
The io.vidocq.heisenberg.api.FtMetricsRecorder SPI is implemented in parallel by two recorders:
| Recorder | Module |
|---|---|
|
|
|
|
The FaultToleranceInterceptor injects @Any Instance<FtMetricsRecorder>. If several recorders are present, MetricsRecorderResolver.resolve() wraps them in a CompositeFtMetricsRecorder (fan-out) — this avoids AmbiguousResolutionException and publishes MP Metrics and OTel simultaneously.
If no recorder is present (OpenTelemetry absent, Dirac absent), a NoopFtMetricsRecorder is used — invocation cost is negligible. The run-tck-no-observability.sh script validates this no-observability path.
Sealed types and pattern matching
The public API uses sealed types to expose an exhaustive model to the compiler.
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 uses an exhaustive switch on this — the MP FT 4.1 spec is frozen, so static exhaustivity is appropriate.