From 1b50cb4f5820b3d634d1618b5930e4e12cd29399 Mon Sep 17 00:00:00 2001 From: Doksanbir Date: Thu, 3 Sep 2026 12:18:29 +0300 Subject: [PATCH] feat: add Timeout pattern (#2845) --- pom.xml | 1 + timeout/README.md | 287 ++++++++++++++++++ timeout/etc/timeout.urm.puml | 65 ++++ timeout/pom.xml | 70 +++++ .../main/java/com/iluwatar/timeout/App.java | 113 +++++++ .../timeout/ProductCatalogService.java | 63 ++++ .../timeout/RecommendationService.java | 73 +++++ .../timeout/ServiceCallException.java | 44 +++ .../com/iluwatar/timeout/TimeoutExecutor.java | 113 +++++++ .../com/iluwatar/timeout/TimeoutMetrics.java | 71 +++++ .../com/iluwatar/timeout/TimeoutPolicy.java | 63 ++++ .../com/iluwatar/timeout/TimeoutRegistry.java | 72 +++++ .../java/com/iluwatar/timeout/AppTest.java | 91 ++++++ .../timeout/RecommendationServiceTest.java | 54 ++++ .../iluwatar/timeout/TimeoutExecutorTest.java | 159 ++++++++++ .../iluwatar/timeout/TimeoutPolicyTest.java | 53 ++++ .../iluwatar/timeout/TimeoutRegistryTest.java | 54 ++++ 17 files changed, 1446 insertions(+) create mode 100644 timeout/README.md create mode 100644 timeout/etc/timeout.urm.puml create mode 100644 timeout/pom.xml create mode 100644 timeout/src/main/java/com/iluwatar/timeout/App.java create mode 100644 timeout/src/main/java/com/iluwatar/timeout/ProductCatalogService.java create mode 100644 timeout/src/main/java/com/iluwatar/timeout/RecommendationService.java create mode 100644 timeout/src/main/java/com/iluwatar/timeout/ServiceCallException.java create mode 100644 timeout/src/main/java/com/iluwatar/timeout/TimeoutExecutor.java create mode 100644 timeout/src/main/java/com/iluwatar/timeout/TimeoutMetrics.java create mode 100644 timeout/src/main/java/com/iluwatar/timeout/TimeoutPolicy.java create mode 100644 timeout/src/main/java/com/iluwatar/timeout/TimeoutRegistry.java create mode 100644 timeout/src/test/java/com/iluwatar/timeout/AppTest.java create mode 100644 timeout/src/test/java/com/iluwatar/timeout/RecommendationServiceTest.java create mode 100644 timeout/src/test/java/com/iluwatar/timeout/TimeoutExecutorTest.java create mode 100644 timeout/src/test/java/com/iluwatar/timeout/TimeoutPolicyTest.java create mode 100644 timeout/src/test/java/com/iluwatar/timeout/TimeoutRegistryTest.java diff --git a/pom.xml b/pom.xml index a71630d289d3..49fa5aec66ba 100644 --- a/pom.xml +++ b/pom.xml @@ -260,6 +260,7 @@ rate-limiting-pattern fallback onion-architecture + timeout diff --git a/timeout/README.md b/timeout/README.md new file mode 100644 index 000000000000..fb0496e767ef --- /dev/null +++ b/timeout/README.md @@ -0,0 +1,287 @@ +--- +title: "Timeout Pattern in Java: Bounding the Wait for Slow Dependencies" +shortTitle: Timeout +description: "Learn the Timeout pattern in Java: give every downstream call a per-service time limit, cancel calls that overrun, log and count the events, and continue with a fallback so slow dependencies cannot stall the whole system." +category: Resilience +language: en +tag: + - Asynchronous + - Cloud distributed + - Fault tolerance + - Microservices + - Resilience +--- + +## Also known as + +* Time Limiter +* Deadline + +## Intent of Timeout Design Pattern + +Bound how long a caller waits for a downstream service. When the limit is exceeded the call is abandoned, the event is recorded, and the caller continues with a fallback, so the latency of one slow dependency never becomes the latency of the whole system. + +## Detailed Explanation of Timeout Pattern with Real-World Examples + +Real-world example + +> A pizza chain's online shop asks a separate recommendation engine which side dishes to suggest during checkout. One evening the recommendation engine starts taking twenty seconds per request. Without a limit, every checkout waits those twenty seconds, threads pile up, and soon nobody can order a pizza at all. With a 100 ms limit, the shop stops waiting, shows the always available "most popular sides" list instead, and the order goes through. The slow engine is logged and counted so the on-call engineer can look at it in the morning. + +In plain words + +> Decide up front how long you are willing to wait for a dependency, and when the time is up, stop waiting and move on with a plan B. + +microservices.io says + +> Prevent a client from waiting indefinitely for a response from a service by aborting the request after a specified time period. + +Sequence diagram + +```mermaid +sequenceDiagram + participant Caller + participant TimeoutExecutor + participant Worker as Worker thread + participant Service as Downstream service + + Caller->>TimeoutExecutor: execute(policy, call, fallback) + TimeoutExecutor->>Worker: submit(call) + Worker->>Service: invoke + TimeoutExecutor->>TimeoutExecutor: wait at most policy.timeout() + alt response arrives in time + Service-->>Worker: result + Worker-->>TimeoutExecutor: result + TimeoutExecutor-->>Caller: result + else limit exceeded + TimeoutExecutor->>Worker: cancel(interrupt) + TimeoutExecutor->>TimeoutExecutor: log warning, count timeout + TimeoutExecutor-->>Caller: fallback.get() + end +``` + +## Programmatic Example of Timeout Pattern in Java + +The example models an online shop that calls two downstream services. The product catalog is fast; the recommendation engine is slow. Each gets its own time limit. + +1. **Declare a limit per service** + + A `TimeoutPolicy` couples a service name with the maximum time the caller is willing to wait. The record validates that the limit is positive. + +```java +public record TimeoutPolicy(String serviceName, Duration timeout) { + + public TimeoutPolicy { + Objects.requireNonNull(serviceName, "serviceName"); + Objects.requireNonNull(timeout, "timeout"); + if (serviceName.isBlank()) { + throw new IllegalArgumentException("serviceName must not be blank"); + } + if (timeout.isZero() || timeout.isNegative()) { + throw new IllegalArgumentException("timeout must be positive"); + } + } + + public static TimeoutPolicy of(String serviceName, long millis) { + return new TimeoutPolicy(serviceName, Duration.ofMillis(millis)); + } +} +``` + +2. **Keep the limits configurable in one place** + + A `TimeoutRegistry` stores the policies and hands out a default for services nobody configured explicitly. + +```java +public class TimeoutRegistry { + + private final Map policies = new ConcurrentHashMap<>(); + private final Duration defaultTimeout; + + public TimeoutRegistry(Duration defaultTimeout) { + this.defaultTimeout = Objects.requireNonNull(defaultTimeout, "defaultTimeout"); + } + + public TimeoutRegistry register(TimeoutPolicy policy) { + policies.put(policy.serviceName(), policy); + return this; + } + + public TimeoutPolicy policyFor(String serviceName) { + return policies.getOrDefault(serviceName, new TimeoutPolicy(serviceName, defaultTimeout)); + } +} +``` + +3. **Enforce the limit** + + `TimeoutExecutor` runs the call on a worker thread and waits for at most the configured duration. On a timeout it cancels the worker with an interrupt, logs the event, counts it in `TimeoutMetrics`, and returns the fallback. A failure raised by the service is not a timeout and is rethrown as `ServiceCallException`. + +```java +@Slf4j +public class TimeoutExecutor implements AutoCloseable { + + private final ExecutorService executor; + private final TimeoutMetrics metrics = new TimeoutMetrics(); + + public TimeoutExecutor() { + this(Executors.newVirtualThreadPerTaskExecutor()); + } + + public T execute(TimeoutPolicy policy, Callable call, Supplier fallback) { + var serviceName = policy.serviceName(); + var limitMillis = policy.timeout().toMillis(); + var future = executor.submit(call); + try { + var result = future.get(limitMillis, TimeUnit.MILLISECONDS); + LOGGER.info("{} responded within its {} ms limit", serviceName, limitMillis); + return result; + } catch (TimeoutException e) { + future.cancel(true); + metrics.recordTimeout(serviceName); + LOGGER.warn( + "{} exceeded its {} ms limit; call cancelled, using fallback", serviceName, limitMillis); + return fallback.get(); + } catch (ExecutionException e) { + throw new ServiceCallException(serviceName, e.getCause()); + } catch (InterruptedException e) { + future.cancel(true); + Thread.currentThread().interrupt(); + throw new ServiceCallException(serviceName, e); + } + } + + @Override + public void close() { + executor.shutdownNow(); + } +} +``` + +4. **Make the slow service cooperate with cancellation** + + The simulated `RecommendationService` sleeps interruptibly, so the interrupt sent by the executor actually stops the work instead of leaving it running in the background. + +```java +public List recommendationsFor(String customer) throws InterruptedException { + LOGGER.info( + "{}: computing recommendations for {}, expected latency {} ms", + NAME, + customer, + latency.toMillis()); + try { + Thread.sleep(latency); + } catch (InterruptedException e) { + LOGGER.info("{}: interrupted, abandoning the computation for {}", NAME, customer); + throw e; + } + return List.of("Mechanical keyboard", "USB-C dock"); +} +``` + +5. **Wire it together** + + `App` registers a 500 ms limit for the catalog and a 100 ms limit for recommendations. Each call lives in a small helper that pairs the policy with the call and its fallback; `main` runs both. The catalog answers in time; the recommendation engine needs 400 ms, so the customer sees popular items instead and the timeout counter shows one event. + +```java +static List loadProducts( + TimeoutExecutor executor, TimeoutRegistry registry, ProductCatalogService catalog) { + return executor.execute( + registry.policyFor(ProductCatalogService.NAME), catalog::fetchProducts, List::of); +} + +static List loadRecommendations( + TimeoutExecutor executor, + TimeoutRegistry registry, + RecommendationService recommendations, + String customer) { + return executor.execute( + registry.policyFor(RecommendationService.NAME), + () -> recommendations.recommendationsFor(customer), + () -> POPULAR_ITEMS); +} +``` + +```java +var registry = + new TimeoutRegistry(Duration.ofMillis(300)) + .register(TimeoutPolicy.of(ProductCatalogService.NAME, 500)) + .register(TimeoutPolicy.of(RecommendationService.NAME, 100)); + +var catalog = new ProductCatalogService(Duration.ofMillis(50)); +var recommendations = new RecommendationService(Duration.ofMillis(400)); + +try (var executor = new TimeoutExecutor()) { + var products = loadProducts(executor, registry, catalog); + LOGGER.info("Products: {}", products); + + var suggested = loadRecommendations(executor, registry, recommendations, "alice"); + LOGGER.info("Recommendations shown to alice: {}", suggested); + + LOGGER.info("Timeouts per service: {}", executor.metrics().snapshot()); +} +``` + +Running the application produces output along these lines: + +``` +Configured per-service limits: catalog 500 ms, recommendations 100 ms +Calling product-catalog +product-catalog: fetching products, expected latency 50 ms +product-catalog responded within its 500 ms limit +Products: [Laptop, Headphones, Monitor] +Calling recommendations +recommendations: computing recommendations for alice, expected latency 400 ms +recommendations exceeded its 100 ms limit; call cancelled, using fallback +recommendations: interrupted, abandoning the computation for alice +Recommendations shown to alice: [Wireless mouse, Webcam] +Timeouts per service: {recommendations=1} +``` + +## Class diagram + +See [timeout.urm.puml](./etc/timeout.urm.puml) for the PlantUML class diagram. + +## When to Use the Timeout Pattern in Java + +* Whenever a call leaves the process: HTTP and gRPC calls, database queries, message broker round trips, third-party APIs. +* When a degraded answer delivered on time is worth more than a perfect answer delivered late. +* When threads, connections or other pooled resources are held for the duration of a call and must not be tied up by a stalled dependency. +* When different dependencies have different latency profiles and need individually tuned limits. + +## Real-World Applications of Timeout Pattern in Java + +* [Resilience4j TimeLimiter](https://resilience4j.readme.io/docs/timelimiter) wraps a `CompletableFuture` or `Future` with a configurable limit and optional cancellation. +* [Netflix Hystrix](https://github.com/Netflix/Hystrix/wiki/Configuration#execution.isolation.thread.timeoutInMilliseconds) applied a per-command execution timeout before falling back. +* [gRPC deadlines](https://grpc.io/docs/guides/deadlines/) propagate a limit across service hops. +* `java.net.http.HttpClient` connect and request timeouts, JDBC `queryTimeout`, and `Future.get(long, TimeUnit)` in the JDK. + +## Benefits and Trade-offs of Timeout Pattern + +Benefits: + +* **Predictable latency**: The caller's worst case is the configured limit plus the fallback cost, not the dependency's worst case. +* **Failure containment**: Stalled dependencies stop consuming threads and connections, which prevents cascading failures. +* **Observability**: Every timeout is logged and counted, exposing dependencies that regularly miss their budget. +* **Independent tuning**: Each service gets a limit that matches its normal latency. + +Trade-offs: + +* **Choosing the value is hard**: Too short causes false alarms under normal jitter; too long defeats the purpose. +* **Wasted work**: A cancelled call may already have done its side effects, so operations that are not idempotent need care. +* **Cooperative cancellation**: An interrupt only stops code that checks for it; blocking calls that ignore interrupts keep running until they finish on their own. +* **Fallback quality**: The fallback must be genuinely cheap and safe, otherwise the pattern only moves the problem. + +## Related Java Design Patterns + +* [Fallback](../fallback): Supplies the alternative answer once a timeout fires. The fallback module treats the time limit as one of several triggers; this module makes the limit itself the subject, with per-service configuration, cancellation and metrics. +* [Circuit Breaker](../circuit-breaker): Counts timeouts as failures and stops calling a dependency that keeps overrunning its limit. +* [Retry](../retry): Retries a call that timed out, ideally with a total deadline so retries cannot multiply the wait. +* Bulkhead: Limits how many concurrent calls a dependency may hold, complementing the limit on how long each call may take. + +## References and Credits + +* [Timeout pattern (microservices.io)](https://microservices.io/patterns/reliability/timeout.html) +* [Release It! Design and Deploy Production-Ready Software](https://amzn.to/4aqTNEP) +* [Microservices Patterns: With examples in Java](https://amzn.to/3xaZwk0) +* [Resilience4j TimeLimiter documentation](https://resilience4j.readme.io/docs/timelimiter) +* [gRPC deadlines](https://grpc.io/docs/guides/deadlines/) diff --git a/timeout/etc/timeout.urm.puml b/timeout/etc/timeout.urm.puml new file mode 100644 index 000000000000..a13d37c318b0 --- /dev/null +++ b/timeout/etc/timeout.urm.puml @@ -0,0 +1,65 @@ +@startuml +package com.iluwatar.timeout { + class TimeoutPolicy { + - serviceName : String + - timeout : Duration + + TimeoutPolicy(serviceName : String, timeout : Duration) + + of(serviceName : String, millis : long) : TimeoutPolicy {static} + + serviceName() : String + + timeout() : Duration + } + class TimeoutRegistry { + - policies : Map + - defaultTimeout : Duration + + TimeoutRegistry(defaultTimeout : Duration) + + register(policy : TimeoutPolicy) : TimeoutRegistry + + policyFor(serviceName : String) : TimeoutPolicy + } + class TimeoutMetrics { + - timeouts : ConcurrentMap + + TimeoutMetrics() + + recordTimeout(serviceName : String) : void + + timeoutCount(serviceName : String) : int + + snapshot() : Map + } + class TimeoutExecutor { + - executor : ExecutorService + - metrics : TimeoutMetrics + + TimeoutExecutor() + + TimeoutExecutor(executor : ExecutorService) + + execute(policy : TimeoutPolicy, call : Callable, fallback : Supplier) : T + + metrics() : TimeoutMetrics + + close() : void + } + class ServiceCallException { + + ServiceCallException(serviceName : String, cause : Throwable) + } + class ProductCatalogService { + + NAME : String {static} + - latency : Duration + + ProductCatalogService(latency : Duration) + + fetchProducts() : List + } + class RecommendationService { + + NAME : String {static} + - latency : Duration + + RecommendationService(latency : Duration) + + recommendationsFor(customer : String) : List + } + class App { + - POPULAR_ITEMS : List {static} + + App() + + main(args : String[]) : void + ~ loadProducts(executor : TimeoutExecutor, registry : TimeoutRegistry, catalog : ProductCatalogService) : List {static} + ~ loadRecommendations(executor : TimeoutExecutor, registry : TimeoutRegistry, recommendations : RecommendationService, customer : String) : List {static} + } +} +TimeoutExecutor --> TimeoutMetrics +TimeoutExecutor ..> TimeoutPolicy +TimeoutExecutor ..> ServiceCallException +TimeoutRegistry --> "*" TimeoutPolicy +App ..> TimeoutRegistry +App ..> TimeoutExecutor +App ..> ProductCatalogService +App ..> RecommendationService +@enduml diff --git a/timeout/pom.xml b/timeout/pom.xml new file mode 100644 index 000000000000..b35428f53a8d --- /dev/null +++ b/timeout/pom.xml @@ -0,0 +1,70 @@ + + + + 4.0.0 + + com.iluwatar + java-design-patterns + 1.26.0-SNAPSHOT + + timeout + + + 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.timeout.App + + + + + + + + + diff --git a/timeout/src/main/java/com/iluwatar/timeout/App.java b/timeout/src/main/java/com/iluwatar/timeout/App.java new file mode 100644 index 000000000000..7a0001809475 --- /dev/null +++ b/timeout/src/main/java/com/iluwatar/timeout/App.java @@ -0,0 +1,113 @@ +/* + * 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.timeout; + +import java.time.Duration; +import java.util.List; +import lombok.extern.slf4j.Slf4j; + +/** + * The Timeout pattern bounds how long a caller waits for a downstream service. Without a limit a + * single slow dependency can hold threads, connections and user requests hostage until the whole + * system stalls. With a limit the caller abandons the slow call, records the event and continues + * with a fallback, keeping latency predictable and failures contained. + * + *

The building blocks are a {@link TimeoutPolicy} per service, a {@link TimeoutRegistry} that + * makes the limits configurable in one place, and a {@link TimeoutExecutor} that enforces them, + * cancels calls that overrun, and counts timeouts in {@link TimeoutMetrics}. + * + *

The demo wires two services with different limits. The product catalog answers well within its + * 500 ms budget and returns real data. The recommendation engine needs 400 ms but is only allowed + * 100 ms, so its call is cancelled and the customer sees popular items instead. The timeout + * counters are printed at the end. + */ +@Slf4j +public class App { + + private static final List POPULAR_ITEMS = List.of("Wireless mouse", "Webcam"); + + /** + * Program entry point. + * + * @param args command line arguments, not used + */ + public static void main(String[] args) { + var registry = + new TimeoutRegistry(Duration.ofMillis(300)) + .register(TimeoutPolicy.of(ProductCatalogService.NAME, 500)) + .register(TimeoutPolicy.of(RecommendationService.NAME, 100)); + LOGGER.info("Configured per-service limits: catalog 500 ms, recommendations 100 ms"); + + var catalog = new ProductCatalogService(Duration.ofMillis(50)); + var recommendations = new RecommendationService(Duration.ofMillis(400)); + + try (var executor = new TimeoutExecutor()) { + LOGGER.info("Calling {}", ProductCatalogService.NAME); + var products = loadProducts(executor, registry, catalog); + LOGGER.info("Products: {}", products); + + LOGGER.info("Calling {}", RecommendationService.NAME); + var suggested = loadRecommendations(executor, registry, recommendations, "alice"); + LOGGER.info("Recommendations shown to alice: {}", suggested); + + LOGGER.info("Timeouts per service: {}", executor.metrics().snapshot()); + } + } + + /** + * Loads the catalog under its time limit, showing an empty catalog if the limit is exceeded. + * + * @param executor executor enforcing the limit + * @param registry registry holding the catalog's policy + * @param catalog the downstream catalog service + * @return the products, or an empty list on timeout + */ + static List loadProducts( + TimeoutExecutor executor, TimeoutRegistry registry, ProductCatalogService catalog) { + return executor.execute( + registry.policyFor(ProductCatalogService.NAME), catalog::fetchProducts, List::of); + } + + /** + * Loads personalised recommendations under their time limit, showing popular items instead if the + * limit is exceeded. + * + * @param executor executor enforcing the limit + * @param registry registry holding the recommendation service's policy + * @param recommendations the downstream recommendation service + * @param customer customer to personalise for + * @return the recommendations, or the popular items on timeout + */ + static List loadRecommendations( + TimeoutExecutor executor, + TimeoutRegistry registry, + RecommendationService recommendations, + String customer) { + return executor.execute( + registry.policyFor(RecommendationService.NAME), + () -> recommendations.recommendationsFor(customer), + () -> POPULAR_ITEMS); + } +} diff --git a/timeout/src/main/java/com/iluwatar/timeout/ProductCatalogService.java b/timeout/src/main/java/com/iluwatar/timeout/ProductCatalogService.java new file mode 100644 index 000000000000..d16d6d7019a5 --- /dev/null +++ b/timeout/src/main/java/com/iluwatar/timeout/ProductCatalogService.java @@ -0,0 +1,63 @@ +/* + * 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.timeout; + +import java.time.Duration; +import java.util.List; +import lombok.extern.slf4j.Slf4j; + +/** + * Simulated product catalog service. It answers quickly, so its calls normally complete well within + * their limit. + */ +@Slf4j +public class ProductCatalogService { + + /** Name under which the service is registered in the {@link TimeoutRegistry}. */ + public static final String NAME = "product-catalog"; + + private final Duration latency; + + /** + * Creates the service. + * + * @param latency simulated response time + */ + public ProductCatalogService(Duration latency) { + this.latency = latency; + } + + /** + * Lists the products in the catalog. + * + * @return product names + * @throws InterruptedException if the call is cancelled while waiting for the simulated backend + */ + public List fetchProducts() throws InterruptedException { + LOGGER.info("{}: fetching products, expected latency {} ms", NAME, latency.toMillis()); + Thread.sleep(latency); + return List.of("Laptop", "Headphones", "Monitor"); + } +} diff --git a/timeout/src/main/java/com/iluwatar/timeout/RecommendationService.java b/timeout/src/main/java/com/iluwatar/timeout/RecommendationService.java new file mode 100644 index 000000000000..cdaf8fb64c04 --- /dev/null +++ b/timeout/src/main/java/com/iluwatar/timeout/RecommendationService.java @@ -0,0 +1,73 @@ +/* + * 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.timeout; + +import java.time.Duration; +import java.util.List; +import lombok.extern.slf4j.Slf4j; + +/** + * Simulated recommendation engine. It is slow, so it demonstrates what happens when a dependency + * misses its limit: the call is interrupted and the caller continues with a fallback. + */ +@Slf4j +public class RecommendationService { + + /** Name under which the service is registered in the {@link TimeoutRegistry}. */ + public static final String NAME = "recommendations"; + + private final Duration latency; + + /** + * Creates the service. + * + * @param latency simulated response time + */ + public RecommendationService(Duration latency) { + this.latency = latency; + } + + /** + * Computes personalised recommendations for a customer. + * + * @param customer customer identifier + * @return recommended product names + * @throws InterruptedException if the call is cancelled before the computation finishes + */ + public List recommendationsFor(String customer) throws InterruptedException { + LOGGER.info( + "{}: computing recommendations for {}, expected latency {} ms", + NAME, + customer, + latency.toMillis()); + try { + Thread.sleep(latency); + } catch (InterruptedException e) { + LOGGER.info("{}: interrupted, abandoning the computation for {}", NAME, customer); + throw e; + } + return List.of("Mechanical keyboard", "USB-C dock"); + } +} diff --git a/timeout/src/main/java/com/iluwatar/timeout/ServiceCallException.java b/timeout/src/main/java/com/iluwatar/timeout/ServiceCallException.java new file mode 100644 index 000000000000..c21a387dc276 --- /dev/null +++ b/timeout/src/main/java/com/iluwatar/timeout/ServiceCallException.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.timeout; + +/** + * Signals that a downstream call failed for a reason other than exceeding its time limit. + * + *

Such failures are deliberately not masked by the fallback: a timeout means "too slow", while + * an exception from the service means "broken", and the two deserve different handling. + */ +public class ServiceCallException extends RuntimeException { + + /** + * Creates the exception. + * + * @param serviceName name of the service whose call failed + * @param cause the failure raised by the service + */ + public ServiceCallException(String serviceName, Throwable cause) { + super("Call to " + serviceName + " failed", cause); + } +} diff --git a/timeout/src/main/java/com/iluwatar/timeout/TimeoutExecutor.java b/timeout/src/main/java/com/iluwatar/timeout/TimeoutExecutor.java new file mode 100644 index 000000000000..33f248b1aff9 --- /dev/null +++ b/timeout/src/main/java/com/iluwatar/timeout/TimeoutExecutor.java @@ -0,0 +1,113 @@ +/* + * 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.timeout; + +import java.util.Objects; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.function.Supplier; +import lombok.extern.slf4j.Slf4j; + +/** + * Runs downstream calls under the time limit declared by their {@link TimeoutPolicy}. + * + *

The call is executed on a separate thread while the caller waits for at most the configured + * duration. When the limit is exceeded the call is cancelled with an interrupt, the event is logged + * and counted in {@link TimeoutMetrics}, and the supplied fallback provides the answer instead. + * Failures raised by the service itself are not treated as timeouts; they surface as {@link + * ServiceCallException}. + */ +@Slf4j +public class TimeoutExecutor implements AutoCloseable { + + private final ExecutorService executor; + private final TimeoutMetrics metrics = new TimeoutMetrics(); + + /** Creates an executor that runs every call on its own virtual thread. */ + public TimeoutExecutor() { + this(Executors.newVirtualThreadPerTaskExecutor()); + } + + /** + * Creates an executor backed by the given thread pool. + * + * @param executor pool used to run the calls + */ + public TimeoutExecutor(ExecutorService executor) { + this.executor = Objects.requireNonNull(executor, "executor"); + } + + /** + * Executes a call within the limit of its policy. + * + * @param policy limit that applies to the call + * @param call the downstream invocation + * @param fallback answer to use when the call does not complete in time + * @param type of the response + * @return the response of the call, or the fallback if the limit was exceeded + * @throws ServiceCallException if the call fails or the waiting thread is interrupted + */ + public T execute(TimeoutPolicy policy, Callable call, Supplier fallback) { + var serviceName = policy.serviceName(); + var limitMillis = policy.timeout().toMillis(); + var future = executor.submit(call); + try { + var result = future.get(limitMillis, TimeUnit.MILLISECONDS); + LOGGER.info("{} responded within its {} ms limit", serviceName, limitMillis); + return result; + } catch (TimeoutException e) { + future.cancel(true); + metrics.recordTimeout(serviceName); + LOGGER.warn( + "{} exceeded its {} ms limit; call cancelled, using fallback", serviceName, limitMillis); + return fallback.get(); + } catch (ExecutionException e) { + throw new ServiceCallException(serviceName, e.getCause()); + } catch (InterruptedException e) { + future.cancel(true); + Thread.currentThread().interrupt(); + throw new ServiceCallException(serviceName, e); + } + } + + /** + * Exposes the timeout counters. + * + * @return the metrics collected so far + */ + public TimeoutMetrics metrics() { + return metrics; + } + + /** Stops the underlying thread pool, interrupting any call that is still running. */ + @Override + public void close() { + executor.shutdownNow(); + } +} diff --git a/timeout/src/main/java/com/iluwatar/timeout/TimeoutMetrics.java b/timeout/src/main/java/com/iluwatar/timeout/TimeoutMetrics.java new file mode 100644 index 000000000000..bcc820ef3250 --- /dev/null +++ b/timeout/src/main/java/com/iluwatar/timeout/TimeoutMetrics.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.timeout; + +import java.util.Map; +import java.util.TreeMap; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Counts timeout events per service so that operators can spot dependencies that regularly miss + * their limits. + */ +public class TimeoutMetrics { + + private final ConcurrentMap timeouts = new ConcurrentHashMap<>(); + + /** + * Records one timeout for a service. + * + * @param serviceName name of the service that missed its limit + */ + public void recordTimeout(String serviceName) { + timeouts.computeIfAbsent(serviceName, name -> new AtomicInteger()).incrementAndGet(); + } + + /** + * Returns how many times a service has timed out. + * + * @param serviceName name of the service + * @return the timeout count, zero if the service never timed out + */ + public int timeoutCount(String serviceName) { + var counter = timeouts.get(serviceName); + return counter == null ? 0 : counter.get(); + } + + /** + * Returns a sorted, read-only view of all counters. + * + * @return service name to timeout count + */ + public Map snapshot() { + var snapshot = new TreeMap(); + timeouts.forEach((name, counter) -> snapshot.put(name, counter.get())); + return Map.copyOf(snapshot); + } +} diff --git a/timeout/src/main/java/com/iluwatar/timeout/TimeoutPolicy.java b/timeout/src/main/java/com/iluwatar/timeout/TimeoutPolicy.java new file mode 100644 index 000000000000..00e1145e3fec --- /dev/null +++ b/timeout/src/main/java/com/iluwatar/timeout/TimeoutPolicy.java @@ -0,0 +1,63 @@ +/* + * 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.timeout; + +import java.time.Duration; +import java.util.Objects; + +/** + * Declares how long a call to a named downstream service may take before it is abandoned. + * + *

Each service gets its own policy so that a fast catalog lookup and a slow recommendation + * engine can be governed by different limits. + * + * @param serviceName name of the downstream service the policy applies to + * @param timeout maximum time the caller is willing to wait for a response + */ +public record TimeoutPolicy(String serviceName, Duration timeout) { + + /** Validates that the policy names a service and carries a positive limit. */ + public TimeoutPolicy { + Objects.requireNonNull(serviceName, "serviceName"); + Objects.requireNonNull(timeout, "timeout"); + if (serviceName.isBlank()) { + throw new IllegalArgumentException("serviceName must not be blank"); + } + if (timeout.isZero() || timeout.isNegative()) { + throw new IllegalArgumentException("timeout must be positive"); + } + } + + /** + * Convenience factory for millisecond based limits. + * + * @param serviceName name of the downstream service + * @param millis limit in milliseconds + * @return the policy + */ + public static TimeoutPolicy of(String serviceName, long millis) { + return new TimeoutPolicy(serviceName, Duration.ofMillis(millis)); + } +} diff --git a/timeout/src/main/java/com/iluwatar/timeout/TimeoutRegistry.java b/timeout/src/main/java/com/iluwatar/timeout/TimeoutRegistry.java new file mode 100644 index 000000000000..67b712406983 --- /dev/null +++ b/timeout/src/main/java/com/iluwatar/timeout/TimeoutRegistry.java @@ -0,0 +1,72 @@ +/* + * 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.timeout; + +import java.time.Duration; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Holds the {@link TimeoutPolicy} configured for each downstream service. + * + *

Services that have no explicit policy fall back to a default limit, so callers never have to + * hard code a duration next to the call site. + */ +public class TimeoutRegistry { + + private final Map policies = new ConcurrentHashMap<>(); + private final Duration defaultTimeout; + + /** + * Creates a registry. + * + * @param defaultTimeout limit applied to services without an explicit policy + */ + public TimeoutRegistry(Duration defaultTimeout) { + this.defaultTimeout = Objects.requireNonNull(defaultTimeout, "defaultTimeout"); + } + + /** + * Registers or replaces the policy of a service. + * + * @param policy the policy to store + * @return this registry for chaining + */ + public TimeoutRegistry register(TimeoutPolicy policy) { + policies.put(policy.serviceName(), policy); + return this; + } + + /** + * Looks up the policy of a service. + * + * @param serviceName name of the downstream service + * @return the registered policy, or one built from the default limit + */ + public TimeoutPolicy policyFor(String serviceName) { + return policies.getOrDefault(serviceName, new TimeoutPolicy(serviceName, defaultTimeout)); + } +} diff --git a/timeout/src/test/java/com/iluwatar/timeout/AppTest.java b/timeout/src/test/java/com/iluwatar/timeout/AppTest.java new file mode 100644 index 000000000000..8c77b5cf5bff --- /dev/null +++ b/timeout/src/test/java/com/iluwatar/timeout/AppTest.java @@ -0,0 +1,91 @@ +/* + * 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.timeout; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.time.Duration; +import java.util.List; +import org.junit.jupiter.api.Test; + +class AppTest { + + private static final TimeoutRegistry GENEROUS = new TimeoutRegistry(Duration.ofSeconds(5)); + private static final TimeoutRegistry STRICT = new TimeoutRegistry(Duration.ofMillis(50)); + + @Test + void shouldLaunchApp() { + assertDoesNotThrow(() -> App.main(new String[] {})); + } + + @Test + void shouldBeInstantiable() { + assertNotNull(new App(), "App should be instantiable"); + } + + @Test + void loadsProductsWithinLimit() { + try (var executor = new TimeoutExecutor()) { + var products = + App.loadProducts(executor, GENEROUS, new ProductCatalogService(Duration.ofMillis(1))); + + assertEquals(List.of("Laptop", "Headphones", "Monitor"), products); + } + } + + @Test + void showsEmptyCatalogWhenCatalogExceedsLimit() { + try (var executor = new TimeoutExecutor()) { + var products = + App.loadProducts(executor, STRICT, new ProductCatalogService(Duration.ofSeconds(60))); + + assertEquals(List.of(), products); + } + } + + @Test + void loadsRecommendationsWithinLimit() { + try (var executor = new TimeoutExecutor()) { + var suggested = + App.loadRecommendations( + executor, GENEROUS, new RecommendationService(Duration.ofMillis(1)), "alice"); + + assertEquals(List.of("Mechanical keyboard", "USB-C dock"), suggested); + } + } + + @Test + void showsPopularItemsWhenRecommendationsExceedLimit() { + try (var executor = new TimeoutExecutor()) { + var suggested = + App.loadRecommendations( + executor, STRICT, new RecommendationService(Duration.ofSeconds(60)), "alice"); + + assertEquals(List.of("Wireless mouse", "Webcam"), suggested); + } + } +} diff --git a/timeout/src/test/java/com/iluwatar/timeout/RecommendationServiceTest.java b/timeout/src/test/java/com/iluwatar/timeout/RecommendationServiceTest.java new file mode 100644 index 000000000000..0b838956a8ff --- /dev/null +++ b/timeout/src/test/java/com/iluwatar/timeout/RecommendationServiceTest.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.timeout; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.time.Duration; +import java.util.List; +import org.junit.jupiter.api.Test; + +class RecommendationServiceTest { + + @Test + void returnsRecommendationsAfterSimulatedLatency() throws InterruptedException { + var service = new RecommendationService(Duration.ofMillis(1)); + + assertEquals(List.of("Mechanical keyboard", "USB-C dock"), service.recommendationsFor("alice")); + } + + @Test + void abandonsComputationWhenInterrupted() { + var service = new RecommendationService(Duration.ofSeconds(60)); + + Thread.currentThread().interrupt(); + try { + assertThrows(InterruptedException.class, () -> service.recommendationsFor("alice")); + } finally { + Thread.interrupted(); + } + } +} diff --git a/timeout/src/test/java/com/iluwatar/timeout/TimeoutExecutorTest.java b/timeout/src/test/java/com/iluwatar/timeout/TimeoutExecutorTest.java new file mode 100644 index 000000000000..98b92b8c3b4a --- /dev/null +++ b/timeout/src/test/java/com/iluwatar/timeout/TimeoutExecutorTest.java @@ -0,0 +1,159 @@ +/* + * 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.timeout; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Map; +import java.util.concurrent.Callable; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +class TimeoutExecutorTest { + + private static final TimeoutPolicy GENEROUS = TimeoutPolicy.of("generous", 5_000); + private static final TimeoutPolicy STRICT = TimeoutPolicy.of("strict", 50); + + private final TimeoutExecutor executor = new TimeoutExecutor(); + + @AfterEach + void tearDown() { + executor.close(); + } + + @Test + void returnsResultWhenCallCompletesWithinLimit() { + var result = executor.execute(GENEROUS, () -> "fresh", () -> "fallback"); + + assertEquals("fresh", result); + assertEquals(0, executor.metrics().timeoutCount(GENEROUS.serviceName())); + } + + @Test + void returnsFallbackAndCancelsCallWhenLimitExceeded() throws InterruptedException { + var interrupted = new CountDownLatch(1); + Callable hangingCall = + () -> { + try { + Thread.sleep(60_000); + } catch (InterruptedException e) { + interrupted.countDown(); + throw e; + } + return "too late"; + }; + + var result = executor.execute(STRICT, hangingCall, () -> "fallback"); + + assertEquals("fallback", result); + assertTrue(interrupted.await(5, TimeUnit.SECONDS), "slow call should have been interrupted"); + assertEquals(1, executor.metrics().timeoutCount(STRICT.serviceName())); + } + + @Test + void propagatesServiceFailureInsteadOfFallingBack() { + var failure = new IllegalStateException("backend down"); + + var thrown = + assertThrows( + ServiceCallException.class, + () -> + executor.execute( + GENEROUS, + () -> { + throw failure; + }, + () -> "fallback")); + + assertSame(failure, thrown.getCause()); + assertEquals(0, executor.metrics().timeoutCount(GENEROUS.serviceName())); + } + + @Test + void appliesDifferentLimitsPerService() { + Callable slowCall = + () -> { + Thread.sleep(200); + return "slow but done"; + }; + + var withinBudget = executor.execute(GENEROUS, slowCall, () -> "fallback"); + var overBudget = executor.execute(STRICT, slowCall, () -> "fallback"); + + assertEquals("slow but done", withinBudget); + assertEquals("fallback", overBudget); + } + + @Test + void countsTimeoutsPerService() { + var other = TimeoutPolicy.of("other", 50); + Callable slowCall = + () -> { + Thread.sleep(60_000); + return "never"; + }; + + executor.execute(STRICT, slowCall, () -> "fallback"); + executor.execute(STRICT, slowCall, () -> "fallback"); + executor.execute(other, slowCall, () -> "fallback"); + + assertEquals(Map.of("strict", 2, "other", 1), executor.metrics().snapshot()); + } + + @Test + void propagatesInterruptionOfTheCaller() { + Callable slowCall = + () -> { + Thread.sleep(60_000); + return "never"; + }; + + Thread.currentThread().interrupt(); + var thrown = + assertThrows( + ServiceCallException.class, + () -> executor.execute(GENEROUS, slowCall, () -> "fallback")); + + assertTrue(Thread.interrupted(), "interrupt flag should be preserved for the caller"); + assertInstanceOf(InterruptedException.class, thrown.getCause()); + assertEquals(0, executor.metrics().timeoutCount(GENEROUS.serviceName())); + } + + @Test + void rejectsCallsAfterClose() { + executor.close(); + + assertThrows( + RejectedExecutionException.class, + () -> executor.execute(GENEROUS, () -> "ignored", () -> "fallback")); + } +} diff --git a/timeout/src/test/java/com/iluwatar/timeout/TimeoutPolicyTest.java b/timeout/src/test/java/com/iluwatar/timeout/TimeoutPolicyTest.java new file mode 100644 index 000000000000..b65933eedd1c --- /dev/null +++ b/timeout/src/test/java/com/iluwatar/timeout/TimeoutPolicyTest.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.timeout; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.time.Duration; +import org.junit.jupiter.api.Test; + +class TimeoutPolicyTest { + + @Test + void buildsPolicyFromMilliseconds() { + var policy = TimeoutPolicy.of("catalog", 250); + + assertEquals("catalog", policy.serviceName()); + assertEquals(Duration.ofMillis(250), policy.timeout()); + } + + @Test + void rejectsBlankServiceName() { + assertThrows(IllegalArgumentException.class, () -> TimeoutPolicy.of(" ", 250)); + } + + @Test + void rejectsNonPositiveTimeout() { + assertThrows(IllegalArgumentException.class, () -> TimeoutPolicy.of("catalog", 0)); + assertThrows(IllegalArgumentException.class, () -> TimeoutPolicy.of("catalog", -1)); + } +} diff --git a/timeout/src/test/java/com/iluwatar/timeout/TimeoutRegistryTest.java b/timeout/src/test/java/com/iluwatar/timeout/TimeoutRegistryTest.java new file mode 100644 index 000000000000..069b0e39e93c --- /dev/null +++ b/timeout/src/test/java/com/iluwatar/timeout/TimeoutRegistryTest.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.timeout; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.time.Duration; +import org.junit.jupiter.api.Test; + +class TimeoutRegistryTest { + + private final TimeoutRegistry registry = new TimeoutRegistry(Duration.ofMillis(300)); + + @Test + void returnsRegisteredPolicy() { + registry.register(TimeoutPolicy.of("catalog", 500)); + + assertEquals(TimeoutPolicy.of("catalog", 500), registry.policyFor("catalog")); + } + + @Test + void fallsBackToDefaultLimitForUnknownService() { + assertEquals(TimeoutPolicy.of("unknown", 300), registry.policyFor("unknown")); + } + + @Test + void replacesExistingPolicy() { + registry.register(TimeoutPolicy.of("catalog", 500)).register(TimeoutPolicy.of("catalog", 50)); + + assertEquals(Duration.ofMillis(50), registry.policyFor("catalog").timeout()); + } +}