Skip to main content

Bulkhead

← all patterns

pattern

Bounds how many calls may execute concurrently, isolating a protected resource from overload — excess calls either fail fast or wait for a permit to free up.

01

Admission control

A Bulkhead is built on a java.util.concurrent.Semaphore initialized with maxConcurrentCalls permits. Before an operation executes, the call tries to acquire a permit; it releases the permit when the operation finishes, whether it succeeded or failed.

Blocking a virtual thread to wait for a permit is cheap, so a caller over the limit can either fail fast or wait, depending on configuration:

  • maxWait zero (the default) — the call is rejected the instant no permit is free. No blocking.
  • maxWait greater than zero — the call blocks for up to maxWait for a permit to become available before being rejected.

Each withX method on Bulkhead returns a new, independent instance with its own fresh, unused set of permits — configuration is immutable, but the permits themselves are live state, so the same instance must be shared across every caller that should compete for the same permits.

02

Behavior

When a permit is acquired, a Permitted event fires and the operation runs. On completion — success or failure — the permit is released and a Finished event fires. If the wait for a permit elapses without one becoming available, a Rejected event fires and the call fails with BulkheadFullException.

The number of calls currently holding a permit is derived from the semaphore's available permits (maxConcurrentCalls - permits.availablePermits()) and reported on Permitted and Finished events as activeCalls. This count is best-effort under concurrency: another thread may acquire or release a permit between the read and the event being observed.

If the waiting thread is interrupted while blocked on tryAcquire, the interrupt status is restored on the current thread and the call fails with a ResilientException wrapping the InterruptedException — it does not throw BulkheadFullException in that case, since the permit wait didn't run to completion or exhaustion, it was cut short.

Duration.toMillis() overflows for extreme maxWait values; the Bulkhead clamps the wait to Long.MAX_VALUE milliseconds instead of letting that exception escape.

03

Configuration

propertyrequireddescription
nameyesIdentifier used in events and exceptions (instance-specific). First positional argument of of(name, maxConcurrentCalls), no wither
maxConcurrentCallsyesMaximum number of concurrent executions. Second positional argument of of(name, maxConcurrentCalls); also settable via withMaxConcurrentCalls(). Must be >= 1
withMaxWait()noHow long an excess call may wait for a permit before being rejected. Default: Duration.ZERO (fail fast)
withListener()noSubscribe to bulkhead events (Permitted, Rejected, Finished)
withClock()noUse a custom Clock for event timestamps instead of the system clock (mainly for testing). The permit wait itself is still enforced against real elapsed time
name()inspectThe configured name
maxConcurrentCalls()inspectThe configured concurrency limit
maxWait()inspectThe configured maximum wait for a permit

04

Events & failure

  • Permitted — a permit was acquired, call will proceed. Carries: timestamp, name, activeCalls.
  • Rejected — no permit became available within maxWait. Carries: timestamp, name, maxConcurrentCalls, maxWait.
  • Finished — a call completed and its permit was released. Carries: timestamp, name, activeCalls.

Throws BulkheadFullException when the concurrency limit is reached and no permit becomes free within maxWait. Fields: name, maxConcurrentCalls, maxWait. The name carried here must be a static, compile-time-known string, since it is used as a cardinality-bounded key in metrics and logging.