From c4563e94ba3c4fad89a1ff367216935607dcb71d Mon Sep 17 00:00:00 2001 From: Doksanbir Date: Thu, 3 Sep 2026 12:18:59 +0300 Subject: [PATCH 1/2] feat: add Scatter-Gather pattern (#3577) --- pom.xml | 1 + scatter-gather/README.md | 243 ++++++++++++++++++ scatter-gather/etc/scatter-gather.urm.puml | 74 ++++++ scatter-gather/pom.xml | 70 +++++ .../iluwatar/scattergather/Aggregator.java | 54 ++++ .../java/com/iluwatar/scattergather/App.java | 88 +++++++ .../scattergather/DelayedRateProvider.java | 67 +++++ .../scattergather/FailingRateProvider.java | 53 ++++ .../scattergather/InMemoryRateProvider.java | 59 +++++ .../iluwatar/scattergather/RateProvider.java | 44 ++++ .../com/iluwatar/scattergather/RateQuote.java | 36 +++ .../iluwatar/scattergather/RateRequest.java | 46 ++++ .../iluwatar/scattergather/ScatterGather.java | 154 +++++++++++ .../scattergather/AggregatorTest.java | 54 ++++ .../com/iluwatar/scattergather/AppTest.java | 57 ++++ .../scattergather/RateProviderTest.java | 71 +++++ .../scattergather/ScatterGatherTest.java | 205 +++++++++++++++ 17 files changed, 1376 insertions(+) create mode 100644 scatter-gather/README.md create mode 100644 scatter-gather/etc/scatter-gather.urm.puml create mode 100644 scatter-gather/pom.xml create mode 100644 scatter-gather/src/main/java/com/iluwatar/scattergather/Aggregator.java create mode 100644 scatter-gather/src/main/java/com/iluwatar/scattergather/App.java create mode 100644 scatter-gather/src/main/java/com/iluwatar/scattergather/DelayedRateProvider.java create mode 100644 scatter-gather/src/main/java/com/iluwatar/scattergather/FailingRateProvider.java create mode 100644 scatter-gather/src/main/java/com/iluwatar/scattergather/InMemoryRateProvider.java create mode 100644 scatter-gather/src/main/java/com/iluwatar/scattergather/RateProvider.java create mode 100644 scatter-gather/src/main/java/com/iluwatar/scattergather/RateQuote.java create mode 100644 scatter-gather/src/main/java/com/iluwatar/scattergather/RateRequest.java create mode 100644 scatter-gather/src/main/java/com/iluwatar/scattergather/ScatterGather.java create mode 100644 scatter-gather/src/test/java/com/iluwatar/scattergather/AggregatorTest.java create mode 100644 scatter-gather/src/test/java/com/iluwatar/scattergather/AppTest.java create mode 100644 scatter-gather/src/test/java/com/iluwatar/scattergather/RateProviderTest.java create mode 100644 scatter-gather/src/test/java/com/iluwatar/scattergather/ScatterGatherTest.java diff --git a/pom.xml b/pom.xml index a71630d289d3..bd3086577753 100644 --- a/pom.xml +++ b/pom.xml @@ -260,6 +260,7 @@ rate-limiting-pattern fallback onion-architecture + scatter-gather diff --git a/scatter-gather/README.md b/scatter-gather/README.md new file mode 100644 index 000000000000..01e8e3a7e406 --- /dev/null +++ b/scatter-gather/README.md @@ -0,0 +1,243 @@ +--- +title: "Scatter-Gather Pattern in Java: Broadcasting One Request and Aggregating Many Replies" +shortTitle: Scatter-Gather +description: "Learn the Scatter-Gather pattern in Java. Send one request to several independent services at once, gather the replies that arrive in time, and aggregate them into a single answer while tolerating slow or failed recipients." +category: Concurrency +language: en +tag: + - Asynchronous + - Decoupling + - Integration + - Messaging + - Scalability +--- + +## Also known as + +* Broadcast and Aggregate +* Request-Reply Broadcast + +## Intent of Scatter-Gather Design Pattern + +Send the same request to a number of independent recipients concurrently, collect the replies that arrive within a deadline, and combine them into one result. The caller pays roughly the latency of the slowest tolerated recipient instead of the sum of all latencies, and a single slow or failing recipient does not prevent an answer. + +## Detailed Explanation of Scatter-Gather Pattern with Real-World Examples + +Real-world example + +> A travel site has to show the price of a hotel stay. It does not own a single price list; several rate providers each have their own. Asking them one after another would make the page as slow as all of them together, and one provider that is down would block the result. Instead the site scatters the same request to every provider at the same time, waits a few hundred milliseconds for their replies, drops the ones that did not answer in time, and shows the cheapest of the quotes it gathered. + +In plain words + +> Ask everybody the same question at once, wait a bounded time, and aggregate the answers you got. + +Enterprise Integration Patterns says + +> Use a Scatter-Gather that broadcasts a message to multiple recipients and re-aggregates the responses back into a single message. + +How it differs from Fan-Out/Fan-In + +> [Fan-Out/Fan-In](../fanout-fanin) splits one job into sub-tasks of the same kind and needs all of them back to build the result. Scatter-Gather sends the same message to different, independent services, expects heterogeneous replies, and is designed to produce a result even when some recipients are slow or unavailable. + +Sequence diagram + +```mermaid +sequenceDiagram + participant Client + participant ScatterGather + participant Atlas as Atlas Hotels + participant Harbor as Harbor Stays + participant Sleepy as Sleepy Suites (slow) + participant Flaky as Flaky Inns (down) + + Client->>ScatterGather: scatter(request) + par broadcast + ScatterGather->>Atlas: quote(request) + ScatterGather->>Harbor: quote(request) + ScatterGather->>Sleepy: quote(request) + ScatterGather->>Flaky: quote(request) + end + Atlas-->>ScatterGather: 387.00 + Harbor-->>ScatterGather: 295.50 + Flaky-->>ScatterGather: error + Note over ScatterGather,Sleepy: timeout expires, reply dropped + ScatterGather->>ScatterGather: gather() keeps 2 of 4 replies + ScatterGather->>Client: aggregate() returns Harbor Stays 295.50 +``` + +## Programmatic Example of Scatter-Gather Pattern in Java + +The request is a plain immutable value. Every recipient receives exactly the same instance. + +```java +public record RateRequest(String city, LocalDate checkIn, int nights) {} + +public record RateQuote(String provider, BigDecimal total) {} +``` + +Each recipient implements `RateProvider`. In a real system these would be remote services with their own latency and failure profile. The demo ships a fast in-memory provider, a decorator that delays any provider, and a provider that is down. + +```java +public interface RateProvider { + String name(); + + RateQuote quote(RateRequest request); +} +``` + +The `Aggregator` reduces the gathered replies. Because it is a separate strategy the same coordinator can serve callers that want the cheapest quote, an average, or the full list. + +```java +@FunctionalInterface +public interface Aggregator { + R aggregate(List replies); + + static Aggregator> cheapestQuote() { + return replies -> replies.stream().min(Comparator.comparing(RateQuote::total)); + } +} +``` + +`ScatterGather` implements the three phases. The scatter phase submits one asynchronous call per provider and attaches the timeout to each future. Nothing is awaited yet. + +```java +public List scatter(RateRequest request, List providers) { + var pending = new ArrayList(); + for (var provider : providers) { + var reply = + CompletableFuture.supplyAsync(() -> provider.quote(request), executor) + .orTimeout(timeout.toMillis(), TimeUnit.MILLISECONDS); + pending.add(new PendingReply(provider, reply)); + } + return pending; +} +``` + +The gather phase waits until every future has settled, then keeps the successful replies. Timeouts and failures are logged and dropped, which is what allows the caller to get an answer from the providers that did respond. + +```java +public List gather(List pending) { + CompletableFuture.allOf(pending.stream().map(PendingReply::reply).toArray(CompletableFuture[]::new)) + .exceptionally(ex -> null) + .join(); + var quotes = new ArrayList(); + for (var entry : pending) { + try { + quotes.add(entry.reply().join()); + } catch (CompletionException e) { + if (e.getCause() instanceof TimeoutException) { + LOGGER.warn("Dropping {}: no reply within {} ms", entry.provider().name(), timeout.toMillis()); + } else { + LOGGER.warn("Dropping {}: {}", entry.provider().name(), e.getCause().getMessage()); + } + } + } + return quotes; +} +``` + +A convenience method chains the phases together. + +```java +public R scatterGather( + RateRequest request, List providers, Aggregator aggregator) { + return aggregator.aggregate(gather(scatter(request, providers))); +} +``` + +The demo application asks four providers for the same three-night stay. One provider needs two seconds while the gather timeout is 300 milliseconds, and one provider is down. The site still answers with the cheapest of the two quotes it gathered. + +```java +var request = new RateRequest("Lisbon", LocalDate.of(2026, 10, 3), 3); +var providers = + List.of( + new InMemoryRateProvider("Atlas Hotels", new BigDecimal("129.00")), + new InMemoryRateProvider("Harbor Stays", new BigDecimal("98.50")), + new DelayedRateProvider( + new InMemoryRateProvider("Sleepy Suites", new BigDecimal("75.00")), + Duration.ofSeconds(2)), + new FailingRateProvider("Flaky Inns")); + +try (var scatterGather = + new ScatterGather(Executors.newFixedThreadPool(providers.size()), Duration.ofMillis(300))) { + var pending = scatterGather.scatter(request, providers); + var quotes = scatterGather.gather(pending); + reportBestOffer(Aggregator.cheapestQuote().aggregate(quotes)); +} +``` + +The result is reported by a small helper so the empty case is handled explicitly. + +```java +static void reportBestOffer(Optional best) { + best.ifPresentOrElse( + offer -> LOGGER.info("Best offer: {} at {}", offer.provider(), offer.total()), + () -> LOGGER.info("No provider answered in time")); +} +``` + +Running the program produces output similar to the following. + +``` +Scatter phase: broadcasting the same request to every provider +Scattering request for 3 nights in Lisbon to 4 providers +Atlas Hotels quotes 387.00 for 3 nights in Lisbon +Harbor Stays quotes 295.50 for 3 nights in Lisbon +Gather phase: collecting replies that arrive within the timeout +Sleepy Suites is slow and will need 2000 ms to answer +Gathered quote 387.00 from Atlas Hotels +Gathered quote 295.50 from Harbor Stays +Dropping Sleepy Suites: no reply within 300 ms +Dropping Flaky Inns: Flaky Inns is unavailable +Gathered 2 of 4 replies +Aggregate phase: choosing the cheapest of 2 quotes +Best offer: Harbor Stays at 295.50 +``` + +## Class diagram + +See [scatter-gather.urm.puml](./etc/scatter-gather.urm.puml) for the PlantUML class diagram. + +## When to Use the Scatter-Gather Pattern in Java + +* The same question has to be answered by several independent services, such as price comparison, search federation, or quorum reads. +* The latency of asking recipients sequentially is unacceptable. +* A partial answer built from the recipients that replied in time is more valuable than no answer. +* The recipients are unknown or change at runtime, so the caller should only depend on a common contract. + +## Real-World Applications of Scatter-Gather Pattern in Java + +* Travel and shopping comparison sites that query many suppliers for the same product. +* Distributed search engines that broadcast a query to every index shard and merge the ranked results. +* Quorum reads in replicated data stores that ask several replicas and accept the first consistent majority. +* [Apache Camel Scatter-Gather EIP](https://camel.apache.org/components/latest/eips/scatter-gather.html) +* [Spring Integration Scatter-Gather](https://docs.spring.io/spring-integration/reference/scatter-gather.html) +* [Akka scatter-gather with `ask` and `Future.sequence`](https://doc.akka.io/docs/akka/current/futures.html) + +## Benefits and Trade-offs of Scatter-Gather Pattern + +Benefits: + +* **Lower latency**: recipients are called concurrently, so the caller waits for the slowest tolerated reply rather than the sum of all replies. +* **Resilience**: a timeout bounds the wait and a failed recipient is simply left out of the aggregate. +* **Decoupling**: the caller depends only on the recipient contract and the aggregation strategy, not on the number or identity of recipients. + +Trade-offs: + +* **Partial results**: the caller must be able to live with an answer built from a subset of recipients, and the aggregator must handle an empty set. +* **Resource usage**: every request occupies one thread or connection per recipient; a dropped reply may still be computed by the recipient. +* **Tuning**: the timeout is a compromise between completeness and responsiveness and usually needs measurement to get right. + +## Related Java Design Patterns + +* [Fan-Out/Fan-In](../fanout-fanin): splits one task into homogeneous sub-tasks and waits for all of them; Scatter-Gather broadcasts one request to heterogeneous recipients and tolerates missing replies. +* [Microservices Aggregator](../microservices-aggregrator): a service that composes the responses of several downstream services; Scatter-Gather is a way to fetch those responses concurrently. +* [Async Method Invocation](../async-method-invocation): the mechanism used to call each recipient without blocking the caller. +* [Promise](../promise): each pending reply is a promise that either completes with a quote or fails. +* Timeout: bounds how long the gather phase waits for each recipient. + +## References and Credits + +* [Enterprise Integration Patterns](https://www.amazon.com/gp/product/0321200683) (Gregor Hohpe and Bobby Woolf) +* [Scatter-Gather at enterpriseintegrationpatterns.com](https://www.enterpriseintegrationpatterns.com/patterns/messaging/BroadcastAggregate.html) +* [Java Concurrency in Practice](https://www.amazon.com/gp/product/0321349601) (Brian Goetz) diff --git a/scatter-gather/etc/scatter-gather.urm.puml b/scatter-gather/etc/scatter-gather.urm.puml new file mode 100644 index 000000000000..42a7a26d5b2c --- /dev/null +++ b/scatter-gather/etc/scatter-gather.urm.puml @@ -0,0 +1,74 @@ +@startuml +package com.iluwatar.scattergather { + class RateRequest { + + RateRequest(city : String, checkIn : LocalDate, nights : int) + + city() : String + + checkIn() : LocalDate + + nights() : int + } + class RateQuote { + + RateQuote(provider : String, total : BigDecimal) + + provider() : String + + total() : BigDecimal + } + interface RateProvider { + + name() : String {abstract} + + quote(request : RateRequest) : RateQuote {abstract} + } + class InMemoryRateProvider { + - name : String + - nightlyRate : BigDecimal + + InMemoryRateProvider(name : String, nightlyRate : BigDecimal) + + name() : String + + quote(request : RateRequest) : RateQuote + } + class DelayedRateProvider { + - delegate : RateProvider + - delay : Duration + + DelayedRateProvider(delegate : RateProvider, delay : Duration) + + name() : String + + quote(request : RateRequest) : RateQuote + } + class FailingRateProvider { + - name : String + + FailingRateProvider(name : String) + + name() : String + + quote(request : RateRequest) : RateQuote + } + interface Aggregator { + + aggregate(replies : List) : R {abstract} + + cheapestQuote() : Aggregator> {static} + } + class PendingReply { + + PendingReply(provider : RateProvider, reply : CompletableFuture) + + provider() : RateProvider + + reply() : CompletableFuture + } + class ScatterGather { + - executor : ExecutorService + - timeout : Duration + + ScatterGather(executor : ExecutorService, timeout : Duration) + + scatter(request : RateRequest, providers : List) : List + + gather(pending : List) : List + + scatterGather(request : RateRequest, providers : List, aggregator : Aggregator) : R + + close() : void + } + class App { + + App() + + main(args : String[]) : void + ~ reportBestOffer(best : Optional) : void {static} + } +} +InMemoryRateProvider ..|> RateProvider +DelayedRateProvider ..|> RateProvider +DelayedRateProvider --> RateProvider +FailingRateProvider ..|> RateProvider +RateProvider ..> RateRequest +RateProvider ..> RateQuote +PendingReply --> RateProvider +PendingReply ..> RateQuote +ScatterGather ..> PendingReply +ScatterGather ..> Aggregator +ScatterGather --> "*" RateProvider +App ..> ScatterGather +@enduml diff --git a/scatter-gather/pom.xml b/scatter-gather/pom.xml new file mode 100644 index 000000000000..34012dd88a31 --- /dev/null +++ b/scatter-gather/pom.xml @@ -0,0 +1,70 @@ + + + + 4.0.0 + + com.iluwatar + java-design-patterns + 1.26.0-SNAPSHOT + + scatter-gather + + + 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.scattergather.App + + + + + + + + + diff --git a/scatter-gather/src/main/java/com/iluwatar/scattergather/Aggregator.java b/scatter-gather/src/main/java/com/iluwatar/scattergather/Aggregator.java new file mode 100644 index 000000000000..4cac13e31747 --- /dev/null +++ b/scatter-gather/src/main/java/com/iluwatar/scattergather/Aggregator.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.scattergather; + +import java.util.Comparator; +import java.util.List; +import java.util.Optional; + +/** + * Reduces the gathered replies into a single result. The aggregation strategy is pluggable so the + * same scatter and gather machinery can serve callers that want the cheapest quote, the average + * price, or the full list. + * + * @param the type of the gathered replies + * @param the type of the aggregated result + */ +@FunctionalInterface +public interface Aggregator { + + /** + * Combines the gathered replies. + * + * @param replies the replies that arrived in time, possibly empty + * @return the aggregated result + */ + R aggregate(List replies); + + /** Returns an aggregator that picks the quote with the lowest total price. */ + static Aggregator> cheapestQuote() { + return replies -> replies.stream().min(Comparator.comparing(RateQuote::total)); + } +} diff --git a/scatter-gather/src/main/java/com/iluwatar/scattergather/App.java b/scatter-gather/src/main/java/com/iluwatar/scattergather/App.java new file mode 100644 index 000000000000..21136f6e574b --- /dev/null +++ b/scatter-gather/src/main/java/com/iluwatar/scattergather/App.java @@ -0,0 +1,88 @@ +/* + * 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.scattergather; + +import java.math.BigDecimal; +import java.time.Duration; +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.Executors; +import lombok.extern.slf4j.Slf4j; + +/** + * The Scatter-Gather pattern sends one request to several independent recipients at the same time, + * gathers whatever replies arrive within a deadline, and aggregates them into a single answer. It + * is the integration counterpart of the fan-out/fan-in pattern: fan-out/fan-in splits one job into + * sub-tasks of the same kind, whereas scatter-gather broadcasts the same message to different + * services and has to cope with some of them being slow or unavailable. + * + *

This demo models a travel site that asks four hotel rate providers for the price of the same + * stay. One provider is slower than the gather timeout and one is down. The site still answers, + * using the quotes from the two healthy providers, and the {@link Aggregator} picks the cheapest. + */ +@Slf4j +public class App { + + /** + * Program entry point. + * + * @param args command line arguments, unused + */ + public static void main(String[] args) { + var request = new RateRequest("Lisbon", LocalDate.of(2026, 10, 3), 3); + var providers = + List.of( + new InMemoryRateProvider("Atlas Hotels", new BigDecimal("129.00")), + new InMemoryRateProvider("Harbor Stays", new BigDecimal("98.50")), + new DelayedRateProvider( + new InMemoryRateProvider("Sleepy Suites", new BigDecimal("75.00")), + Duration.ofSeconds(2)), + new FailingRateProvider("Flaky Inns")); + + try (var scatterGather = + new ScatterGather(Executors.newFixedThreadPool(providers.size()), Duration.ofMillis(300))) { + LOGGER.info("Scatter phase: broadcasting the same request to every provider"); + var pending = scatterGather.scatter(request, providers); + + LOGGER.info("Gather phase: collecting replies that arrive within the timeout"); + var quotes = scatterGather.gather(pending); + + LOGGER.info("Aggregate phase: choosing the cheapest of {} quotes", quotes.size()); + reportBestOffer(Aggregator.cheapestQuote().aggregate(quotes)); + } + } + + /** + * Logs the aggregated result, or the fact that no provider answered in time. + * + * @param best the cheapest gathered quote, empty when every provider failed or timed out + */ + static void reportBestOffer(Optional best) { + best.ifPresentOrElse( + offer -> LOGGER.info("Best offer: {} at {}", offer.provider(), offer.total()), + () -> LOGGER.info("No provider answered in time")); + } +} diff --git a/scatter-gather/src/main/java/com/iluwatar/scattergather/DelayedRateProvider.java b/scatter-gather/src/main/java/com/iluwatar/scattergather/DelayedRateProvider.java new file mode 100644 index 000000000000..88ca680fe195 --- /dev/null +++ b/scatter-gather/src/main/java/com/iluwatar/scattergather/DelayedRateProvider.java @@ -0,0 +1,67 @@ +/* + * 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.scattergather; + +import java.time.Duration; +import lombok.extern.slf4j.Slf4j; + +/** + * Wraps another provider and delays its reply, simulating a slow remote service. When the delay + * exceeds the gather timeout the reply is dropped and the remaining quotes are used instead. + */ +@Slf4j +public class DelayedRateProvider implements RateProvider { + + private final RateProvider delegate; + private final Duration delay; + + /** + * Creates a provider that answers only after the given delay. + * + * @param delegate the provider that produces the actual quote + * @param delay how long to wait before delegating + */ + public DelayedRateProvider(RateProvider delegate, Duration delay) { + this.delegate = delegate; + this.delay = delay; + } + + @Override + public String name() { + return delegate.name(); + } + + @Override + public RateQuote quote(RateRequest request) { + LOGGER.info("{} is slow and will need {} ms to answer", name(), delay.toMillis()); + try { + Thread.sleep(delay); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(name() + " was interrupted before answering", e); + } + return delegate.quote(request); + } +} diff --git a/scatter-gather/src/main/java/com/iluwatar/scattergather/FailingRateProvider.java b/scatter-gather/src/main/java/com/iluwatar/scattergather/FailingRateProvider.java new file mode 100644 index 000000000000..f2fa3cdbc1fc --- /dev/null +++ b/scatter-gather/src/main/java/com/iluwatar/scattergather/FailingRateProvider.java @@ -0,0 +1,53 @@ +/* + * 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.scattergather; + +/** + * A provider that is currently unavailable. Its failure must not prevent the caller from receiving + * the quotes of the healthy providers. + */ +public class FailingRateProvider implements RateProvider { + + private final String name; + + /** + * Creates a provider that always fails. + * + * @param name the provider name + */ + public FailingRateProvider(String name) { + this.name = name; + } + + @Override + public String name() { + return name; + } + + @Override + public RateQuote quote(RateRequest request) { + throw new IllegalStateException(name + " is unavailable"); + } +} diff --git a/scatter-gather/src/main/java/com/iluwatar/scattergather/InMemoryRateProvider.java b/scatter-gather/src/main/java/com/iluwatar/scattergather/InMemoryRateProvider.java new file mode 100644 index 000000000000..71571143ac8e --- /dev/null +++ b/scatter-gather/src/main/java/com/iluwatar/scattergather/InMemoryRateProvider.java @@ -0,0 +1,59 @@ +/* + * 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.scattergather; + +import java.math.BigDecimal; +import lombok.extern.slf4j.Slf4j; + +/** A fast provider that answers immediately with a fixed nightly rate. */ +@Slf4j +public class InMemoryRateProvider implements RateProvider { + + private final String name; + private final BigDecimal nightlyRate; + + /** + * Creates a provider with a fixed price per night. + * + * @param name the provider name + * @param nightlyRate the price charged for one night + */ + public InMemoryRateProvider(String name, BigDecimal nightlyRate) { + this.name = name; + this.nightlyRate = nightlyRate; + } + + @Override + public String name() { + return name; + } + + @Override + public RateQuote quote(RateRequest request) { + var total = nightlyRate.multiply(BigDecimal.valueOf(request.nights())); + LOGGER.info("{} quotes {} for {} nights in {}", name, total, request.nights(), request.city()); + return new RateQuote(name, total); + } +} diff --git a/scatter-gather/src/main/java/com/iluwatar/scattergather/RateProvider.java b/scatter-gather/src/main/java/com/iluwatar/scattergather/RateProvider.java new file mode 100644 index 000000000000..d5cee3448dbd --- /dev/null +++ b/scatter-gather/src/main/java/com/iluwatar/scattergather/RateProvider.java @@ -0,0 +1,44 @@ +/* + * 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.scattergather; + +/** + * A recipient of the scattered request. In a real system each provider would be a separate remote + * service with its own latency and failure profile, which is exactly why the caller cannot assume + * that every reply arrives, or arrives in time. + */ +public interface RateProvider { + + /** Returns the provider's name, used to identify its reply in logs and quotes. */ + String name(); + + /** + * Produces a quote for the given request. + * + * @param request the stay to quote + * @return the provider's offer + */ + RateQuote quote(RateRequest request); +} diff --git a/scatter-gather/src/main/java/com/iluwatar/scattergather/RateQuote.java b/scatter-gather/src/main/java/com/iluwatar/scattergather/RateQuote.java new file mode 100644 index 000000000000..964531395611 --- /dev/null +++ b/scatter-gather/src/main/java/com/iluwatar/scattergather/RateQuote.java @@ -0,0 +1,36 @@ +/* + * 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.scattergather; + +import java.math.BigDecimal; + +/** + * A single reply gathered from one {@link RateProvider}. The gather phase collects these and the + * {@link Aggregator} reduces them into one answer for the caller. + * + * @param provider the name of the provider that produced the quote + * @param total the total price for the whole stay + */ +public record RateQuote(String provider, BigDecimal total) {} diff --git a/scatter-gather/src/main/java/com/iluwatar/scattergather/RateRequest.java b/scatter-gather/src/main/java/com/iluwatar/scattergather/RateRequest.java new file mode 100644 index 000000000000..f6d86c731f87 --- /dev/null +++ b/scatter-gather/src/main/java/com/iluwatar/scattergather/RateRequest.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.scattergather; + +import java.time.LocalDate; + +/** + * The request that is scattered unchanged to every {@link RateProvider}. Each recipient receives + * the same message, which is what distinguishes Scatter-Gather from patterns that split work into + * different sub-tasks. + * + * @param city the destination city + * @param checkIn the first night of the stay + * @param nights how many nights the guest stays + */ +public record RateRequest(String city, LocalDate checkIn, int nights) { + + /** Validates the request so that recipients never have to. */ + public RateRequest { + if (nights <= 0) { + throw new IllegalArgumentException("nights must be positive"); + } + } +} diff --git a/scatter-gather/src/main/java/com/iluwatar/scattergather/ScatterGather.java b/scatter-gather/src/main/java/com/iluwatar/scattergather/ScatterGather.java new file mode 100644 index 000000000000..f3e116bba3a8 --- /dev/null +++ b/scatter-gather/src/main/java/com/iluwatar/scattergather/ScatterGather.java @@ -0,0 +1,154 @@ +/* + * 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.scattergather; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import lombok.extern.slf4j.Slf4j; + +/** + * Coordinates the three phases of the pattern. + * + *

    + *
  1. Scatter: the same request is sent to every provider concurrently. + *
  2. Gather: replies are collected until each one has either arrived, failed, or exceeded + * the timeout. Late and failed replies are logged and dropped so a single slow provider + * cannot hold up the whole answer. + *
  3. Aggregate: the gathered replies are reduced by an {@link Aggregator}. + *
+ * + *

The class owns the executor it was given and shuts it down on {@link #close()}. + */ +@Slf4j +public class ScatterGather implements AutoCloseable { + + /** + * A reply that is still in flight after the scatter phase. + * + * @param provider the provider the request was sent to + * @param reply the future that completes with the provider's quote, or exceptionally + */ + public record PendingReply(RateProvider provider, CompletableFuture reply) {} + + private final ExecutorService executor; + private final Duration timeout; + + /** + * Creates a coordinator. + * + * @param executor runs the calls to the providers; it is shut down when this object is closed + * @param timeout how long the gather phase waits for each reply + */ + public ScatterGather(ExecutorService executor, Duration timeout) { + this.executor = executor; + this.timeout = timeout; + } + + /** + * Scatter phase: sends the request to every provider without waiting for any reply. + * + * @param request the request to broadcast + * @param providers the recipients + * @return one pending reply per provider, in the same order as the providers + */ + public List scatter(RateRequest request, List providers) { + LOGGER.info( + "Scattering request for {} nights in {} to {} providers", + request.nights(), + request.city(), + providers.size()); + var pending = new ArrayList(); + for (var provider : providers) { + var reply = + CompletableFuture.supplyAsync(() -> provider.quote(request), executor) + .orTimeout(timeout.toMillis(), TimeUnit.MILLISECONDS); + pending.add(new PendingReply(provider, reply)); + } + return pending; + } + + /** + * Gather phase: waits until every pending reply has settled and keeps the successful ones. + * + * @param pending the replies produced by {@link #scatter} + * @return the quotes that arrived in time, possibly fewer than the number of providers + */ + public List gather(List pending) { + CompletableFuture.allOf( + pending.stream().map(PendingReply::reply).toArray(CompletableFuture[]::new)) + .exceptionally(ex -> null) + .join(); + var quotes = new ArrayList(); + for (var entry : pending) { + try { + var quote = entry.reply().join(); + LOGGER.info("Gathered quote {} from {}", quote.total(), entry.provider().name()); + quotes.add(quote); + } catch (CompletionException e) { + if (e.getCause() instanceof TimeoutException) { + LOGGER.warn( + "Dropping {}: no reply within {} ms", entry.provider().name(), timeout.toMillis()); + } else { + LOGGER.warn("Dropping {}: {}", entry.provider().name(), e.getCause().getMessage()); + } + } + } + LOGGER.info("Gathered {} of {} replies", quotes.size(), pending.size()); + return quotes; + } + + /** + * Runs all three phases: scatter, gather, and aggregate. + * + * @param request the request to broadcast + * @param providers the recipients + * @param aggregator reduces the gathered quotes + * @param the aggregated result type + * @return the aggregated result + */ + public R scatterGather( + RateRequest request, List providers, Aggregator aggregator) { + return aggregator.aggregate(gather(scatter(request, providers))); + } + + /** Stops the executor, interrupting providers that are still working on a dropped request. */ + @Override + public void close() { + executor.shutdownNow(); + try { + if (!executor.awaitTermination(1, TimeUnit.SECONDS)) { + LOGGER.warn("Executor did not terminate within one second"); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } +} diff --git a/scatter-gather/src/test/java/com/iluwatar/scattergather/AggregatorTest.java b/scatter-gather/src/test/java/com/iluwatar/scattergather/AggregatorTest.java new file mode 100644 index 000000000000..611e7338a363 --- /dev/null +++ b/scatter-gather/src/test/java/com/iluwatar/scattergather/AggregatorTest.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.scattergather; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.math.BigDecimal; +import java.util.List; +import org.junit.jupiter.api.Test; + +class AggregatorTest { + + @Test + void shouldPickLowestTotal() { + var quotes = + List.of( + new RateQuote("a", new BigDecimal("200.00")), + new RateQuote("b", new BigDecimal("99.99")), + new RateQuote("c", new BigDecimal("100.00"))); + + var best = Aggregator.cheapestQuote().aggregate(quotes); + + assertTrue(best.isPresent()); + assertEquals("b", best.get().provider()); + } + + @Test + void shouldReturnEmptyForNoQuotes() { + assertTrue(Aggregator.cheapestQuote().aggregate(List.of()).isEmpty()); + } +} diff --git a/scatter-gather/src/test/java/com/iluwatar/scattergather/AppTest.java b/scatter-gather/src/test/java/com/iluwatar/scattergather/AppTest.java new file mode 100644 index 000000000000..588c5021382c --- /dev/null +++ b/scatter-gather/src/test/java/com/iluwatar/scattergather/AppTest.java @@ -0,0 +1,57 @@ +/* + * 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.scattergather; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.math.BigDecimal; +import java.util.Optional; +import org.junit.jupiter.api.Test; + +class AppTest { + + @Test + void shouldLaunchApp() { + assertDoesNotThrow(() -> App.main(new String[] {})); + } + + @Test + void shouldBeInstantiable() { + assertNotNull(new App(), "App should be instantiable"); + } + + @Test + void shouldReportBestOfferWhenPresent() { + var best = Optional.of(new RateQuote("Harbor Stays", new BigDecimal("295.50"))); + + assertDoesNotThrow(() -> App.reportBestOffer(best)); + } + + @Test + void shouldReportWhenNoProviderAnswered() { + assertDoesNotThrow(() -> App.reportBestOffer(Optional.empty())); + } +} diff --git a/scatter-gather/src/test/java/com/iluwatar/scattergather/RateProviderTest.java b/scatter-gather/src/test/java/com/iluwatar/scattergather/RateProviderTest.java new file mode 100644 index 000000000000..b0f1bf16d853 --- /dev/null +++ b/scatter-gather/src/test/java/com/iluwatar/scattergather/RateProviderTest.java @@ -0,0 +1,71 @@ +/* + * 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.scattergather; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.math.BigDecimal; +import java.time.Duration; +import java.time.LocalDate; +import org.junit.jupiter.api.Test; + +class RateProviderTest { + + private static final RateRequest REQUEST = + new RateRequest("Madrid", LocalDate.of(2026, 3, 10), 4); + + @Test + void inMemoryProviderMultipliesNightlyRateByNights() { + var provider = new InMemoryRateProvider("inn", new BigDecimal("25.50")); + + assertEquals("inn", provider.name()); + assertEquals(new RateQuote("inn", new BigDecimal("102.00")), provider.quote(REQUEST)); + } + + @Test + void delayedProviderDelegatesAfterWaiting() { + var provider = + new DelayedRateProvider( + new InMemoryRateProvider("slow", new BigDecimal("10.00")), Duration.ofMillis(10)); + + assertEquals("slow", provider.name()); + assertEquals(new RateQuote("slow", new BigDecimal("40.00")), provider.quote(REQUEST)); + } + + @Test + void failingProviderThrows() { + var provider = new FailingRateProvider("down"); + + assertEquals("down", provider.name()); + assertThrows(IllegalStateException.class, () -> provider.quote(REQUEST)); + } + + @Test + void requestRejectsNonPositiveNights() { + assertThrows( + IllegalArgumentException.class, () -> new RateRequest("Rome", LocalDate.of(2026, 1, 1), 0)); + } +} diff --git a/scatter-gather/src/test/java/com/iluwatar/scattergather/ScatterGatherTest.java b/scatter-gather/src/test/java/com/iluwatar/scattergather/ScatterGatherTest.java new file mode 100644 index 000000000000..5fead3458b6a --- /dev/null +++ b/scatter-gather/src/test/java/com/iluwatar/scattergather/ScatterGatherTest.java @@ -0,0 +1,205 @@ +/* + * 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.scattergather; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.math.BigDecimal; +import java.time.Duration; +import java.time.LocalDate; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class ScatterGatherTest { + + private static final RateRequest REQUEST = new RateRequest("Porto", LocalDate.of(2026, 5, 1), 2); + private static final Duration TIMEOUT = Duration.ofMillis(300); + + private final CountDownLatch gate = new CountDownLatch(1); + private ExecutorService executor; + private ScatterGather scatterGather; + + @BeforeEach + void setUp() { + executor = Executors.newFixedThreadPool(4); + scatterGather = new ScatterGather(executor, TIMEOUT); + } + + @AfterEach + void tearDown() { + gate.countDown(); + scatterGather.close(); + } + + @Test + void shouldGatherEveryReplyWhenAllProvidersAnswer() { + var providers = List.of(provider("A", "120.00"), provider("B", "80.00")); + + var quotes = scatterGather.gather(scatterGather.scatter(REQUEST, providers)); + + assertEquals(2, quotes.size()); + assertEquals(new RateQuote("A", new BigDecimal("240.00")), quotes.get(0)); + assertEquals(new RateQuote("B", new BigDecimal("160.00")), quotes.get(1)); + } + + @Test + void shouldDropProviderThatMissesTheTimeout() { + var providers = List.of(provider("fast", "100.00"), blockedProvider("stuck")); + + var quotes = scatterGather.gather(scatterGather.scatter(REQUEST, providers)); + + assertEquals(List.of(new RateQuote("fast", new BigDecimal("200.00"))), quotes); + } + + @Test + void shouldDropProviderThatFails() { + var providers = List.of(new FailingRateProvider("down"), provider("up", "50.00")); + + var quotes = scatterGather.gather(scatterGather.scatter(REQUEST, providers)); + + assertEquals(List.of(new RateQuote("up", new BigDecimal("100.00"))), quotes); + } + + @Test + void shouldAggregateCheapestQuote() { + var providers = + List.of( + provider("pricey", "300.00"), provider("cheap", "90.00"), provider("mid", "150.00")); + + var best = scatterGather.scatterGather(REQUEST, providers, Aggregator.cheapestQuote()); + + assertTrue(best.isPresent()); + assertEquals("cheap", best.get().provider()); + assertEquals(new BigDecimal("180.00"), best.get().total()); + } + + @Test + void shouldReturnEmptyResultWhenNoProviderAnswers() { + var providers = + List.of(new FailingRateProvider("down"), blockedProvider("stuck")); + + var best = scatterGather.scatterGather(REQUEST, providers, Aggregator.cheapestQuote()); + + assertTrue(best.isEmpty()); + } + + @Test + void shouldShutDownExecutorOnClose() { + scatterGather.scatter(REQUEST, List.of(blockedProvider("stuck"))); + + scatterGather.close(); + + assertTrue(executor.isShutdown()); + assertTrue(executor.isTerminated()); + } + + @Test + void shouldPreserveInterruptFlagWhenCloseIsInterrupted() { + var stubborn = new CountDownLatch(1); + var ownExecutor = Executors.newSingleThreadExecutor(); + var subject = new ScatterGather(ownExecutor, Duration.ofSeconds(10)); + subject.scatter(REQUEST, List.of(interruptIgnoringProvider("stubborn", stubborn))); + try { + Thread.currentThread().interrupt(); + + subject.close(); + + assertTrue(Thread.interrupted(), "close must re-set the interrupt flag it swallowed"); + assertTrue(ownExecutor.isShutdown()); + } finally { + stubborn.countDown(); + } + } + + @Test + void shouldReturnFromCloseWhenTaskIgnoresInterrupts() { + var stubborn = new CountDownLatch(1); + var ownExecutor = Executors.newSingleThreadExecutor(); + var subject = new ScatterGather(ownExecutor, Duration.ofSeconds(10)); + subject.scatter(REQUEST, List.of(interruptIgnoringProvider("stubborn", stubborn))); + try { + subject.close(); + + assertTrue(ownExecutor.isShutdown()); + assertFalse(ownExecutor.isTerminated(), "the stubborn task is still running after close"); + } finally { + stubborn.countDown(); + } + } + + private static RateProvider provider(String name, String nightlyRate) { + return new InMemoryRateProvider(name, new BigDecimal(nightlyRate)); + } + + /** A provider that does not answer until the test releases the gate. */ + private RateProvider blockedProvider(String name) { + return new RateProvider() { + @Override + public String name() { + return name; + } + + @Override + public RateQuote quote(RateRequest request) { + try { + gate.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("interrupted", e); + } + return new RateQuote(name, BigDecimal.ONE); + } + }; + } + + /** A provider that keeps waiting on the latch even when its thread is interrupted. */ + private static RateProvider interruptIgnoringProvider(String name, CountDownLatch latch) { + return new RateProvider() { + @Override + public String name() { + return name; + } + + @Override + public RateQuote quote(RateRequest request) { + while (true) { + try { + latch.await(); + return new RateQuote(name, BigDecimal.ONE); + } catch (InterruptedException ignored) { + // deliberately keeps waiting to simulate a task that does not honour interrupts + } + } + } + }; + } +} From 5fab90eddc645450c25427ec37c6361a96785f3b Mon Sep 17 00:00:00 2001 From: Doksanbir Date: Mon, 7 Sep 2026 11:04:34 +0300 Subject: [PATCH 2/2] fix: cancel timed-out provider calls and simplify the aggregator A reply that times out or is cancelled cancels its task, gather tolerates cancelled replies, Aggregator loses its unused type parameter and the shutdown grace is configurable for tests. The class diagram is a rendered PNG. --- scatter-gather/README.md | 36 +++++++---- scatter-gather/etc/scatter-gather.urm.png | Bin 0 -> 70486 bytes scatter-gather/etc/scatter-gather.urm.puml | 14 +++-- .../iluwatar/scattergather/Aggregator.java | 7 +-- .../iluwatar/scattergather/ScatterGather.java | 57 +++++++++++++++--- .../scattergather/ScatterGatherTest.java | 48 ++++++++++++++- 6 files changed, 129 insertions(+), 33 deletions(-) create mode 100644 scatter-gather/etc/scatter-gather.urm.png diff --git a/scatter-gather/README.md b/scatter-gather/README.md index 01e8e3a7e406..19f37347f012 100644 --- a/scatter-gather/README.md +++ b/scatter-gather/README.md @@ -65,6 +65,8 @@ sequenceDiagram ScatterGather->>Client: aggregate() returns Harbor Stays 295.50 ``` +![Scatter-Gather class diagram](./etc/scatter-gather.urm.png) + ## Programmatic Example of Scatter-Gather Pattern in Java The request is a plain immutable value. Every recipient receives exactly the same instance. @@ -89,10 +91,10 @@ The `Aggregator` reduces the gathered replies. Because it is a separate strategy ```java @FunctionalInterface -public interface Aggregator { - R aggregate(List replies); +public interface Aggregator { + R aggregate(List replies); - static Aggregator> cheapestQuote() { + static Aggregator> cheapestQuote() { return replies -> replies.stream().min(Comparator.comparing(RateQuote::total)); } } @@ -104,9 +106,21 @@ public interface Aggregator { public List scatter(RateRequest request, List providers) { var pending = new ArrayList(); for (var provider : providers) { - var reply = - CompletableFuture.supplyAsync(() -> provider.quote(request), executor) - .orTimeout(timeout.toMillis(), TimeUnit.MILLISECONDS); + var reply = new CompletableFuture(); + var task = executor.submit(() -> { + try { + reply.complete(provider.quote(request)); + } catch (RuntimeException e) { + reply.completeExceptionally(e); + } + }); + // a reply that times out or is cancelled also cancels its task, interrupting the provider call + reply.orTimeout(timeout.toMillis(), TimeUnit.MILLISECONDS) + .whenComplete((quote, failure) -> { + if (failure != null && !task.isDone()) { + task.cancel(true); + } + }); pending.add(new PendingReply(provider, reply)); } return pending; @@ -140,7 +154,7 @@ A convenience method chains the phases together. ```java public R scatterGather( - RateRequest request, List providers, Aggregator aggregator) { + RateRequest request, List providers, Aggregator aggregator) { return aggregator.aggregate(gather(scatter(request, providers))); } ``` @@ -194,10 +208,6 @@ Aggregate phase: choosing the cheapest of 2 quotes Best offer: Harbor Stays at 295.50 ``` -## Class diagram - -See [scatter-gather.urm.puml](./etc/scatter-gather.urm.puml) for the PlantUML class diagram. - ## When to Use the Scatter-Gather Pattern in Java * The same question has to be answered by several independent services, such as price comparison, search federation, or quorum reads. @@ -225,7 +235,7 @@ Benefits: Trade-offs: * **Partial results**: the caller must be able to live with an answer built from a subset of recipients, and the aggregator must handle an empty set. -* **Resource usage**: every request occupies one thread or connection per recipient; a dropped reply may still be computed by the recipient. +* **Resource usage**: every request occupies one thread or connection per recipient; dropping a reply interrupts the local call, but a remote recipient may still finish computing an answer nobody reads. * **Tuning**: the timeout is a compromise between completeness and responsiveness and usually needs measurement to get right. ## Related Java Design Patterns @@ -234,7 +244,7 @@ Trade-offs: * [Microservices Aggregator](../microservices-aggregrator): a service that composes the responses of several downstream services; Scatter-Gather is a way to fetch those responses concurrently. * [Async Method Invocation](../async-method-invocation): the mechanism used to call each recipient without blocking the caller. * [Promise](../promise): each pending reply is a promise that either completes with a quote or fails. -* Timeout: bounds how long the gather phase waits for each recipient. +* Timeout: bounds how long after the scatter each reply has to arrive; the timer starts when the request is scattered, not when gather is called. ## References and Credits diff --git a/scatter-gather/etc/scatter-gather.urm.png b/scatter-gather/etc/scatter-gather.urm.png new file mode 100644 index 0000000000000000000000000000000000000000..435a53e55fed34ab9d79a85c5eb72966a7be6bf6 GIT binary patch literal 70486 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 Balrrp literal 0 HcmV?d00001 diff --git a/scatter-gather/etc/scatter-gather.urm.puml b/scatter-gather/etc/scatter-gather.urm.puml index 42a7a26d5b2c..e56120145629 100644 --- a/scatter-gather/etc/scatter-gather.urm.puml +++ b/scatter-gather/etc/scatter-gather.urm.puml @@ -35,9 +35,9 @@ package com.iluwatar.scattergather { + name() : String + quote(request : RateRequest) : RateQuote } - interface Aggregator { - + aggregate(replies : List) : R {abstract} - + cheapestQuote() : Aggregator> {static} + interface Aggregator { + + aggregate(replies : List) : R {abstract} + + cheapestQuote() : Aggregator> {static} } class PendingReply { + PendingReply(provider : RateProvider, reply : CompletableFuture) @@ -47,10 +47,12 @@ package com.iluwatar.scattergather { class ScatterGather { - executor : ExecutorService - timeout : Duration + - shutdownGrace : Duration + ScatterGather(executor : ExecutorService, timeout : Duration) + ~ ScatterGather(executor : ExecutorService, timeout : Duration, shutdownGrace : Duration) + scatter(request : RateRequest, providers : List) : List + gather(pending : List) : List - + scatterGather(request : RateRequest, providers : List, aggregator : Aggregator) : R + + scatterGather(request : RateRequest, providers : List, aggregator : Aggregator) : R + close() : void } class App { @@ -67,8 +69,8 @@ RateProvider ..> RateRequest RateProvider ..> RateQuote PendingReply --> RateProvider PendingReply ..> RateQuote -ScatterGather ..> PendingReply +ScatterGather +-- PendingReply ScatterGather ..> Aggregator -ScatterGather --> "*" RateProvider +ScatterGather ..> RateProvider App ..> ScatterGather @enduml diff --git a/scatter-gather/src/main/java/com/iluwatar/scattergather/Aggregator.java b/scatter-gather/src/main/java/com/iluwatar/scattergather/Aggregator.java index 4cac13e31747..952df4e89921 100644 --- a/scatter-gather/src/main/java/com/iluwatar/scattergather/Aggregator.java +++ b/scatter-gather/src/main/java/com/iluwatar/scattergather/Aggregator.java @@ -33,11 +33,10 @@ * same scatter and gather machinery can serve callers that want the cheapest quote, the average * price, or the full list. * - * @param the type of the gathered replies * @param the type of the aggregated result */ @FunctionalInterface -public interface Aggregator { +public interface Aggregator { /** * Combines the gathered replies. @@ -45,10 +44,10 @@ public interface Aggregator { * @param replies the replies that arrived in time, possibly empty * @return the aggregated result */ - R aggregate(List replies); + R aggregate(List replies); /** Returns an aggregator that picks the quote with the lowest total price. */ - static Aggregator> cheapestQuote() { + static Aggregator> cheapestQuote() { return replies -> replies.stream().min(Comparator.comparing(RateQuote::total)); } } diff --git a/scatter-gather/src/main/java/com/iluwatar/scattergather/ScatterGather.java b/scatter-gather/src/main/java/com/iluwatar/scattergather/ScatterGather.java index f3e116bba3a8..2df5057a7663 100644 --- a/scatter-gather/src/main/java/com/iluwatar/scattergather/ScatterGather.java +++ b/scatter-gather/src/main/java/com/iluwatar/scattergather/ScatterGather.java @@ -27,6 +27,7 @@ import java.time.Duration; import java.util.ArrayList; import java.util.List; +import java.util.concurrent.CancellationException; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; import java.util.concurrent.ExecutorService; @@ -41,7 +42,9 @@ *

  • Scatter: the same request is sent to every provider concurrently. *
  • Gather: replies are collected until each one has either arrived, failed, or exceeded * the timeout. Late and failed replies are logged and dropped so a single slow provider - * cannot hold up the whole answer. + * cannot hold up the whole answer. Dropping a reply also cancels the provider call, so a + * timed-out provider is interrupted and its pool thread is freed instead of staying busy with + * work nobody will read. *
  • Aggregate: the gathered replies are reduced by an {@link Aggregator}. * * @@ -58,18 +61,35 @@ public class ScatterGather implements AutoCloseable { */ public record PendingReply(RateProvider provider, CompletableFuture reply) {} + private static final Duration DEFAULT_SHUTDOWN_GRACE = Duration.ofSeconds(1); + private final ExecutorService executor; private final Duration timeout; + private final Duration shutdownGrace; /** - * Creates a coordinator. + * Creates a coordinator that gives the executor one second to terminate on {@link #close()}. * * @param executor runs the calls to the providers; it is shut down when this object is closed - * @param timeout how long the gather phase waits for each reply + * @param timeout how long after the scatter each reply has to arrive; the timer starts when the + * request is scattered, not when gather is called */ public ScatterGather(ExecutorService executor, Duration timeout) { + this(executor, timeout, DEFAULT_SHUTDOWN_GRACE); + } + + /** + * Creates a coordinator with an explicit shutdown grace period, so tests that close a coordinator + * whose task ignores interrupts do not have to wait out the default one. + * + * @param executor runs the calls to the providers; it is shut down when this object is closed + * @param timeout how long after the scatter each reply has to arrive + * @param shutdownGrace how long {@link #close()} waits for the executor to terminate + */ + ScatterGather(ExecutorService executor, Duration timeout, Duration shutdownGrace) { this.executor = executor; this.timeout = timeout; + this.shutdownGrace = shutdownGrace; } /** @@ -87,9 +107,26 @@ public List scatter(RateRequest request, List provid providers.size()); var pending = new ArrayList(); for (var provider : providers) { - var reply = - CompletableFuture.supplyAsync(() -> provider.quote(request), executor) - .orTimeout(timeout.toMillis(), TimeUnit.MILLISECONDS); + var reply = new CompletableFuture(); + var task = + executor.submit( + () -> { + try { + reply.complete(provider.quote(request)); + } catch (RuntimeException e) { + reply.completeExceptionally(e); + } + }); + // A reply that times out or is cancelled also cancels its task, interrupting the provider + // call so that the pool thread is handed back instead of finishing work nobody will read. + reply + .orTimeout(timeout.toMillis(), TimeUnit.MILLISECONDS) + .whenComplete( + (quote, failure) -> { + if (failure != null && !task.isDone()) { + task.cancel(true); + } + }); pending.add(new PendingReply(provider, reply)); } return pending; @@ -119,6 +156,8 @@ public List gather(List pending) { } else { LOGGER.warn("Dropping {}: {}", entry.provider().name(), e.getCause().getMessage()); } + } catch (CancellationException e) { + LOGGER.warn("Dropping {}: the reply was cancelled", entry.provider().name()); } } LOGGER.info("Gathered {} of {} replies", quotes.size(), pending.size()); @@ -135,7 +174,7 @@ public List gather(List pending) { * @return the aggregated result */ public R scatterGather( - RateRequest request, List providers, Aggregator aggregator) { + RateRequest request, List providers, Aggregator aggregator) { return aggregator.aggregate(gather(scatter(request, providers))); } @@ -144,8 +183,8 @@ public R scatterGather( public void close() { executor.shutdownNow(); try { - if (!executor.awaitTermination(1, TimeUnit.SECONDS)) { - LOGGER.warn("Executor did not terminate within one second"); + if (!executor.awaitTermination(shutdownGrace.toMillis(), TimeUnit.MILLISECONDS)) { + LOGGER.warn("Executor did not terminate within {} ms", shutdownGrace.toMillis()); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); diff --git a/scatter-gather/src/test/java/com/iluwatar/scattergather/ScatterGatherTest.java b/scatter-gather/src/test/java/com/iluwatar/scattergather/ScatterGatherTest.java index 5fead3458b6a..cabd56b5c3e9 100644 --- a/scatter-gather/src/test/java/com/iluwatar/scattergather/ScatterGatherTest.java +++ b/scatter-gather/src/test/java/com/iluwatar/scattergather/ScatterGatherTest.java @@ -35,6 +35,7 @@ import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -43,6 +44,7 @@ class ScatterGatherTest { private static final RateRequest REQUEST = new RateRequest("Porto", LocalDate.of(2026, 5, 1), 2); private static final Duration TIMEOUT = Duration.ofMillis(300); + private static final Duration SHORT_SHUTDOWN_GRACE = Duration.ofMillis(50); private final CountDownLatch gate = new CountDownLatch(1); private ExecutorService executor; @@ -80,6 +82,39 @@ void shouldDropProviderThatMissesTheTimeout() { assertEquals(List.of(new RateQuote("fast", new BigDecimal("200.00"))), quotes); } + @Test + void shouldInterruptProviderThatMissesTheTimeoutAndFreeItsThread() throws Exception { + var interrupted = new CountDownLatch(1); + var slow = + new RateProvider() { + @Override + public String name() { + return "slow"; + } + + @Override + public RateQuote quote(RateRequest request) { + try { + gate.await(); + } catch (InterruptedException e) { + interrupted.countDown(); + Thread.currentThread().interrupt(); + throw new IllegalStateException("interrupted", e); + } + return new RateQuote(name(), BigDecimal.ONE); + } + }; + var ownExecutor = Executors.newSingleThreadExecutor(); + try (var subject = new ScatterGather(ownExecutor, TIMEOUT)) { + assertTrue(subject.gather(subject.scatter(REQUEST, List.of(slow))).isEmpty()); + + assertTrue(interrupted.await(1, TimeUnit.SECONDS), "timed-out provider was not interrupted"); + // the only pool thread is free again, so a fresh call answers well within the timeout + var quotes = subject.gather(subject.scatter(REQUEST, List.of(provider("fast", "100.00")))); + assertEquals(List.of(new RateQuote("fast", new BigDecimal("200.00"))), quotes); + } + } + @Test void shouldDropProviderThatFails() { var providers = List.of(new FailingRateProvider("down"), provider("up", "50.00")); @@ -89,6 +124,17 @@ void shouldDropProviderThatFails() { assertEquals(List.of(new RateQuote("up", new BigDecimal("100.00"))), quotes); } + @Test + void shouldDropProviderWhoseReplyWasCancelled() { + var providers = List.of(blockedProvider("stuck"), provider("up", "50.00")); + var pending = scatterGather.scatter(REQUEST, providers); + pending.get(0).reply().cancel(true); + + var quotes = scatterGather.gather(pending); + + assertEquals(List.of(new RateQuote("up", new BigDecimal("100.00"))), quotes); + } + @Test void shouldAggregateCheapestQuote() { var providers = @@ -144,7 +190,7 @@ void shouldPreserveInterruptFlagWhenCloseIsInterrupted() { void shouldReturnFromCloseWhenTaskIgnoresInterrupts() { var stubborn = new CountDownLatch(1); var ownExecutor = Executors.newSingleThreadExecutor(); - var subject = new ScatterGather(ownExecutor, Duration.ofSeconds(10)); + var subject = new ScatterGather(ownExecutor, Duration.ofSeconds(10), SHORT_SHUTDOWN_GRACE); subject.scatter(REQUEST, List.of(interruptIgnoringProvider("stubborn", stubborn))); try { subject.close();