Skip to content

Commit 2458cf8

Browse files
committed
refactor: simplify the timeout demo and address review findings
The metrics snapshot stays sorted, a rejected submission surfaces as ServiceCallException, TimeoutRegistry is dropped and the two demo services merge into DownstreamService. The class diagram is a rendered PNG.
1 parent 1b50cb4 commit 2458cf8

13 files changed

Lines changed: 151 additions & 392 deletions

‎timeout/README.md‎

Lines changed: 35 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ sequenceDiagram
5959
end
6060
```
6161

62+
![Timeout class diagram](./etc/timeout.urm.png)
63+
6264
## Programmatic Example of Timeout Pattern in Java
6365

6466
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.
@@ -87,34 +89,9 @@ public record TimeoutPolicy(String serviceName, Duration timeout) {
8789
}
8890
```
8991

90-
2. **Keep the limits configurable in one place**
92+
2. **Enforce the limit**
9193

92-
A `TimeoutRegistry` stores the policies and hands out a default for services nobody configured explicitly.
93-
94-
```java
95-
public class TimeoutRegistry {
96-
97-
private final Map<String, TimeoutPolicy> policies = new ConcurrentHashMap<>();
98-
private final Duration defaultTimeout;
99-
100-
public TimeoutRegistry(Duration defaultTimeout) {
101-
this.defaultTimeout = Objects.requireNonNull(defaultTimeout, "defaultTimeout");
102-
}
103-
104-
public TimeoutRegistry register(TimeoutPolicy policy) {
105-
policies.put(policy.serviceName(), policy);
106-
return this;
107-
}
108-
109-
public TimeoutPolicy policyFor(String serviceName) {
110-
return policies.getOrDefault(serviceName, new TimeoutPolicy(serviceName, defaultTimeout));
111-
}
112-
}
113-
```
114-
115-
3. **Enforce the limit**
116-
117-
`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`.
94+
`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, or a call the pool refuses to accept, is not a timeout and is rethrown as `ServiceCallException`.
11895

11996
```java
12097
@Slf4j
@@ -130,8 +107,9 @@ public class TimeoutExecutor implements AutoCloseable {
130107
public <T> T execute(TimeoutPolicy policy, Callable<T> call, Supplier<T> fallback) {
131108
var serviceName = policy.serviceName();
132109
var limitMillis = policy.timeout().toMillis();
133-
var future = executor.submit(call);
110+
Future<T> future = null;
134111
try {
112+
future = executor.submit(call);
135113
var result = future.get(limitMillis, TimeUnit.MILLISECONDS);
136114
LOGGER.info("{} responded within its {} ms limit", serviceName, limitMillis);
137115
return result;
@@ -147,6 +125,8 @@ public class TimeoutExecutor implements AutoCloseable {
147125
future.cancel(true);
148126
Thread.currentThread().interrupt();
149127
throw new ServiceCallException(serviceName, e);
128+
} catch (RejectedExecutionException e) {
129+
throw new ServiceCallException(serviceName, e);
150130
}
151131
}
152132

@@ -157,64 +137,52 @@ public class TimeoutExecutor implements AutoCloseable {
157137
}
158138
```
159139

160-
4. **Make the slow service cooperate with cancellation**
140+
3. **Make the service cooperate with cancellation**
161141

162-
The simulated `RecommendationService` sleeps interruptibly, so the interrupt sent by the executor actually stops the work instead of leaving it running in the background.
142+
The simulated `DownstreamService` sleeps interruptibly, so the interrupt sent by the executor actually stops the work instead of leaving it running in the background. Its name, latency and payload are constructor arguments, so the same class plays both the fast catalog and the slow recommendation engine.
163143

164144
```java
165-
public List<String> recommendationsFor(String customer) throws InterruptedException {
166-
LOGGER.info(
167-
"{}: computing recommendations for {}, expected latency {} ms",
168-
NAME,
169-
customer,
170-
latency.toMillis());
145+
public List<String> fetch() throws InterruptedException {
146+
LOGGER.info("{}: responding, expected latency {} ms", name, latency.toMillis());
171147
try {
172148
Thread.sleep(latency);
173149
} catch (InterruptedException e) {
174-
LOGGER.info("{}: interrupted, abandoning the computation for {}", NAME, customer);
150+
LOGGER.info("{}: interrupted, abandoning the call", name);
175151
throw e;
176152
}
177-
return List.of("Mechanical keyboard", "USB-C dock");
153+
return items;
178154
}
179155
```
180156

181-
5. **Wire it together**
157+
4. **Wire it together**
182158

183-
`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.
159+
`App` gives the catalog a 500 ms limit and recommendations a 100 ms one, then runs both calls through a small helper that pairs the policy with the call and its fallback. 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.
184160

185161
```java
186-
static List<String> loadProducts(
187-
TimeoutExecutor executor, TimeoutRegistry registry, ProductCatalogService catalog) {
188-
return executor.execute(
189-
registry.policyFor(ProductCatalogService.NAME), catalog::fetchProducts, List::of);
190-
}
191-
192-
static List<String> loadRecommendations(
162+
static List<String> call(
193163
TimeoutExecutor executor,
194-
TimeoutRegistry registry,
195-
RecommendationService recommendations,
196-
String customer) {
197-
return executor.execute(
198-
registry.policyFor(RecommendationService.NAME),
199-
() -> recommendations.recommendationsFor(customer),
200-
() -> POPULAR_ITEMS);
164+
TimeoutPolicy policy,
165+
DownstreamService service,
166+
List<String> fallback) {
167+
return executor.execute(policy, service::fetch, () -> fallback);
201168
}
202169
```
203170

204171
```java
205-
var registry =
206-
new TimeoutRegistry(Duration.ofMillis(300))
207-
.register(TimeoutPolicy.of(ProductCatalogService.NAME, 500))
208-
.register(TimeoutPolicy.of(RecommendationService.NAME, 100));
209-
210-
var catalog = new ProductCatalogService(Duration.ofMillis(50));
211-
var recommendations = new RecommendationService(Duration.ofMillis(400));
172+
var catalog =
173+
new DownstreamService(
174+
"product-catalog", Duration.ofMillis(50), List.of("Laptop", "Headphones", "Monitor"));
175+
var recommendations =
176+
new DownstreamService(
177+
"recommendations", Duration.ofMillis(400), List.of("Mechanical keyboard", "USB-C dock"));
178+
var catalogPolicy = TimeoutPolicy.of(catalog.name(), 500);
179+
var recommendationPolicy = TimeoutPolicy.of(recommendations.name(), 100);
212180

213181
try (var executor = new TimeoutExecutor()) {
214-
var products = loadProducts(executor, registry, catalog);
182+
var products = call(executor, catalogPolicy, catalog, List.of());
215183
LOGGER.info("Products: {}", products);
216184

217-
var suggested = loadRecommendations(executor, registry, recommendations, "alice");
185+
var suggested = call(executor, recommendationPolicy, recommendations, POPULAR_ITEMS);
218186
LOGGER.info("Recommendations shown to alice: {}", suggested);
219187

220188
LOGGER.info("Timeouts per service: {}", executor.metrics().snapshot());
@@ -226,20 +194,18 @@ Running the application produces output along these lines:
226194
```
227195
Configured per-service limits: catalog 500 ms, recommendations 100 ms
228196
Calling product-catalog
229-
product-catalog: fetching products, expected latency 50 ms
197+
product-catalog: responding, expected latency 50 ms
230198
product-catalog responded within its 500 ms limit
231199
Products: [Laptop, Headphones, Monitor]
232200
Calling recommendations
233-
recommendations: computing recommendations for alice, expected latency 400 ms
201+
recommendations: responding, expected latency 400 ms
202+
recommendations: interrupted, abandoning the call
234203
recommendations exceeded its 100 ms limit; call cancelled, using fallback
235-
recommendations: interrupted, abandoning the computation for alice
236204
Recommendations shown to alice: [Wireless mouse, Webcam]
237205
Timeouts per service: {recommendations=1}
238206
```
239207

240-
## Class diagram
241-
242-
See [timeout.urm.puml](./etc/timeout.urm.puml) for the PlantUML class diagram.
208+
The "exceeded its 100 ms limit" line is written by the caller and the "interrupted, abandoning" line by the worker thread, so the two may appear in either order from run to run.
243209

244210
## When to Use the Timeout Pattern in Java
245211

‎timeout/etc/timeout.urm.png‎

44.8 KB
Loading

‎timeout/etc/timeout.urm.puml‎

Lines changed: 13 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,19 @@
11
@startuml
22
package com.iluwatar.timeout {
3-
class TimeoutPolicy {
3+
class TimeoutPolicy <<record>> {
44
- serviceName : String
55
- timeout : Duration
66
+ TimeoutPolicy(serviceName : String, timeout : Duration)
77
+ of(serviceName : String, millis : long) : TimeoutPolicy {static}
88
+ serviceName() : String
99
+ timeout() : Duration
1010
}
11-
class TimeoutRegistry {
12-
- policies : Map<String, TimeoutPolicy>
13-
- defaultTimeout : Duration
14-
+ TimeoutRegistry(defaultTimeout : Duration)
15-
+ register(policy : TimeoutPolicy) : TimeoutRegistry
16-
+ policyFor(serviceName : String) : TimeoutPolicy
17-
}
1811
class TimeoutMetrics {
1912
- timeouts : ConcurrentMap<String, AtomicInteger>
2013
+ TimeoutMetrics()
2114
+ recordTimeout(serviceName : String) : void
2215
+ timeoutCount(serviceName : String) : int
23-
+ snapshot() : Map<String, Integer>
16+
+ snapshot() : SortedMap<String, Integer>
2417
}
2518
class TimeoutExecutor {
2619
- executor : ExecutorService
@@ -34,32 +27,26 @@ package com.iluwatar.timeout {
3427
class ServiceCallException {
3528
+ ServiceCallException(serviceName : String, cause : Throwable)
3629
}
37-
class ProductCatalogService {
38-
+ NAME : String {static}
39-
- latency : Duration
40-
+ ProductCatalogService(latency : Duration)
41-
+ fetchProducts() : List<String>
42-
}
43-
class RecommendationService {
44-
+ NAME : String {static}
30+
class DownstreamService {
31+
- name : String
4532
- latency : Duration
46-
+ RecommendationService(latency : Duration)
47-
+ recommendationsFor(customer : String) : List<String>
33+
- items : List<String>
34+
+ DownstreamService(name : String, latency : Duration, items : List<String>)
35+
+ name() : String
36+
+ fetch() : List<String>
4837
}
4938
class App {
5039
- POPULAR_ITEMS : List<String> {static}
51-
+ App()
5240
+ main(args : String[]) : void
53-
~ loadProducts(executor : TimeoutExecutor, registry : TimeoutRegistry, catalog : ProductCatalogService) : List<String> {static}
54-
~ loadRecommendations(executor : TimeoutExecutor, registry : TimeoutRegistry, recommendations : RecommendationService, customer : String) : List<String> {static}
41+
~ call(executor : TimeoutExecutor, policy : TimeoutPolicy, service : DownstreamService, fallback : List<String>) : List<String> {static}
5542
}
5643
}
44+
ServiceCallException --|> RuntimeException
5745
TimeoutExecutor --> TimeoutMetrics
5846
TimeoutExecutor ..> TimeoutPolicy
5947
TimeoutExecutor ..> ServiceCallException
60-
TimeoutRegistry --> "*" TimeoutPolicy
61-
App ..> TimeoutRegistry
48+
App ..> TimeoutPolicy
6249
App ..> TimeoutExecutor
63-
App ..> ProductCatalogService
64-
App ..> RecommendationService
50+
App ..> TimeoutMetrics
51+
App ..> DownstreamService
6552
@enduml

‎timeout/src/main/java/com/iluwatar/timeout/App.java‎

Lines changed: 26 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,8 @@
3434
* system stalls. With a limit the caller abandons the slow call, records the event and continues
3535
* with a fallback, keeping latency predictable and failures contained.
3636
*
37-
* <p>The building blocks are a {@link TimeoutPolicy} per service, a {@link TimeoutRegistry} that
38-
* makes the limits configurable in one place, and a {@link TimeoutExecutor} that enforces them,
39-
* cancels calls that overrun, and counts timeouts in {@link TimeoutMetrics}.
37+
* <p>The building blocks are a {@link TimeoutPolicy} per service and a {@link TimeoutExecutor} that
38+
* enforces them, cancels calls that overrun, and counts timeouts in {@link TimeoutMetrics}.
4039
*
4140
* <p>The demo wires two services with different limits. The product catalog answers well within its
4241
* 500 ms budget and returns real data. The recommendation engine needs 400 ms but is only allowed
@@ -54,60 +53,45 @@ public class App {
5453
* @param args command line arguments, not used
5554
*/
5655
public static void main(String[] args) {
57-
var registry =
58-
new TimeoutRegistry(Duration.ofMillis(300))
59-
.register(TimeoutPolicy.of(ProductCatalogService.NAME, 500))
60-
.register(TimeoutPolicy.of(RecommendationService.NAME, 100));
56+
var catalog =
57+
new DownstreamService(
58+
"product-catalog", Duration.ofMillis(50), List.of("Laptop", "Headphones", "Monitor"));
59+
var recommendations =
60+
new DownstreamService(
61+
"recommendations",
62+
Duration.ofMillis(400),
63+
List.of("Mechanical keyboard", "USB-C dock"));
64+
var catalogPolicy = TimeoutPolicy.of(catalog.name(), 500);
65+
var recommendationPolicy = TimeoutPolicy.of(recommendations.name(), 100);
6166
LOGGER.info("Configured per-service limits: catalog 500 ms, recommendations 100 ms");
6267

63-
var catalog = new ProductCatalogService(Duration.ofMillis(50));
64-
var recommendations = new RecommendationService(Duration.ofMillis(400));
65-
6668
try (var executor = new TimeoutExecutor()) {
67-
LOGGER.info("Calling {}", ProductCatalogService.NAME);
68-
var products = loadProducts(executor, registry, catalog);
69+
LOGGER.info("Calling {}", catalog.name());
70+
var products = call(executor, catalogPolicy, catalog, List.of());
6971
LOGGER.info("Products: {}", products);
7072

71-
LOGGER.info("Calling {}", RecommendationService.NAME);
72-
var suggested = loadRecommendations(executor, registry, recommendations, "alice");
73+
LOGGER.info("Calling {}", recommendations.name());
74+
var suggested = call(executor, recommendationPolicy, recommendations, POPULAR_ITEMS);
7375
LOGGER.info("Recommendations shown to alice: {}", suggested);
7476

7577
LOGGER.info("Timeouts per service: {}", executor.metrics().snapshot());
7678
}
7779
}
7880

7981
/**
80-
* Loads the catalog under its time limit, showing an empty catalog if the limit is exceeded.
81-
*
82-
* @param executor executor enforcing the limit
83-
* @param registry registry holding the catalog's policy
84-
* @param catalog the downstream catalog service
85-
* @return the products, or an empty list on timeout
86-
*/
87-
static List<String> loadProducts(
88-
TimeoutExecutor executor, TimeoutRegistry registry, ProductCatalogService catalog) {
89-
return executor.execute(
90-
registry.policyFor(ProductCatalogService.NAME), catalog::fetchProducts, List::of);
91-
}
92-
93-
/**
94-
* Loads personalised recommendations under their time limit, showing popular items instead if the
95-
* limit is exceeded.
82+
* Calls a service under its time limit, answering with the fallback if the limit is exceeded.
9683
*
9784
* @param executor executor enforcing the limit
98-
* @param registry registry holding the recommendation service's policy
99-
* @param recommendations the downstream recommendation service
100-
* @param customer customer to personalise for
101-
* @return the recommendations, or the popular items on timeout
85+
* @param policy limit that applies to the service
86+
* @param service the downstream service to call
87+
* @param fallback answer to use when the limit is exceeded
88+
* @return the response of the service, or the fallback on timeout
10289
*/
103-
static List<String> loadRecommendations(
90+
static List<String> call(
10491
TimeoutExecutor executor,
105-
TimeoutRegistry registry,
106-
RecommendationService recommendations,
107-
String customer) {
108-
return executor.execute(
109-
registry.policyFor(RecommendationService.NAME),
110-
() -> recommendations.recommendationsFor(customer),
111-
() -> POPULAR_ITEMS);
92+
TimeoutPolicy policy,
93+
DownstreamService service,
94+
List<String> fallback) {
95+
return executor.execute(policy, service::fetch, () -> fallback);
11296
}
11397
}

0 commit comments

Comments
 (0)