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, from the bean module’s own lookup when the container supplies one (how a fallback method is reached); -
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.
How a fallback method is reached NEW
FallbackResolver turns a fallbackMethod into a MethodHandle with MethodHandles.privateLookupIn(owner, lookup). On a strict module path, a private lookup taken from io.vidocq.heisenberg.core needs two things the application does not give: a read edge from the core to the application module, and the bean’s package opened to the core. The official TCK runs on the class path, where both always hold, so it never saw that a bean with a fallbackMethod in a module that opens nothing failed deployment with Invalid fallbackMethod (BUG-003, heisenberg#22).
heisenberg-core stays container-agnostic. FallbackResolver asks a hook, FallbackResolver.LookupSource, for a lookup on the bean class, and resolves in this order:
-
A lookup from the container.
heisenberg-cdi-vaubaninstalls the hook (VaubanLookupSource) with Vauban’sModuleLookups: its lookup is supplied by the generated_VaubanComponentsof the bean’s package — written by the annotation processor for the application, or by the Vauban Maven plugin for a scanned dependency. That lookup lives in the bean’s own module, so the private lookup derived from it needs no read edge and noopens.io.vidocq.vauban.coreexportsModuleLookupsto the trusted extension modules only, andheisenberg-cdi-vaubanreaches it throughrequires static io.vidocq.vauban.core(Maven scopeprovided): under another container the hook answers nothing. -
No lookup — no Vauban provider for that package (for instance an archive compiled by an older processor), another container, or the engines used without a container. The core adds the read edge to the bean’s module itself (
Module.addReads), so anopens <pkg> to io.vidocq.heisenberg.corein the application is the only thing left to declare.
Either way, no setAccessible(true) and no --add-reads on the command line. heisenberg-cdi-vauban-module-it (FallbackMethodModulePathTest) covers private and package-private fallback methods on the module path with an application that opens nothing; the heisenberg-cdi-vauban unit tests, which call the interceptor without a container, keep the opens-only path covered.
Optional observability NEW
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.
Both recorders are always deployed, and neither bean class refers to a type of its API: each forwards to a delegate (DiracFtMetrics, OtelFtMetrics, not beans) that it creates in @PostConstruct only when the API can be loaded and read from io.vidocq.heisenberg.cdi.vauban. A container inspects the fields and methods of every bean class it discovers, and a class that refers to a missing type fails to link: Weld drops it with WELD-000119, Vauban stops the start (BUG-005). Keeping the API types out of the beans makes an application with Fault Tolerance alone start on any container.
If no recorder is active (OpenTelemetry absent, MicroProfile Metrics absent), MetricsRecorderResolver leaves the inactive recorders out and the FtMetricsRecorder.NOOP constant is used — invocation cost is negligible. MetricsRecordersWithoutObservabilityTest (heisenberg-cdi-vauban-module-it) deploys both recorders on a module path that has neither API. 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.