Skip to content

Commit 8580c13

Browse files
committed
fix: cancel the provider call when its reply times out
A reply that times out or is cancelled now cancels the task behind it, so the provider call is interrupted and its pool thread is handed back instead of finishing work that nobody reads. A new test times out a blocked provider on a single-thread executor and checks that the provider was interrupted and that the thread answers a fresh call afterwards. The class javadoc, the README snippet and the resource usage trade-off follow.
1 parent bc38fb3 commit 8580c13

3 files changed

Lines changed: 73 additions & 10 deletions

File tree

‎scatter-gather/README.md‎

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -106,9 +106,21 @@ public interface Aggregator<R> {
106106
public List<PendingReply> scatter(RateRequest request, List<RateProvider> providers) {
107107
var pending = new ArrayList<PendingReply>();
108108
for (var provider : providers) {
109-
var reply =
110-
CompletableFuture.supplyAsync(() -> provider.quote(request), executor)
111-
.orTimeout(timeout.toMillis(), TimeUnit.MILLISECONDS);
109+
var reply = new CompletableFuture<RateQuote>();
110+
var task = executor.submit(() -> {
111+
try {
112+
reply.complete(provider.quote(request));
113+
} catch (RuntimeException e) {
114+
reply.completeExceptionally(e);
115+
}
116+
});
117+
// a reply that times out or is cancelled also cancels its task, interrupting the provider call
118+
reply.orTimeout(timeout.toMillis(), TimeUnit.MILLISECONDS)
119+
.whenComplete((quote, failure) -> {
120+
if (failure != null && !task.isDone()) {
121+
task.cancel(true);
122+
}
123+
});
112124
pending.add(new PendingReply(provider, reply));
113125
}
114126
return pending;
@@ -223,7 +235,7 @@ Benefits:
223235
Trade-offs:
224236

225237
* **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.
226-
* **Resource usage**: every request occupies one thread or connection per recipient; a dropped reply may still be computed by the recipient.
238+
* **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.
227239
* **Tuning**: the timeout is a compromise between completeness and responsiveness and usually needs measurement to get right.
228240

229241
## Related Java Design Patterns

‎scatter-gather/src/main/java/com/iluwatar/scattergather/ScatterGather.java‎

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,9 @@
4242
* <li><b>Scatter</b>: the same request is sent to every provider concurrently.
4343
* <li><b>Gather</b>: replies are collected until each one has either arrived, failed, or exceeded
4444
* the timeout. Late and failed replies are logged and dropped so a single slow provider
45-
* cannot hold up the whole answer. Dropping a reply does not stop the provider: the task of a
46-
* timed-out provider keeps running on its pool thread until it finishes on its own or the
47-
* coordinator is closed.
45+
* cannot hold up the whole answer. Dropping a reply also cancels the provider call, so a
46+
* timed-out provider is interrupted and its pool thread is freed instead of staying busy with
47+
* work nobody will read.
4848
* <li><b>Aggregate</b>: the gathered replies are reduced by an {@link Aggregator}.
4949
* </ol>
5050
*
@@ -107,9 +107,26 @@ public List<PendingReply> scatter(RateRequest request, List<RateProvider> provid
107107
providers.size());
108108
var pending = new ArrayList<PendingReply>();
109109
for (var provider : providers) {
110-
var reply =
111-
CompletableFuture.supplyAsync(() -> provider.quote(request), executor)
112-
.orTimeout(timeout.toMillis(), TimeUnit.MILLISECONDS);
110+
var reply = new CompletableFuture<RateQuote>();
111+
var task =
112+
executor.submit(
113+
() -> {
114+
try {
115+
reply.complete(provider.quote(request));
116+
} catch (RuntimeException e) {
117+
reply.completeExceptionally(e);
118+
}
119+
});
120+
// A reply that times out or is cancelled also cancels its task, interrupting the provider
121+
// call so that the pool thread is handed back instead of finishing work nobody will read.
122+
reply
123+
.orTimeout(timeout.toMillis(), TimeUnit.MILLISECONDS)
124+
.whenComplete(
125+
(quote, failure) -> {
126+
if (failure != null && !task.isDone()) {
127+
task.cancel(true);
128+
}
129+
});
113130
pending.add(new PendingReply(provider, reply));
114131
}
115132
return pending;

‎scatter-gather/src/test/java/com/iluwatar/scattergather/ScatterGatherTest.java‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
import java.util.concurrent.CountDownLatch;
3636
import java.util.concurrent.ExecutorService;
3737
import java.util.concurrent.Executors;
38+
import java.util.concurrent.TimeUnit;
3839
import org.junit.jupiter.api.AfterEach;
3940
import org.junit.jupiter.api.BeforeEach;
4041
import org.junit.jupiter.api.Test;
@@ -81,6 +82,39 @@ void shouldDropProviderThatMissesTheTimeout() {
8182
assertEquals(List.of(new RateQuote("fast", new BigDecimal("200.00"))), quotes);
8283
}
8384

85+
@Test
86+
void shouldInterruptProviderThatMissesTheTimeoutAndFreeItsThread() throws Exception {
87+
var interrupted = new CountDownLatch(1);
88+
var slow =
89+
new RateProvider() {
90+
@Override
91+
public String name() {
92+
return "slow";
93+
}
94+
95+
@Override
96+
public RateQuote quote(RateRequest request) {
97+
try {
98+
gate.await();
99+
} catch (InterruptedException e) {
100+
interrupted.countDown();
101+
Thread.currentThread().interrupt();
102+
throw new IllegalStateException("interrupted", e);
103+
}
104+
return new RateQuote(name(), BigDecimal.ONE);
105+
}
106+
};
107+
var ownExecutor = Executors.newSingleThreadExecutor();
108+
try (var subject = new ScatterGather(ownExecutor, TIMEOUT)) {
109+
assertTrue(subject.gather(subject.scatter(REQUEST, List.of(slow))).isEmpty());
110+
111+
assertTrue(interrupted.await(1, TimeUnit.SECONDS), "timed-out provider was not interrupted");
112+
// the only pool thread is free again, so a fresh call answers well within the timeout
113+
var quotes = subject.gather(subject.scatter(REQUEST, List.of(provider("fast", "100.00"))));
114+
assertEquals(List.of(new RateQuote("fast", new BigDecimal("200.00"))), quotes);
115+
}
116+
}
117+
84118
@Test
85119
void shouldDropProviderThatFails() {
86120
var providers = List.<RateProvider>of(new FailingRateProvider("down"), provider("up", "50.00"));

0 commit comments

Comments
 (0)