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

Diagram
  1. FaultToleranceInterceptor.around() intercepts the InvocationContext. For @Asynchronous, it immediately switches onto a virtual thread.

  2. PolicyComposer reads the resolved annotations (direct annotations + MP Config overrides) and builds the chain in canonical §2.5 order.

  3. Each engine applies its logic:

    1. RetryEngine loops up to maxRetries with delay + jitter randomness.

    2. TimeoutEngine runs the attempt on a virtual thread and calls Thread.join(Duration); on expiry, it raises TimeoutException and interrupts the worker.

    3. BulkheadEngine acquires a fair Semaphore (sync mode); in async mode a second bounded Semaphore models the waitingTaskQueue.

    4. CircuitBreakerEngine reads/updates the three-state machine held by the shared registry; its sliding window counts requests and failures with AtomicInteger counters.

    5. FallbackResolver resolves fallbackMethod or instantiates the FallbackHandler<T> through CDI lookup.

  4. FtMetricsRecorder emits §9 (MP Metrics) and §10 (OpenTelemetry) metrics on every transition.

Pure Java 25 engines

Engine Implementation

RetryEngine

for loop bounded by maxRetries / maxDuration. Thread.sleep(Duration) for delay. ThreadLocalRandom for jitter (lock-free).

TimeoutEngine

Thread worker = Thread.ofVirtual().start(task). worker.join(Duration.ofMillis(timeoutMs)). If not done: worker.interrupt() and TimeoutException.

CircuitBreakerEngine

Finite-state machine (CLOSED/OPEN/HALF_OPEN) stored in the shared registry as a volatile per-method snapshot; sliding window backed by a ConcurrentLinkedDeque plus AtomicInteger request/failure counters. No lock.

BulkheadEngine

Fair Semaphore with value permits. Async mode: a second fair Semaphore bounds the waiting queue to waitingTaskQueue permits.

FallbackResolver

MethodHandle resolved once at construction (via MethodHandles.privateLookupIn, from a lookup taken in the bean’s own module when the container supplies one — see how a fallback method is reached). No Proxy.newProxyInstance.

PolicyComposer

Builds the chain inside out by nesting Invocation lambdas (@Bulkhead first, @Fallback / @Asynchronous last), from the annotation snapshot read by AnnotationReader.

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

StateRegistryBean (@ApplicationScoped)

Implements CircuitBreakerStateRegistry. Keyed by beanClass#method; ConcurrentHashMap.computeIfAbsent returns a per-breaker snapshot (volatile state, opened-at timestamp, HALF_OPEN success counter).

BulkheadStateRegistryBean (@ApplicationScoped)

Implements BulkheadStateRegistry. Keyed by beanClass#method; fair concurrency Semaphore, plus a fair waiting-queue Semaphore in async mode.

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 returned CompletionStage.

  • BulkheadEngine (async) — waiting invocations block on the fair Semaphore from their own virtual thread; no dispatcher thread exists.

StructuredTaskScope (JEP 505, finalised in Java 25) is available but the current implementation uses Thread.join(Duration) to stay strictly compatible with Java 21+. Migration to StructuredTaskScope is listed in ROADMAP.md.

No synchronized, no ThreadLocal

The repo CLAUDE.md mandates strictly, and the engines follow through:

  • no synchronized — ReentrantLock with tryLock when a lock is unavoidable (so far, none has been);

  • no ThreadLocal — invocation state travels through explicit parameters and captured lambdas;

  • no setAccessible(true) — MethodHandles.privateLookupIn for 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 — direct MethodHandle.

This discipline guarantees that virtual threads are never pinned to their carrier thread — otherwise the memory cost of @Asynchronous explodes under load.

Lock-free counters

CircuitBreakerEngine counts requests and failures with AtomicInteger counters behind a ConcurrentLinkedDeque sliding window; the shared registry keeps each breaker’s state in a volatile snapshot field. No lock is taken on the hot path.

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:

  1. A lookup from the container. heisenberg-cdi-vauban installs the hook (VaubanLookupSource) with Vauban’s ModuleLookups: its lookup is supplied by the generated _VaubanComponents of 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 no opens. io.vidocq.vauban.core exports ModuleLookups to the trusted extension modules only, and heisenberg-cdi-vauban reaches it through requires static io.vidocq.vauban.core (Maven scope provided): under another container the hook answers nothing.

  2. 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 an opens <pkg> to io.vidocq.heisenberg.core in 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

DiracFtMetricsRecorder

heisenberg-cdi-vauban — MP Metrics §9 via Dirac, counters ft.invocations.total, ft.retry.retries.total, etc. Publishes through DiracFtMetrics when the MicroProfile Metrics API is present.

OtelFtMetricsRecorder

heisenberg-cdi-vauban — OpenTelemetry §10, via GlobalOpenTelemetry. Publishes through OtelFtMetrics when the OpenTelemetry API is present. Declared requires static io.opentelemetry.api.

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.

Going further

  • Concepts — composition order, CircuitBreaker states.

  • Reference — annotations, MP Config keys, public SPI.

  • TCK — execution and exclusions.