From 4cd5d568bc185bcb9e5d1581d98d2dac6ab311ae Mon Sep 17 00:00:00 2001 From: Doksanbir Date: Thu, 3 Sep 2026 12:18:29 +0300 Subject: [PATCH] feat: add Microservices Bulkhead pattern (#3228) --- microservices-bulkhead/README.md | 320 ++++++++++++++++++ .../etc/microservices-bulkhead.urm.puml | 53 +++ microservices-bulkhead/pom.xml | 70 ++++ .../main/java/com/iluwatar/bulkhead/App.java | 132 ++++++++ .../java/com/iluwatar/bulkhead/Bulkhead.java | 146 ++++++++ .../bulkhead/BulkheadFullException.java | 46 +++ .../iluwatar/bulkhead/InventoryService.java | 41 +++ .../com/iluwatar/bulkhead/PaymentService.java | 60 ++++ .../com/iluwatar/bulkhead/RemoteService.java | 42 +++ .../java/com/iluwatar/bulkhead/AppTest.java | 151 +++++++++ .../com/iluwatar/bulkhead/BulkheadTest.java | 156 +++++++++ .../bulkhead/InventoryServiceTest.java | 39 +++ .../iluwatar/bulkhead/PaymentServiceTest.java | 54 +++ pom.xml | 1 + 14 files changed, 1311 insertions(+) create mode 100644 microservices-bulkhead/README.md create mode 100644 microservices-bulkhead/etc/microservices-bulkhead.urm.puml create mode 100644 microservices-bulkhead/pom.xml create mode 100644 microservices-bulkhead/src/main/java/com/iluwatar/bulkhead/App.java create mode 100644 microservices-bulkhead/src/main/java/com/iluwatar/bulkhead/Bulkhead.java create mode 100644 microservices-bulkhead/src/main/java/com/iluwatar/bulkhead/BulkheadFullException.java create mode 100644 microservices-bulkhead/src/main/java/com/iluwatar/bulkhead/InventoryService.java create mode 100644 microservices-bulkhead/src/main/java/com/iluwatar/bulkhead/PaymentService.java create mode 100644 microservices-bulkhead/src/main/java/com/iluwatar/bulkhead/RemoteService.java create mode 100644 microservices-bulkhead/src/test/java/com/iluwatar/bulkhead/AppTest.java create mode 100644 microservices-bulkhead/src/test/java/com/iluwatar/bulkhead/BulkheadTest.java create mode 100644 microservices-bulkhead/src/test/java/com/iluwatar/bulkhead/InventoryServiceTest.java create mode 100644 microservices-bulkhead/src/test/java/com/iluwatar/bulkhead/PaymentServiceTest.java diff --git a/microservices-bulkhead/README.md b/microservices-bulkhead/README.md new file mode 100644 index 000000000000..46e5cd990592 --- /dev/null +++ b/microservices-bulkhead/README.md @@ -0,0 +1,320 @@ +--- +title: "Bulkhead Pattern in Java: Isolating Failures in Microservices" +shortTitle: Bulkhead +description: "Learn the Bulkhead pattern in Java. Isolate each downstream dependency in its own thread pool so that a slow or failing service cannot exhaust the resources of the whole application. Includes a working example, class diagram, and trade-offs." +category: Resilience +language: en +tag: + - Concurrency + - Fault tolerance + - Isolation + - Microservices + - Resource management +--- + +## Also known as + +* Compartmentalization +* Resource isolation + +## Intent of Bulkhead Design Pattern + +Partition the resources of a service, typically its threads and connections, into isolated compartments so that a failure or an overload in one downstream dependency cannot consume the resources needed by the others. The compartment that is full rejects new calls immediately instead of letting them pile up. + +## Detailed Explanation of Bulkhead Pattern with Real-World Examples + +Real-world example + +> The hull of a ship is divided into watertight compartments called bulkheads. If the hull is breached, only the flooded compartment fills with water and the ship stays afloat. In an order service, the calls to a payment provider and the calls to an inventory system are placed in separate compartments. When the payment provider becomes slow and its compartment fills up, the extra payment requests are turned away at once, while inventory lookups keep flowing through their own compartment as if nothing happened. + +In plain words + +> Give every downstream dependency its own bounded pool of threads, so one misbehaving dependency can only exhaust its own pool. + +Microservices.io says + +> Bulkhead is a pattern that isolates the resources used by a service so that a failure in one part of the system does not cascade to other parts. + +Sequence diagram + +```mermaid +sequenceDiagram + participant Caller as Order service + participant PB as Bulkhead payment (2 threads, queue 2) + participant IB as Bulkhead inventory (2 threads, queue 2) + participant Pay as Payment provider (slow) + participant Inv as Inventory system (healthy) + + Caller->>PB: submit payment call 1..4 + PB->>Pay: run 2 calls, queue 2 calls + Caller->>PB: submit payment call 5 + PB-->>Caller: BulkheadFullException (fail fast) + Caller->>IB: submit inventory call + IB->>Inv: run call on a free thread + Inv-->>IB: Inventory reserved + IB-->>Caller: response without waiting for payment +``` + +## Programmatic Example of Bulkhead Pattern in Java + +Our order service depends on two remote systems. Both implement the same `RemoteService` contract. + +```java +@FunctionalInterface +public interface RemoteService { + String call(String request); +} +``` + +The payment provider has become slow: every call takes the configured latency. The inventory system is healthy and answers immediately. + +```java +@Slf4j +public class PaymentService implements RemoteService { + + private final Duration latency; + + public PaymentService(Duration latency) { + this.latency = latency; + } + + @Override + public String call(String request) { + LOGGER.info("Payment provider received '{}', it will take {} ms", request, latency.toMillis()); + try { + Thread.sleep(latency); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Payment for '" + request + "' was interrupted", e); + } + return "Payment approved for " + request; + } +} + +@Slf4j +public class InventoryService implements RemoteService { + + @Override + public String call(String request) { + LOGGER.info("Inventory system received '{}'", request); + return "Inventory reserved for " + request; + } +} +``` + +The `Bulkhead` is the compartment. It owns a `ThreadPoolExecutor` with a fixed number of worker threads and a bounded queue. The `AbortPolicy` makes the executor throw when both are full, and the bulkhead translates that into a `BulkheadFullException` so the caller fails fast. It also keeps a counter of rejected calls for monitoring. + +```java +@Slf4j +public class Bulkhead implements AutoCloseable { + + @Getter private final String name; + @Getter private final int maxConcurrentCalls; + @Getter private final int maxQueueSize; + private final ThreadPoolExecutor executor; + private final AtomicLong rejectedCalls = new AtomicLong(); + + public Bulkhead(String name, int maxConcurrentCalls, int maxQueueSize) { + // argument validation omitted + this.name = name; + this.maxConcurrentCalls = maxConcurrentCalls; + this.maxQueueSize = maxQueueSize; + BlockingQueue queue = + maxQueueSize == 0 ? new SynchronousQueue<>() : new ArrayBlockingQueue<>(maxQueueSize); + var threadCounter = new AtomicInteger(); + this.executor = + new ThreadPoolExecutor( + maxConcurrentCalls, + maxConcurrentCalls, + 0L, + TimeUnit.MILLISECONDS, + queue, + runnable -> + new Thread(runnable, "bulkhead-" + name + "-" + threadCounter.incrementAndGet()), + new ThreadPoolExecutor.AbortPolicy()); + } + + public Future submit(Callable task) { + if (executor.isShutdown()) { + throw new IllegalStateException("Bulkhead '" + name + "' is shut down"); + } + try { + return executor.submit(task); + } catch (RejectedExecutionException e) { + rejectedCalls.incrementAndGet(); + LOGGER.warn( + "Bulkhead '{}' is full ({} active, {} queued), rejecting call", + name, + executor.getActiveCount(), + executor.getQueue().size()); + throw new BulkheadFullException(name); + } + } + + public int getActiveCalls() { + return executor.getActiveCount(); + } + + public int getQueuedCalls() { + return executor.getQueue().size(); + } + + public long getRejectedCalls() { + return rejectedCalls.get(); + } + + public void shutdown() { + executor.shutdownNow(); + } + + @Override + public void close() { + shutdown(); + } +} +``` + +`BulkheadFullException` carries the name of the compartment that turned the call away. + +```java +public class BulkheadFullException extends RuntimeException { + + private final String bulkheadName; + + public BulkheadFullException(String bulkheadName) { + super("Bulkhead '" + bulkheadName + "' is full, call rejected"); + this.bulkheadName = bulkheadName; + } + + public String getBulkheadName() { + return bulkheadName; + } +} +``` + +The application runs two scenarios. In the first one every downstream call goes through one shared pool of two threads with a queue of two. Four slow payment calls fill the pool, and the next inventory call is rejected although the inventory system is healthy. In the second scenario each dependency gets its own bulkhead. The payment compartment still saturates and rejects the excess calls immediately, but the inventory compartment keeps answering because payment calls can no longer take its threads. + +```java +public static void main(String[] args) { + var payment = new PaymentService(PAYMENT_LATENCY); + var inventory = new InventoryService(); + + LOGGER.info("--- Scenario 1: one shared thread pool for every downstream call ---"); + try (var sharedPool = new Bulkhead("shared-pool", 2, 2)) { + var paymentFutures = flood(sharedPool, payment, "order", 4); + callInventory(sharedPool, inventory, "order-5"); + awaitAll(paymentFutures); + } + + LOGGER.info("--- Scenario 2: a dedicated bulkhead for each downstream dependency ---"); + try (var paymentBulkhead = new Bulkhead("payment", 2, 2); + var inventoryBulkhead = new Bulkhead("inventory", 2, 2)) { + var paymentFutures = flood(paymentBulkhead, payment, "order", 10); + for (var i = 1; i <= 3; i++) { + callInventory(inventoryBulkhead, inventory, "order-" + i); + } + awaitAll(paymentFutures); + LOGGER.info( + "Bulkhead '{}' rejected {} of 10 calls, bulkhead '{}' rejected {} of 3 calls", + paymentBulkhead.getName(), + paymentBulkhead.getRejectedCalls(), + inventoryBulkhead.getName(), + inventoryBulkhead.getRejectedCalls()); + } +} +``` + +`flood` submits a burst of calls and logs the ones that are rejected, `callInventory` submits one inventory call and reports whether it was served or rejected, and `awaitAll` waits for the accepted payment calls. + +```java +private static List> flood( + Bulkhead bulkhead, RemoteService service, String requestPrefix, int calls) { + var accepted = new ArrayList>(); + for (var i = 1; i <= calls; i++) { + var request = requestPrefix + "-" + i; + try { + accepted.add(bulkhead.submit(() -> service.call(request))); + } catch (BulkheadFullException e) { + LOGGER.info("Request '{}' rejected immediately: {}", request, e.getMessage()); + } + } + return accepted; +} +``` + +Running the application produces output similar to the following. + +``` +--- Scenario 1: one shared thread pool for every downstream call --- +Payment provider received 'order-1', it will take 300 ms +Payment provider received 'order-2', it will take 300 ms +Bulkhead 'shared-pool' is full (2 active, 2 queued), rejecting call +Inventory check for 'order-5' rejected although the inventory system is healthy: Bulkhead 'shared-pool' is full, call rejected +Payment response: Payment approved for order-1 +... +--- Scenario 2: a dedicated bulkhead for each downstream dependency --- +Payment provider received 'order-1', it will take 300 ms +Payment provider received 'order-2', it will take 300 ms +Bulkhead 'payment' is full (2 active, 2 queued), rejecting call +Request 'order-5' rejected immediately: Bulkhead 'payment' is full, call rejected +... +Inventory system received 'order-1' +Inventory response: Inventory reserved for order-1 +Inventory system received 'order-2' +Inventory response: Inventory reserved for order-2 +Inventory system received 'order-3' +Inventory response: Inventory reserved for order-3 +Payment response: Payment approved for order-1 +... +Bulkhead 'payment' rejected 6 of 10 calls, bulkhead 'inventory' rejected 0 of 3 calls +``` + +## Class diagram + +See [microservices-bulkhead.urm.puml](./etc/microservices-bulkhead.urm.puml) for the PlantUML class diagram. + +## When to Use the Bulkhead Pattern in Java + +* A service calls several downstream dependencies and a slowdown in one of them must not degrade the others. +* Requests have different importance and the critical ones need guaranteed capacity. +* Threads, connections, or memory are shared and an overloaded consumer could starve the rest of the application. +* You prefer rejecting excess load quickly over queueing it indefinitely and timing out later. + +## Real-World Applications of Bulkhead Pattern in Java + +* [Resilience4j Bulkhead](https://resilience4j.readme.io/docs/bulkhead) offers a semaphore based and a thread pool based bulkhead. +* [Netflix Hystrix](https://github.com/Netflix/Hystrix/wiki/How-it-Works#isolation) isolated every command in its own thread pool. +* Separate connection pools per database or per tenant in JDBC and HTTP client configurations. +* Kubernetes resource limits and separate node pools that keep noisy workloads apart. + +## Benefits and Trade-offs of Bulkhead Pattern + +Benefits: + +* Contains failures: an overloaded dependency can only exhaust its own compartment. +* Fails fast: callers learn immediately that a compartment is full and can degrade gracefully. +* Predictable capacity: every dependency has a known, bounded share of the resources. +* Easy to observe: active, queued, and rejected calls per compartment are natural metrics. + +Trade-offs: + +* Resources sit idle in one compartment while another is saturated, so overall utilisation can drop. +* Every compartment needs sizing and tuning, which adds configuration and operational overhead. +* Thread pool bulkheads add a thread hop and a small latency cost for every call. +* Rejected calls still need a strategy, such as a fallback or a retry, to give the user a sensible result. + +## Related Java Design Patterns + +* [Circuit Breaker](../circuit-breaker): stops calling a dependency that keeps failing, while a bulkhead limits how much of the caller a dependency can occupy. +* [Fallback](../fallback): supplies a degraded response when a bulkhead rejects a call. +* [Retry](../retry): retries a call that was rejected once the compartment has free capacity again. +* [Throttling](../throttling) and [Rate Limiting](../rate-limiting-pattern): limit how many calls a client may make over time, whereas a bulkhead limits how many calls may run at once. +* [Health Check](../health-check): reports the state of dependencies that bulkheads protect. + +## References and Credits + +* [Release It!: Design and Deploy Production-Ready Software](https://amzn.to/3Uul4kF) +* [Microservices Patterns: With examples in Java](https://amzn.to/3UyWD5O) +* [Bulkhead pattern (microservices.io)](https://microservices.io/patterns/reliability/bulkhead.html) +* [Bulkhead pattern (Azure Architecture Center)](https://learn.microsoft.com/en-us/azure/architecture/patterns/bulkhead) +* [Resilience4j Bulkhead](https://resilience4j.readme.io/docs/bulkhead) diff --git a/microservices-bulkhead/etc/microservices-bulkhead.urm.puml b/microservices-bulkhead/etc/microservices-bulkhead.urm.puml new file mode 100644 index 000000000000..038e37526989 --- /dev/null +++ b/microservices-bulkhead/etc/microservices-bulkhead.urm.puml @@ -0,0 +1,53 @@ +@startuml +package com.iluwatar.bulkhead { + interface RemoteService { + + call(request : String) : String {abstract} + } + class Bulkhead { + - name : String + - maxConcurrentCalls : int + - maxQueueSize : int + - executor : ThreadPoolExecutor + - rejectedCalls : AtomicLong + + Bulkhead(name : String, maxConcurrentCalls : int, maxQueueSize : int) + + submit(task : Callable) : Future + + getName() : String + + getMaxConcurrentCalls() : int + + getMaxQueueSize() : int + + getActiveCalls() : int + + getQueuedCalls() : int + + getRejectedCalls() : long + + shutdown() : void + + close() : void + } + class BulkheadFullException { + - bulkheadName : String + + BulkheadFullException(bulkheadName : String) + + getBulkheadName() : String + } + class PaymentService { + - latency : Duration + + PaymentService(latency : Duration) + + call(request : String) : String + } + class InventoryService { + + InventoryService() + + call(request : String) : String + } + class App { + - PAYMENT_LATENCY : Duration {static} + - WAIT_TIMEOUT : Duration {static} + + App() + + main(args : String[]) : void {static} + } +} +Bulkhead ..|> AutoCloseable +BulkheadFullException --|> RuntimeException +PaymentService ..|> RemoteService +InventoryService ..|> RemoteService +Bulkhead ..> BulkheadFullException +App ..> Bulkhead +App ..> PaymentService +App ..> InventoryService +App ..> BulkheadFullException +@enduml diff --git a/microservices-bulkhead/pom.xml b/microservices-bulkhead/pom.xml new file mode 100644 index 000000000000..a261642c597d --- /dev/null +++ b/microservices-bulkhead/pom.xml @@ -0,0 +1,70 @@ + + + + 4.0.0 + + com.iluwatar + java-design-patterns + 1.26.0-SNAPSHOT + + microservices-bulkhead + + + org.slf4j + slf4j-api + + + ch.qos.logback + logback-classic + + + org.junit.jupiter + junit-jupiter-engine + test + + + + + + org.apache.maven.plugins + maven-assembly-plugin + + + + + + com.iluwatar.bulkhead.App + + + + + + + + + diff --git a/microservices-bulkhead/src/main/java/com/iluwatar/bulkhead/App.java b/microservices-bulkhead/src/main/java/com/iluwatar/bulkhead/App.java new file mode 100644 index 000000000000..eddb17b27c32 --- /dev/null +++ b/microservices-bulkhead/src/main/java/com/iluwatar/bulkhead/App.java @@ -0,0 +1,132 @@ +/* + * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * + * The MIT License + * Copyright © 2014-2022 Ilkka Seppälä + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.iluwatar.bulkhead; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import lombok.extern.slf4j.Slf4j; + +/** + * The Bulkhead pattern partitions the resources of a service so that a failure or an overload in + * one dependency cannot sink the whole service, just like the watertight compartments in a ship's + * hull keep a single leak from flooding the entire vessel. + * + *

In this example an order service calls two downstream dependencies: a payment provider that + * has become very slow and an inventory system that is perfectly healthy. The demo first sends both + * kinds of calls through one shared thread pool. The slow payment calls fill every thread and the + * queue, so a request for the healthy inventory system is rejected although nothing is wrong with + * it. The demo then gives each dependency its own {@link Bulkhead}. The payment compartment still + * saturates and rejects the excess calls fast, but the inventory compartment keeps answering + * immediately because the payment calls can no longer consume its threads. + */ +@Slf4j +public class App { + + private static final Duration PAYMENT_LATENCY = Duration.ofMillis(300); + private static final Duration WAIT_TIMEOUT = Duration.ofSeconds(5); + + /** + * Program entry point. + * + * @param args command line arguments, not used + */ + public static void main(String[] args) { + var payment = new PaymentService(PAYMENT_LATENCY); + var inventory = new InventoryService(); + + LOGGER.info("--- Scenario 1: one shared thread pool for every downstream call ---"); + try (var sharedPool = new Bulkhead("shared-pool", 2, 2)) { + var paymentFutures = flood(sharedPool, payment, "order", 4); + callInventory(sharedPool, inventory, "order-5"); + awaitAll(paymentFutures); + } + + LOGGER.info("--- Scenario 2: a dedicated bulkhead for each downstream dependency ---"); + try (var paymentBulkhead = new Bulkhead("payment", 2, 2); + var inventoryBulkhead = new Bulkhead("inventory", 2, 2)) { + var paymentFutures = flood(paymentBulkhead, payment, "order", 10); + for (var i = 1; i <= 3; i++) { + callInventory(inventoryBulkhead, inventory, "order-" + i); + } + awaitAll(paymentFutures); + LOGGER.info( + "Bulkhead '{}' rejected {} of 10 calls, bulkhead '{}' rejected {} of 3 calls", + paymentBulkhead.getName(), + paymentBulkhead.getRejectedCalls(), + inventoryBulkhead.getName(), + inventoryBulkhead.getRejectedCalls()); + } + } + + static List> flood( + Bulkhead bulkhead, RemoteService service, String requestPrefix, int calls) { + var accepted = new ArrayList>(); + for (var i = 1; i <= calls; i++) { + var request = requestPrefix + "-" + i; + try { + accepted.add(bulkhead.submit(() -> service.call(request))); + } catch (BulkheadFullException e) { + LOGGER.info("Request '{}' rejected immediately: {}", request, e.getMessage()); + } + } + return accepted; + } + + static void callInventory(Bulkhead bulkhead, RemoteService inventory, String request) { + try { + var response = bulkhead.submit(() -> inventory.call(request)); + LOGGER.info( + "Inventory response: {}", response.get(WAIT_TIMEOUT.toMillis(), TimeUnit.MILLISECONDS)); + } catch (BulkheadFullException e) { + LOGGER.error( + "Inventory check for '{}' rejected although the inventory system is healthy: {}", + request, + e.getMessage()); + } catch (ExecutionException | TimeoutException e) { + LOGGER.error("Inventory check for '{}' failed", request, e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + static void awaitAll(List> futures) { + for (var future : futures) { + try { + LOGGER.info( + "Payment response: {}", future.get(WAIT_TIMEOUT.toMillis(), TimeUnit.MILLISECONDS)); + } catch (ExecutionException | TimeoutException e) { + LOGGER.error("Payment call failed", e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + } + } +} diff --git a/microservices-bulkhead/src/main/java/com/iluwatar/bulkhead/Bulkhead.java b/microservices-bulkhead/src/main/java/com/iluwatar/bulkhead/Bulkhead.java new file mode 100644 index 000000000000..430b935a9a8c --- /dev/null +++ b/microservices-bulkhead/src/main/java/com/iluwatar/bulkhead/Bulkhead.java @@ -0,0 +1,146 @@ +/* + * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * + * The MIT License + * Copyright © 2014-2022 Ilkka Seppälä + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.iluwatar.bulkhead; + +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.Callable; +import java.util.concurrent.Future; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.SynchronousQueue; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +/** + * Isolates the calls made to one downstream dependency inside a dedicated, bounded thread pool. + * + *

The pool has a fixed number of worker threads and a bounded waiting queue. When both are full + * the bulkhead does not block the caller and it cannot borrow threads from anywhere else: the call + * fails fast with a {@link BulkheadFullException}. Each dependency gets its own instance, so a slow + * or hanging dependency can only exhaust its own compartment while the rest of the system keeps + * serving requests. + */ +@Slf4j +public class Bulkhead implements AutoCloseable { + + @Getter private final String name; + @Getter private final int maxConcurrentCalls; + @Getter private final int maxQueueSize; + private final ThreadPoolExecutor executor; + private final AtomicLong rejectedCalls = new AtomicLong(); + + /** + * Creates a bulkhead with its own thread pool. + * + * @param name name of the compartment, used in logs and worker thread names + * @param maxConcurrentCalls number of calls that may run at the same time + * @param maxQueueSize number of calls that may wait for a free thread; zero disables queueing + */ + public Bulkhead(String name, int maxConcurrentCalls, int maxQueueSize) { + if (maxConcurrentCalls < 1) { + throw new IllegalArgumentException("maxConcurrentCalls must be at least 1"); + } + if (maxQueueSize < 0) { + throw new IllegalArgumentException("maxQueueSize must not be negative"); + } + this.name = name; + this.maxConcurrentCalls = maxConcurrentCalls; + this.maxQueueSize = maxQueueSize; + BlockingQueue queue = + maxQueueSize == 0 ? new SynchronousQueue<>() : new ArrayBlockingQueue<>(maxQueueSize); + var threadCounter = new AtomicInteger(); + this.executor = + new ThreadPoolExecutor( + maxConcurrentCalls, + maxConcurrentCalls, + 0L, + TimeUnit.MILLISECONDS, + queue, + runnable -> + new Thread(runnable, "bulkhead-" + name + "-" + threadCounter.incrementAndGet()), + new ThreadPoolExecutor.AbortPolicy()); + } + + /** + * Submits a call to this compartment. + * + * @param task the call to execute + * @param type of the result + * @return a future that completes with the result of the call + * @throws BulkheadFullException if every thread is busy and the queue is full + * @throws IllegalStateException if the bulkhead has been shut down + */ + public Future submit(Callable task) { + if (executor.isShutdown()) { + throw new IllegalStateException("Bulkhead '" + name + "' is shut down"); + } + try { + var future = executor.submit(task); + LOGGER.debug( + "Bulkhead '{}' accepted call ({} active, {} queued)", + name, + executor.getActiveCount(), + executor.getQueue().size()); + return future; + } catch (RejectedExecutionException e) { + rejectedCalls.incrementAndGet(); + LOGGER.warn( + "Bulkhead '{}' is full ({} active, {} queued), rejecting call", + name, + executor.getActiveCount(), + executor.getQueue().size()); + throw new BulkheadFullException(name); + } + } + + /** Number of calls currently running. */ + public int getActiveCalls() { + return executor.getActiveCount(); + } + + /** Number of calls waiting for a free thread. */ + public int getQueuedCalls() { + return executor.getQueue().size(); + } + + /** Number of calls rejected since the bulkhead was created. */ + public long getRejectedCalls() { + return rejectedCalls.get(); + } + + /** Stops the compartment, interrupting calls that are still running. */ + public void shutdown() { + executor.shutdownNow(); + } + + @Override + public void close() { + shutdown(); + } +} diff --git a/microservices-bulkhead/src/main/java/com/iluwatar/bulkhead/BulkheadFullException.java b/microservices-bulkhead/src/main/java/com/iluwatar/bulkhead/BulkheadFullException.java new file mode 100644 index 000000000000..de4c0bb516bb --- /dev/null +++ b/microservices-bulkhead/src/main/java/com/iluwatar/bulkhead/BulkheadFullException.java @@ -0,0 +1,46 @@ +/* + * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * + * The MIT License + * Copyright © 2014-2022 Ilkka Seppälä + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.iluwatar.bulkhead; + +/** + * Thrown by a {@link Bulkhead} when a call cannot be accepted because every worker thread is busy + * and the waiting queue is full. The caller receives this exception immediately instead of + * blocking, which is the fail-fast behaviour the pattern relies on: the caller can degrade + * gracefully while the overloaded dependency keeps consuming only the capacity reserved for it. + */ +public class BulkheadFullException extends RuntimeException { + + private final String bulkheadName; + + public BulkheadFullException(String bulkheadName) { + super("Bulkhead '" + bulkheadName + "' is full, call rejected"); + this.bulkheadName = bulkheadName; + } + + /** Name of the bulkhead that rejected the call. */ + public String getBulkheadName() { + return bulkheadName; + } +} diff --git a/microservices-bulkhead/src/main/java/com/iluwatar/bulkhead/InventoryService.java b/microservices-bulkhead/src/main/java/com/iluwatar/bulkhead/InventoryService.java new file mode 100644 index 000000000000..a1648738a613 --- /dev/null +++ b/microservices-bulkhead/src/main/java/com/iluwatar/bulkhead/InventoryService.java @@ -0,0 +1,41 @@ +/* + * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * + * The MIT License + * Copyright © 2014-2022 Ilkka Seppälä + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.iluwatar.bulkhead; + +import lombok.extern.slf4j.Slf4j; + +/** + * Simulates a healthy inventory system that answers immediately. It represents the dependency that + * should keep working even while another dependency is overloaded. + */ +@Slf4j +public class InventoryService implements RemoteService { + + @Override + public String call(String request) { + LOGGER.info("Inventory system received '{}'", request); + return "Inventory reserved for " + request; + } +} diff --git a/microservices-bulkhead/src/main/java/com/iluwatar/bulkhead/PaymentService.java b/microservices-bulkhead/src/main/java/com/iluwatar/bulkhead/PaymentService.java new file mode 100644 index 000000000000..e4c1599a4a3d --- /dev/null +++ b/microservices-bulkhead/src/main/java/com/iluwatar/bulkhead/PaymentService.java @@ -0,0 +1,60 @@ +/* + * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * + * The MIT License + * Copyright © 2014-2022 Ilkka Seppälä + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.iluwatar.bulkhead; + +import java.time.Duration; +import lombok.extern.slf4j.Slf4j; + +/** + * Simulates a payment provider that has become slow. Every call takes the configured latency, so a + * burst of payment requests keeps the threads that serve it busy for a long time. Without a + * bulkhead these calls would also occupy the threads needed by healthy dependencies. + */ +@Slf4j +public class PaymentService implements RemoteService { + + private final Duration latency; + + /** + * Creates a payment service. + * + * @param latency time every call takes to complete + */ + public PaymentService(Duration latency) { + this.latency = latency; + } + + @Override + public String call(String request) { + LOGGER.info("Payment provider received '{}', it will take {} ms", request, latency.toMillis()); + try { + Thread.sleep(latency); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Payment for '" + request + "' was interrupted", e); + } + return "Payment approved for " + request; + } +} diff --git a/microservices-bulkhead/src/main/java/com/iluwatar/bulkhead/RemoteService.java b/microservices-bulkhead/src/main/java/com/iluwatar/bulkhead/RemoteService.java new file mode 100644 index 000000000000..be4f21941415 --- /dev/null +++ b/microservices-bulkhead/src/main/java/com/iluwatar/bulkhead/RemoteService.java @@ -0,0 +1,42 @@ +/* + * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * + * The MIT License + * Copyright © 2014-2022 Ilkka Seppälä + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.iluwatar.bulkhead; + +/** + * A remote dependency that a service calls over the network, such as a payment provider or an + * inventory system. Every call is routed through a {@link Bulkhead} so that the caller never lets + * one dependency monopolise its threads. + */ +@FunctionalInterface +public interface RemoteService { + + /** + * Performs the remote call. + * + * @param request identifier of the request, used in the response and in the logs + * @return the response of the dependency + */ + String call(String request); +} diff --git a/microservices-bulkhead/src/test/java/com/iluwatar/bulkhead/AppTest.java b/microservices-bulkhead/src/test/java/com/iluwatar/bulkhead/AppTest.java new file mode 100644 index 000000000000..35c85bf9c77d --- /dev/null +++ b/microservices-bulkhead/src/test/java/com/iluwatar/bulkhead/AppTest.java @@ -0,0 +1,151 @@ +/* + * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * + * The MIT License + * Copyright © 2014-2022 Ilkka Seppälä + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.iluwatar.bulkhead; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +class AppTest { + + private static final long TIMEOUT_SECONDS = 5; + + @AfterEach + void clearInterruptFlag() { + Thread.interrupted(); + } + + @Test + void shouldLaunchApp() { + assertDoesNotThrow(() -> App.main(new String[] {})); + } + + @Test + void shouldBeInstantiable() { + assertNotNull(new App(), "App should be instantiable"); + } + + @Test + void callInventoryShouldReportFailureOfTheRemoteCall() { + try (var bulkhead = new Bulkhead("inventory", 1, 1)) { + RemoteService failing = + request -> { + throw new IllegalStateException("inventory system down"); + }; + + assertDoesNotThrow(() -> App.callInventory(bulkhead, failing, "order-1")); + } + } + + @Test + void callInventoryShouldReportRejectionWhenBulkheadIsFull() throws Exception { + var started = new CountDownLatch(1); + var gate = new CountDownLatch(1); + try (var bulkhead = new Bulkhead("inventory", 1, 0)) { + var blocking = bulkhead.submit(blockOn(started, gate)); + assertTrue(started.await(TIMEOUT_SECONDS, TimeUnit.SECONDS)); + + assertDoesNotThrow(() -> App.callInventory(bulkhead, new InventoryService(), "order-1")); + + gate.countDown(); + blocking.get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + } + } + + @Test + void callInventoryShouldKeepInterruptFlagWhenWaitingIsInterrupted() throws Exception { + var started = new CountDownLatch(1); + var gate = new CountDownLatch(1); + try (var bulkhead = new Bulkhead("inventory", 1, 1)) { + RemoteService slow = + request -> { + started.countDown(); + awaitQuietly(gate); + return "late"; + }; + Thread.currentThread().interrupt(); + + assertDoesNotThrow(() -> App.callInventory(bulkhead, slow, "order-1")); + + assertTrue(Thread.interrupted()); + gate.countDown(); + assertTrue(started.await(TIMEOUT_SECONDS, TimeUnit.SECONDS)); + } + } + + @Test + void awaitAllShouldReportFailedCalls() { + try (var bulkhead = new Bulkhead("payment", 1, 1)) { + Future failed = + bulkhead.submit( + () -> { + throw new IllegalStateException("payment provider down"); + }); + + assertDoesNotThrow(() -> App.awaitAll(List.of(failed))); + } + } + + @Test + void awaitAllShouldStopAndKeepInterruptFlagWhenWaitingIsInterrupted() throws Exception { + var started = new CountDownLatch(1); + var gate = new CountDownLatch(1); + try (var bulkhead = new Bulkhead("payment", 1, 1)) { + var blocking = bulkhead.submit(blockOn(started, gate)); + assertTrue(started.await(TIMEOUT_SECONDS, TimeUnit.SECONDS)); + Thread.currentThread().interrupt(); + + assertDoesNotThrow(() -> App.awaitAll(List.of(blocking))); + + assertTrue(Thread.interrupted()); + gate.countDown(); + blocking.get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + } + } + + private static Callable blockOn(CountDownLatch started, CountDownLatch gate) { + return () -> { + started.countDown(); + awaitQuietly(gate); + return "done"; + }; + } + + private static void awaitQuietly(CountDownLatch gate) { + try { + gate.await(TIMEOUT_SECONDS, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } +} diff --git a/microservices-bulkhead/src/test/java/com/iluwatar/bulkhead/BulkheadTest.java b/microservices-bulkhead/src/test/java/com/iluwatar/bulkhead/BulkheadTest.java new file mode 100644 index 000000000000..1ba207328a74 --- /dev/null +++ b/microservices-bulkhead/src/test/java/com/iluwatar/bulkhead/BulkheadTest.java @@ -0,0 +1,156 @@ +/* + * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * + * The MIT License + * Copyright © 2014-2022 Ilkka Seppälä + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.iluwatar.bulkhead; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.concurrent.Callable; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Test; + +class BulkheadTest { + + private static final long TIMEOUT_SECONDS = 5; + + @Test + void shouldExecuteCallWhenCapacityIsAvailable() throws Exception { + try (var bulkhead = new Bulkhead("test", 1, 1)) { + var future = bulkhead.submit(() -> "ok"); + + assertEquals("ok", future.get(TIMEOUT_SECONDS, TimeUnit.SECONDS)); + assertEquals(0, bulkhead.getRejectedCalls()); + } + } + + @Test + void shouldRejectCallWhenThreadsAndQueueAreFull() throws Exception { + var started = new CountDownLatch(1); + var gate = new CountDownLatch(1); + try (var bulkhead = new Bulkhead("payment", 1, 1)) { + var running = bulkhead.submit(blockOn(started, gate)); + assertTrue(started.await(TIMEOUT_SECONDS, TimeUnit.SECONDS)); + var queued = bulkhead.submit(() -> "queued"); + + var exception = + assertThrows(BulkheadFullException.class, () -> bulkhead.submit(() -> "rejected")); + + assertEquals("payment", exception.getBulkheadName()); + assertEquals("Bulkhead 'payment' is full, call rejected", exception.getMessage()); + assertEquals(1, bulkhead.getActiveCalls()); + assertEquals(1, bulkhead.getQueuedCalls()); + assertEquals(1, bulkhead.getRejectedCalls()); + + gate.countDown(); + assertEquals("running", running.get(TIMEOUT_SECONDS, TimeUnit.SECONDS)); + assertEquals("queued", queued.get(TIMEOUT_SECONDS, TimeUnit.SECONDS)); + } + } + + @Test + void shouldAcceptCallsAgainAfterCapacityIsReleased() throws Exception { + var started = new CountDownLatch(1); + var gate = new CountDownLatch(1); + try (var bulkhead = new Bulkhead("payment", 1, 1)) { + var running = bulkhead.submit(blockOn(started, gate)); + assertTrue(started.await(TIMEOUT_SECONDS, TimeUnit.SECONDS)); + var queued = bulkhead.submit(() -> "queued"); + assertThrows(BulkheadFullException.class, () -> bulkhead.submit(() -> "rejected")); + + gate.countDown(); + assertEquals("running", running.get(TIMEOUT_SECONDS, TimeUnit.SECONDS)); + assertEquals("queued", queued.get(TIMEOUT_SECONDS, TimeUnit.SECONDS)); + + // The queue is empty again, so the next call is accepted even if the worker thread has not + // yet returned to polling the queue. A bulkhead without a queue would race here, because the + // hand-off to the single thread only succeeds once that thread is idle. + var afterRelease = bulkhead.submit(() -> "accepted"); + assertEquals("accepted", afterRelease.get(TIMEOUT_SECONDS, TimeUnit.SECONDS)); + assertEquals(1, bulkhead.getRejectedCalls()); + } + } + + @Test + void shouldPropagateFailureOfTheCallThroughTheFuture() { + try (var bulkhead = new Bulkhead("test", 1, 1)) { + var future = + bulkhead.submit( + () -> { + throw new IllegalStateException("downstream failure"); + }); + + var exception = + assertThrows( + ExecutionException.class, () -> future.get(TIMEOUT_SECONDS, TimeUnit.SECONDS)); + + assertInstanceOf(IllegalStateException.class, exception.getCause()); + assertEquals(0, bulkhead.getRejectedCalls()); + } + } + + @Test + void shouldRunCallsOnThreadsNamedAfterTheBulkhead() throws Exception { + try (var bulkhead = new Bulkhead("payment", 1, 1)) { + var threadName = bulkhead.submit(() -> Thread.currentThread().getName()); + + assertTrue(threadName.get(TIMEOUT_SECONDS, TimeUnit.SECONDS).startsWith("bulkhead-payment-")); + } + } + + @Test + void shouldRejectSubmissionAfterShutdown() { + var bulkhead = new Bulkhead("test", 1, 1); + bulkhead.shutdown(); + + assertThrows(IllegalStateException.class, () -> bulkhead.submit(() -> "late")); + } + + @Test + void shouldRejectInvalidConfiguration() { + assertThrows(IllegalArgumentException.class, () -> new Bulkhead("test", 0, 1)); + assertThrows(IllegalArgumentException.class, () -> new Bulkhead("test", 1, -1)); + } + + @Test + void shouldExposeConfiguration() { + try (var bulkhead = new Bulkhead("inventory", 3, 4)) { + assertEquals("inventory", bulkhead.getName()); + assertEquals(3, bulkhead.getMaxConcurrentCalls()); + assertEquals(4, bulkhead.getMaxQueueSize()); + } + } + + private static Callable blockOn(CountDownLatch started, CountDownLatch gate) { + return () -> { + started.countDown(); + gate.await(TIMEOUT_SECONDS, TimeUnit.SECONDS); + return "running"; + }; + } +} diff --git a/microservices-bulkhead/src/test/java/com/iluwatar/bulkhead/InventoryServiceTest.java b/microservices-bulkhead/src/test/java/com/iluwatar/bulkhead/InventoryServiceTest.java new file mode 100644 index 000000000000..96d4e2a38732 --- /dev/null +++ b/microservices-bulkhead/src/test/java/com/iluwatar/bulkhead/InventoryServiceTest.java @@ -0,0 +1,39 @@ +/* + * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * + * The MIT License + * Copyright © 2014-2022 Ilkka Seppälä + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.iluwatar.bulkhead; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +class InventoryServiceTest { + + @Test + void shouldReserveInventoryImmediately() { + var service = new InventoryService(); + + assertEquals("Inventory reserved for order-1", service.call("order-1")); + } +} diff --git a/microservices-bulkhead/src/test/java/com/iluwatar/bulkhead/PaymentServiceTest.java b/microservices-bulkhead/src/test/java/com/iluwatar/bulkhead/PaymentServiceTest.java new file mode 100644 index 000000000000..7b1c685f3093 --- /dev/null +++ b/microservices-bulkhead/src/test/java/com/iluwatar/bulkhead/PaymentServiceTest.java @@ -0,0 +1,54 @@ +/* + * This project is licensed under the MIT license. Module model-view-viewmodel is using ZK framework licensed under LGPL (see lgpl-3.0.txt). + * + * The MIT License + * Copyright © 2014-2022 Ilkka Seppälä + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.iluwatar.bulkhead; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Duration; +import org.junit.jupiter.api.Test; + +class PaymentServiceTest { + + @Test + void shouldApprovePaymentAfterLatency() { + var service = new PaymentService(Duration.ofMillis(10)); + + assertEquals("Payment approved for order-1", service.call("order-1")); + } + + @Test + void shouldFailAndKeepInterruptFlagWhenInterrupted() { + var service = new PaymentService(Duration.ofSeconds(10)); + Thread.currentThread().interrupt(); + try { + assertThrows(IllegalStateException.class, () -> service.call("order-1")); + assertTrue(Thread.currentThread().isInterrupted()); + } finally { + assertTrue(Thread.interrupted()); + } + } +} diff --git a/pom.xml b/pom.xml index a71630d289d3..072bd46ac88f 100644 --- a/pom.xml +++ b/pom.xml @@ -260,6 +260,7 @@ rate-limiting-pattern fallback onion-architecture + microservices-bulkhead