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, which is how the project’s own JUnit 5 tests verify a RetryEngine’s logic without deploying an application. Its `io.vidocq.heisenberg.internal package is exported only to the CDI module — it is not application API.
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 a fairSemaphore(sync mode); in async mode a second boundedSemaphoremodels thewaitingTaskQueue. -
CircuitBreakerEnginereads/updates the three-state machine held by the shared registry; its sliding window counts requests and failures withAtomicIntegercounters. -
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 (CLOSED/OPEN/HALF_OPEN) stored in the shared registry as a |
|
Fair |
|
|
|
Builds the chain inside out by nesting |
Shared state registries
@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 |
|---|---|
|
Implements |
|
Implements |
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). -
AsynchronousEngine(heisenberg-core) —Thread.ofVirtual().start(…)to run the full chain and complete the returnedCompletionStage. -
BulkheadEngine(async) — waiting invocations block on the fairSemaphorefrom their own virtual thread; no dispatcher thread exists.
|
|
No synchronized, no ThreadLocal
The repo CLAUDE.md mandates strictly, and the engines follow through:
-
no
synchronized—ReentrantLockwithtryLockwhen a lock is unavoidable (so far, none has been); -
no
ThreadLocal— invocation state travels through explicit parameters and captured lambdas; -
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.internal.HeisenbergExtension, a CDI 4.1 Build Compatible Extension that uses only the @Enhancement phase — two hooks:
// Simplified from the real extension — both hooks are @Enhancement.
@Enhancement(types = FaultToleranceInterceptor.class)
public void configureInterceptorPriority(ClassConfig classConfig) {
// Rewrites the interceptor's @Priority when
// mp.fault.tolerance.interceptor.priority differs from the default 4010.
}
@Enhancement(types = Object.class, withSubtypes = true,
withAnnotations = { Retry.class, Timeout.class, CircuitBreaker.class,
Bulkhead.class, Asynchronous.class, Fallback.class })
public void addFaultToleranceBinding(ClassConfig classConfig) {
// Validates the FT definitions (@Asynchronous return type, @Bulkhead
// bounds, @Fallback method signature, ...) and adds the
// @FaultToleranceBinding marker so CDI selects the interceptor.
}
Startup validation is crucial: any malformed annotation (@Asynchronous void m(), @Bulkhead(value=0), a @Fallback whose fallback method does not match the guarded signature) raises FaultToleranceDefinitionException and 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), the FtMetricsRecorder.NOOP constant is used — invocation cost is negligible. The run-tck-no-observability.sh script validates this no-observability path.
Records and exhaustive switches
The internal model leans on records and enums rather than a sealed result hierarchy. Policy configurations are immutable records — RetryConfig, TimeoutConfig, CircuitBreakerConfig, BulkheadConfig, FallbackConfig — that validate their bounds in compact constructors. State is enum-driven: CircuitBreakerState (CLOSED/OPEN/HALF_OPEN) and the FtMetricsRecorder enums (RetryResult, CBCallResult, CBState) feed exhaustive switch expressions in PolicyComposer and the recorders — the MP FT 4.1 spec is frozen, so static exhaustivity is appropriate. The records and CircuitBreakerState live in the qualified-export io.vidocq.heisenberg.internal package (the recorder enums are nested in the public FtMetricsRecorder); the application-facing API remains the spec annotations plus the small io.vidocq.heisenberg.api SPI.