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). 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;

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

Optional observability

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.

OtelFtMetricsRecorder

heisenberg-cdi-vauban — OpenTelemetry §10, via GlobalOpenTelemetry. 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.

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.

Going further

  • Concepts — composition order, CircuitBreaker states.

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

  • TCK — execution and exclusions.