Bulkhead: Containing the Blast Radius

What You'll Learn

  • How a bulkhead isolates resource usage to prevent cascading failures
  • How to configure concurrency limits, wait queues, and fairness
  • The distinction between Bulkhead and VStreamPar
  • How to protect VTask operations with concurrency limiting
  • How to chain withBulkhead on Path carriers, with typed rejections on the railway

A ship's bulkhead divides the hull into compartments. If one compartment floods, the others stay dry. Without bulkheads, a single breach sinks the entire vessel.

Software systems face the same risk. If your application calls three external services and one becomes very slow, every thread that calls that service blocks indefinitely. Eventually those threads are exhausted and the other two services, both perfectly healthy, become unreachable because there are no threads left to call them. One slow service has sunk the ship.

A Bulkhead prevents this by limiting how many concurrent callers can access a shared resource. If the limit is reached, additional callers either wait briefly or are turned away immediately.

How It Works

    Incoming requests
    ─────┬──────┬──────┬──────┬──────┬──────┬──────
         │      │      │      │      │      │
         ▼      ▼      ▼      ▼      ▼      ▼
    ┌─────────────────────────────────────────────┐
    │              Bulkhead (max=3)               │
    │                                             │
    │   ┌─────┐  ┌─────┐  ┌─────┐                 │
    │   │ R1  │  │ R2  │  │ R3  │  ← executing    │
    │   └─────┘  └─────┘  └─────┘                 │
    │                                             │
    │   ┌─────┐  ┌─────┐                          │
    │   │ R4  │  │ R5  │  ← waiting for permit    │
    │   └─────┘  └─────┘                          │
    │                                             │
    │   R6 → BulkheadFullException                │
    │         (wait queue full or timeout)        │
    └─────────────────────────────────────────────┘

Creating a Bulkhead

// Simple: just a concurrency limit
Bulkhead dbBulkhead = Bulkhead.withMaxConcurrent(10);

// Full configuration
Bulkhead apiBulkhead = Bulkhead.create(BulkheadConfig.builder()
    .maxConcurrent(5)                         // 5 concurrent callers
    .maxWait(10)                              // Up to 10 callers can wait
    .waitTimeout(Duration.ofSeconds(2))       // Wait up to 2 seconds for a permit
    .fairness(true)                           // FIFO ordering for waiting callers
    .build());
SettingDefaultDescription
maxConcurrent10Maximum simultaneous executions
maxWait0Maximum callers in the wait queue (0 = no limit)
waitTimeout5sHow long to wait for a permit before giving up
fairnessfalseWhether to serve waiting callers in FIFO order

Protecting VTask Operations

Bulkhead dbBulkhead = Bulkhead.withMaxConcurrent(10);

VTask<Result> protectedQuery = dbBulkhead.protect(
    VTask.of(() -> database.query(sql)));

// When the permit is acquired, the task runs normally.
// When the bulkhead is full, BulkheadFullException is thrown.
Result result = protectedQuery.run();

Like CircuitBreaker.protect(), the method is generic: one bulkhead can protect calls returning different types.

Handling Rejection

When the bulkhead cannot accept a caller, it throws BulkheadFullException:

VTask<Result> resilient = dbBulkhead.protect(
        VTask.of(() -> database.query(sql)))
    .recover(ex -> {
        if (ex instanceof BulkheadFullException) {
            log.warn("Database connection pool exhausted");
            return Result.fromCache(sql);
        }
        throw (ex instanceof RuntimeException re) ? re : new RuntimeException(ex);
    });

Path-Native Bulkheads

The lazy Path carriers chain bulkhead protection directly:

IOPath<Result> guarded = Path.io(() -> database.query(sql))
    .withBulkhead(dbBulkhead);

VTaskPath<Result> guardedAsync = Path.vtask(() -> database.query(sql))
    .withBulkhead(dbBulkhead);

On the typed-error carriers, the typed overload keeps a rejected execution on the typed channel: BulkheadFullException becomes a Left instead of a thrown exception or defect.

// VResultPath: instance combinator, rejection lands as a Left
VResultPath<OrderError, Reservation> guarded =
    reserveInventory(order)
        .withBulkhead(
            inventoryBulkhead,
            full -> OrderError.SystemError.fromException("Inventory service busy", full));

// EitherPath is eager, so the combinator is static and takes the step as a Supplier
EitherPath<OrderError, Reservation> reserved = EitherPath.withBulkhead(
    () -> reserveInventory(order),
    inventoryBulkhead,
    full -> OrderError.SystemError.fromException("Inventory service busy", full));

Without the onFull argument, BulkheadFullException propagates as-is.

Bulkhead vs VStreamPar

Both limit concurrency, but at different scopes:

BulkheadVStreamPar
ScopePer-service (shared across callers)Per-stream (within a pipeline)
Use case"This database allows 10 connections""Process this stream with 4 in-flight"
SharedOne instance across the applicationPer-stream instance
SemanticsAcquire/release permitBounded parallel map

They compose naturally. A stream can use VStreamPar for pipeline parallelism and have each element's processing protected by a shared bulkhead:

Bulkhead serviceBulkhead = Bulkhead.withMaxConcurrent(10);

Path.vstreamFromList(userIds)
    .parEvalMap(4, id ->
        serviceBulkhead.protect(
            VTask.of(() -> userService.fetch(id))))
    .toList()
    .run();

Here, parEvalMap(4, ...) limits the stream to 4 in-flight elements, whilst serviceBulkhead ensures that across all streams in the application, no more than 10 concurrent calls reach the user service.

Inspecting State

int available = dbBulkhead.availablePermits();  // How many more callers can enter
int active = dbBulkhead.activeCount();           // How many callers are currently executing

See Also


Previous: Circuit Breaker Next: Saga