Skip to content

Commit 7e07edb

Browse files
committed
Add concurrency test for GraphQL subscriptions
Signed-off-by: Marvin Froeder <velo.br@gmail.com>
1 parent afc0764 commit 7e07edb

1 file changed

Lines changed: 281 additions & 0 deletions

File tree

Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
1+
/*
2+
* Copyright © 2012 The Feign Authors (feign@commonhaus.dev)
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package feign.graphql;
17+
18+
import static org.assertj.core.api.Assertions.assertThat;
19+
20+
import com.fasterxml.jackson.databind.DeserializationFeature;
21+
import com.fasterxml.jackson.databind.ObjectMapper;
22+
import feign.Feign;
23+
import feign.jackson.JacksonCodec;
24+
import java.time.Duration;
25+
import java.util.List;
26+
import java.util.concurrent.Callable;
27+
import java.util.concurrent.CountDownLatch;
28+
import java.util.concurrent.CyclicBarrier;
29+
import java.util.concurrent.Executors;
30+
import java.util.concurrent.Flow;
31+
import java.util.concurrent.TimeUnit;
32+
import java.util.concurrent.atomic.AtomicInteger;
33+
import java.util.stream.IntStream;
34+
import java.util.stream.Stream;
35+
import okhttp3.Response;
36+
import okhttp3.WebSocket;
37+
import okhttp3.WebSocketListener;
38+
import okhttp3.mockwebserver.Dispatcher;
39+
import okhttp3.mockwebserver.MockResponse;
40+
import okhttp3.mockwebserver.MockWebServer;
41+
import okhttp3.mockwebserver.RecordedRequest;
42+
import org.junit.jupiter.api.AfterEach;
43+
import org.junit.jupiter.api.BeforeEach;
44+
import org.junit.jupiter.api.Test;
45+
46+
/**
47+
* Exercises the subscription wiring under concurrent load: many sockets open at once, sharing one
48+
* capability, one JSON codec and one worker pool.
49+
*/
50+
class GraphqlSubscriptionConcurrencyTest {
51+
52+
private static final int SUBSCRIPTIONS = 24;
53+
private static final int EVENTS_EACH = 20;
54+
55+
private final ObjectMapper mapper =
56+
new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
57+
58+
private MockWebServer server;
59+
60+
/** Counts sockets the client closed, so leaks show up as a shortfall. */
61+
private final CountDownLatch closed = new CountDownLatch(SUBSCRIPTIONS);
62+
63+
private final AtomicInteger openSockets = new AtomicInteger();
64+
65+
public static class Price {
66+
public String symbol;
67+
public double price;
68+
}
69+
70+
interface StockApi {
71+
72+
@GraphqlQuery(
73+
"subscription onPrice($symbol: String!) {"
74+
+ " priceChanged(symbol: $symbol) { symbol price } }")
75+
Stream<Price> onPrice(String symbol);
76+
77+
@GraphqlQuery(
78+
"subscription onPrice($symbol: String!) {"
79+
+ " priceChanged(symbol: $symbol) { symbol price } }")
80+
Flow.Publisher<Price> publishPrice(String symbol);
81+
}
82+
83+
@BeforeEach
84+
void setUp() throws Exception {
85+
server = new MockWebServer();
86+
// A dispatcher rather than a queue: every connection gets its own upgrade and its own listener,
87+
// so the subscriptions are genuinely independent sockets.
88+
server.setDispatcher(
89+
new Dispatcher() {
90+
@Override
91+
public MockResponse dispatch(RecordedRequest request) {
92+
return new MockResponse().withWebSocketUpgrade(new EchoingServer());
93+
}
94+
});
95+
server.start();
96+
}
97+
98+
@AfterEach
99+
void tearDown() throws Exception {
100+
server.shutdown();
101+
}
102+
103+
/** Replays the handshake, then emits the requested symbol back with the client's own id. */
104+
private final class EchoingServer extends WebSocketListener {
105+
106+
@Override
107+
public void onOpen(WebSocket webSocket, Response response) {
108+
openSockets.incrementAndGet();
109+
}
110+
111+
@Override
112+
public void onMessage(WebSocket webSocket, String text) {
113+
try {
114+
var message = mapper.readTree(text);
115+
var type = message.get("type").asText();
116+
if ("connection_init".equals(type)) {
117+
webSocket.send("{\"type\":\"connection_ack\"}");
118+
return;
119+
}
120+
if (!"subscribe".equals(type)) {
121+
return;
122+
}
123+
var id = message.get("id").asText();
124+
var symbol = message.get("payload").get("variables").get("symbol").asText();
125+
for (var i = 0; i < EVENTS_EACH; i++) {
126+
webSocket.send(
127+
"{\"id\":\""
128+
+ id
129+
+ "\",\"type\":\"next\",\"payload\":{\"data\":{\"priceChanged\":{\"symbol\":\""
130+
+ symbol
131+
+ "\",\"price\":"
132+
+ i
133+
+ "}}}}");
134+
}
135+
webSocket.send("{\"id\":\"" + id + "\",\"type\":\"complete\"}");
136+
} catch (Exception e) {
137+
throw new IllegalStateException("bad client message: " + text, e);
138+
}
139+
}
140+
141+
@Override
142+
public void onClosing(WebSocket webSocket, int code, String reason) {
143+
webSocket.close(code, reason);
144+
closed.countDown();
145+
}
146+
147+
@Override
148+
public void onFailure(WebSocket webSocket, Throwable t, Response response) {
149+
closed.countDown();
150+
}
151+
}
152+
153+
private StockApi buildClient() {
154+
return Feign.builder()
155+
.addCapability(new GraphqlCapability(new JacksonCodec(mapper), Duration.ofSeconds(30)))
156+
.target(StockApi.class, server.url("/graphql").toString());
157+
}
158+
159+
private <T> List<T> runAllAtOnce(List<Callable<T>> tasks) throws Exception {
160+
var pool = Executors.newFixedThreadPool(tasks.size());
161+
try {
162+
var barrier = new CyclicBarrier(tasks.size());
163+
var futures =
164+
tasks.stream()
165+
.map(
166+
task ->
167+
pool.submit(
168+
() -> {
169+
barrier.await(30, TimeUnit.SECONDS);
170+
return task.call();
171+
}))
172+
.toList();
173+
var results = new java.util.ArrayList<T>();
174+
for (var future : futures) {
175+
results.add(future.get(60, TimeUnit.SECONDS));
176+
}
177+
return results;
178+
} finally {
179+
pool.shutdownNow();
180+
}
181+
}
182+
183+
@Test
184+
void concurrentStreamsStayIsolated() throws Exception {
185+
var api = buildClient();
186+
187+
List<Callable<List<Price>>> tasks =
188+
IntStream.range(0, SUBSCRIPTIONS)
189+
.<Callable<List<Price>>>mapToObj(
190+
index ->
191+
() -> {
192+
try (var prices = api.onPrice("SYM" + index)) {
193+
return prices.toList();
194+
}
195+
})
196+
.toList();
197+
198+
var results = runAllAtOnce(tasks);
199+
200+
// Every subscription sees exactly its own events, in order, with nothing from its neighbours.
201+
for (var index = 0; index < SUBSCRIPTIONS; index++) {
202+
var prices = results.get(index);
203+
assertThat(prices).hasSize(EVENTS_EACH);
204+
assertThat(prices).extracting(price -> price.symbol).containsOnly("SYM" + index);
205+
assertThat(prices)
206+
.extracting(price -> price.price)
207+
.containsExactlyElementsOf(
208+
IntStream.range(0, EVENTS_EACH).mapToObj(i -> (double) i).toList());
209+
}
210+
211+
assertThat(openSockets).hasValue(SUBSCRIPTIONS);
212+
assertThat(closed.await(30, TimeUnit.SECONDS))
213+
.as("every socket should have been closed, not leaked")
214+
.isTrue();
215+
}
216+
217+
@Test
218+
void concurrentPublishersDeliverEveryEvent() throws Exception {
219+
var api = buildClient();
220+
221+
List<Callable<Integer>> tasks =
222+
IntStream.range(0, SUBSCRIPTIONS)
223+
.<Callable<Integer>>mapToObj(
224+
index ->
225+
() -> {
226+
var delivered = new AtomicInteger();
227+
var done = new CountDownLatch(1);
228+
api.publishPrice("SYM" + index)
229+
.subscribe(
230+
new Flow.Subscriber<Price>() {
231+
@Override
232+
public void onSubscribe(Flow.Subscription subscription) {
233+
subscription.request(Long.MAX_VALUE);
234+
}
235+
236+
@Override
237+
public void onNext(Price item) {
238+
delivered.incrementAndGet();
239+
}
240+
241+
@Override
242+
public void onError(Throwable throwable) {
243+
done.countDown();
244+
}
245+
246+
@Override
247+
public void onComplete() {
248+
done.countDown();
249+
}
250+
});
251+
assertThat(done.await(60, TimeUnit.SECONDS)).isTrue();
252+
return delivered.get();
253+
})
254+
.toList();
255+
256+
assertThat(runAllAtOnce(tasks)).containsOnly(EVENTS_EACH);
257+
assertThat(closed.await(30, TimeUnit.SECONDS)).isTrue();
258+
}
259+
260+
@Test
261+
void closingMidStreamFromAnotherThreadTerminatesPromptly() throws Exception {
262+
var api = buildClient();
263+
264+
List<Callable<Integer>> tasks =
265+
IntStream.range(0, SUBSCRIPTIONS)
266+
.<Callable<Integer>>mapToObj(
267+
index ->
268+
() -> {
269+
// Take a couple of events and walk away while the server is still pushing.
270+
try (var prices = api.onPrice("SYM" + index)) {
271+
return prices.limit(2).toList().size();
272+
}
273+
})
274+
.toList();
275+
276+
assertThat(runAllAtOnce(tasks)).containsOnly(2);
277+
assertThat(closed.await(30, TimeUnit.SECONDS))
278+
.as("abandoning a stream must still close its socket")
279+
.isTrue();
280+
}
281+
}

0 commit comments

Comments
 (0)