Reactive Bulkhead Operators#
Resilience4j's bulkhead operators wrap reactive publishers (Reactor Mono/Flux, RxJava 2/3 Single/Maybe/Completable/Observable/Flowable) and enforce concurrency limits by acquiring a SemaphoreBulkhead permission at subscription time. The entry points are:
- Reactor β
BulkheadOperator.of(bulkhead), which dispatches toMonoBulkheadorFluxBulkhead - RxJava 2 β
BulkheadOperatorproducingCompletableBulkhead,FlowableBulkhead,MaybeBulkhead,ObserverBulkhead,SingleBulkhead(all underio.github.resilience4j.bulkhead.operator) - RxJava 3 β same operator names under
io.github.resilience4j.rxjava3.bulkhead.operator
The Thread-Blocking Problem#
Until very recently, all reactive bulkhead operators acquired permissions synchronously via Bulkhead.tryAcquirePermission(). SemaphoreBulkhead implements that method with java.util.concurrent.Semaphore.tryAcquire(timeout, TimeUnit.MILLISECONDS), which parks the calling thread when the bulkhead is full and maxWaitDuration > 0. In a WebFlux application this means Netty I/O event-loop threads are blocked, starving other requests. The fail-fast alternative (maxWaitDuration = 0) is not a substitute: benchmarks in issue #1592 show the reject path competing for the same event loops and causing ~45% throughput loss at saturation.
Fix: acquirePermissionAsync() (merged Sept 2026)#
PR #2506 β merged 2026-09-23 β introduced Bulkhead#acquirePermissionAsync(), which returns a CompletableFuture<Void> with three outcomes :
| Bulkhead state | maxWaitDuration | Future result |
|---|---|---|
| Permit available | any | Completed immediately (no allocation) |
| Full | = 0 | Fails immediately with BulkheadFullException |
| Full | > 0 | Queued in a ConcurrentLinkedQueue, granted FIFO on release, or fails after maxWaitDuration |
The SemaphoreBulkhead now overrides this method with a fully non-blocking implementation. Custom Bulkhead implementations get a default method that bridges to the blocking acquirePermission(), preserving backward compatibility.
Reactor operators (MonoBulkhead, FluxBulkhead) were updated in the same PR to call acquirePermissionAsync(). When the future is already done the fast path is taken synchronously; when pending, a PermissionAwaitingSubscription (built on Operators.DeferredSubscription) is handed to the downstream. It accumulates demand, replays it once the permission is granted, and cancels the pending future if the downstream disposes .
Cross-Module Inconsistency (as of 2026-09-23)#
The RxJava 2 and RxJava 3 operators have not yet been fixed in the merged codebase. As of the snapshot at commit 7e3ab52, they still call tryAcquirePermission() β see for example:
- RxJava 2
MaybeBulkhead.subscribeActual - RxJava 3
FlowableBulkhead.subscribeActual - RxJava 2
CompletableBulkhead.subscribeActual - RxJava 3
CompletableBulkhead.subscribeActual
PR #2526 (open, follow-up to #2506) addresses this gap: it switches all five RxJava 2 and five RxJava 3 operator types to acquirePermissionAsync() and adds PermissionAwaitingDisposable / PermissionAwaitingSubscription helpers for each module .
Pending queue observability and bounded depth#
A further follow-up, PR #2528, adds a queue-depth metric (getQueuedCalls) and an optional maxQueuedCalls config property. Until that lands the async queue is only time-bounded (by maxWaitDuration).
Key source locations#
| What | Where |
|---|---|
Bulkhead.acquirePermissionAsync() | resilience4j-bulkhead/.../Bulkhead.java |
SemaphoreBulkhead async impl + queue draining | resilience4j-bulkhead/.../internal/SemaphoreBulkhead.java |
| Timeout scheduler | SchedulerFactory.java (new in #2506) |
| Reactor operator entry point | BulkheadOperator.java |
Reactor MonoBulkhead (updated) | MonoBulkhead.java |
| Reactor deferred subscription | PermissionAwaitingSubscription.java |
| RxJava 2 operators (still blocking) | resilience4j-rxjava2/.../bulkhead/operator/ |
| RxJava 3 operators (still blocking) | resilience4j-rxjava3/.../bulkhead/operator/ |
| Fix tracking issue | GitHub issue #1592 |