Skip to content

Commit 3577c00

Browse files
committed
Fix subscription worker starvation under a small thread pool
Signed-off-by: Marvin Froeder <velo.br@gmail.com>
1 parent 7e07edb commit 3577c00

3 files changed

Lines changed: 149 additions & 32 deletions

File tree

graphql/src/main/java/feign/graphql/GraphqlCapability.java

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,7 @@
2828
import java.time.Duration;
2929
import java.util.ArrayList;
3030
import java.util.concurrent.Executor;
31-
import java.util.concurrent.SynchronousQueue;
32-
import java.util.concurrent.ThreadPoolExecutor;
33-
import java.util.concurrent.TimeUnit;
31+
import java.util.concurrent.Executors;
3432
import java.util.concurrent.atomic.AtomicLong;
3533

3634
@Experimental
@@ -90,18 +88,14 @@ public GraphqlCapability(
9088
}
9189

9290
/**
93-
* Bounded and daemon, so a runaway subscription count fails fast rather than exhausting threads.
94-
* A synchronous handoff queue is deliberate: subscription workers are long-lived, so queueing
95-
* them would hide exhaustion until the heap gave out.
91+
* Each open {@code Flow.Publisher} or {@code CompletableFuture} subscription holds one worker for
92+
* its lifetime, so the default pool grows on demand and reaps idle threads rather than capping
93+
* concurrent subscriptions at a guess. Supply a bounded executor to cap them deliberately: the
94+
* excess is refused with {@code RejectedExecutionException} rather than left hanging.
9695
*/
9796
private static Executor defaultExecutor() {
9897
var threads = new AtomicLong();
99-
return new ThreadPoolExecutor(
100-
0,
101-
Math.max(8, Runtime.getRuntime().availableProcessors() * 4),
102-
60L,
103-
TimeUnit.SECONDS,
104-
new SynchronousQueue<>(),
98+
return Executors.newCachedThreadPool(
10599
runnable -> {
106100
var thread =
107101
new Thread(runnable, "feign-graphql-subscription-" + threads.incrementAndGet());

graphql/src/main/java/feign/graphql/GraphqlDecoder.java

Lines changed: 34 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
import java.util.concurrent.CompletableFuture;
3535
import java.util.concurrent.Executor;
3636
import java.util.concurrent.Flow;
37+
import java.util.concurrent.RejectedExecutionException;
3738
import java.util.concurrent.SubmissionPublisher;
3839
import java.util.concurrent.atomic.AtomicBoolean;
3940
import java.util.stream.Stream;
@@ -210,14 +211,19 @@ private CompletableFuture<Object> futureOf(Subscription subscription, Type eleme
210211
subscription.unsubscribe();
211212
}
212213
});
213-
executor.execute(
214-
() -> {
215-
try {
216-
future.complete(first(subscription, elementType, 0).orElse(null));
217-
} catch (Throwable e) {
218-
future.completeExceptionally(e);
219-
}
220-
});
214+
try {
215+
executor.execute(
216+
() -> {
217+
try {
218+
future.complete(first(subscription, elementType, 0).orElse(null));
219+
} catch (Throwable e) {
220+
future.completeExceptionally(e);
221+
}
222+
});
223+
} catch (RejectedExecutionException e) {
224+
subscription.unsubscribe();
225+
future.completeExceptionally(e);
226+
}
221227
return future;
222228
}
223229

@@ -235,7 +241,9 @@ private Stream<Object> elements(Subscription subscription, Type elementType, lon
235241
}
236242

237243
private Flow.Publisher<Object> publish(Subscription subscription, Type elementType) {
238-
var publisher = new SubmissionPublisher<>(executor, Flow.defaultBufferSize());
244+
// Runnable::run delivers on the pump thread: one worker per subscription in total, delivery can
245+
// never be rejected by a busy pool, and onNext is inherently ordered.
246+
var publisher = new SubmissionPublisher<>(Runnable::run, Flow.defaultBufferSize());
239247
var started = new AtomicBoolean();
240248
// Pumping starts on the first subscribe, so hasSubscribers() is meaningful from the first
241249
// element onwards and there is no pre-subscribe window to latch around.
@@ -244,18 +252,24 @@ private Flow.Publisher<Object> publish(Subscription subscription, Type elementTy
244252
if (!started.compareAndSet(false, true)) {
245253
return;
246254
}
247-
executor.execute(
248-
() -> {
249-
try (var elements = elements(subscription, elementType, 0)) {
250-
var iterator = elements.iterator();
251-
while (iterator.hasNext() && publisher.hasSubscribers()) {
252-
publisher.submit(iterator.next());
255+
try {
256+
executor.execute(
257+
() -> {
258+
try (var elements = elements(subscription, elementType, 0)) {
259+
var iterator = elements.iterator();
260+
while (iterator.hasNext() && publisher.hasSubscribers()) {
261+
publisher.submit(iterator.next());
262+
}
263+
publisher.close();
264+
} catch (Throwable e) {
265+
publisher.closeExceptionally(e);
253266
}
254-
publisher.close();
255-
} catch (Throwable e) {
256-
publisher.closeExceptionally(e);
257-
}
258-
});
267+
});
268+
} catch (RejectedExecutionException e) {
269+
// A subscriber must always get a terminal signal; stranding it is worse than failing it.
270+
subscription.unsubscribe();
271+
publisher.closeExceptionally(e);
272+
}
259273
};
260274
}
261275

graphql/src/test/java/feign/graphql/GraphqlSubscriptionConcurrencyTest.java

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,14 @@
2424
import java.time.Duration;
2525
import java.util.List;
2626
import java.util.concurrent.Callable;
27+
import java.util.concurrent.CompletableFuture;
2728
import java.util.concurrent.CountDownLatch;
2829
import java.util.concurrent.CyclicBarrier;
30+
import java.util.concurrent.Executor;
2931
import java.util.concurrent.Executors;
3032
import java.util.concurrent.Flow;
33+
import java.util.concurrent.SynchronousQueue;
34+
import java.util.concurrent.ThreadPoolExecutor;
3135
import java.util.concurrent.TimeUnit;
3236
import java.util.concurrent.atomic.AtomicInteger;
3337
import java.util.stream.IntStream;
@@ -62,6 +66,11 @@ class GraphqlSubscriptionConcurrencyTest {
6266

6367
private final AtomicInteger openSockets = new AtomicInteger();
6468

69+
/**
70+
* When false the server acknowledges the subscribe and then stays quiet, as a real feed would.
71+
*/
72+
private volatile boolean emitEvents = true;
73+
6574
public static class Price {
6675
public String symbol;
6776
public double price;
@@ -78,6 +87,11 @@ interface StockApi {
7887
"subscription onPrice($symbol: String!) {"
7988
+ " priceChanged(symbol: $symbol) { symbol price } }")
8089
Flow.Publisher<Price> publishPrice(String symbol);
90+
91+
@GraphqlQuery(
92+
"subscription onPrice($symbol: String!) {"
93+
+ " priceChanged(symbol: $symbol) { symbol price } }")
94+
CompletableFuture<Price> futurePrice(String symbol);
8195
}
8296

8397
@BeforeEach
@@ -120,6 +134,9 @@ public void onMessage(WebSocket webSocket, String text) {
120134
if (!"subscribe".equals(type)) {
121135
return;
122136
}
137+
if (!emitEvents) {
138+
return;
139+
}
123140
var id = message.get("id").asText();
124141
var symbol = message.get("payload").get("variables").get("symbol").asText();
125142
for (var i = 0; i < EVENTS_EACH; i++) {
@@ -156,6 +173,42 @@ private StockApi buildClient() {
156173
.target(StockApi.class, server.url("/graphql").toString());
157174
}
158175

176+
private StockApi buildClient(Executor executor) {
177+
return Feign.builder()
178+
.addCapability(
179+
new GraphqlCapability(new JacksonCodec(mapper), Duration.ofSeconds(30), executor))
180+
.target(StockApi.class, server.url("/graphql").toString());
181+
}
182+
183+
private int drain(Flow.Publisher<Price> publisher) throws Exception {
184+
var delivered = new AtomicInteger();
185+
var done = new CountDownLatch(1);
186+
publisher.subscribe(
187+
new Flow.Subscriber<Price>() {
188+
@Override
189+
public void onSubscribe(Flow.Subscription subscription) {
190+
subscription.request(Long.MAX_VALUE);
191+
}
192+
193+
@Override
194+
public void onNext(Price item) {
195+
delivered.incrementAndGet();
196+
}
197+
198+
@Override
199+
public void onError(Throwable throwable) {
200+
done.countDown();
201+
}
202+
203+
@Override
204+
public void onComplete() {
205+
done.countDown();
206+
}
207+
});
208+
assertThat(done.await(60, TimeUnit.SECONDS)).isTrue();
209+
return delivered.get();
210+
}
211+
159212
private <T> List<T> runAllAtOnce(List<Callable<T>> tasks) throws Exception {
160213
var pool = Executors.newFixedThreadPool(tasks.size());
161214
try {
@@ -257,6 +310,62 @@ public void onComplete() {
257310
assertThat(closed.await(30, TimeUnit.SECONDS)).isTrue();
258311
}
259312

313+
@Test
314+
void aPoolSizedForTheSubscriptionsIsEnough() throws Exception {
315+
// One worker per open subscription is the documented cost. Needing a second thread per
316+
// subscription for delivery would starve this pool and hang instead.
317+
var pool = Executors.newFixedThreadPool(SUBSCRIPTIONS);
318+
try {
319+
var api = buildClient(pool);
320+
List<Callable<Integer>> tasks =
321+
IntStream.range(0, SUBSCRIPTIONS)
322+
.<Callable<Integer>>mapToObj(index -> () -> drain(api.publishPrice("SYM" + index)))
323+
.toList();
324+
325+
assertThat(runAllAtOnce(tasks)).containsOnly(EVENTS_EACH);
326+
} finally {
327+
pool.shutdownNow();
328+
}
329+
}
330+
331+
@Test
332+
void aPoolTooSmallRefusesRatherThanStranding() throws Exception {
333+
var pool = new ThreadPoolExecutor(0, 4, 60L, TimeUnit.SECONDS, new SynchronousQueue<>());
334+
try {
335+
var api = buildClient(pool);
336+
List<Callable<Integer>> tasks =
337+
IntStream.range(0, SUBSCRIPTIONS)
338+
.<Callable<Integer>>mapToObj(index -> () -> drain(api.publishPrice("SYM" + index)))
339+
.toList();
340+
341+
// Far more subscriptions than workers. Some are refused, but every subscriber must reach a
342+
// terminal signal — drain() asserts that. Leaving one waiting forever is the failure mode.
343+
assertThat(runAllAtOnce(tasks)).hasSize(SUBSCRIPTIONS);
344+
} finally {
345+
pool.shutdownNow();
346+
}
347+
}
348+
349+
@Test
350+
void longLivedSubscriptionsAreNotCappedByCoreCount() throws Exception {
351+
emitEvents = false;
352+
var api = buildClient();
353+
354+
// Each of these holds its worker parked on the queue for as long as it is open, which is what a
355+
// real feed does. A pool sized from the core count would refuse the excess synchronously.
356+
var futures =
357+
IntStream.range(0, SUBSCRIPTIONS)
358+
.mapToObj(index -> api.futurePrice("SYM" + index))
359+
.toList();
360+
361+
assertThat(futures)
362+
.as("no subscription should have been refused a worker")
363+
.allSatisfy(future -> assertThat(future).isNotCompleted());
364+
365+
futures.forEach(future -> future.cancel(true));
366+
assertThat(closed.await(30, TimeUnit.SECONDS)).isTrue();
367+
}
368+
260369
@Test
261370
void closingMidStreamFromAnotherThreadTerminatesPromptly() throws Exception {
262371
var api = buildClient();

0 commit comments

Comments
 (0)