2424import java .time .Duration ;
2525import java .util .List ;
2626import java .util .concurrent .Callable ;
27+ import java .util .concurrent .CompletableFuture ;
2728import java .util .concurrent .CountDownLatch ;
2829import java .util .concurrent .CyclicBarrier ;
30+ import java .util .concurrent .Executor ;
2931import java .util .concurrent .Executors ;
3032import java .util .concurrent .Flow ;
33+ import java .util .concurrent .SynchronousQueue ;
34+ import java .util .concurrent .ThreadPoolExecutor ;
3135import java .util .concurrent .TimeUnit ;
3236import java .util .concurrent .atomic .AtomicInteger ;
3337import 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